@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.
@@ -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
  }