@skitterbyte/skitterspec-linear 7.0.2 → 8.0.1

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
 
@@ -126,8 +126,14 @@ function joinOpenSpans(text) {
126
126
  const c = text[i]
127
127
  if (c === '\n') {
128
128
  if (!code && (bold || italic || link)) {
129
- out.push(' ')
130
- while (i + 1 < text.length && (text[i + 1] === ' ' || text[i + 1] === '\t')) i++
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
131
137
  } else {
132
138
  out.push('\n')
133
139
  }
@@ -200,6 +206,11 @@ function canonicalizeMarkdown(text) {
200
206
  .map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
201
207
  .join('\n')
202
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')
203
214
  .replace(/\n{3,}/g, '\n\n')
204
215
  .trim()
205
216
  }
@@ -275,7 +286,7 @@ function readPhaseFiles(snapshotDir) {
275
286
  // Collapsed, not just captured: the goal becomes a milestone description,
276
287
  // and Linear may canonicalize a soft line break away on save. Collapsing
277
288
  // both sides keeps a wrapped goal from diffing forever.
278
- 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] || '')
279
290
  const tasks = findTaskBlocks(body.split('\n')).map((b) => `[${b.mark}] ${b.text}`)
280
291
  return {
281
292
  phase: file.replace(/\.md$/, ''),
@@ -335,24 +346,39 @@ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
335
346
  */
336
347
  function normalizeLocal(snapshotDir, config) {
337
348
  const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
338
- const milestonesKeyed = !!(config.sync.keyedFields && config.sync.keyedFields.milestones)
349
+ // Phases sync as first-class Milestones whenever `milestones` is in the pushed
350
+ // projection, so strip the `## Phases` index from the description to avoid
351
+ // duplicating it (as prose AND as milestones) in the Linear mirror.
352
+ const milestonesProjected = !!(config.sync.fieldOwnership && 'milestones' in config.sync.fieldOwnership)
339
353
  const extracted = {
340
354
  description: buildDescription(
341
355
  title,
342
356
  sections,
343
357
  config.sync.localOnlySections,
344
- milestonesKeyed ? ['Phases'] : [],
358
+ milestonesProjected ? ['Phases'] : [],
345
359
  ),
346
- // Keyed milestone items: a phase's linked milestone id (or null when
347
- // unlinked), its title and goal. Unlinked phases carry id:null and are
348
- // skipped by the keyed compare until they're linked.
360
+ // Milestone projection items. `ref` is the phase-file basename the local
361
+ // handle the push skill stamps a newly-created milestone id back into.
349
362
  milestones: phases
350
363
  .filter((p) => p.name)
351
- .map((p) => ({ id: p.id, name: p.name, goal: p.goal })),
352
- // Keyed task items across all phases: inline issue id (or null), text, done.
353
- // Deliberately just {id,text,done} so it hashes equal to a remote issue — the
354
- // owning phase is recovered at push time by locating the task line.
355
- tasks: phases.flatMap((p) => p.tasks.map(parseTaskLine).filter(Boolean)),
364
+ .map((p) => ({ id: p.id, ref: p.phase, name: p.name, goal: p.goal })),
365
+ // Issue projection items across all phases. `title` is the first-sentence
366
+ // Linear title; `description` is the full task text (the mirror keeps both).
367
+ // `ref` = the collapsed text the push skill matches to stamp a new id in;
368
+ // `milestoneRef` links the issue to its milestone (id if linked, else phase).
369
+ tasks: phases.flatMap((p) =>
370
+ p.tasks
371
+ .map(parseTaskLine)
372
+ .filter(Boolean)
373
+ .map((t) => ({
374
+ id: t.id,
375
+ ref: t.text,
376
+ title: titleFromText(t.text),
377
+ description: t.text,
378
+ done: t.done,
379
+ milestoneRef: p.id || p.phase,
380
+ })),
381
+ ),
356
382
  phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
357
383
  acceptanceCriteria: sections['Acceptance criteria'] || null,
358
384
  taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
@@ -399,87 +425,65 @@ function remoteStateName(project) {
399
425
  return st
400
426
  }
401
427
 
402
- // Real Linear priority is an object `{ value, name }`; accept a bare number too.
403
- function remotePriority(priority) {
404
- if (priority == null) return null
405
- if (typeof priority === 'object') return priority.value != null ? priority.value : null
406
- return priority
407
- }
408
-
409
- // Real Linear labels are `[{ id, name }]`; accept bare strings too.
410
- function remoteLabels(labels) {
411
- if (!Array.isArray(labels)) return []
412
- return labels
413
- .map((l) => (typeof l === 'string' ? l : l && l.name != null ? l.name : null))
414
- .filter((n) => n != null)
428
+ // The ONE thing one-way sync reads back: the mirror's current workflow state,
429
+ // mapped to the local lifecycle bucket, so `/spec-status` can report a drift
430
+ // ("Linear says Done, your spec says In Progress"). Read-only — it never writes.
431
+ function remoteWorkflowState(project, config) {
432
+ const name = remoteStateName(project || {})
433
+ return name != null ? bucketForState(name, config) : null
415
434
  }
416
435
 
417
- // Whether a remote Linear issue is complete. The real MCP shape is a flat
418
- // `statusType` ("completed") accept the legacy `state.type` and `completedAt`
419
- // / bare `done` too (fixtures, older shapes).
420
- function remoteIssueDone(iss) {
421
- if (iss.done === true) return true
422
- const type = iss.statusType != null ? iss.statusType : iss.state && iss.state.type
423
- if (type != null) return String(type).toLowerCase() === 'completed'
424
- return iss.completedAt != null
436
+ // Derive a short Linear issue title from a task's full text: the first sentence,
437
+ // falling back to the first `max` chars at a word boundary. A paragraph-length
438
+ // task keeps its full text as the issue *description* (see the projection); this
439
+ // is only the title, so it must read as a one-liner. Guards decimals/versions
440
+ // (`7.0.2`) and common abbreviations (`e.g.`, `i.e.`, `etc.`) so it doesn't cut
441
+ // mid-number or mid-abbreviation.
442
+ function titleFromText(text, max = 100) {
443
+ const s = collapse(text)
444
+ if (!s) return s
445
+ let title = s
446
+ const re = /[.!?]/g
447
+ let m
448
+ while ((m = re.exec(s)) !== null) {
449
+ const i = m.index
450
+ const after = s[i + 1]
451
+ if (after !== undefined && !/\s/.test(after)) continue // not a sentence end
452
+ if (/\d/.test(s[i - 1] || '') && /\d/.test(after || '')) continue // 7.0.2, 3.14
453
+ if (/(^|\s)(e\.g|i\.e|etc|vs|no|fig|cf)$/i.test(s.slice(0, i))) continue // abbrev
454
+ title = s.slice(0, i) // drop the terminator
455
+ break
456
+ }
457
+ title = title.trim()
458
+ if (title.length > max) {
459
+ const cut = title.slice(0, max)
460
+ const sp = cut.lastIndexOf(' ')
461
+ title = (sp > 40 ? cut.slice(0, sp) : cut).trim()
462
+ }
463
+ return title
425
464
  }
426
465
 
427
- /**
428
- * Normalize a remote Project projection (from the MCP adapter, or a fixture)
429
- * into the same field set as `normalizeLocal`.
430
- */
431
- function normalizeRemote(project, config) {
432
- const p = project || {}
433
- const milestones = Array.isArray(p.milestones) ? p.milestones : []
434
- const stateName = remoteStateName(p)
435
- const extracted = {
436
- description: p.description != null ? canonicalizeMarkdown(p.description) : null,
437
- // Keyed milestone items mirroring normalizeLocal: id, title, goal (the Linear
438
- // milestone's description). Progress is Linear-derived and not synced.
439
- milestones: milestones.map((m) => ({
440
- id: m.id != null ? String(m.id) : null,
441
- name: m.name,
442
- // A milestone's description mirrors the phase's `**Goal:**` line; strip the
443
- // label so it hashes equal to the local goal (which readPhaseFiles already
444
- // captures without it).
445
- goal: (m.description != null ? m.description : '')
446
- .replace(/^\s*\*\*Goal:\*\*\s*/, '')
447
- .trim(),
448
- })),
449
- phaseBodies: milestones.map((m) => ({
450
- phase: m.name,
451
- goal: (m.description != null ? m.description : '').trim(),
452
- })),
453
- acceptanceCriteria: p.acceptanceCriteria != null ? p.acceptanceCriteria : null,
454
- // Keyed task items from the project's issues: keyed by the human identifier
455
- // (SKI-123, what the inline task-line id carries — the Linear MCP returns it as
456
- // the issue's `id`), text ← title, done ← a completed-type workflow state.
457
- tasks: (Array.isArray(p.issues) ? p.issues : []).map((iss) => ({
458
- id: iss.identifier != null ? String(iss.identifier) : iss.id != null ? String(iss.id) : null,
459
- text: iss.title != null ? iss.title : '',
460
- done: remoteIssueDone(iss),
461
- })),
462
- taskBreakdown: milestones.map((m) => ({
463
- phase: m.name,
464
- tasks: Array.isArray(m.tasks) ? m.tasks : [],
465
- })),
466
- workflowState: stateName != null ? bucketForState(stateName, config) : null,
467
- priority: remotePriority(p.priority),
468
- labels: remoteLabels(p.labels),
469
- }
470
- return toFieldSet(extracted, config)
466
+ // Which configured state NAMES are absent from the live workspace. The skill
467
+ // fetches the workspace's project-status names over MCP and passes them here;
468
+ // a non-empty result means a typo/rename that Linear would silently no-op.
469
+ function validateStates(config, workspaceStates) {
470
+ const configured = Object.values((config && config.states) || {}).filter((v) => typeof v === 'string')
471
+ const have = new Set((workspaceStates || []).map((s) => String(s).toLowerCase().trim()))
472
+ return configured.filter((name) => !have.has(name.toLowerCase().trim()))
471
473
  }
472
474
 
473
475
  module.exports = {
474
476
  normalizeLocal,
475
- normalizeRemote,
476
477
  readSnapshot,
477
478
  parseFrontmatter,
478
479
  parseSections,
479
480
  parsePhaseIndex,
480
481
  parseTaskLine,
482
+ titleFromText,
483
+ validateStates,
481
484
  canonicalRemoteStatus,
482
485
  canonicalizeMarkdown,
483
486
  joinEmphasisAcrossBreaks,
484
487
  bucketForState,
488
+ remoteWorkflowState,
485
489
  }