@skitterbyte/skitterspec-linear 7.0.2 → 8.0.1

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,147 +1,53 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * `push` — repo → remote, three-way aware and ownership-respecting.
4
+ * `push` — repo → Linear, one-way. The repo is the source of truth; Linear is a
5
+ * generated mirror. We never read remote content: `push` builds the local
6
+ * projection, diffs it against the committed **last-pushed snapshot**, and
7
+ * returns a **create/update plan** the provider skill applies over MCP.
5
8
  *
6
- * Never writes a `pull`-owned field or a `localOnlySection` (those aren't in the
7
- * pushable set / the field set at all). Optimistic concurrency: if the remote has
8
- * moved past the base detected both by the classifier (any remote-only/conflict
9
- * field) and by the recorded `updatedAt` it aborts with "pull first" unless
10
- * `--force`. It also **re-reads the remote immediately before writing** to catch a
11
- * writer that raced in during the compare. `--force` makes local win after backing
12
- * up the remote side. On success it rewrites the base and stamps `last_synced_at`.
9
+ * The skill: applies the plan stamps each newly-created id back into the phase
10
+ * frontmatter / task line calls `recordPush`, which re-reads the (now
11
+ * id-stamped) projection and writes the snapshot. That closes the loop: the next
12
+ * `push` sees an unchanged snapshot and produces an empty plan.
13
13
  *
14
- * Pure orchestration over an injected `adapter` (readProject + updateProject) and
15
- * injected `timestamp`. Tests drive it with a fake in-memory adapter.
14
+ * Pure aside from reading the snapshot; no adapter, no remote read, no
15
+ * Date.now(). `recordPush` writes the snapshot sidecar.
16
16
  */
17
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 } = require('./write.js')
18
+ const { normalizeLocal } = require('./normalize.js')
19
+ const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
20
+ const { readBase, writeBase } = require('./base.js')
22
21
 
23
- async function push({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
22
+ // Build the one-way projection from a local snapshot: the project prose + status,
23
+ // and the milestone/issue items. `status` is the local lifecycle bucket; the
24
+ // skill maps it to the Linear project-state NAME via config.states at apply time.
25
+ function projectionOf(snapshotDir, config) {
24
26
  const local = normalizeLocal(snapshotDir, config)
25
- const remoteRaw = await adapter.readProject(projectId)
26
- if (!remoteRaw) {
27
- return { ok: false, error: `remote project not found: ${projectId}` }
28
- }
29
- const remote = normalizeRemote(remoteRaw, config)
30
- const base = readBase(dir, identifier, config)
31
- const fields = classify(local, remote, base, config)
32
-
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.
40
- const remoteDivergedFields = fields
41
- .filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
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
- }
46
- const moved = remoteDivergedFields.length > 0
47
-
48
- if (moved && !force) {
49
- return {
50
- ok: false,
51
- blocked: true,
52
- reason: 'remote-moved',
53
- movedFields: remoteDivergedFields,
54
- message:
55
- 'push refused — remote moved since the last sync' +
56
- (remoteDivergedFields.length ? ` (${remoteDivergedFields.join(', ')})` : '') +
57
- '. Pull first, or re-run with --force (local wins).',
58
- }
59
- }
60
-
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) {
92
- return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
93
- }
94
-
95
- // Optimistic concurrency: re-read immediately before writing to catch a racer.
96
- const remoteRaw2 = await adapter.readProject(projectId)
97
- if (remoteRaw2 && remoteRaw2.updatedAt !== remoteRaw.updatedAt && !force) {
98
- return {
99
- ok: false,
100
- blocked: true,
101
- reason: 'concurrent-write',
102
- message: 'push refused — remote changed during the push. Pull first, or --force.',
103
- }
104
- }
105
-
106
- // --force clobbers the remote side — back it up first.
107
- let backupPath = null
108
- if (force) {
109
- backupPath = backup('remote', dir, identifier, config, { timestamp, data: remoteRaw2 || remoteRaw })
110
- }
111
-
112
- const updates = {}
113
- for (const f of pushFields) updates[f.field] = local[f.field]
114
- const updated = Object.keys(updates).length
115
- ? (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
116
- : remoteRaw2 || remoteRaw
117
- const updatedRemote = normalizeRemote(updated, config)
118
-
119
- // Reconciled base: local is the source of truth for the fields we pushed (and
120
- // for unchanged/local-only fields); pull-owned fields keep remote's value so
121
- // they don't read as pending next time.
122
- const newBase = { ...local }
123
- for (const [field, own] of Object.entries(config.sync.fieldOwnership)) {
124
- if (own === 'pull') newBase[field] = updatedRemote[field]
27
+ return {
28
+ description: local.description ?? null,
29
+ status: local.workflowState ?? null,
30
+ priority: local.priority ?? null,
31
+ labels: Array.isArray(local.labels) ? local.labels : [],
32
+ milestones: Array.isArray(local.milestones) ? local.milestones : [],
33
+ issues: Array.isArray(local.tasks) ? local.tasks : [],
125
34
  }
126
- newBase.__meta = { updatedAt: updated.updatedAt || null, syncedAt: timestamp }
127
- const basePath = writeBase(dir, identifier, config, newBase)
35
+ }
128
36
 
129
- if (timestamp) writeFrontmatter(snapshotDir, config, { last_synced_at: timestamp })
37
+ function push({ dir, snapshotDir, identifier, config }) {
38
+ const projection = projectionOf(snapshotDir, config)
39
+ const snapshot = readBase(dir, identifier, config)
40
+ const plan = planChanges(projection, snapshot)
41
+ return { ok: true, empty: isEmptyPlan(plan), plan, projection }
42
+ }
130
43
 
131
- return {
132
- ok: true,
133
- blocked: false,
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 } : {}),
139
- skipped: fields
140
- .filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
141
- .map((f) => f.field),
142
- backupPath,
143
- basePath,
144
- }
44
+ /**
45
+ * Record the last-pushed snapshot from the CURRENT files — call after the skill
46
+ * has applied the plan and stamped new ids. Returns the snapshot path.
47
+ */
48
+ function recordPush({ dir, snapshotDir, identifier, config }) {
49
+ const projection = projectionOf(snapshotDir, config)
50
+ return writeBase(dir, identifier, config, snapshotOf(projection))
145
51
  }
146
52
 
147
- module.exports = { push }
53
+ module.exports = { push, recordPush, projectionOf }
@@ -15,12 +15,7 @@
15
15
  * Pure string→string (`sanitizeSpecMarkdown`); the CLI wraps it with fs walking.
16
16
  */
17
17
 
18
- const {
19
- wrapEmphasisAware,
20
- collapse,
21
- inferWidth,
22
- DEFAULT_WIDTH,
23
- } = require('./task-block.js')
18
+ const { wrapEmphasisAware, collapseHyphenAware, inferWidth, DEFAULT_WIDTH } = require('./task-block.js')
24
19
  const { joinEmphasisAcrossBreaks } = require('./normalize.js')
25
20
 
26
21
  // A run of non-blank lines contains an emphasis straddle / mangle iff joining
@@ -30,9 +25,10 @@ function hasStraddle(text) {
30
25
  }
31
26
 
32
27
  // Turn a multi-line region into its clean single logical line: repair mangles and
33
- // join straddles first (needs the newlines), then collapse whitespace.
28
+ // join straddles first (needs the newlines), then collapse whitespace — hyphen-
29
+ // aware, so a compound wrapped at the hyphen isn't spaced out.
34
30
  function cleanLogicalLine(text) {
35
- return collapse(joinEmphasisAcrossBreaks(text))
31
+ return collapseHyphenAware(joinEmphasisAcrossBreaks(text))
36
32
  }
37
33
 
38
34
  // A bullet line: indent, marker (`- `, `* `, `+ `, `1. `, optionally a `[ ]`/`[x]`
@@ -26,6 +26,17 @@ function collapse(text) {
26
26
  return String(text).replace(/\s+/g, ' ').trim()
27
27
  }
28
28
 
29
+ // Like `collapse`, but a word wrapped at a hyphen (`state-entry-with-`⏎
30
+ // `assignment`) rejoins TIGHT — one compound — instead of gaining a space. Every
31
+ // other break collapses to a single space. Use this wherever hand-wrapped prose
32
+ // is flattened to one logical line, so a hard wrap at a hyphen isn't data loss.
33
+ function collapseHyphenAware(text) {
34
+ return String(text)
35
+ .replace(/(\w-)[ \t]*\n[ \t]*(?=\w)/g, '$1')
36
+ .replace(/\s+/g, ' ')
37
+ .trim()
38
+ }
39
+
29
40
  // Mark every character of `body` that lies inside an inline emphasis or link
30
41
  // span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
31
42
  // one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
@@ -84,7 +95,7 @@ function findTaskBlocks(lines) {
84
95
  end: j,
85
96
  indent: m[1],
86
97
  mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
87
- text: collapse(parts.join(' ')),
98
+ text: collapseHyphenAware(parts.join('\n')),
88
99
  })
89
100
  i = j - 1
90
101
  }
@@ -171,6 +182,7 @@ module.exports = {
171
182
  wrapEmphasisAware,
172
183
  spanMask,
173
184
  collapse,
185
+ collapseHyphenAware,
174
186
  inferWidth,
175
187
  DEFAULT_WIDTH,
176
188
  }
@@ -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
- }