@skitterbyte/skitterspec-linear 7.0.1 → 8.0.0

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.
@@ -1,147 +1,53 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * `push` — repo → remote, three-way aware and ownership-respecting.
4
+ * `push` — repo → Linear, one-way. The repo is the source of truth; Linear is a
5
+ * generated mirror. We never read remote content: `push` builds the local
6
+ * projection, diffs it against the committed **last-pushed snapshot**, and
7
+ * returns a **create/update plan** the provider skill applies over MCP.
5
8
  *
6
- * Never writes a `pull`-owned field or a `localOnlySection` (those aren't in the
7
- * pushable set / the field set at all). Optimistic concurrency: if the remote has
8
- * moved past the base detected both by the classifier (any remote-only/conflict
9
- * field) and by the recorded `updatedAt` it aborts with "pull first" unless
10
- * `--force`. It also **re-reads the remote immediately before writing** to catch a
11
- * writer that raced in during the compare. `--force` makes local win after backing
12
- * up the remote side. On success it rewrites the base and stamps `last_synced_at`.
9
+ * The skill: applies the plan stamps each newly-created id back into the phase
10
+ * frontmatter / task line calls `recordPush`, which re-reads the (now
11
+ * id-stamped) projection and writes the snapshot. That closes the loop: the next
12
+ * `push` sees an unchanged snapshot and produces an empty plan.
13
13
  *
14
- * Pure orchestration over an injected `adapter` (readProject + updateProject) and
15
- * injected `timestamp`. Tests drive it with a fake in-memory adapter.
14
+ * Pure aside from reading the snapshot; no adapter, no remote read, no
15
+ * Date.now(). `recordPush` writes the snapshot sidecar.
16
16
  */
17
17
 
18
- const { normalizeLocal, normalizeRemote } = require('./normalize.js')
19
- const { classify } = require('./compare.js')
20
- const { readBase, writeBase, backup } = require('./base.js')
21
- const { writeFrontmatter } = require('./write.js')
18
+ const { normalizeLocal } = require('./normalize.js')
19
+ const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
20
+ const { readBase, writeBase } = require('./base.js')
22
21
 
23
- async function push({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
22
+ // Build the one-way projection from a local snapshot: the project prose + status,
23
+ // and the milestone/issue items. `status` is the local lifecycle bucket; the
24
+ // skill maps it to the Linear project-state NAME via config.states at apply time.
25
+ function projectionOf(snapshotDir, config) {
24
26
  const local = normalizeLocal(snapshotDir, config)
25
- const remoteRaw = await adapter.readProject(projectId)
26
- if (!remoteRaw) {
27
- return { ok: false, error: `remote project not found: ${projectId}` }
28
- }
29
- const remote = normalizeRemote(remoteRaw, config)
30
- const base = readBase(dir, identifier, config)
31
- const fields = classify(local, remote, base, config)
32
-
33
- // Remote moved past base only if a *co-authored* (`both`) field diverged on the
34
- // remote side — that's the case the repo can't safely overwrite without a pull.
35
- // For a keyed field the equivalent is a same-item conflict (independent edits to
36
- // different items don't collide, so they don't block). A `pull`-owned change
37
- // (status/priority/labels) is Linear's to own and must NOT block a content push,
38
- // and a bare `updatedAt` bump is too coarse to gate on — the pre-write re-read
39
- // below still catches a racer that lands during the push itself.
40
- const remoteDivergedFields = fields
41
- .filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
42
- .map((f) => f.field)
43
- for (const f of fields) {
44
- if (f.keyed) for (const it of f.items) if (it.status === 'conflict') remoteDivergedFields.push(`${f.field}#${it.id}`)
45
- }
46
- const moved = remoteDivergedFields.length > 0
47
-
48
- if (moved && !force) {
49
- return {
50
- ok: false,
51
- blocked: true,
52
- reason: 'remote-moved',
53
- movedFields: remoteDivergedFields,
54
- message:
55
- 'push refused — remote moved since the last sync' +
56
- (remoteDivergedFields.length ? ` (${remoteDivergedFields.join(', ')})` : '') +
57
- '. Pull first, or re-run with --force (local wins).',
58
- }
59
- }
60
-
61
- // Scalar push goes through the project adapter here. Keyed body fields
62
- // (milestones) can't be written by the offline engine — the provider skill does
63
- // the MCP create/update and stamps new ids — so the engine emits a *plan* the
64
- // skill applies. The base still advances to local (below): a created milestone's
65
- // id:null item is skipped by the keyed compare until the skill stamps it, then
66
- // it converges on the next sync, so no special base handling is needed.
67
- const pushFields = fields.filter((f) => f.pushable && !f.keyed)
68
- // A per-field create/update plan for each keyed collection. The item content
69
- // (minus its id) is exactly what the skill sends to the Linear save tool.
70
- const keyedPush = {}
71
- for (const f of fields) {
72
- if (!f.keyed) continue
73
- const strip = (obj) => {
74
- const { [f.idKey]: _omit, ...rest } = obj
75
- return rest
76
- }
77
- const plan = { create: [], update: [] }
78
- // Edits to already-linked items (matched by id) → update.
79
- for (const it of f.items) {
80
- if (!it.pushable || !it.local) continue
81
- if (it.status !== 'added') plan.update.push({ id: it.id, ...strip(it.local) })
82
- }
83
- // Unlinked local items (no id yet) are new content to create; the keyed
84
- // compare skips them (nothing to key on), so collect them straight from local.
85
- const localItems = Array.isArray(local[f.field]) ? local[f.field] : []
86
- for (const li of localItems) if (li && li[f.idKey] == null) plan.create.push(strip(li))
87
- if (plan.create.length || plan.update.length) keyedPush[f.field] = plan
88
- }
89
- const hasKeyedPush = Object.keys(keyedPush).length > 0
90
-
91
- if (!pushFields.length && !hasKeyedPush && !force) {
92
- return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
93
- }
94
-
95
- // Optimistic concurrency: re-read immediately before writing to catch a racer.
96
- const remoteRaw2 = await adapter.readProject(projectId)
97
- if (remoteRaw2 && remoteRaw2.updatedAt !== remoteRaw.updatedAt && !force) {
98
- return {
99
- ok: false,
100
- blocked: true,
101
- reason: 'concurrent-write',
102
- message: 'push refused — remote changed during the push. Pull first, or --force.',
103
- }
104
- }
105
-
106
- // --force clobbers the remote side — back it up first.
107
- let backupPath = null
108
- if (force) {
109
- backupPath = backup('remote', dir, identifier, config, { timestamp, data: remoteRaw2 || remoteRaw })
110
- }
111
-
112
- const updates = {}
113
- for (const f of pushFields) updates[f.field] = local[f.field]
114
- const updated = Object.keys(updates).length
115
- ? (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
116
- : remoteRaw2 || remoteRaw
117
- const updatedRemote = normalizeRemote(updated, config)
118
-
119
- // Reconciled base: local is the source of truth for the fields we pushed (and
120
- // for unchanged/local-only fields); pull-owned fields keep remote's value so
121
- // they don't read as pending next time.
122
- const newBase = { ...local }
123
- for (const [field, own] of Object.entries(config.sync.fieldOwnership)) {
124
- if (own === 'pull') newBase[field] = updatedRemote[field]
27
+ return {
28
+ description: local.description ?? null,
29
+ status: local.workflowState ?? null,
30
+ priority: local.priority ?? null,
31
+ labels: Array.isArray(local.labels) ? local.labels : [],
32
+ milestones: Array.isArray(local.milestones) ? local.milestones : [],
33
+ issues: Array.isArray(local.tasks) ? local.tasks : [],
125
34
  }
126
- newBase.__meta = { updatedAt: updated.updatedAt || null, syncedAt: timestamp }
127
- const basePath = writeBase(dir, identifier, config, newBase)
35
+ }
128
36
 
129
- if (timestamp) writeFrontmatter(snapshotDir, config, { last_synced_at: timestamp })
37
+ function push({ dir, snapshotDir, identifier, config }) {
38
+ const projection = projectionOf(snapshotDir, config)
39
+ const snapshot = readBase(dir, identifier, config)
40
+ const plan = planChanges(projection, snapshot)
41
+ return { ok: true, empty: isEmptyPlan(plan), plan, projection }
42
+ }
130
43
 
131
- return {
132
- ok: true,
133
- blocked: false,
134
- written: pushFields.map((f) => f.field),
135
- // The skill applies these Linear writes (create stamp the new id back into
136
- // the phase file / task line; update → save by id). Omitted when empty.
137
- ...(keyedPush.milestones ? { milestonesPush: keyedPush.milestones } : {}),
138
- ...(keyedPush.tasks ? { issuesPush: keyedPush.tasks } : {}),
139
- skipped: fields
140
- .filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
141
- .map((f) => f.field),
142
- backupPath,
143
- basePath,
144
- }
44
+ /**
45
+ * Record the last-pushed snapshot from the CURRENT files — call after the skill
46
+ * has applied the plan and stamped new ids. Returns the snapshot path.
47
+ */
48
+ function recordPush({ dir, snapshotDir, identifier, config }) {
49
+ const projection = projectionOf(snapshotDir, config)
50
+ return writeBase(dir, identifier, config, snapshotOf(projection))
145
51
  }
146
52
 
147
- module.exports = { push }
53
+ module.exports = { push, recordPush, projectionOf }
@@ -0,0 +1,143 @@
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 { wrapEmphasisAware, collapseHyphenAware, inferWidth, DEFAULT_WIDTH } = require('./task-block.js')
19
+ const { joinEmphasisAcrossBreaks } = require('./normalize.js')
20
+
21
+ // A run of non-blank lines contains an emphasis straddle / mangle iff joining
22
+ // spans across breaks changes it.
23
+ function hasStraddle(text) {
24
+ return joinEmphasisAcrossBreaks(text) !== text
25
+ }
26
+
27
+ // Turn a multi-line region into its clean single logical line: repair mangles and
28
+ // join straddles first (needs the newlines), then collapse whitespace — hyphen-
29
+ // aware, so a compound wrapped at the hyphen isn't spaced out.
30
+ function cleanLogicalLine(text) {
31
+ return collapseHyphenAware(joinEmphasisAcrossBreaks(text))
32
+ }
33
+
34
+ // A bullet line: indent, marker (`- `, `* `, `+ `, `1. `, optionally a `[ ]`/`[x]`
35
+ // checkbox), then the text.
36
+ const BULLET_RE = /^(\s*)((?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)$/
37
+ // Structural blocks we never reflow.
38
+ const PASSTHROUGH_RE = /^\s*(#{1,6}\s|>|\||[-*_]{3,}\s*$|<)/
39
+ // A GFM table separator row (only pipes/colons/dashes/spaces, with a pipe AND a
40
+ // dash). Detecting a table by this — not by any stray `|` — so a pipe inside an
41
+ // inline `code|span` doesn't make us treat a whole list as an untouchable table.
42
+ const TABLE_SEP_RE = /^[\s|:-]*\|[\s|:-]*-[\s|:-]*$|^[\s|:-]*-[\s|:-]*\|[\s|:-]*$/
43
+
44
+ // Split a block (consecutive non-blank lines) into items when it's a list; each
45
+ // item is its bullet line plus more-indented continuation lines.
46
+ function splitListItems(block) {
47
+ const items = []
48
+ let cur = null
49
+ for (const line of block) {
50
+ if (BULLET_RE.test(line)) {
51
+ cur = [line]
52
+ items.push(cur)
53
+ } else if (cur) {
54
+ cur.push(line) // continuation of the current bullet
55
+ } else {
56
+ return null // leading non-bullet line — not a clean list block
57
+ }
58
+ }
59
+ return items
60
+ }
61
+
62
+ // Reflow one bullet item (raw lines) to width, emphasis-aware. Returns the item's
63
+ // lines unchanged when it carries no straddle.
64
+ function sanitizeItem(itemLines, width) {
65
+ const raw = itemLines.join('\n')
66
+ if (!hasStraddle(raw)) return { lines: itemLines, fixed: false }
67
+ const m = BULLET_RE.exec(itemLines[0])
68
+ const indent = m[1]
69
+ const marker = m[2]
70
+ const firstPrefix = indent + marker
71
+ const hang = ' '.repeat(firstPrefix.length)
72
+ // Body = the item's text (first line after the marker + continuations).
73
+ const bodyRaw = [m[3], ...itemLines.slice(1)].join('\n')
74
+ const body = cleanLogicalLine(bodyRaw)
75
+ return { lines: wrapEmphasisAware(body, { firstPrefix, hang, width }), fixed: true }
76
+ }
77
+
78
+ // Reflow a plain prose paragraph (raw lines) to width, preserving its leading
79
+ // indent. Unchanged when it carries no straddle.
80
+ function sanitizeProse(block, width) {
81
+ const raw = block.join('\n')
82
+ if (!hasStraddle(raw)) return { lines: block, fixed: false }
83
+ const indent = (/^(\s*)/.exec(block[0]) || ['', ''])[1]
84
+ const body = cleanLogicalLine(raw)
85
+ return { lines: wrapEmphasisAware(body, { firstPrefix: indent, hang: indent, width }), fixed: true }
86
+ }
87
+
88
+ /**
89
+ * Sanitise one markdown document.
90
+ * @returns {{ text:string, changed:boolean, fixes:number }} fixes = blocks/items reflowed.
91
+ */
92
+ function sanitizeSpecMarkdown(text, { width } = {}) {
93
+ const src = String(text)
94
+ const lines = src.split('\n')
95
+ const w = width || inferWidth(lines) || DEFAULT_WIDTH
96
+ const out = []
97
+ let i = 0
98
+ let fixes = 0
99
+ while (i < lines.length) {
100
+ const line = lines[i]
101
+ // Fenced code — copy verbatim through the closing fence.
102
+ if (/^[ \t]*```/.test(line)) {
103
+ out.push(line)
104
+ i++
105
+ while (i < lines.length && !/^[ \t]*```/.test(lines[i])) out.push(lines[i++])
106
+ if (i < lines.length) out.push(lines[i++])
107
+ continue
108
+ }
109
+ if (!line.trim()) {
110
+ out.push(line)
111
+ i++
112
+ continue
113
+ }
114
+ // Gather a block of consecutive non-blank, non-fence lines.
115
+ let j = i
116
+ while (j < lines.length && lines[j].trim() && !/^[ \t]*```/.test(lines[j])) j++
117
+ const block = lines.slice(i, j)
118
+ i = j
119
+
120
+ // Never reflow structural blocks (headings, tables, quotes, rules, HTML).
121
+ if (PASSTHROUGH_RE.test(block[0]) || block.some((l) => TABLE_SEP_RE.test(l))) {
122
+ out.push(...block)
123
+ continue
124
+ }
125
+
126
+ const items = BULLET_RE.test(block[0]) ? splitListItems(block) : null
127
+ if (items) {
128
+ for (const it of items) {
129
+ const r = sanitizeItem(it, w)
130
+ out.push(...r.lines)
131
+ if (r.fixed) fixes++
132
+ }
133
+ } else {
134
+ const r = sanitizeProse(block, w)
135
+ out.push(...r.lines)
136
+ if (r.fixed) fixes++
137
+ }
138
+ }
139
+ const result = out.join('\n')
140
+ return { text: result, changed: result !== src, fixes }
141
+ }
142
+
143
+ module.exports = { sanitizeSpecMarkdown, hasStraddle }
@@ -26,6 +26,51 @@ function collapse(text) {
26
26
  return String(text).replace(/\s+/g, ' ').trim()
27
27
  }
28
28
 
29
+ // Like `collapse`, but a word wrapped at a hyphen (`state-entry-with-`⏎
30
+ // `assignment`) rejoins TIGHT — one compound — instead of gaining a space. Every
31
+ // other break collapses to a single space. Use this wherever hand-wrapped prose
32
+ // is flattened to one logical line, so a hard wrap at a hyphen isn't data loss.
33
+ function collapseHyphenAware(text) {
34
+ return String(text)
35
+ .replace(/(\w-)[ \t]*\n[ \t]*(?=\w)/g, '$1')
36
+ .replace(/\s+/g, ' ')
37
+ .trim()
38
+ }
39
+
40
+ // Mark every character of `body` that lies inside an inline emphasis or link
41
+ // span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
42
+ // one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
43
+ // spans are exempt (Linear only rejoins them, harmlessly) but their contents are
44
+ // neutralised first so a `*` inside code can't spoof an italic marker.
45
+ function spanMask(body) {
46
+ const mask = new Array(body.length).fill(false)
47
+ // Neutralise code-span contents to same-length filler (indices stay aligned).
48
+ const chars = body.split('')
49
+ let m
50
+ const codeRe = /`[^`]*`/g
51
+ while ((m = codeRe.exec(body)) !== null) {
52
+ for (let i = m.index; i < m.index + m[0].length; i++) chars[i] = 'x'
53
+ }
54
+ let masked = chars.join('')
55
+ const cover = (re) => {
56
+ re.lastIndex = 0
57
+ let mm
58
+ while ((mm = re.exec(masked)) !== null) {
59
+ for (let i = mm.index; i < mm.index + mm[0].length; i++) mask[i] = true
60
+ if (mm[0].length === 0) re.lastIndex++
61
+ }
62
+ }
63
+ cover(/\*\*.+?\*\*/g) // bold
64
+ cover(/\[[^\]]*\]\([^)]*\)/g) // link
65
+ // Neutralise the bold/link spans already found, so their asterisks aren't
66
+ // reused when scanning for single-`*` italics.
67
+ const m2 = masked.split('')
68
+ for (let i = 0; i < mask.length; i++) if (mask[i]) m2[i] = 'x'
69
+ masked = m2.join('')
70
+ cover(/\*[^*]+?\*/g) // italic
71
+ return mask
72
+ }
73
+
29
74
  /**
30
75
  * Find every task bullet in `lines` as a logical block.
31
76
  * @returns {Array<{start:number, end:number, indent:string, mark:string, text:string}>}
@@ -50,7 +95,7 @@ function findTaskBlocks(lines) {
50
95
  end: j,
51
96
  indent: m[1],
52
97
  mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
53
- text: collapse(parts.join(' ')),
98
+ text: collapseHyphenAware(parts.join('\n')),
54
99
  })
55
100
  i = j - 1
56
101
  }
@@ -62,16 +107,34 @@ function findTaskBlocks(lines) {
62
107
  * style: `- [x] ` opener, continuations aligned under the text.
63
108
  * @returns {string[]}
64
109
  */
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})` : '')
110
+ // Wrap `body` (a single logical line) into file lines, emphasis-aware: a break is
111
+ // only taken at a word gap that lies OUTSIDE every `**`/`*`/link span, so an
112
+ // emphasis run never straddles a line (Linear mangles one that does). The first
113
+ // line is prefixed with `firstPrefix`, continuations with `hang`. An over-width
114
+ // single span overflows rather than being split.
115
+ function wrapEmphasisAware(body, { firstPrefix = '', hang = '', width = DEFAULT_WIDTH } = {}) {
116
+ const mask = spanMask(body)
117
+ const words = body.split(' ')
118
+ // The index in `body` of the space that precedes each word.
119
+ const spaceBefore = []
120
+ let pos = 0
121
+ for (let k = 0; k < words.length; k++) {
122
+ if (k > 0) {
123
+ spaceBefore[k] = pos
124
+ pos += 1
125
+ }
126
+ pos += words[k].length
127
+ }
69
128
 
70
129
  const out = []
71
- let line = opener
130
+ let line = firstPrefix
72
131
  let first = true
73
- for (const word of body.split(' ')) {
74
- if (!first && line.length + 1 + word.length > width) {
132
+ for (let k = 0; k < words.length; k++) {
133
+ const word = words[k]
134
+ // Break only at a safe gap; a gap inside a span overflows instead of
135
+ // splitting it (so an over-width single span stays whole on one line).
136
+ const canBreak = k > 0 && !mask[spaceBefore[k]]
137
+ if (!first && canBreak && line.length + 1 + word.length > width) {
75
138
  out.push(line)
76
139
  line = hang + word
77
140
  } else {
@@ -83,13 +146,43 @@ function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH)
83
146
  return out
84
147
  }
85
148
 
149
+ function renderTaskBlock({ indent = '', done, text, id }, width = DEFAULT_WIDTH) {
150
+ const opener = `${indent}- [${done ? 'x' : ' '}] `
151
+ const hang = ' '.repeat(opener.length)
152
+ const body = collapse(text) + (id ? ` (${id})` : '')
153
+ return wrapEmphasisAware(body, { firstPrefix: opener, hang, width })
154
+ }
155
+
86
156
  // 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.
157
+ // different column. Only *prose* lines count a single wide table row or a long
158
+ // line of fenced code would otherwise pull the whole file's prose to a wider
159
+ // column than the author wrapped at. Falls back to the default when there's
160
+ // nothing prose-like to learn from.
88
161
  function inferWidth(lines, fallback = DEFAULT_WIDTH) {
89
- const widths = lines.filter((l) => l.trim()).map((l) => l.length)
162
+ let inFence = false
163
+ const widths = []
164
+ for (const l of lines) {
165
+ if (/^[ \t]*```/.test(l)) {
166
+ inFence = !inFence
167
+ continue
168
+ }
169
+ if (inFence) continue
170
+ if (!l.trim()) continue
171
+ if (l.includes('|')) continue // table row
172
+ widths.push(l.length)
173
+ }
90
174
  if (!widths.length) return fallback
91
175
  const max = Math.max(...widths)
92
176
  return max > 40 && max <= 120 ? Math.max(max, 60) : fallback
93
177
  }
94
178
 
95
- module.exports = { findTaskBlocks, renderTaskBlock, collapse, inferWidth, DEFAULT_WIDTH }
179
+ module.exports = {
180
+ findTaskBlocks,
181
+ renderTaskBlock,
182
+ wrapEmphasisAware,
183
+ spanMask,
184
+ collapse,
185
+ collapseHyphenAware,
186
+ inferWidth,
187
+ DEFAULT_WIDTH,
188
+ }