@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.
@@ -32,13 +32,17 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
32
32
 
33
33
  // Remote moved past base only if a *co-authored* (`both`) field diverged on the
34
34
  // remote side — that's the case the repo can't safely overwrite without a pull.
35
- // A `pull`-owned change (status/priority/labels) is Linear's to own and must
36
- // NOT block a content push, and a bare `updatedAt` bump (which any Linear edit
37
- // produces) is too coarse to gate on the pre-write re-read below still catches
38
- // a racer that lands during the push itself.
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.
39
40
  const remoteDivergedFields = fields
40
- .filter((f) => f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
41
+ .filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
41
42
  .map((f) => f.field)
43
+ for (const f of fields) {
44
+ if (f.keyed) for (const it of f.items) if (it.status === 'conflict') remoteDivergedFields.push(`${f.field}#${it.id}`)
45
+ }
42
46
  const moved = remoteDivergedFields.length > 0
43
47
 
44
48
  if (moved && !force) {
@@ -54,8 +58,37 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
54
58
  }
55
59
  }
56
60
 
57
- const pushFields = fields.filter((f) => f.pushable)
58
- 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) {
59
92
  return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
60
93
  }
61
94
 
@@ -78,7 +111,9 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
78
111
 
79
112
  const updates = {}
80
113
  for (const f of pushFields) updates[f.field] = local[f.field]
81
- 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
82
117
  const updatedRemote = normalizeRemote(updated, config)
83
118
 
84
119
  // Reconciled base: local is the source of truth for the fields we pushed (and
@@ -97,8 +132,12 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
97
132
  ok: true,
98
133
  blocked: false,
99
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 } : {}),
100
139
  skipped: fields
101
- .filter((f) => !f.pushable && f.status !== 'unchanged')
140
+ .filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
102
141
  .map((f) => f.field),
103
142
  backupPath,
104
143
  basePath,
@@ -79,8 +79,255 @@ function writeFrontmatter(snapshotDir, config, patch) {
79
79
  return Object.keys(clean)
80
80
  }
81
81
 
82
+ // --- phase-file denormalizer (keyed milestone pull) ------------------------
83
+ //
84
+ // Writes pulled milestone edits back into the *body* — the phase files — which
85
+ // the frontmatter writer above never touches. An edit updates the matching phase
86
+ // file (by its linear_milestone_id) in place, leaving everything else
87
+ // byte-untouched; a Linear-only milestone becomes a new phase file. Removals are
88
+ // never applied here (report-only, Decision 7).
89
+
90
+ // Phase files in a snapshot dir (01-*.md …), execution order.
91
+ function listPhaseFiles(snapshotDir) {
92
+ try {
93
+ return fs
94
+ .readdirSync(snapshotDir)
95
+ .filter((f) => /^\d\d-.*\.md$/.test(f) && !f.startsWith('00-'))
96
+ .sort()
97
+ } catch {
98
+ return []
99
+ }
100
+ }
101
+
102
+ // The linear_milestone_id recorded in a phase file's frontmatter, or null.
103
+ function phaseMilestoneId(raw) {
104
+ const { fmLines } = splitFrontmatter(raw)
105
+ for (const line of fmLines) {
106
+ const m = /^linear_milestone_id:\s*(.*)$/.exec(line)
107
+ if (m) return m[1].trim().replace(/^["']|["']$/g, '') || null
108
+ }
109
+ return null
110
+ }
111
+
112
+ // Find the phase file linked to a milestone id, or null.
113
+ function findPhaseFileByMilestoneId(snapshotDir, id) {
114
+ const want = String(id)
115
+ for (const file of listPhaseFiles(snapshotDir)) {
116
+ const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
117
+ if (phaseMilestoneId(raw) === want) return file
118
+ }
119
+ return null
120
+ }
121
+
122
+ // Find the phase file whose h1 title matches `name` (used to link a freshly
123
+ // created milestone back to the phase it came from, before it has an id).
124
+ function findPhaseFileByTitle(snapshotDir, name) {
125
+ const want = String(name).trim()
126
+ for (const file of listPhaseFiles(snapshotDir)) {
127
+ const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
128
+ const h1 = /^#\s+(.*)$/m.exec(splitFrontmatter(raw).body)
129
+ if (!h1) continue
130
+ const title = h1[1]
131
+ .replace(/\s*[⬜🔄✅]\s*$/u, '')
132
+ .replace(/^Phase\s+\d+\s*[—–-]\s*/i, '')
133
+ .trim()
134
+ if (title === want) return file
135
+ }
136
+ return null
137
+ }
138
+
139
+ // Update a phase file's title (h1, preserving the "Phase N — " prefix + status
140
+ // emoji) and its `**Goal:**` line, leaving everything else untouched.
141
+ function writeMilestoneFields(snapshotDir, file, { name, goal }) {
142
+ const p = path.join(snapshotDir, file)
143
+ let raw = fs.readFileSync(p, 'utf-8')
144
+ if (name != null) {
145
+ raw = raw.replace(/^(#[ \t]+)(.*)$/m, (_full, hash, rest) => {
146
+ const pm = /^(Phase\s+\d+\s*[—–-]\s*)(.*?)(\s*[⬜🔄✅])?\s*$/.exec(rest)
147
+ return pm ? `${hash}${pm[1]}${name}${pm[3] || ''}` : `${hash}${name}`
148
+ })
149
+ }
150
+ if (goal != null && /^\*\*Goal:\*\*/m.test(raw)) {
151
+ raw = raw.replace(/^(\*\*Goal:\*\*[ \t]*).*$/m, `$1${goal}`)
152
+ }
153
+ fs.writeFileSync(p, raw, 'utf-8')
154
+ }
155
+
156
+ // Add/update linear_milestone_id in a phase file's frontmatter (in place).
157
+ function stampMilestoneId(snapshotDir, file, id) {
158
+ const p = path.join(snapshotDir, file)
159
+ const raw = fs.readFileSync(p, 'utf-8')
160
+ const { fmLines, body, had } = splitFrontmatter(raw)
161
+ const patched = patchFrontmatterLines(fmLines, { linear_milestone_id: String(id) })
162
+ const fm = `---\n${patched.join('\n')}\n---\n`
163
+ fs.writeFileSync(p, had ? fm + body : fm + '\n' + raw, 'utf-8')
164
+ }
165
+
166
+ const slugify = (name) =>
167
+ String(name || 'phase')
168
+ .toLowerCase()
169
+ .replace(/[^a-z0-9]+/g, '-')
170
+ .replace(/^-+|-+$/g, '')
171
+ .slice(0, 40) || 'phase'
172
+
173
+ // Next phase number (max existing + 1).
174
+ function nextPhaseNumber(snapshotDir) {
175
+ const nums = listPhaseFiles(snapshotDir)
176
+ .map((f) => parseInt(f.slice(0, 2), 10))
177
+ .filter(Number.isFinite)
178
+ return nums.length ? Math.max(...nums) + 1 : 1
179
+ }
180
+
181
+ // Create a new phase file for a Linear-only milestone. Returns the filename.
182
+ function createPhaseFileForMilestone(snapshotDir, { id, name, goal }) {
183
+ const n = nextPhaseNumber(snapshotDir)
184
+ const file = `${String(n).padStart(2, '0')}-${slugify(name)}.md`
185
+ const content =
186
+ `---\nlinear_milestone_id: ${JSON.stringify(String(id))}\n---\n\n` +
187
+ `# Phase ${n} — ${name || 'Untitled'} ⬜\n\n` +
188
+ `> Spec: [00-overview.md](00-overview.md) · **Status:** Not started\n\n` +
189
+ `**Goal:** ${goal || ''}\n\n## Tasks\n\n- [ ] (pulled from Linear — flesh out)\n`
190
+ fs.writeFileSync(path.join(snapshotDir, file), content, 'utf-8')
191
+ return file
192
+ }
193
+
194
+ /**
195
+ * Apply a pull's keyed milestone item outcomes to the phase files.
196
+ * @param items classifyItems output for the milestones field.
197
+ * @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
198
+ */
199
+ function applyMilestonesPull(snapshotDir, items) {
200
+ const applied = []
201
+ const created = []
202
+ const reported = []
203
+ for (const it of items || []) {
204
+ if (it.report) {
205
+ reported.push(it.id)
206
+ continue
207
+ }
208
+ if (!it.pullable || !it.remote) continue
209
+ if (it.status === 'added') {
210
+ const file = createPhaseFileForMilestone(snapshotDir, it.remote)
211
+ created.push({ id: it.id, file })
212
+ } else if (it.status === 'edited' || it.status === 'conflict') {
213
+ const file = findPhaseFileByMilestoneId(snapshotDir, it.id)
214
+ if (file) {
215
+ writeMilestoneFields(snapshotDir, file, it.remote)
216
+ applied.push(it.id)
217
+ }
218
+ }
219
+ }
220
+ return { applied, created, reported }
221
+ }
222
+
223
+ // --- task-line denormalizer (keyed issue pull) -----------------------------
224
+ //
225
+ // Tasks live as checkbox lines inside phase files. A pulled issue edit rewrites
226
+ // the matching line (by its inline id) in place; a Linear-only issue appends a
227
+ // new task line; a created issue's id is stamped inline. Removals report-only.
228
+
229
+ const TASK_RE = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/
230
+ const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
231
+
232
+ // Render a task line from an item.
233
+ function taskLine(indent, { id, text, done }) {
234
+ return `${indent}- [${done ? 'x' : ' '}] ${text}${id ? ` (${id})` : ''}`
235
+ }
236
+
237
+ // Update the task line carrying inline id `id` (text + checkbox), in place.
238
+ function updateTaskLine(snapshotDir, id, { text, done }) {
239
+ const want = String(id)
240
+ for (const file of listPhaseFiles(snapshotDir)) {
241
+ const p = path.join(snapshotDir, file)
242
+ const lines = fs.readFileSync(p, 'utf-8').split('\n')
243
+ for (let i = 0; i < lines.length; i++) {
244
+ const m = TASK_RE.exec(lines[i])
245
+ if (!m) continue
246
+ const idm = INLINE_ID_RE.exec(m[3])
247
+ if (idm && idm[1] === want) {
248
+ lines[i] = taskLine(m[1], { id: want, text, done })
249
+ fs.writeFileSync(p, lines.join('\n'), 'utf-8')
250
+ return true
251
+ }
252
+ }
253
+ }
254
+ return false
255
+ }
256
+
257
+ // Append a task line for a Linear-only issue after the last existing task line
258
+ // (falls back to end of the last phase file). Returns the file it landed in.
259
+ function addTaskLine(snapshotDir, item) {
260
+ const files = listPhaseFiles(snapshotDir)
261
+ const file = files[files.length - 1]
262
+ if (!file) return null
263
+ const p = path.join(snapshotDir, file)
264
+ const lines = fs.readFileSync(p, 'utf-8').split('\n')
265
+ let lastTask = -1
266
+ for (let i = 0; i < lines.length; i++) if (TASK_RE.test(lines[i])) lastTask = i
267
+ const line = taskLine('', item)
268
+ if (lastTask >= 0) lines.splice(lastTask + 1, 0, line)
269
+ else lines.push(line)
270
+ fs.writeFileSync(p, lines.join('\n'), 'utf-8')
271
+ return file
272
+ }
273
+
274
+ // Stamp an inline id onto the (idless) task line whose text matches — used after
275
+ // the skill creates an issue for a new local task.
276
+ function stampIssueId(snapshotDir, text, id) {
277
+ const want = String(text).trim()
278
+ for (const file of listPhaseFiles(snapshotDir)) {
279
+ const p = path.join(snapshotDir, file)
280
+ const lines = fs.readFileSync(p, 'utf-8').split('\n')
281
+ for (let i = 0; i < lines.length; i++) {
282
+ const m = TASK_RE.exec(lines[i])
283
+ if (!m || INLINE_ID_RE.test(m[3])) continue
284
+ if (m[3].trim() === want) {
285
+ lines[i] = `${m[1]}- [${m[2].toLowerCase() === 'x' ? 'x' : ' '}] ${want} (${id})`
286
+ fs.writeFileSync(p, lines.join('\n'), 'utf-8')
287
+ return file
288
+ }
289
+ }
290
+ }
291
+ return null
292
+ }
293
+
294
+ /**
295
+ * Apply a pull's keyed task item outcomes to the phase files' task lines.
296
+ * @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
297
+ */
298
+ function applyTasksPull(snapshotDir, items) {
299
+ const applied = []
300
+ const created = []
301
+ const reported = []
302
+ for (const it of items || []) {
303
+ if (it.report) {
304
+ reported.push(it.id)
305
+ continue
306
+ }
307
+ if (!it.pullable || !it.remote) continue
308
+ if (it.status === 'added') {
309
+ const file = addTaskLine(snapshotDir, it.remote)
310
+ if (file) created.push({ id: it.id, file })
311
+ } else if (it.status === 'edited' || it.status === 'conflict') {
312
+ if (updateTaskLine(snapshotDir, it.id, it.remote)) applied.push(it.id)
313
+ }
314
+ }
315
+ return { applied, created, reported }
316
+ }
317
+
82
318
  module.exports = {
83
319
  writeFrontmatter,
84
320
  splitFrontmatter,
85
321
  serialize,
322
+ listPhaseFiles,
323
+ findPhaseFileByMilestoneId,
324
+ findPhaseFileByTitle,
325
+ writeMilestoneFields,
326
+ stampMilestoneId,
327
+ createPhaseFileForMilestone,
328
+ applyMilestonesPull,
329
+ updateTaskLine,
330
+ addTaskLine,
331
+ stampIssueId,
332
+ applyTasksPull,
86
333
  }