@skitterbyte/skitterspec-linear 7.0.1 → 8.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.
@@ -1,32 +1,25 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * Local snapshot writes for pull (remote repo).
4
+ * Push-side writeback: after `push` creates milestones/issues in Linear, the
5
+ * skill stamps each returned id back into the repo so the next push updates
6
+ * rather than recreates. Everything here edits the repo in place:
5
7
  *
6
- * Phase 2 applies **frontmatter-mapped** pulled fields the `pull`-owned data
7
- * remote genuinely owns (`workflowState` `spec_status`, `priority`, `labels`)
8
- * plus sync bookkeeping (`last_synced_at`, ids) by surgically editing the YAML
9
- * frontmatter of `00-overview.md` and leaving the markdown body byte-for-byte
10
- * untouched. Existing keys are updated in place (order preserved); new keys are
11
- * appended; a file with no frontmatter gets one prepended.
8
+ * - `stampMilestoneId(dir, file, id)`add/update `linear_milestone_id` in a
9
+ * phase file's frontmatter (locate the file with `findPhaseFileByTitle`).
10
+ * - `stampIssueId(dir, text, id)` — append `(ID)` to the matching task line,
11
+ * re-wrapped in the file's own style.
12
+ * - `writeFrontmatter(dir, config, patch)` patch `00-overview.md` frontmatter
13
+ * (e.g. `last_synced_at`).
12
14
  *
13
- * Body/`both`-owned fields (`description`, `milestones`, …) are NOT written back
14
- * here — that denormalizer is a tracked follow-up (see the spec). Callers advance
15
- * the base only for fields they actually applied, so an un-applied remote edit
16
- * stays pending rather than being silently marked synced.
15
+ * No remote read, no pull writeback the repo is the source of truth.
17
16
  */
18
17
 
19
18
  const fs = require('node:fs')
20
19
  const path = require('node:path')
21
- const {
22
- findTaskBlocks,
23
- renderTaskBlock,
24
- collapse,
25
- inferWidth,
26
- } = require('./task-block.js')
20
+ const { findTaskBlocks, renderTaskBlock, collapse, inferWidth } = require('./task-block.js')
27
21
 
28
- // Serialize a JS value as a YAML-ish frontmatter scalar. null/undefined → the
29
- // key is dropped (caller shouldn't pass those).
22
+ // Serialize a JS value as a YAML-ish frontmatter scalar.
30
23
  function serialize(value) {
31
24
  if (Array.isArray(value)) return JSON.stringify(value)
32
25
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
@@ -54,7 +47,6 @@ function patchFrontmatterLines(lines, patch) {
54
47
  out.push(line)
55
48
  }
56
49
  }
57
- // Append any new keys not already present.
58
50
  for (const key of Object.keys(patch)) {
59
51
  if (!seen.has(key)) out.push(`${key}: ${serialize(patch[key])}`)
60
52
  }
@@ -85,14 +77,6 @@ function writeFrontmatter(snapshotDir, config, patch) {
85
77
  return Object.keys(clean)
86
78
  }
87
79
 
88
- // --- phase-file denormalizer (keyed milestone pull) ------------------------
89
- //
90
- // Writes pulled milestone edits back into the *body* — the phase files — which
91
- // the frontmatter writer above never touches. An edit updates the matching phase
92
- // file (by its linear_milestone_id) in place, leaving everything else
93
- // byte-untouched; a Linear-only milestone becomes a new phase file. Removals are
94
- // never applied here (report-only, Decision 7).
95
-
96
80
  // Phase files in a snapshot dir (01-*.md …), execution order.
97
81
  function listPhaseFiles(snapshotDir) {
98
82
  try {
@@ -105,28 +89,8 @@ function listPhaseFiles(snapshotDir) {
105
89
  }
106
90
  }
107
91
 
108
- // The linear_milestone_id recorded in a phase file's frontmatter, or null.
109
- function phaseMilestoneId(raw) {
110
- const { fmLines } = splitFrontmatter(raw)
111
- for (const line of fmLines) {
112
- const m = /^linear_milestone_id:\s*(.*)$/.exec(line)
113
- if (m) return m[1].trim().replace(/^["']|["']$/g, '') || null
114
- }
115
- return null
116
- }
117
-
118
- // Find the phase file linked to a milestone id, or null.
119
- function findPhaseFileByMilestoneId(snapshotDir, id) {
120
- const want = String(id)
121
- for (const file of listPhaseFiles(snapshotDir)) {
122
- const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
123
- if (phaseMilestoneId(raw) === want) return file
124
- }
125
- return null
126
- }
127
-
128
- // Find the phase file whose h1 title matches `name` (used to link a freshly
129
- // created milestone back to the phase it came from, before it has an id).
92
+ // Find the phase file whose h1 title matches `name` (used to stamp a freshly
93
+ // created milestone's id back into the phase it came from).
130
94
  function findPhaseFileByTitle(snapshotDir, name) {
131
95
  const want = String(name).trim()
132
96
  for (const file of listPhaseFiles(snapshotDir)) {
@@ -142,23 +106,6 @@ function findPhaseFileByTitle(snapshotDir, name) {
142
106
  return null
143
107
  }
144
108
 
145
- // Update a phase file's title (h1, preserving the "Phase N — " prefix + status
146
- // emoji) and its `**Goal:**` line, leaving everything else untouched.
147
- function writeMilestoneFields(snapshotDir, file, { name, goal }) {
148
- const p = path.join(snapshotDir, file)
149
- let raw = fs.readFileSync(p, 'utf-8')
150
- if (name != null) {
151
- raw = raw.replace(/^(#[ \t]+)(.*)$/m, (_full, hash, rest) => {
152
- const pm = /^(Phase\s+\d+\s*[—–-]\s*)(.*?)(\s*[⬜🔄✅])?\s*$/.exec(rest)
153
- return pm ? `${hash}${pm[1]}${name}${pm[3] || ''}` : `${hash}${name}`
154
- })
155
- }
156
- if (goal != null && /^\*\*Goal:\*\*/m.test(raw)) {
157
- raw = raw.replace(/^(\*\*Goal:\*\*[ \t]*).*$/m, `$1${goal}`)
158
- }
159
- fs.writeFileSync(p, raw, 'utf-8')
160
- }
161
-
162
109
  // Add/update linear_milestone_id in a phase file's frontmatter (in place).
163
110
  function stampMilestoneId(snapshotDir, file, id) {
164
111
  const p = path.join(snapshotDir, file)
@@ -169,111 +116,8 @@ function stampMilestoneId(snapshotDir, file, id) {
169
116
  fs.writeFileSync(p, had ? fm + body : fm + '\n' + raw, 'utf-8')
170
117
  }
171
118
 
172
- const slugify = (name) =>
173
- String(name || 'phase')
174
- .toLowerCase()
175
- .replace(/[^a-z0-9]+/g, '-')
176
- .replace(/^-+|-+$/g, '')
177
- .slice(0, 40) || 'phase'
178
-
179
- // Next phase number (max existing + 1).
180
- function nextPhaseNumber(snapshotDir) {
181
- const nums = listPhaseFiles(snapshotDir)
182
- .map((f) => parseInt(f.slice(0, 2), 10))
183
- .filter(Number.isFinite)
184
- return nums.length ? Math.max(...nums) + 1 : 1
185
- }
186
-
187
- // Create a new phase file for a Linear-only milestone. Returns the filename.
188
- function createPhaseFileForMilestone(snapshotDir, { id, name, goal }) {
189
- const n = nextPhaseNumber(snapshotDir)
190
- const file = `${String(n).padStart(2, '0')}-${slugify(name)}.md`
191
- const content =
192
- `---\nlinear_milestone_id: ${JSON.stringify(String(id))}\n---\n\n` +
193
- `# Phase ${n} — ${name || 'Untitled'} ⬜\n\n` +
194
- `> Spec: [00-overview.md](00-overview.md) · **Status:** Not started\n\n` +
195
- `**Goal:** ${goal || ''}\n\n## Tasks\n\n- [ ] (pulled from Linear — flesh out)\n`
196
- fs.writeFileSync(path.join(snapshotDir, file), content, 'utf-8')
197
- return file
198
- }
199
-
200
- /**
201
- * Apply a pull's keyed milestone item outcomes to the phase files.
202
- * @param items classifyItems output for the milestones field.
203
- * @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
204
- */
205
- function applyMilestonesPull(snapshotDir, items) {
206
- const applied = []
207
- const created = []
208
- const reported = []
209
- for (const it of items || []) {
210
- if (it.report) {
211
- reported.push(it.id)
212
- continue
213
- }
214
- if (!it.pullable || !it.remote) continue
215
- if (it.status === 'added') {
216
- const file = createPhaseFileForMilestone(snapshotDir, it.remote)
217
- created.push({ id: it.id, file })
218
- } else if (it.status === 'edited' || it.status === 'conflict') {
219
- const file = findPhaseFileByMilestoneId(snapshotDir, it.id)
220
- if (file) {
221
- writeMilestoneFields(snapshotDir, file, it.remote)
222
- applied.push(it.id)
223
- }
224
- }
225
- }
226
- return { applied, created, reported }
227
- }
228
-
229
- // --- task-line denormalizer (keyed issue pull) -----------------------------
230
- //
231
- // Tasks live as (hand-wrapped) checkbox bullets inside phase files. A pulled
232
- // issue edit rewrites the matching bullet — whole block, re-wrapped — by its
233
- // inline id; a Linear-only issue appends a new bullet; a created issue's id is
234
- // stamped inline. Removals report-only. See task-block.js for the wrapping.
235
-
236
119
  const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
237
120
 
238
- // Update the task carrying inline id `id` (text + checkbox), in place.
239
- //
240
- // Block-aware: a task bullet is hand-wrapped across several lines, so the whole
241
- // block is replaced and the new text re-wrapped in the file's own style. Editing
242
- // only the first line would strand its continuation lines as orphaned prose.
243
- function updateTaskLine(snapshotDir, id, { text, done }) {
244
- const want = String(id)
245
- for (const file of listPhaseFiles(snapshotDir)) {
246
- const p = path.join(snapshotDir, file)
247
- const lines = fs.readFileSync(p, 'utf-8').split('\n')
248
- const width = inferWidth(lines)
249
- for (const b of findTaskBlocks(lines)) {
250
- const idm = INLINE_ID_RE.exec(b.text)
251
- if (!idm || idm[1] !== want) continue
252
- const rendered = renderTaskBlock({ indent: b.indent, done, text, id: want }, width)
253
- lines.splice(b.start, b.end - b.start, ...rendered)
254
- fs.writeFileSync(p, lines.join('\n'), 'utf-8')
255
- return true
256
- }
257
- }
258
- return false
259
- }
260
-
261
- // Append a task line for a Linear-only issue after the last existing task line
262
- // (falls back to end of the last phase file). Returns the file it landed in.
263
- function addTaskLine(snapshotDir, item) {
264
- const files = listPhaseFiles(snapshotDir)
265
- const file = files[files.length - 1]
266
- if (!file) return null
267
- const p = path.join(snapshotDir, file)
268
- const lines = fs.readFileSync(p, 'utf-8').split('\n')
269
- const blocks = findTaskBlocks(lines)
270
- const rendered = renderTaskBlock({ indent: '', ...item }, inferWidth(lines))
271
- if (blocks.length) lines.splice(blocks[blocks.length - 1].end, 0, ...rendered)
272
- else lines.push(...rendered)
273
- fs.writeFileSync(p, lines.join('\n'), 'utf-8')
274
- return file
275
- }
276
-
277
121
  // Stamp an inline id onto the (idless) task line whose text matches — used after
278
122
  // the skill creates an issue for a new local task.
279
123
  function stampIssueId(snapshotDir, text, id) {
@@ -285,10 +129,7 @@ function stampIssueId(snapshotDir, text, id) {
285
129
  for (const b of findTaskBlocks(lines)) {
286
130
  if (INLINE_ID_RE.test(b.text)) continue
287
131
  if (b.text !== want) continue
288
- const rendered = renderTaskBlock(
289
- { indent: b.indent, done: b.mark === 'x', text: want, id },
290
- width,
291
- )
132
+ const rendered = renderTaskBlock({ indent: b.indent, done: b.mark === 'x', text: want, id }, width)
292
133
  lines.splice(b.start, b.end - b.start, ...rendered)
293
134
  fs.writeFileSync(p, lines.join('\n'), 'utf-8')
294
135
  return file
@@ -297,43 +138,12 @@ function stampIssueId(snapshotDir, text, id) {
297
138
  return null
298
139
  }
299
140
 
300
- /**
301
- * Apply a pull's keyed task item outcomes to the phase files' task lines.
302
- * @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
303
- */
304
- function applyTasksPull(snapshotDir, items) {
305
- const applied = []
306
- const created = []
307
- const reported = []
308
- for (const it of items || []) {
309
- if (it.report) {
310
- reported.push(it.id)
311
- continue
312
- }
313
- if (!it.pullable || !it.remote) continue
314
- if (it.status === 'added') {
315
- const file = addTaskLine(snapshotDir, it.remote)
316
- if (file) created.push({ id: it.id, file })
317
- } else if (it.status === 'edited' || it.status === 'conflict') {
318
- if (updateTaskLine(snapshotDir, it.id, it.remote)) applied.push(it.id)
319
- }
320
- }
321
- return { applied, created, reported }
322
- }
323
-
324
141
  module.exports = {
325
142
  writeFrontmatter,
326
143
  splitFrontmatter,
327
144
  serialize,
328
145
  listPhaseFiles,
329
- findPhaseFileByMilestoneId,
330
146
  findPhaseFileByTitle,
331
- writeMilestoneFields,
332
147
  stampMilestoneId,
333
- createPhaseFileForMilestone,
334
- applyMilestonesPull,
335
- updateTaskLine,
336
- addTaskLine,
337
148
  stampIssueId,
338
- applyTasksPull,
339
149
  }
@@ -1,49 +0,0 @@
1
- ---
2
- name: spec-pull
3
- description: Pull a spec's linked Linear project into the local spec (Linear → repo), three-way aware. Applies remote-only fields; refuses to clobber local edits on a conflict unless --force (which backs up the local side first). Fetches Linear over MCP and runs `skitterspec spec-sync pull`. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-pull", "pull from Linear", "sync Linear changes down", or "update this spec from Linear".
4
- ---
5
-
6
- # /spec-pull — bring Linear changes into the spec
7
-
8
- Linear → repo. Applies fields Linear changed since the last sync (status,
9
- priority, labels, and co-authored fields), rewrites the committed base, and
10
- stamps `last_synced_at`. It **refuses** to overwrite a local edit that conflicts
11
- with a Linear edit unless you pass `--force`.
12
-
13
- **Opt-in**: only runs when `specs/.core/linear.config.json` exists. If absent,
14
- tell the user how to enable Linear sync and stop.
15
-
16
- ## 1. Identify the target spec
17
-
18
- Use the argument, else the spec in context; ask if unclear.
19
-
20
- ## 2. Fetch the Linear project
21
-
22
- - Read `linear_project_id` from `00-overview.md` frontmatter; if missing, the
23
- spec isn't linked — stop and point at `/spec`.
24
- - Discover the Linear MCP project-read tool at runtime. If Linear isn't
25
- connected, relay the fix and stop — **do nothing destructive**.
26
- - Call it (include milestones) and write the project JSON to a temp file. When
27
- tasks are keyed, also list the project's issues and add them as an `issues`
28
- array on that JSON (each `{ identifier, title, state }`) so the engine can
29
- reconcile task lines.
30
-
31
- ## 3. Run the engine
32
-
33
- ```
34
- skitterspec spec-sync pull <spec> --remote <tempfile> [--force]
35
- ```
36
-
37
- - **No conflict** — the engine applies remote-only fields to the local snapshot,
38
- rewrites the base, and stamps the sync. Body fields with no local home yet are
39
- reported as `deferred` (apply them by hand from Linear if needed).
40
- - **Conflict** (a co-authored field changed on both sides) — the engine
41
- **refuses** and lists the fields. Relay that; do not force on the user's behalf.
42
- - **`--force`** — only when the user explicitly asks. Remote wins after the engine
43
- backs up the local side under `sync.backupDir` (the reflog). Relay the backup
44
- path.
45
-
46
- ## 4. Report
47
-
48
- Relay the git-like summary (applied / deferred / conflicts / backup / base). If
49
- fields were applied, remind the user to review and commit the refreshed snapshot.
@@ -1,66 +0,0 @@
1
- 'use strict'
2
-
3
- /**
4
- * Translate normalized field values into a local frontmatter patch (pull side).
5
- *
6
- * Only the `pull`-owned, frontmatter-backed fields have a local home in Phase 2:
7
- * workflowState → spec_status (remote state name mapped back to the bucket),
8
- * priority → priority,
9
- * labels → labels.
10
- * Any other field handed in (a body field like `description`/`milestones`) has no
11
- * frontmatter mapping yet, so it's returned in `deferred` — the caller must NOT
12
- * advance its base, keeping the remote edit pending instead of falsely synced.
13
- */
14
-
15
- // field name → frontmatter key.
16
- const FRONTMATTER_FIELD = {
17
- workflowState: 'spec_status',
18
- priority: 'priority',
19
- labels: 'labels',
20
- }
21
-
22
- // Invert config.states ({ bucket: "remote Name" }) → { "remote name": bucket }.
23
- function invertStates(config) {
24
- const out = {}
25
- const states = (config && config.states) || {}
26
- for (const [bucket, name] of Object.entries(states)) {
27
- if (typeof name === 'string') out[name.toLowerCase()] = bucket
28
- }
29
- return out
30
- }
31
-
32
- // Map a remote workflowState (a remote state name) back to a local bucket. Falls
33
- // back to the raw value when it isn't one of the configured states.
34
- function localWorkflowState(value, config) {
35
- if (value == null) return null
36
- const bucket = invertStates(config)[String(value).toLowerCase()]
37
- return bucket || String(value)
38
- }
39
-
40
- /**
41
- * Build the frontmatter patch for a set of applied field values.
42
- * @param {object} fieldValues { fieldName: value } to write locally
43
- * @returns {{ patch:object, applied:string[], deferred:string[] }}
44
- */
45
- function frontmatterPatchFor(fieldValues, config) {
46
- const patch = {}
47
- const applied = []
48
- const deferred = []
49
- for (const [field, value] of Object.entries(fieldValues)) {
50
- const key = FRONTMATTER_FIELD[field]
51
- if (!key) {
52
- deferred.push(field)
53
- continue
54
- }
55
- patch[key] = field === 'workflowState' ? localWorkflowState(value, config) : value
56
- applied.push(field)
57
- }
58
- return { patch, applied, deferred }
59
- }
60
-
61
- module.exports = {
62
- frontmatterPatchFor,
63
- localWorkflowState,
64
- invertStates,
65
- FRONTMATTER_FIELD,
66
- }
@@ -1,115 +0,0 @@
1
- 'use strict'
2
-
3
- /**
4
- * `pull` — remote → repo, three-way aware.
5
- *
6
- * Applies remote-only fields to the local snapshot; a `both`-owned field where
7
- * both sides moved off base is a real **conflict** and pull refuses (unless
8
- * `--force`, which makes remote win after backing up the local side). On success
9
- * it rewrites the base for the fields it actually reconciled and stamps
10
- * `last_synced_at`. Body fields with no local frontmatter home yet are reported
11
- * as `deferred` and their base is deliberately left pending (not falsely synced).
12
- *
13
- * Pure orchestration over an injected `adapter` (readProject) + injected
14
- * `timestamp`; no clock, no MCP knowledge here (that's mcp.js). Tests drive it
15
- * with a fake in-memory adapter.
16
- */
17
-
18
- const { normalizeLocal, normalizeRemote } = require('./normalize.js')
19
- const { classify } = require('./compare.js')
20
- const { readBase, writeBase, backup } = require('./base.js')
21
- const { writeFrontmatter, applyMilestonesPull, applyTasksPull } = require('./write.js')
22
- const { frontmatterPatchFor } = require('./apply.js')
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
-
38
- async function pull({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
39
- const local = normalizeLocal(snapshotDir, config)
40
- const remoteRaw = await adapter.readProject(projectId)
41
- if (!remoteRaw) {
42
- return { ok: false, error: `remote project not found: ${projectId}` }
43
- }
44
- const remote = normalizeRemote(remoteRaw, config)
45
- const base = readBase(dir, identifier, config)
46
- const fields = classify(local, remote, base, config)
47
-
48
- const conflicts = collectConflicts(fields)
49
- if (conflicts.length && !force) {
50
- return {
51
- ok: false,
52
- blocked: true,
53
- reason: 'conflict',
54
- conflicts,
55
- message: `pull refused — ${conflicts.length} unit(s) changed on both sides: ` +
56
- `${conflicts.join(', ')}. Resolve locally or re-run with --force (remote wins).`,
57
- }
58
- }
59
-
60
- // --force overwrites local edits — back the local side up first.
61
- let backupPath = null
62
- if (force) {
63
- backupPath = backup('local', dir, identifier, config, { timestamp, data: local })
64
- }
65
-
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
-
86
- if (applied.length || timestamp) {
87
- writeFrontmatter(snapshotDir, config, { ...patch, last_synced_at: timestamp })
88
- }
89
-
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 }
96
- for (const field of applied) newBase[field] = remote[field]
97
- newBase.__meta = { updatedAt: remoteRaw.updatedAt || null, syncedAt: timestamp }
98
- const basePath = writeBase(dir, identifier, config, newBase)
99
-
100
- return {
101
- ok: true,
102
- blocked: false,
103
- applied,
104
- deferred,
105
- keyedApplied,
106
- keyedCreated,
107
- keyedReported,
108
- conflictsForced: force ? conflicts : [],
109
- backupPath,
110
- basePath,
111
- pulled: [...scalarPull.map((f) => f.field), ...keyedApplied],
112
- }
113
- }
114
-
115
- module.exports = { pull }