@skitterbyte/skitterspec-linear 3.1.0 → 4.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.
@@ -52,6 +52,94 @@ function rawStatus(localH, remoteH, baseH) {
52
52
  return 'conflict'
53
53
  }
54
54
 
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 }
109
+ }
110
+
111
+ /**
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.
115
+ */
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
+ })
139
+ }
140
+ return items
141
+ }
142
+
55
143
  // Collapse the raw status through ownership into an effective status + flags.
56
144
  function collapse(raw, ownership) {
57
145
  const canPush = ownership === 'both' || ownership === 'push'
@@ -78,9 +166,36 @@ function collapse(raw, ownership) {
78
166
  */
79
167
  function classify(local, remote, base, config) {
80
168
  const ownership = config.sync.fieldOwnership
169
+ const keyed = (config.sync && config.sync.keyedFields) || {}
81
170
  const baseObj = base || {}
82
171
  return Object.keys(ownership).map((field) => {
83
172
  const own = ownership[field]
173
+ const idKey = keyed[field]
174
+
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
+ }
196
+ }
197
+
198
+ // Scalar / whole-field (behaviour unchanged).
84
199
  const localH = hashField(local ? local[field] : null)
85
200
  const remoteH = hashField(remote ? remote[field] : null)
86
201
  const baseH = hashField(field in baseObj ? baseObj[field] : null)
@@ -92,6 +207,7 @@ function classify(local, remote, base, config) {
92
207
 
93
208
  module.exports = {
94
209
  classify,
210
+ classifyItems,
95
211
  hashField,
96
212
  stableStringify,
97
213
  rawStatus,
@@ -82,6 +82,25 @@ function parseSections(body) {
82
82
  return { title, sections }
83
83
  }
84
84
 
85
+ // Canonicalise markdown so semantically-equal content hashes equal across the
86
+ // boundary. Linear reserializes markdown on save (authored `-` bullets come back
87
+ // as `*`, trailing whitespace trimmed, blank runs collapsed), so without this a
88
+ // clean push→pull would report `description` as perpetually changed. Applied to
89
+ // the description on BOTH sides. Conservative: only unifies list markers and
90
+ // whitespace — the transforms actually observed from Linear.
91
+ function canonicalizeMarkdown(text) {
92
+ if (text == null) return text
93
+ return String(text)
94
+ .replace(/\r\n/g, '\n')
95
+ .split('\n')
96
+ // Unordered-list marker at line start (`*`/`+`/`-`) → `-`. Requires a space
97
+ // after the marker so bold/emphasis (`**Goal:**`) is untouched.
98
+ .map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
99
+ .join('\n')
100
+ .replace(/\n{3,}/g, '\n\n')
101
+ .trim()
102
+ }
103
+
85
104
  // Canonical milestone status from the phase-index emoji.
86
105
  const EMOJI_STATUS = { '⬜': 'not-started', '🔄': 'in-progress', '✅': 'done' }
87
106
 
@@ -102,7 +121,37 @@ function parsePhaseIndex(phasesSection) {
102
121
  return rows
103
122
  }
104
123
 
105
- // Read the phase files (01-*.md, 02-*.md …) in execution order.
124
+ // The phase's descriptive title: the h1 with the "Phase N " prefix and any
125
+ // trailing status emoji stripped (so it matches its Linear Milestone name).
126
+ function phaseTitle(body) {
127
+ const h1 = /^#\s+(.*)$/m.exec(body)
128
+ if (!h1) return null
129
+ const t = h1[1]
130
+ .replace(/\s*[⬜🔄✅]\s*$/u, '')
131
+ .replace(/^Phase\s+\d+\s*[—–-]\s*/i, '')
132
+ .trim()
133
+ return t || null
134
+ }
135
+
136
+ // Parse a task line (already stripped of its leading "- ") into a keyed item:
137
+ // its checkbox state, its text, and the inline Linear issue identifier if present
138
+ // (`… (SKI-123)`). Returns null for a non-task line.
139
+ function parseTaskLine(line) {
140
+ const m = /^\[([ xX])\]\s*(.*)$/.exec(line)
141
+ if (!m) return null
142
+ const done = m[1].toLowerCase() === 'x'
143
+ let text = m[2].trim()
144
+ let id = null
145
+ const idm = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/.exec(text)
146
+ if (idm) {
147
+ id = idm[1]
148
+ text = text.slice(0, idm.index).trim()
149
+ }
150
+ return { id, text, done }
151
+ }
152
+
153
+ // Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
154
+ // linked milestone id (from optional frontmatter), title, goal and tasks.
106
155
  function readPhaseFiles(snapshotDir) {
107
156
  let entries
108
157
  try {
@@ -115,11 +164,19 @@ function readPhaseFiles(snapshotDir) {
115
164
  .sort()
116
165
  .map((file) => {
117
166
  const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
118
- const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(raw) || [])[1] || ''
119
- const tasks = (raw.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
167
+ const { data, body } = parseFrontmatter(raw)
168
+ const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(body) || [])[1] || ''
169
+ const tasks = (body.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
120
170
  t.replace(/^-\s*/, '').trim(),
121
171
  )
122
- return { phase: file.replace(/\.md$/, ''), goal: goal.trim(), tasks }
172
+ return {
173
+ phase: file.replace(/\.md$/, ''),
174
+ file,
175
+ id: data.linear_milestone_id != null ? String(data.linear_milestone_id) : null,
176
+ name: phaseTitle(body),
177
+ goal: goal.trim(),
178
+ tasks,
179
+ }
123
180
  })
124
181
  }
125
182
 
@@ -151,16 +208,18 @@ function readSnapshot(snapshotDir, config) {
151
208
  }
152
209
 
153
210
  // Build the pushed description: the overview prose with local-only sections
154
- // removed. Keeps the title line for context.
155
- function buildDescription(title, sections, localOnlySections) {
156
- const skip = new Set(localOnlySections || [])
211
+ // removed. Keeps the title line for context. `extraSkip` drops additional
212
+ // sections (e.g. "Phases" once milestones sync as first-class Linear objects, so
213
+ // the phase list isn't duplicated in the description — Decision 5).
214
+ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
215
+ const skip = new Set([...(localOnlySections || []), ...extraSkip])
157
216
  const parts = []
158
217
  if (title) parts.push(`# ${title}`)
159
218
  for (const [heading, content] of Object.entries(sections)) {
160
219
  if (skip.has(heading)) continue
161
220
  parts.push(`## ${heading}\n\n${content}`.trim())
162
221
  }
163
- return parts.join('\n\n').trim() || null
222
+ return canonicalizeMarkdown(parts.join('\n\n')) || null
164
223
  }
165
224
 
166
225
  /**
@@ -168,9 +227,24 @@ function buildDescription(title, sections, localOnlySections) {
168
227
  */
169
228
  function normalizeLocal(snapshotDir, config) {
170
229
  const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
230
+ const milestonesKeyed = !!(config.sync.keyedFields && config.sync.keyedFields.milestones)
171
231
  const extracted = {
172
- description: buildDescription(title, sections, config.sync.localOnlySections),
173
- milestones: parsePhaseIndex(sections.Phases),
232
+ description: buildDescription(
233
+ title,
234
+ sections,
235
+ config.sync.localOnlySections,
236
+ milestonesKeyed ? ['Phases'] : [],
237
+ ),
238
+ // Keyed milestone items: a phase's linked milestone id (or null when
239
+ // unlinked), its title and goal. Unlinked phases carry id:null and are
240
+ // skipped by the keyed compare until they're linked.
241
+ milestones: phases
242
+ .filter((p) => p.name)
243
+ .map((p) => ({ id: p.id, name: p.name, goal: p.goal })),
244
+ // Keyed task items across all phases: inline issue id (or null), text, done.
245
+ // Deliberately just {id,text,done} so it hashes equal to a remote issue — the
246
+ // owning phase is recovered at push time by locating the task line.
247
+ tasks: phases.flatMap((p) => p.tasks.map(parseTaskLine).filter(Boolean)),
174
248
  phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
175
249
  acceptanceCriteria: sections['Acceptance criteria'] || null,
176
250
  taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
@@ -208,31 +282,82 @@ function canonicalRemoteStatus(state) {
208
282
  return s
209
283
  }
210
284
 
285
+ // The real Linear projection carries the project's workflow state in `status`
286
+ // (an object `{ name, type }`); accept a bare string / legacy `state` too.
287
+ function remoteStateName(project) {
288
+ const st = project.status != null ? project.status : project.state
289
+ if (st == null) return null
290
+ if (typeof st === 'object') return st.name != null ? st.name : st.type != null ? st.type : null
291
+ return st
292
+ }
293
+
294
+ // Real Linear priority is an object `{ value, name }`; accept a bare number too.
295
+ function remotePriority(priority) {
296
+ if (priority == null) return null
297
+ if (typeof priority === 'object') return priority.value != null ? priority.value : null
298
+ return priority
299
+ }
300
+
301
+ // Real Linear labels are `[{ id, name }]`; accept bare strings too.
302
+ function remoteLabels(labels) {
303
+ if (!Array.isArray(labels)) return []
304
+ return labels
305
+ .map((l) => (typeof l === 'string' ? l : l && l.name != null ? l.name : null))
306
+ .filter((n) => n != null)
307
+ }
308
+
309
+ // Whether a remote Linear issue is complete. The real MCP shape is a flat
310
+ // `statusType` ("completed") — accept the legacy `state.type` and `completedAt`
311
+ // / bare `done` too (fixtures, older shapes).
312
+ function remoteIssueDone(iss) {
313
+ if (iss.done === true) return true
314
+ const type = iss.statusType != null ? iss.statusType : iss.state && iss.state.type
315
+ if (type != null) return String(type).toLowerCase() === 'completed'
316
+ return iss.completedAt != null
317
+ }
318
+
211
319
  /**
212
- * Normalize a remote Project projection (from the Phase 2 MCP adapter, or a
213
- * fixture) into the same field set as `normalizeLocal`.
320
+ * Normalize a remote Project projection (from the MCP adapter, or a fixture)
321
+ * into the same field set as `normalizeLocal`.
214
322
  */
215
323
  function normalizeRemote(project, config) {
216
324
  const p = project || {}
217
325
  const milestones = Array.isArray(p.milestones) ? p.milestones : []
326
+ const stateName = remoteStateName(p)
218
327
  const extracted = {
219
- description: p.description != null ? p.description : null,
328
+ description: p.description != null ? canonicalizeMarkdown(p.description) : null,
329
+ // Keyed milestone items mirroring normalizeLocal: id, title, goal (the Linear
330
+ // milestone's description). Progress is Linear-derived and not synced.
220
331
  milestones: milestones.map((m) => ({
332
+ id: m.id != null ? String(m.id) : null,
221
333
  name: m.name,
222
- status: canonicalRemoteStatus(m.status != null ? m.status : m.state),
334
+ // A milestone's description mirrors the phase's `**Goal:**` line; strip the
335
+ // label so it hashes equal to the local goal (which readPhaseFiles already
336
+ // captures without it).
337
+ goal: (m.description != null ? m.description : '')
338
+ .replace(/^\s*\*\*Goal:\*\*\s*/, '')
339
+ .trim(),
223
340
  })),
224
341
  phaseBodies: milestones.map((m) => ({
225
342
  phase: m.name,
226
343
  goal: (m.description != null ? m.description : '').trim(),
227
344
  })),
228
345
  acceptanceCriteria: p.acceptanceCriteria != null ? p.acceptanceCriteria : null,
346
+ // Keyed task items from the project's issues: keyed by the human identifier
347
+ // (SKI-123, what the inline task-line id carries — the Linear MCP returns it as
348
+ // the issue's `id`), text ← title, done ← a completed-type workflow state.
349
+ tasks: (Array.isArray(p.issues) ? p.issues : []).map((iss) => ({
350
+ id: iss.identifier != null ? String(iss.identifier) : iss.id != null ? String(iss.id) : null,
351
+ text: iss.title != null ? iss.title : '',
352
+ done: remoteIssueDone(iss),
353
+ })),
229
354
  taskBreakdown: milestones.map((m) => ({
230
355
  phase: m.name,
231
356
  tasks: Array.isArray(m.tasks) ? m.tasks : [],
232
357
  })),
233
- workflowState: p.state != null ? bucketForState(p.state, config) : null,
234
- priority: p.priority != null ? p.priority : null,
235
- labels: Array.isArray(p.labels) ? p.labels : [],
358
+ workflowState: stateName != null ? bucketForState(stateName, config) : null,
359
+ priority: remotePriority(p.priority),
360
+ labels: remoteLabels(p.labels),
236
361
  }
237
362
  return toFieldSet(extracted, config)
238
363
  }
@@ -244,6 +369,8 @@ module.exports = {
244
369
  parseFrontmatter,
245
370
  parseSections,
246
371
  parsePhaseIndex,
372
+ parseTaskLine,
247
373
  canonicalRemoteStatus,
374
+ canonicalizeMarkdown,
248
375
  bucketForState,
249
376
  }
@@ -18,9 +18,23 @@
18
18
  const { normalizeLocal, normalizeRemote } = require('./normalize.js')
19
19
  const { classify } = require('./compare.js')
20
20
  const { readBase, writeBase, backup } = require('./base.js')
21
- const { writeFrontmatter } = require('./write.js')
21
+ const { writeFrontmatter, applyMilestonesPull, applyTasksPull } = require('./write.js')
22
22
  const { frontmatterPatchFor } = require('./apply.js')
23
23
 
24
+ // Collect the conflicting units across scalar (field-level) and keyed
25
+ // (item-level) fields, as stable labels for the refusal message.
26
+ function collectConflicts(fields) {
27
+ const out = []
28
+ for (const f of fields) {
29
+ if (f.keyed) {
30
+ for (const it of f.items) if (it.status === 'conflict') out.push(`${f.field}#${it.id}`)
31
+ } else if (f.status === 'conflict') {
32
+ out.push(f.field)
33
+ }
34
+ }
35
+ return out
36
+ }
37
+
24
38
  async function pull({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
25
39
  const local = normalizeLocal(snapshotDir, config)
26
40
  const remoteRaw = await adapter.readProject(projectId)
@@ -31,40 +45,54 @@ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config,
31
45
  const base = readBase(dir, identifier, config)
32
46
  const fields = classify(local, remote, base, config)
33
47
 
34
- const conflicts = fields.filter((f) => f.status === 'conflict').map((f) => f.field)
48
+ const conflicts = collectConflicts(fields)
35
49
  if (conflicts.length && !force) {
36
50
  return {
37
51
  ok: false,
38
52
  blocked: true,
39
53
  reason: 'conflict',
40
54
  conflicts,
41
- message: `pull refused — ${conflicts.length} field(s) changed on both sides: ` +
55
+ message: `pull refused — ${conflicts.length} unit(s) changed on both sides: ` +
42
56
  `${conflicts.join(', ')}. Resolve locally or re-run with --force (remote wins).`,
43
57
  }
44
58
  }
45
59
 
46
- // Everything remote wants to write down: remote-only fields, plus (under force)
47
- // both-conflict fields where remote wins.
48
- const pullFields = fields.filter((f) => f.pullable)
49
- const fieldValues = {}
50
- for (const f of pullFields) fieldValues[f.field] = remote[f.field]
51
-
52
- const { patch, applied, deferred } = frontmatterPatchFor(fieldValues, config)
53
-
54
60
  // --force overwrites local edits — back the local side up first.
55
61
  let backupPath = null
56
62
  if (force) {
57
63
  backupPath = backup('local', dir, identifier, config, { timestamp, data: local })
58
64
  }
59
65
 
60
- // Apply frontmatter-mapped fields + stamp the sync.
66
+ // Keyed body fields (e.g. milestones) — apply per-item via the denormalizer,
67
+ // which writes/creates the matching phase files. Removals are report-only.
68
+ const keyedApplied = []
69
+ const keyedCreated = []
70
+ const keyedReported = []
71
+ for (const f of fields) {
72
+ if (!f.keyed) continue
73
+ const apply = f.field === 'tasks' ? applyTasksPull : applyMilestonesPull
74
+ const res = apply(snapshotDir, f.items)
75
+ if (res.applied.length || res.created.length) keyedApplied.push(f.field)
76
+ keyedCreated.push(...res.created)
77
+ keyedReported.push(...res.reported.map((id) => `${f.field}#${id}`))
78
+ }
79
+
80
+ // Scalar pull-owned fields → frontmatter (keyed fields handled above).
81
+ const scalarPull = fields.filter((f) => f.pullable && !f.keyed)
82
+ const fieldValues = {}
83
+ for (const f of scalarPull) fieldValues[f.field] = remote[f.field]
84
+ const { patch, applied, deferred } = frontmatterPatchFor(fieldValues, config)
85
+
61
86
  if (applied.length || timestamp) {
62
87
  writeFrontmatter(snapshotDir, config, { ...patch, last_synced_at: timestamp })
63
88
  }
64
89
 
65
- // Advance base only for reconciled fields; deferred (body) fields keep the
66
- // local value as base so the remote edit stays pending, not marked synced.
67
- const newBase = { ...local }
90
+ // Re-normalize local so the base reflects the phase-file writes we just made,
91
+ // then advance base: scalar-applied fields take the remote value; keyed fields
92
+ // take the (now-updated) local value so applied items read in-sync and any
93
+ // report-only removal stays pending.
94
+ const newLocal = normalizeLocal(snapshotDir, config)
95
+ const newBase = { ...newLocal }
68
96
  for (const field of applied) newBase[field] = remote[field]
69
97
  newBase.__meta = { updatedAt: remoteRaw.updatedAt || null, syncedAt: timestamp }
70
98
  const basePath = writeBase(dir, identifier, config, newBase)
@@ -74,10 +102,13 @@ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config,
74
102
  blocked: false,
75
103
  applied,
76
104
  deferred,
105
+ keyedApplied,
106
+ keyedCreated,
107
+ keyedReported,
77
108
  conflictsForced: force ? conflicts : [],
78
109
  backupPath,
79
110
  basePath,
80
- pulled: pullFields.map((f) => f.field),
111
+ pulled: [...scalarPull.map((f) => f.field), ...keyedApplied],
81
112
  }
82
113
  }
83
114
 
@@ -28,16 +28,22 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
28
28
  }
29
29
  const remote = normalizeRemote(remoteRaw, config)
30
30
  const base = readBase(dir, identifier, config)
31
- const baseStamp = base && base.__meta ? base.__meta.updatedAt : null
32
31
  const fields = classify(local, remote, base, config)
33
32
 
34
- // Remote moved past base if the classifier sees remote-side divergence OR the
35
- // recorded updatedAt no longer matches (a change we can't even see as a field).
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.
36
40
  const remoteDivergedFields = fields
37
- .filter((f) => f.raw === 'remote-only' || f.raw === 'conflict')
41
+ .filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
38
42
  .map((f) => f.field)
39
- const stampMoved = baseStamp != null && remoteRaw.updatedAt !== baseStamp
40
- const moved = remoteDivergedFields.length > 0 || stampMoved
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
41
47
 
42
48
  if (moved && !force) {
43
49
  return {
@@ -52,8 +58,37 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
52
58
  }
53
59
  }
54
60
 
55
- const pushFields = fields.filter((f) => f.pushable)
56
- if (!pushFields.length && !force) {
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) {
57
92
  return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
58
93
  }
59
94
 
@@ -76,7 +111,9 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
76
111
 
77
112
  const updates = {}
78
113
  for (const f of pushFields) updates[f.field] = local[f.field]
79
- const updated = (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
114
+ const updated = Object.keys(updates).length
115
+ ? (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
116
+ : remoteRaw2 || remoteRaw
80
117
  const updatedRemote = normalizeRemote(updated, config)
81
118
 
82
119
  // Reconciled base: local is the source of truth for the fields we pushed (and
@@ -95,8 +132,12 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
95
132
  ok: true,
96
133
  blocked: false,
97
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 } : {}),
98
139
  skipped: fields
99
- .filter((f) => !f.pushable && f.status !== 'unchanged')
140
+ .filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
100
141
  .map((f) => f.field),
101
142
  backupPath,
102
143
  basePath,