@skitterbyte/skitterspec-linear 3.4.0 → 5.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.
package/src/prompts.js CHANGED
@@ -53,4 +53,40 @@ async function confirmRemoveReleaseTooling() {
53
53
  return Boolean(ans.remove)
54
54
  }
55
55
 
56
- module.exports = { promptSetup, confirmRemoveReleaseTooling }
56
+ /**
57
+ * Interactive choice when `init` finds an already-set-up repo. Returns one of
58
+ * `leave` | `resync` | `reset`. Defaults to (and cancels to) `leave` — the safe
59
+ * no-op — and asks a second confirm before `reset` (destructive to managed files).
60
+ */
61
+ async function promptExistingSetup() {
62
+ const prompts = require('prompts')
63
+ const { action } = await prompts(
64
+ {
65
+ type: 'select',
66
+ name: 'action',
67
+ message: 'This project already has skitterspec set up. What would you like to do?',
68
+ initial: 0,
69
+ choices: [
70
+ { title: 'Leave alone — make no changes', value: 'leave' },
71
+ { title: 'Resync — update managed files to the latest, keep my edits', value: 'resync' },
72
+ { title: 'Start again — reset the scaffolding (your specs & config are kept)', value: 'reset' },
73
+ ],
74
+ },
75
+ { onCancel: () => {} },
76
+ )
77
+ if (action === 'reset') {
78
+ const { confirm } = await prompts(
79
+ {
80
+ type: 'confirm',
81
+ name: 'confirm',
82
+ message: 'Start again overwrites managed skills/rules. Your specs and config are untouched. Continue?',
83
+ initial: false,
84
+ },
85
+ { onCancel: () => false },
86
+ )
87
+ if (!confirm) return 'leave'
88
+ }
89
+ return action || 'leave'
90
+ }
91
+
92
+ module.exports = { promptSetup, confirmRemoveReleaseTooling, promptExistingSetup }
@@ -112,6 +112,17 @@ function specSyncStatus(dir, config, specArg, flags = {}) {
112
112
  out.push(` ${f.status.padEnd(12)} ${f.field.padEnd(18)} (${f.ownership}, ${dir_})`)
113
113
  }
114
114
  }
115
+ // Deletions are never auto-applied (Decision 7) — surface them for the operator
116
+ // to resolve by hand: a removed keyed item on either side.
117
+ const removed = []
118
+ for (const f of fields) {
119
+ if (!f.keyed) continue
120
+ for (const it of f.items) if (it.report) removed.push(`${f.field}#${it.id} (removed in ${it.side})`)
121
+ }
122
+ if (removed.length) {
123
+ out.push(' needs manual resolution — removed, not auto-applied:')
124
+ for (const r of removed) out.push(` ${r}`)
125
+ }
115
126
  process.stdout.write(out.join('\n') + '\n')
116
127
  }
117
128
 
@@ -157,11 +168,31 @@ function printSyncResult(kind, result) {
157
168
  } else {
158
169
  out.push(`spec-sync ${kind}: ok`)
159
170
  if (kind === 'pull') {
171
+ const keyedApplied = result.keyedApplied || []
172
+ const keyedCreated = result.keyedCreated || []
173
+ const keyedReported = result.keyedReported || []
160
174
  if (result.applied.length) out.push(` applied: ${result.applied.join(', ')}`)
175
+ if (keyedApplied.length) out.push(` updated: ${keyedApplied.join(', ')} (phase files)`)
176
+ if (keyedCreated.length) out.push(` created: ${keyedCreated.map((c) => c.file).join(', ')}`)
177
+ if (keyedReported.length) out.push(` removed: ${keyedReported.join(', ')} (in Linear — resolve manually)`)
161
178
  if (result.deferred.length) out.push(` deferred: ${result.deferred.join(', ')} (body write-back — manual)`)
162
- if (!result.applied.length && !result.deferred.length) out.push(' nothing to pull — up to date')
179
+ if (
180
+ !result.applied.length &&
181
+ !keyedApplied.length &&
182
+ !keyedCreated.length &&
183
+ !keyedReported.length &&
184
+ !result.deferred.length
185
+ ) {
186
+ out.push(' nothing to pull — up to date')
187
+ }
163
188
  } else {
164
189
  if (result.written && result.written.length) out.push(` written: ${result.written.join(', ')}`)
190
+ const mp = result.milestonesPush
191
+ if (mp && mp.create.length) out.push(` milestones create: ${mp.create.map((m) => m.name).join(', ')} (skill applies via MCP)`)
192
+ if (mp && mp.update.length) out.push(` milestones update: ${mp.update.map((m) => m.id).join(', ')} (skill applies via MCP)`)
193
+ const ip = result.issuesPush
194
+ if (ip && ip.create.length) out.push(` issues create: ${ip.create.length} (skill applies via MCP)`)
195
+ if (ip && ip.update.length) out.push(` issues update: ${ip.update.map((i) => i.id).join(', ')} (skill applies via MCP)`)
165
196
  if (result.skipped && result.skipped.length) out.push(` skipped: ${result.skipped.join(', ')} (not pushable)`)
166
197
  if (result.note) out.push(` ${result.note}`)
167
198
  }
@@ -63,6 +63,11 @@ const DEFAULT_CONFIG = Object.freeze({
63
63
  labels: 'pull',
64
64
  }),
65
65
  localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
66
+ // Fields that are keyed collections (arrays of objects with a stable id),
67
+ // compared/merged per item rather than as one opaque value. Map field name →
68
+ // the item's id property. Empty by default — a workspace opts a field in
69
+ // (e.g. { milestones: "id", tasks: "id" }) once the body round-trip is wired.
70
+ keyedFields: Object.freeze({}),
66
71
  }),
67
72
  })
68
73
 
@@ -83,6 +88,7 @@ function defaults() {
83
88
  backupDir: DEFAULT_CONFIG.sync.backupDir,
84
89
  fieldOwnership: { ...DEFAULT_CONFIG.sync.fieldOwnership },
85
90
  localOnlySections: [...DEFAULT_CONFIG.sync.localOnlySections],
91
+ keyedFields: { ...DEFAULT_CONFIG.sync.keyedFields },
86
92
  },
87
93
  }
88
94
  }
@@ -115,6 +121,21 @@ function mergeFieldOwnership(base, parsed) {
115
121
  }
116
122
  }
117
123
 
124
+ // Merge (and validate) sync.keyedFields. Each value is the item's id property
125
+ // name (a non-empty string); a field listed here is compared per item.
126
+ function mergeKeyedFields(base, parsed) {
127
+ if (!isObject(parsed)) return
128
+ for (const [field, idKey] of Object.entries(parsed)) {
129
+ if (typeof idKey !== 'string' || !idKey.trim()) {
130
+ throw new Error(
131
+ `Invalid ${CONFIG_FILE}: sync.keyedFields.${field} = ${JSON.stringify(idKey)} ` +
132
+ '(expected the item id property name, a non-empty string)',
133
+ )
134
+ }
135
+ base[field] = idKey.trim()
136
+ }
137
+ }
138
+
118
139
  /**
119
140
  * Merge a parsed config over the defaults. Only known keys are copied (unknown
120
141
  * keys ignored for forward-compat). Nested objects are merged field-by-field.
@@ -152,6 +173,7 @@ function mergeConfig(base, parsed) {
152
173
  assign(base.sync, parsed.sync, 'baseDir', 'string')
153
174
  assign(base.sync, parsed.sync, 'backupDir', 'string')
154
175
  mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
176
+ mergeKeyedFields(base.sync.keyedFields, parsed.sync.keyedFields)
155
177
  if (Array.isArray(parsed.sync.localOnlySections)) {
156
178
  base.sync.localOnlySections = parsed.sync.localOnlySections
157
179
  .filter((s) => typeof s === 'string' && s.trim())
@@ -102,6 +102,12 @@ function makeAdapter(callTool, resolved) {
102
102
  async updateProject(id, updates) {
103
103
  return callTool(need('projectUpdate'), { id, ...updates })
104
104
  },
105
+ // List a project's milestones (the pull read side). Most Linear reads also
106
+ // return milestones inline on the project via includeMilestones — this is the
107
+ // explicit list op for callers that need it on its own.
108
+ async listMilestones(projectId) {
109
+ return callTool(need('milestoneList'), { project: projectId })
110
+ },
105
111
  // `save_milestone` requires the owning `project`; upserts on `id`.
106
112
  async createMilestone(projectId, milestone) {
107
113
  return callTool(need('milestoneCreate'), { project: projectId, ...milestone })
@@ -109,6 +115,17 @@ function makeAdapter(callTool, resolved) {
109
115
  async updateMilestone(projectId, id, updates) {
110
116
  return callTool(need('milestoneUpdate'), { project: projectId, id, ...updates })
111
117
  },
118
+ // Issues (tasks). List the project's issues (pull read side); `save_issue`
119
+ // upserts on `id`, attached to the project (and optionally a milestone).
120
+ async listIssues(projectId) {
121
+ return callTool(need('issueList'), { project: projectId })
122
+ },
123
+ async createIssue(projectId, issue) {
124
+ return callTool(need('issueCreate'), { project: projectId, ...issue })
125
+ },
126
+ async updateIssue(id, updates) {
127
+ return callTool(need('issueUpdate'), { id, ...updates })
128
+ },
112
129
  }
113
130
  }
114
131
 
@@ -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,
@@ -121,7 +121,37 @@ function parsePhaseIndex(phasesSection) {
121
121
  return rows
122
122
  }
123
123
 
124
- // 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.
125
155
  function readPhaseFiles(snapshotDir) {
126
156
  let entries
127
157
  try {
@@ -134,11 +164,19 @@ function readPhaseFiles(snapshotDir) {
134
164
  .sort()
135
165
  .map((file) => {
136
166
  const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
137
- const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(raw) || [])[1] || ''
138
- 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) =>
139
170
  t.replace(/^-\s*/, '').trim(),
140
171
  )
141
- 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
+ }
142
180
  })
143
181
  }
144
182
 
@@ -170,9 +208,11 @@ function readSnapshot(snapshotDir, config) {
170
208
  }
171
209
 
172
210
  // Build the pushed description: the overview prose with local-only sections
173
- // removed. Keeps the title line for context.
174
- function buildDescription(title, sections, localOnlySections) {
175
- 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])
176
216
  const parts = []
177
217
  if (title) parts.push(`# ${title}`)
178
218
  for (const [heading, content] of Object.entries(sections)) {
@@ -187,9 +227,24 @@ function buildDescription(title, sections, localOnlySections) {
187
227
  */
188
228
  function normalizeLocal(snapshotDir, config) {
189
229
  const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
230
+ const milestonesKeyed = !!(config.sync.keyedFields && config.sync.keyedFields.milestones)
190
231
  const extracted = {
191
- description: buildDescription(title, sections, config.sync.localOnlySections),
192
- 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)),
193
248
  phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
194
249
  acceptanceCriteria: sections['Acceptance criteria'] || null,
195
250
  taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
@@ -251,25 +306,19 @@ function remoteLabels(labels) {
251
306
  .filter((n) => n != null)
252
307
  }
253
308
 
254
- // A real Linear milestone has no workflow state only `progress` ("0%".."100%").
255
- // Fall back to a legacy `status`/`state` when present (fixtures / older shapes).
256
- function remoteMilestoneStatus(m) {
257
- if (m.status != null) return canonicalRemoteStatus(m.status)
258
- if (m.state != null) return canonicalRemoteStatus(m.state)
259
- if (m.progress != null) {
260
- const pct = parseInt(String(m.progress), 10)
261
- if (Number.isFinite(pct)) {
262
- if (pct >= 100) return 'done'
263
- if (pct > 0) return 'in-progress'
264
- }
265
- return 'not-started'
266
- }
267
- return 'not-started'
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
268
317
  }
269
318
 
270
319
  /**
271
- * Normalize a remote Project projection (from the Phase 2 MCP adapter, or a
272
- * 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`.
273
322
  */
274
323
  function normalizeRemote(project, config) {
275
324
  const p = project || {}
@@ -277,15 +326,31 @@ function normalizeRemote(project, config) {
277
326
  const stateName = remoteStateName(p)
278
327
  const extracted = {
279
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.
280
331
  milestones: milestones.map((m) => ({
332
+ id: m.id != null ? String(m.id) : null,
281
333
  name: m.name,
282
- status: remoteMilestoneStatus(m),
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(),
283
340
  })),
284
341
  phaseBodies: milestones.map((m) => ({
285
342
  phase: m.name,
286
343
  goal: (m.description != null ? m.description : '').trim(),
287
344
  })),
288
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
+ })),
289
354
  taskBreakdown: milestones.map((m) => ({
290
355
  phase: m.name,
291
356
  tasks: Array.isArray(m.tasks) ? m.tasks : [],
@@ -304,6 +369,7 @@ module.exports = {
304
369
  parseFrontmatter,
305
370
  parseSections,
306
371
  parsePhaseIndex,
372
+ parseTaskLine,
307
373
  canonicalRemoteStatus,
308
374
  canonicalizeMarkdown,
309
375
  bucketForState,
@@ -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