@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,33 +1,30 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * The three-way compare at the heart of the hybrid sync.
4
+ * One-sided change detection for one-way sync (repo Linear).
5
5
  *
6
- * `classify(local, remote, base, config)` compares each configured field across
7
- * the local snapshot, the remote (remote) projection, and the committed base
8
- * (the last-synced state). Per field it returns a raw three-way `status`
9
- * (unchanged / local-only / remote-only / conflict), then collapses it through
10
- * the field's ownership (`both|pull|push`) into effective `pushable` / `pullable`
11
- * flags. Ownership is what makes most "both sides differ" cases *not* a real
12
- * conflict:
13
- * - a `pull` field never pushes (remote wins) → conflict collapses to remote-only
14
- * - a `push` field never pulls (repo wins) → conflict collapses to local-only
15
- * - only a `both` field where both sides moved off base is a true `conflict`.
6
+ * The repo is the source of truth; Linear is a generated mirror. We never read
7
+ * remote content. Instead we record a **last-pushed snapshot** a content hash
8
+ * per object and diff the current local projection against it:
16
9
  *
17
- * Pure and deterministic: field identity is a stable content hash (sorted-key
18
- * JSON → SHA-1), so `null`, `undefined`, and a missing base all compare equal,
19
- * and object key order never causes a false diff. No Date.now()/Math.random().
10
+ * - an item with no id → CREATE (never pushed)
11
+ * - an item whose hash changed → UPDATE (edited since last push)
12
+ * - an item whose hash matches → skip (unchanged)
13
+ *
14
+ * `planChanges(projection, snapshot)` returns the create/update plan the push
15
+ * skill applies over MCP; `snapshotOf(projection)` is what we record afterwards.
16
+ *
17
+ * Pure and deterministic: hashes are a sorted-key JSON → SHA-1, so key order and
18
+ * null/undefined never cause a false diff. No Date.now()/Math.random().
20
19
  */
21
20
 
22
21
  const { createHash } = require('node:crypto')
23
22
 
24
- // Deterministic JSON: object keys sorted recursively; array order preserved
25
- // (order is meaningful for milestones/tasks). undefined normalises to null.
23
+ // Deterministic JSON: object keys sorted recursively; array order preserved.
24
+ // undefined normalises to null.
26
25
  function stableStringify(value) {
27
26
  if (value === undefined || value === null) return 'null'
28
- if (Array.isArray(value)) {
29
- return '[' + value.map(stableStringify).join(',') + ']'
30
- }
27
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'
31
28
  if (typeof value === 'object') {
32
29
  const keys = Object.keys(value).sort()
33
30
  return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'
@@ -35,181 +32,93 @@ function stableStringify(value) {
35
32
  return JSON.stringify(value)
36
33
  }
37
34
 
38
- // Stable content hash of a single field value.
35
+ // Stable content hash of a value.
39
36
  function hashField(value) {
40
37
  return createHash('sha1').update(stableStringify(value)).digest('hex')
41
38
  }
42
39
 
43
- // Raw three-way status from the three hashes.
44
- function rawStatus(localH, remoteH, baseH) {
45
- const localChanged = localH !== baseH
46
- const remoteChanged = remoteH !== baseH
47
- if (!localChanged && !remoteChanged) return 'unchanged'
48
- if (localChanged && !remoteChanged) return 'local-only'
49
- if (!localChanged && remoteChanged) return 'remote-only'
50
- // both moved off base — but they may have converged on the same value.
51
- if (localH === remoteH) return 'unchanged'
52
- return 'conflict'
53
- }
40
+ // --- content hashes (id / local handles excluded, so they never affect the
41
+ // diff an id stamped in after a create must not read as an edit) ---------
54
42
 
55
- // --- keyed collections (per-item three-way) --------------------------------
56
-
57
- // Index an array of items by their `idKey` value (stringified). Non-arrays and
58
- // items missing the id are skipped — they can't participate in a keyed merge.
59
- function indexById(arr, idKey) {
60
- const map = new Map()
61
- if (Array.isArray(arr)) {
62
- for (const item of arr) {
63
- if (item && typeof item === 'object' && item[idKey] != null) {
64
- map.set(String(item[idKey]), item)
65
- }
66
- }
67
- }
68
- return map
69
- }
70
-
71
- // Signature of an item under a given id: its content hash with the id stripped
72
- // (so an id-only change / reorder is not a content edit), or 'ABSENT' when the
73
- // id isn't present on that side. 'ABSENT' never collides with a hex hash.
74
- function itemSignature(map, id, idKey) {
75
- if (!map.has(id)) return 'ABSENT'
76
- const { [idKey]: _omit, ...content } = map.get(id)
77
- return hashField(content)
78
- }
79
-
80
- // Raw per-item three-way: which side moved off base, and how (added/edited/
81
- // removed). Mirrors rawStatus but tracks presence so add vs edit vs remove is
82
- // distinguishable.
83
- function itemRaw(localSig, remoteSig, baseSig) {
84
- const localChanged = localSig !== baseSig
85
- const remoteChanged = remoteSig !== baseSig
86
- if (!localChanged && !remoteChanged) return { raw: 'unchanged', side: null }
87
- const kind = (sig, base) => (base === 'ABSENT' ? 'added' : sig === 'ABSENT' ? 'removed' : 'edited')
88
- if (localChanged && !remoteChanged) return { raw: kind(localSig, baseSig), side: 'local' }
89
- if (!localChanged && remoteChanged) return { raw: kind(remoteSig, baseSig), side: 'remote' }
90
- if (localSig === remoteSig) return { raw: 'unchanged', side: null } // both converged
91
- return { raw: 'conflict', side: null }
92
- }
93
-
94
- // Collapse a raw per-item outcome through ownership into an effective status +
95
- // push/pull flags. Removals are report-only in v1 (surfaced, never auto-applied).
96
- function collapseItem({ raw, side }, ownership) {
97
- const canPush = ownership === 'both' || ownership === 'push'
98
- const canPull = ownership === 'both' || ownership === 'pull'
99
- if (raw === 'unchanged') return { status: 'unchanged', side: null, pushable: false, pullable: false, report: false }
100
- if (raw === 'removed') return { status: 'removed', side, pushable: false, pullable: false, report: true }
101
- if (raw === 'conflict') {
102
- if (ownership === 'push') return { status: 'edited', side: 'local', pushable: true, pullable: false, report: false }
103
- if (ownership === 'pull') return { status: 'edited', side: 'remote', pushable: false, pullable: true, report: false }
104
- return { status: 'conflict', side: null, pushable: true, pullable: true, report: false }
105
- }
106
- // added / edited
107
- if (side === 'local') return { status: raw, side, pushable: canPush, pullable: false, report: false }
108
- return { status: raw, side, pushable: false, pullable: canPull, report: false }
43
+ // The project fields the repo owns and pushes: prose + workflow state. Priority,
44
+ // labels, cycles and comments are Linear-native triage — one-way sync neither
45
+ // pushes nor reads them, so a PM's triage is never clobbered.
46
+ function projectHash(p) {
47
+ return hashField({ description: p.description ?? null, status: p.status ?? null })
109
48
  }
49
+ const milestoneHash = (m) => hashField({ name: m.name ?? null, goal: m.goal ?? null })
50
+ const issueHash = (t) => hashField({ title: t.title ?? null, description: t.description ?? null, done: !!t.done })
110
51
 
111
52
  /**
112
- * Classify a keyed collection field item-by-item. Returns one entry per id seen
113
- * across local/remote/base, each with its effective status and push/pull flags,
114
- * plus the local/remote item values so a caller can apply the change.
53
+ * The snapshot to commit after a successful push: a content hash per object that
54
+ * currently has an id. Create items (id == null) aren't recorded until the skill
55
+ * stamps their returned id and the next projection includes it.
115
56
  */
116
- function classifyItems(local, remote, base, ownership, idKey) {
117
- const lMap = indexById(local, idKey)
118
- const rMap = indexById(remote, idKey)
119
- const bMap = indexById(base, idKey)
120
- const ids = new Set([...lMap.keys(), ...rMap.keys(), ...bMap.keys()])
121
- const items = []
122
- for (const id of ids) {
123
- const raw = itemRaw(
124
- itemSignature(lMap, id, idKey),
125
- itemSignature(rMap, id, idKey),
126
- itemSignature(bMap, id, idKey),
127
- )
128
- const c = collapseItem(raw, ownership)
129
- items.push({
130
- id,
131
- status: c.status,
132
- side: c.side,
133
- pushable: c.pushable,
134
- pullable: c.pullable,
135
- report: c.report,
136
- local: lMap.get(id) || null,
137
- remote: rMap.get(id) || null,
138
- })
57
+ function snapshotOf(projection) {
58
+ const p = projection || {}
59
+ const byId = (arr, hash) => {
60
+ const out = {}
61
+ for (const item of arr || []) if (item && item.id != null) out[String(item.id)] = hash(item)
62
+ return out
63
+ }
64
+ return {
65
+ project: projectHash(p),
66
+ milestones: byId(p.milestones, milestoneHash),
67
+ issues: byId(p.issues, issueHash),
139
68
  }
140
- return items
141
- }
142
-
143
- // Collapse the raw status through ownership into an effective status + flags.
144
- function collapse(raw, ownership) {
145
- const canPush = ownership === 'both' || ownership === 'push'
146
- const canPull = ownership === 'both' || ownership === 'pull'
147
-
148
- if (raw === 'unchanged') return { status: 'unchanged', pushable: false, pullable: false }
149
- if (raw === 'local-only') return { status: 'local-only', pushable: canPush, pullable: false }
150
- if (raw === 'remote-only') return { status: 'remote-only', pushable: false, pullable: canPull }
151
-
152
- // conflict: both sides diverged off base.
153
- if (ownership === 'push') return { status: 'local-only', pushable: true, pullable: false }
154
- if (ownership === 'pull') return { status: 'remote-only', pushable: false, pullable: true }
155
- return { status: 'conflict', pushable: true, pullable: true }
156
69
  }
157
70
 
158
71
  /**
159
- * Classify every field in `config.sync.fieldOwnership`.
160
- *
161
- * @param {object} local normalized local snapshot (normalizeLocal output)
162
- * @param {object} remote normalized remote projection (normalizeRemote output)
163
- * @param {object|null} base the committed base (same shape) or null (never synced)
164
- * @returns {Array<{field, ownership, raw, status, pushable, pullable}>}
165
- * one entry per configured field, in config order.
72
+ * Diff the local projection against the last-pushed snapshot.
73
+ * @returns {{ project?: object, milestones: {create,update}, issues: {create,update} }}
74
+ * create items carry a `ref` (local handle) and no id; update items carry `id`.
166
75
  */
167
- function classify(local, remote, base, config) {
168
- const ownership = config.sync.fieldOwnership
169
- const keyed = (config.sync && config.sync.keyedFields) || {}
170
- const baseObj = base || {}
171
- return Object.keys(ownership).map((field) => {
172
- const own = ownership[field]
173
- const idKey = keyed[field]
76
+ function planChanges(projection, snapshot) {
77
+ const p = projection || {}
78
+ const snap = snapshot || {}
79
+ const snapM = snap.milestones || {}
80
+ const snapI = snap.issues || {}
81
+
82
+ const milestones = { create: [], update: [] }
83
+ for (const m of p.milestones || []) {
84
+ if (m.id == null) milestones.create.push({ ref: m.ref, name: m.name, goal: m.goal })
85
+ else if (snapM[String(m.id)] !== milestoneHash(m)) milestones.update.push({ id: m.id, name: m.name, goal: m.goal })
86
+ }
174
87
 
175
- // Keyed collection: per-item three-way. Field-level flags aggregate the
176
- // items so existing summary code still sees "does this field have work?".
177
- if (idKey) {
178
- const items = classifyItems(
179
- local ? local[field] : null,
180
- remote ? remote[field] : null,
181
- field in baseObj ? baseObj[field] : null,
182
- own,
183
- idKey,
184
- )
185
- const active = items.filter((i) => i.status !== 'unchanged')
186
- return {
187
- field,
188
- ownership: own,
189
- keyed: true,
190
- idKey,
191
- items,
192
- status: active.length ? 'items-changed' : 'unchanged',
193
- pushable: items.some((i) => i.pushable),
194
- pullable: items.some((i) => i.pullable),
195
- }
88
+ const issues = { create: [], update: [] }
89
+ for (const t of p.issues || []) {
90
+ if (t.id == null) {
91
+ issues.create.push({ ref: t.ref, title: t.title, description: t.description, done: !!t.done, milestoneRef: t.milestoneRef })
92
+ } else if (snapI[String(t.id)] !== issueHash(t)) {
93
+ issues.update.push({ id: t.id, title: t.title, description: t.description, done: !!t.done })
196
94
  }
95
+ }
96
+
97
+ const plan = { milestones, issues }
98
+ if (snap.project !== projectHash(p)) {
99
+ plan.project = { description: p.description ?? null, status: p.status ?? null }
100
+ }
101
+ return plan
102
+ }
197
103
 
198
- // Scalar / whole-field (behaviour unchanged).
199
- const localH = hashField(local ? local[field] : null)
200
- const remoteH = hashField(remote ? remote[field] : null)
201
- const baseH = hashField(field in baseObj ? baseObj[field] : null)
202
- const raw = rawStatus(localH, remoteH, baseH)
203
- const { status, pushable, pullable } = collapse(raw, own)
204
- return { field, ownership: own, raw, status, pushable, pullable }
205
- })
104
+ // True when a plan would push nothing.
105
+ function isEmptyPlan(plan) {
106
+ return (
107
+ !plan.project &&
108
+ !plan.milestones.create.length &&
109
+ !plan.milestones.update.length &&
110
+ !plan.issues.create.length &&
111
+ !plan.issues.update.length
112
+ )
206
113
  }
207
114
 
208
115
  module.exports = {
209
- classify,
210
- classifyItems,
116
+ planChanges,
117
+ snapshotOf,
118
+ isEmptyPlan,
211
119
  hashField,
212
120
  stableStringify,
213
- rawStatus,
214
- collapse,
121
+ projectHash,
122
+ milestoneHash,
123
+ issueHash,
215
124
  }
@@ -16,7 +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
+ const { findTaskBlocks, collapse, collapseHyphenAware } = require('./task-block.js')
20
20
 
21
21
  // --- markdown / frontmatter parsing -----------------------------------------
22
22
 
@@ -83,21 +83,134 @@ function parseSections(body) {
83
83
  return { title, sections }
84
84
  }
85
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
+ // 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.
132
+ let j = i + 1
133
+ while (j < text.length && (text[j] === ' ' || text[j] === '\t')) j++
134
+ const softHyphen = text[i - 1] === '-' && /\w/.test(text[i - 2] || '') && /\w/.test(text[j] || '')
135
+ if (!softHyphen) out.push(' ')
136
+ i = j - 1
137
+ } else {
138
+ out.push('\n')
139
+ }
140
+ continue
141
+ }
142
+ if (c === '`') {
143
+ code = !code
144
+ out.push(c)
145
+ continue
146
+ }
147
+ if (code) {
148
+ out.push(c)
149
+ continue
150
+ }
151
+ if (c === '*' && text[i + 1] === '*') {
152
+ bold = !bold
153
+ out.push('**')
154
+ i++
155
+ continue
156
+ }
157
+ if (c === '*') {
158
+ // Basic flanking so a stray `*` (e.g. `2 * 3`) doesn't open a phantom span:
159
+ // an opener needs a non-space to its right, a closer a non-space to its left.
160
+ const ok = italic ? !/\s/.test(text[i - 1] || '') : !/\s/.test(text[i + 1] || '')
161
+ if (ok) italic = !italic
162
+ out.push(c)
163
+ continue
164
+ }
165
+ if (c === '[' && link === 0) {
166
+ link = 1
167
+ out.push(c)
168
+ continue
169
+ }
170
+ if (c === ']' && link === 1) {
171
+ if (text[i + 1] === '(') {
172
+ link = 2
173
+ out.push('](')
174
+ i++
175
+ } else {
176
+ link = 0
177
+ out.push(c)
178
+ }
179
+ continue
180
+ }
181
+ if (c === ')' && link === 2) {
182
+ link = 0
183
+ out.push(c)
184
+ continue
185
+ }
186
+ out.push(c)
187
+ }
188
+ return out.join('')
189
+ }
190
+
86
191
  // Canonicalise markdown so semantically-equal content hashes equal across the
87
192
  // boundary. Linear reserializes markdown on save (authored `-` bullets come back
88
- // as `*`, trailing whitespace trimmed, blank runs collapsed), so without this a
89
- // clean push→pull would report `description` as perpetually changed. Applied to
90
- // the description on BOTH sides. Conservative: only unifies list markers and
91
- // whitespace the transforms actually observed from Linear.
193
+ // as `*`, trailing whitespace trimmed, blank runs collapsed, emphasis spanning a
194
+ // line break mangled), so without this a clean push→pull would report
195
+ // `description` as perpetually changed. Applied to the description on BOTH sides.
196
+ // Conservative: only unifies list markers, whitespace, and emphasis-across-a-break
197
+ // — the transforms actually observed from Linear.
92
198
  function canonicalizeMarkdown(text) {
93
199
  if (text == null) return text
94
- return String(text)
200
+ const marked = String(text)
95
201
  .replace(/\r\n/g, '\n')
96
202
  .split('\n')
97
203
  // Unordered-list marker at line start (`*`/`+`/`-`) → `-`. Requires a space
98
- // after the marker so bold/emphasis (`**Goal:**`) is untouched.
204
+ // after the marker so bold/emphasis (`**Goal:**`) is untouched. Done BEFORE
205
+ // the emphasis join so a `*` list bullet can't spoof an italic delimiter.
99
206
  .map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
100
207
  .join('\n')
208
+ return joinEmphasisAcrossBreaks(marked)
209
+ // A word wrapped at a hyphen within a paragraph (`state-entry-with-`⏎
210
+ // `assignment`) rejoins TIGHT — otherwise Linear (CommonMark) renders the
211
+ // soft line break as a space and corrupts the compound. Single newline only,
212
+ // so paragraph breaks are preserved.
213
+ .replace(/(\w-)[ \t]*\n[ \t]*(?=\w)/g, '$1')
101
214
  .replace(/\n{3,}/g, '\n\n')
102
215
  .trim()
103
216
  }
@@ -173,7 +286,7 @@ function readPhaseFiles(snapshotDir) {
173
286
  // Collapsed, not just captured: the goal becomes a milestone description,
174
287
  // and Linear may canonicalize a soft line break away on save. Collapsing
175
288
  // both sides keeps a wrapped goal from diffing forever.
176
- const goal = collapse((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
289
+ const goal = collapseHyphenAware((/\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/.exec(body) || [])[1] || '')
177
290
  const tasks = findTaskBlocks(body.split('\n')).map((b) => `[${b.mark}] ${b.text}`)
178
291
  return {
179
292
  phase: file.replace(/\.md$/, ''),
@@ -241,16 +354,28 @@ function normalizeLocal(snapshotDir, config) {
241
354
  config.sync.localOnlySections,
242
355
  milestonesKeyed ? ['Phases'] : [],
243
356
  ),
244
- // Keyed milestone items: a phase's linked milestone id (or null when
245
- // unlinked), its title and goal. Unlinked phases carry id:null and are
246
- // skipped by the keyed compare until they're linked.
357
+ // Milestone projection items. `ref` is the phase-file basename the local
358
+ // handle the push skill stamps a newly-created milestone id back into.
247
359
  milestones: phases
248
360
  .filter((p) => p.name)
249
- .map((p) => ({ id: p.id, name: p.name, goal: p.goal })),
250
- // Keyed task items across all phases: inline issue id (or null), text, done.
251
- // Deliberately just {id,text,done} so it hashes equal to a remote issue — the
252
- // owning phase is recovered at push time by locating the task line.
253
- tasks: phases.flatMap((p) => p.tasks.map(parseTaskLine).filter(Boolean)),
361
+ .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p.goal })),
362
+ // Issue projection items across all phases. `title` is the first-sentence
363
+ // Linear title; `description` is the full task text (the mirror keeps both).
364
+ // `ref` = the collapsed text the push skill matches to stamp a new id in;
365
+ // `milestoneRef` links the issue to its milestone (id if linked, else phase).
366
+ tasks: phases.flatMap((p) =>
367
+ p.tasks
368
+ .map(parseTaskLine)
369
+ .filter(Boolean)
370
+ .map((t) => ({
371
+ id: t.id,
372
+ ref: t.text,
373
+ title: titleFromText(t.text),
374
+ description: t.text,
375
+ done: t.done,
376
+ milestoneRef: p.id || p.phase,
377
+ })),
378
+ ),
254
379
  phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
255
380
  acceptanceCriteria: sections['Acceptance criteria'] || null,
256
381
  taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
@@ -297,86 +422,65 @@ function remoteStateName(project) {
297
422
  return st
298
423
  }
299
424
 
300
- // Real Linear priority is an object `{ value, name }`; accept a bare number too.
301
- function remotePriority(priority) {
302
- if (priority == null) return null
303
- if (typeof priority === 'object') return priority.value != null ? priority.value : null
304
- return priority
425
+ // The ONE thing one-way sync reads back: the mirror's current workflow state,
426
+ // mapped to the local lifecycle bucket, so `/spec-status` can report a drift
427
+ // ("Linear says Done, your spec says In Progress"). Read-only — it never writes.
428
+ function remoteWorkflowState(project, config) {
429
+ const name = remoteStateName(project || {})
430
+ return name != null ? bucketForState(name, config) : null
305
431
  }
306
432
 
307
- // Real Linear labels are `[{ id, name }]`; accept bare strings too.
308
- function remoteLabels(labels) {
309
- if (!Array.isArray(labels)) return []
310
- return labels
311
- .map((l) => (typeof l === 'string' ? l : l && l.name != null ? l.name : null))
312
- .filter((n) => n != null)
313
- }
314
-
315
- // Whether a remote Linear issue is complete. The real MCP shape is a flat
316
- // `statusType` ("completed") — accept the legacy `state.type` and `completedAt`
317
- // / bare `done` too (fixtures, older shapes).
318
- function remoteIssueDone(iss) {
319
- if (iss.done === true) return true
320
- const type = iss.statusType != null ? iss.statusType : iss.state && iss.state.type
321
- if (type != null) return String(type).toLowerCase() === 'completed'
322
- return iss.completedAt != null
433
+ // Derive a short Linear issue title from a task's full text: the first sentence,
434
+ // falling back to the first `max` chars at a word boundary. A paragraph-length
435
+ // task keeps its full text as the issue *description* (see the projection); this
436
+ // is only the title, so it must read as a one-liner. Guards decimals/versions
437
+ // (`7.0.2`) and common abbreviations (`e.g.`, `i.e.`, `etc.`) so it doesn't cut
438
+ // mid-number or mid-abbreviation.
439
+ function titleFromText(text, max = 100) {
440
+ const s = collapse(text)
441
+ if (!s) return s
442
+ let title = s
443
+ const re = /[.!?]/g
444
+ let m
445
+ while ((m = re.exec(s)) !== null) {
446
+ const i = m.index
447
+ const after = s[i + 1]
448
+ if (after !== undefined && !/\s/.test(after)) continue // not a sentence end
449
+ if (/\d/.test(s[i - 1] || '') && /\d/.test(after || '')) continue // 7.0.2, 3.14
450
+ if (/(^|\s)(e\.g|i\.e|etc|vs|no|fig|cf)$/i.test(s.slice(0, i))) continue // abbrev
451
+ title = s.slice(0, i) // drop the terminator
452
+ break
453
+ }
454
+ title = title.trim()
455
+ if (title.length > max) {
456
+ const cut = title.slice(0, max)
457
+ const sp = cut.lastIndexOf(' ')
458
+ title = (sp > 40 ? cut.slice(0, sp) : cut).trim()
459
+ }
460
+ return title
323
461
  }
324
462
 
325
- /**
326
- * Normalize a remote Project projection (from the MCP adapter, or a fixture)
327
- * into the same field set as `normalizeLocal`.
328
- */
329
- function normalizeRemote(project, config) {
330
- const p = project || {}
331
- const milestones = Array.isArray(p.milestones) ? p.milestones : []
332
- const stateName = remoteStateName(p)
333
- const extracted = {
334
- description: p.description != null ? canonicalizeMarkdown(p.description) : null,
335
- // Keyed milestone items mirroring normalizeLocal: id, title, goal (the Linear
336
- // milestone's description). Progress is Linear-derived and not synced.
337
- milestones: milestones.map((m) => ({
338
- id: m.id != null ? String(m.id) : null,
339
- name: m.name,
340
- // A milestone's description mirrors the phase's `**Goal:**` line; strip the
341
- // label so it hashes equal to the local goal (which readPhaseFiles already
342
- // captures without it).
343
- goal: (m.description != null ? m.description : '')
344
- .replace(/^\s*\*\*Goal:\*\*\s*/, '')
345
- .trim(),
346
- })),
347
- phaseBodies: milestones.map((m) => ({
348
- phase: m.name,
349
- goal: (m.description != null ? m.description : '').trim(),
350
- })),
351
- acceptanceCriteria: p.acceptanceCriteria != null ? p.acceptanceCriteria : null,
352
- // Keyed task items from the project's issues: keyed by the human identifier
353
- // (SKI-123, what the inline task-line id carries — the Linear MCP returns it as
354
- // the issue's `id`), text ← title, done ← a completed-type workflow state.
355
- tasks: (Array.isArray(p.issues) ? p.issues : []).map((iss) => ({
356
- id: iss.identifier != null ? String(iss.identifier) : iss.id != null ? String(iss.id) : null,
357
- text: iss.title != null ? iss.title : '',
358
- done: remoteIssueDone(iss),
359
- })),
360
- taskBreakdown: milestones.map((m) => ({
361
- phase: m.name,
362
- tasks: Array.isArray(m.tasks) ? m.tasks : [],
363
- })),
364
- workflowState: stateName != null ? bucketForState(stateName, config) : null,
365
- priority: remotePriority(p.priority),
366
- labels: remoteLabels(p.labels),
367
- }
368
- return toFieldSet(extracted, config)
463
+ // Which configured state NAMES are absent from the live workspace. The skill
464
+ // fetches the workspace's project-status names over MCP and passes them here;
465
+ // a non-empty result means a typo/rename that Linear would silently no-op.
466
+ function validateStates(config, workspaceStates) {
467
+ const configured = Object.values((config && config.states) || {}).filter((v) => typeof v === 'string')
468
+ const have = new Set((workspaceStates || []).map((s) => String(s).toLowerCase().trim()))
469
+ return configured.filter((name) => !have.has(name.toLowerCase().trim()))
369
470
  }
370
471
 
371
472
  module.exports = {
372
473
  normalizeLocal,
373
- normalizeRemote,
374
474
  readSnapshot,
375
475
  parseFrontmatter,
376
476
  parseSections,
377
477
  parsePhaseIndex,
378
478
  parseTaskLine,
479
+ titleFromText,
480
+ validateStates,
379
481
  canonicalRemoteStatus,
380
482
  canonicalizeMarkdown,
483
+ joinEmphasisAcrossBreaks,
381
484
  bucketForState,
485
+ remoteWorkflowState,
382
486
  }