@skitterbyte/skitterspec 0.1.0 → 1.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.
Files changed (40) hide show
  1. package/README.md +97 -2
  2. package/assets/claude-md-section.md +11 -0
  3. package/assets/core/env.config.json.example +24 -0
  4. package/assets/core/env.config.md +83 -0
  5. package/assets/core/linear.config.json.example +39 -0
  6. package/assets/core/linear.config.md +121 -0
  7. package/assets/rules/spec-planning.md +29 -11
  8. package/assets/skills/spec/SKILL.md +61 -13
  9. package/assets/skills/spec-bug/SKILL.md +0 -4
  10. package/assets/skills/spec-cancel/SKILL.md +8 -3
  11. package/assets/skills/spec-complete/SKILL.md +10 -10
  12. package/assets/skills/spec-env/SKILL.md +57 -0
  13. package/assets/skills/spec-env-down/SKILL.md +56 -0
  14. package/assets/skills/spec-go/SKILL.md +39 -3
  15. package/assets/skills/spec-init/SKILL.md +0 -8
  16. package/assets/skills/spec-pull/SKILL.md +46 -0
  17. package/assets/skills/spec-push/SKILL.md +53 -0
  18. package/assets/skills/spec-ready/SKILL.md +0 -2
  19. package/assets/skills/spec-review/SKILL.md +2 -2
  20. package/assets/skills/spec-status/SKILL.md +46 -0
  21. package/bin/skitterspec.js +0 -0
  22. package/package.json +6 -6
  23. package/src/cli.js +497 -2
  24. package/src/env/config.js +152 -0
  25. package/src/env/provision.js +76 -0
  26. package/src/env/registry.js +95 -0
  27. package/src/env/render.js +26 -0
  28. package/src/env/resolve.js +184 -0
  29. package/src/env/teardown.js +94 -0
  30. package/src/init.js +82 -27
  31. package/src/prompts.js +23 -12
  32. package/src/sync/apply.js +66 -0
  33. package/src/sync/base.js +83 -0
  34. package/src/sync/compare.js +99 -0
  35. package/src/sync/config.js +198 -0
  36. package/src/sync/mcp.js +112 -0
  37. package/src/sync/normalize.js +249 -0
  38. package/src/sync/pull.js +84 -0
  39. package/src/sync/push.js +106 -0
  40. package/src/sync/write.js +86 -0
@@ -0,0 +1,112 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The Linear MCP boundary — the one place that knows concrete Linear tool names.
5
+ *
6
+ * `discoverLinear(tools)` resolves the operations the sync needs (read/update a
7
+ * Project, list/create/update Milestones + Issues) against the *connected*
8
+ * server's advertised tool list at runtime, rather than hardcoding names that
9
+ * drift. If Linear isn't connected (empty / zero-match tool list) it returns a
10
+ * clean `{ ok:false, error }` so the caller can stop and do nothing destructive.
11
+ *
12
+ * `makeAdapter(callTool, resolved)` wraps a generic `callTool(name, args)` (the
13
+ * skill's MCP invoker) into the typed async operations push/pull consume. Tests
14
+ * inject a fake adapter directly (an in-memory Project), so the engine stays
15
+ * offline and deterministic; production wires `callTool` to the real MCP server.
16
+ */
17
+
18
+ // Canonical operations, and the regexes that match a Linear MCP tool name to
19
+ // each. Ordered patterns: first match wins. Verified against the connected
20
+ // Linear MCP server during build (resolves the overview's Open questions).
21
+ const MATCHERS = {
22
+ projectRead: [/get_?project\b/i, /read_?project/i, /project_?get/i],
23
+ projectUpdate: [/update_?project/i, /project_?update/i],
24
+ projectCreate: [/create_?project/i, /project_?create/i],
25
+ milestoneList: [/list_?.*milestone/i, /milestones?_?list/i, /get_?.*milestones?/i],
26
+ milestoneCreate: [/create_?.*milestone/i, /milestone_?create/i],
27
+ milestoneUpdate: [/update_?.*milestone/i, /milestone_?update/i],
28
+ issueList: [/list_?issues?/i, /issues?_?list/i, /get_?issues?/i],
29
+ issueCreate: [/create_?issue/i, /issue_?create/i],
30
+ issueUpdate: [/update_?issue/i, /issue_?update/i],
31
+ }
32
+
33
+ // The minimum the push/pull engine can't run without. Milestone/issue ops are
34
+ // optional in Phase 2 (project description round-trips first).
35
+ const REQUIRED = ['projectRead', 'projectUpdate']
36
+
37
+ // Normalise a tools argument (array of strings or {name} objects) to names.
38
+ function toolNames(tools) {
39
+ if (!Array.isArray(tools)) return []
40
+ return tools
41
+ .map((t) => (typeof t === 'string' ? t : t && typeof t === 'object' ? t.name : null))
42
+ .filter((n) => typeof n === 'string' && n)
43
+ }
44
+
45
+ /**
46
+ * Resolve Linear operations against the connected server's tool list.
47
+ * @returns {{ok:true, tools:Record<string,string>}} on success, or
48
+ * {{ok:false, error:string, resolved?:object, missing?:string[]}}.
49
+ */
50
+ function discoverLinear(tools) {
51
+ const names = toolNames(tools)
52
+ if (!names.length) {
53
+ return {
54
+ ok: false,
55
+ error: 'Linear not connected — connect the `linear` MCP server, then retry.',
56
+ }
57
+ }
58
+
59
+ const resolved = {}
60
+ for (const [op, patterns] of Object.entries(MATCHERS)) {
61
+ const hit = names.find((n) => patterns.some((p) => p.test(n)))
62
+ if (hit) resolved[op] = hit
63
+ }
64
+
65
+ const missing = REQUIRED.filter((op) => !resolved[op])
66
+ if (missing.length) {
67
+ return {
68
+ ok: false,
69
+ error:
70
+ `Linear MCP is connected but missing required tools: ${missing.join(', ')}. ` +
71
+ 'Check the linear server exposes project read + update.',
72
+ resolved,
73
+ missing,
74
+ }
75
+ }
76
+
77
+ return { ok: true, tools: resolved }
78
+ }
79
+
80
+ /**
81
+ * Wrap a generic `callTool(name, args) → Promise<result>` into the typed ops the
82
+ * engine uses. `resolved` is `discoverLinear(...).tools`.
83
+ */
84
+ function makeAdapter(callTool, resolved) {
85
+ const need = (op) => {
86
+ const name = resolved[op]
87
+ if (!name) throw new Error(`Linear MCP op not available: ${op}`)
88
+ return name
89
+ }
90
+ return {
91
+ async readProject(id) {
92
+ return callTool(need('projectRead'), { id })
93
+ },
94
+ async updateProject(id, updates) {
95
+ return callTool(need('projectUpdate'), { id, ...updates })
96
+ },
97
+ async createMilestone(projectId, milestone) {
98
+ return callTool(need('milestoneCreate'), { projectId, ...milestone })
99
+ },
100
+ async updateMilestone(id, updates) {
101
+ return callTool(need('milestoneUpdate'), { id, ...updates })
102
+ },
103
+ }
104
+ }
105
+
106
+ module.exports = {
107
+ discoverLinear,
108
+ makeAdapter,
109
+ toolNames,
110
+ MATCHERS,
111
+ REQUIRED,
112
+ }
@@ -0,0 +1,249 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Normalize a Linear Project projection and a local spec snapshot into the SAME
5
+ * field set, so the three-way compare (compare.js) can diff them field by field.
6
+ *
7
+ * Both `normalizeLocal(snapshotDir, config)` and `normalizeRemote(project, config)`
8
+ * return an object whose keys are exactly `config.sync.fieldOwnership`'s keys —
9
+ * identical field sets by construction. A field a given side can't supply is
10
+ * `null` (scalars) or `[]` (collections), never absent, so the sets stay equal.
11
+ *
12
+ * Pure: `normalizeLocal` reads files under `snapshotDir` but makes no other side
13
+ * effects and no Date.now()/Math.random(). `localOnlySections` are stripped from
14
+ * the local `description` so they're never pushed to Linear.
15
+ */
16
+
17
+ const fs = require('node:fs')
18
+ const path = require('node:path')
19
+
20
+ // --- markdown / frontmatter parsing -----------------------------------------
21
+
22
+ // Split `---\n…\n---` frontmatter off the top. Returns { data, body }.
23
+ function parseFrontmatter(raw) {
24
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(raw)
25
+ if (!m) return { data: {}, body: raw }
26
+ const data = {}
27
+ for (const line of m[1].split('\n')) {
28
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
29
+ if (!kv) continue
30
+ data[kv[1]] = parseScalar(kv[2].trim())
31
+ }
32
+ return { data, body: raw.slice(m[0].length) }
33
+ }
34
+
35
+ // Parse a frontmatter scalar: quoted string, JSON array, number, or bare string.
36
+ function parseScalar(v) {
37
+ if (v === '') return null
38
+ const unq = /^["'](.*)["']$/.exec(v)
39
+ if (unq) return unq[1]
40
+ if (v.startsWith('[')) {
41
+ try {
42
+ return JSON.parse(v)
43
+ } catch {
44
+ return v
45
+ .replace(/^\[|\]$/g, '')
46
+ .split(',')
47
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
48
+ .filter(Boolean)
49
+ }
50
+ }
51
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v)
52
+ return v
53
+ }
54
+
55
+ // Split a markdown body into { title, sections } where sections maps a `## `
56
+ // heading text → its content (until the next `## `). The H1 `# ` is the title.
57
+ function parseSections(body) {
58
+ const lines = body.split('\n')
59
+ let title = null
60
+ const sections = {}
61
+ let current = null
62
+ let buf = []
63
+ const flush = () => {
64
+ if (current !== null) sections[current] = buf.join('\n').trim()
65
+ }
66
+ for (const line of lines) {
67
+ const h1 = /^#\s+(.*)$/.exec(line)
68
+ const h2 = /^##\s+(.*)$/.exec(line)
69
+ if (h1 && title === null) {
70
+ title = h1[1].trim()
71
+ continue
72
+ }
73
+ if (h2) {
74
+ flush()
75
+ current = h2[1].trim()
76
+ buf = []
77
+ continue
78
+ }
79
+ if (current !== null) buf.push(line)
80
+ }
81
+ flush()
82
+ return { title, sections }
83
+ }
84
+
85
+ // Canonical milestone status from the phase-index emoji.
86
+ const EMOJI_STATUS = { '⬜': 'not-started', '🔄': 'in-progress', '✅': 'done' }
87
+
88
+ // Parse the "## Phases" index table into [{ name, status }] rows.
89
+ function parsePhaseIndex(phasesSection) {
90
+ if (!phasesSection) return []
91
+ const rows = []
92
+ for (const line of phasesSection.split('\n')) {
93
+ // | 1 | Phase name | ✅ | [01-…](01-…) |
94
+ const cells = line.split('|').map((c) => c.trim())
95
+ if (cells.length < 5) continue
96
+ const n = cells[1]
97
+ if (!/^\d+$/.test(n)) continue // skip header + separator rows
98
+ const name = cells[2]
99
+ const emoji = (cells[3].match(/[⬜🔄✅]/u) || [])[0]
100
+ rows.push({ name, status: EMOJI_STATUS[emoji] || 'not-started' })
101
+ }
102
+ return rows
103
+ }
104
+
105
+ // Read the phase files (01-*.md, 02-*.md …) in execution order.
106
+ function readPhaseFiles(snapshotDir) {
107
+ let entries
108
+ try {
109
+ entries = fs.readdirSync(snapshotDir)
110
+ } catch {
111
+ return []
112
+ }
113
+ return entries
114
+ .filter((f) => /^\d\d-.*\.md$/.test(f) && !f.startsWith('00-'))
115
+ .sort()
116
+ .map((file) => {
117
+ const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
118
+ const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(raw) || [])[1] || ''
119
+ const tasks = (raw.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
120
+ t.replace(/^-\s*/, '').trim(),
121
+ )
122
+ return { phase: file.replace(/\.md$/, ''), goal: goal.trim(), tasks }
123
+ })
124
+ }
125
+
126
+ // --- ownership-driven field set ---------------------------------------------
127
+
128
+ // Reduce an `extracted` map to exactly the configured field keys, defaulting a
129
+ // missing field to `null` so local and remote always share an identical set.
130
+ function toFieldSet(extracted, config) {
131
+ const out = {}
132
+ for (const field of Object.keys(config.sync.fieldOwnership)) {
133
+ out[field] = field in extracted ? extracted[field] : null
134
+ }
135
+ return out
136
+ }
137
+
138
+ // --- local snapshot ---------------------------------------------------------
139
+
140
+ /**
141
+ * Read a spec snapshot (its 00-overview.md + phase files) into the raw pieces the
142
+ * extractors and callers need. Pure aside from reads under `snapshotDir`.
143
+ */
144
+ function readSnapshot(snapshotDir, config) {
145
+ const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
146
+ const raw = fs.readFileSync(path.join(snapshotDir, overviewFile), 'utf-8')
147
+ const { data, body } = parseFrontmatter(raw)
148
+ const { title, sections } = parseSections(body)
149
+ const phases = readPhaseFiles(snapshotDir)
150
+ return { frontmatter: data, title, sections, phases, body }
151
+ }
152
+
153
+ // Build the pushed description: the overview prose with local-only sections
154
+ // removed. Keeps the title line for context.
155
+ function buildDescription(title, sections, localOnlySections) {
156
+ const skip = new Set(localOnlySections || [])
157
+ const parts = []
158
+ if (title) parts.push(`# ${title}`)
159
+ for (const [heading, content] of Object.entries(sections)) {
160
+ if (skip.has(heading)) continue
161
+ parts.push(`## ${heading}\n\n${content}`.trim())
162
+ }
163
+ return parts.join('\n\n').trim() || null
164
+ }
165
+
166
+ /**
167
+ * Normalize a local spec snapshot into the configured field set.
168
+ */
169
+ function normalizeLocal(snapshotDir, config) {
170
+ const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
171
+ const extracted = {
172
+ description: buildDescription(title, sections, config.sync.localOnlySections),
173
+ milestones: parsePhaseIndex(sections.Phases),
174
+ phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
175
+ acceptanceCriteria: sections['Acceptance criteria'] || null,
176
+ taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
177
+ workflowState: frontmatter.spec_status != null ? String(frontmatter.spec_status) : null,
178
+ priority: frontmatter.priority != null ? frontmatter.priority : null,
179
+ labels: Array.isArray(frontmatter.labels) ? frontmatter.labels : [],
180
+ }
181
+ return toFieldSet(extracted, config)
182
+ }
183
+
184
+ // --- remote projection ------------------------------------------------------
185
+
186
+ // Map a Linear workflow-state name back to the local lifecycle bucket (the
187
+ // vocabulary `spec_status` uses) via config.states, so local and remote
188
+ // workflowState hash equal when semantically equal. Falls back to a lowercased
189
+ // raw value when the state isn't one of the configured names.
190
+ function bucketForState(state, config) {
191
+ if (state == null) return null
192
+ const states = (config && config.states) || {}
193
+ const want = String(state).toLowerCase().trim()
194
+ for (const [bucket, name] of Object.entries(states)) {
195
+ if (typeof name === 'string' && name.toLowerCase().trim() === want) return bucket
196
+ }
197
+ return want
198
+ }
199
+
200
+ // Canonicalise a Linear workflow-state name into the same vocabulary the local
201
+ // milestone emojis use, so equal states hash equal.
202
+ function canonicalRemoteStatus(state) {
203
+ const s = String(state || '').toLowerCase().trim()
204
+ if (!s) return 'not-started'
205
+ if (/(done|complete|completed|merged)/.test(s)) return 'done'
206
+ if (/(progress|started|doing|review)/.test(s)) return 'in-progress'
207
+ if (/(backlog|todo|planned|triage)/.test(s)) return 'not-started'
208
+ return s
209
+ }
210
+
211
+ /**
212
+ * Normalize a Linear Project projection (from the Phase 2 MCP adapter, or a
213
+ * fixture) into the same field set as `normalizeLocal`.
214
+ */
215
+ function normalizeRemote(project, config) {
216
+ const p = project || {}
217
+ const milestones = Array.isArray(p.milestones) ? p.milestones : []
218
+ const extracted = {
219
+ description: p.description != null ? p.description : null,
220
+ milestones: milestones.map((m) => ({
221
+ name: m.name,
222
+ status: canonicalRemoteStatus(m.status != null ? m.status : m.state),
223
+ })),
224
+ phaseBodies: milestones.map((m) => ({
225
+ phase: m.name,
226
+ goal: (m.description != null ? m.description : '').trim(),
227
+ })),
228
+ acceptanceCriteria: p.acceptanceCriteria != null ? p.acceptanceCriteria : null,
229
+ taskBreakdown: milestones.map((m) => ({
230
+ phase: m.name,
231
+ tasks: Array.isArray(m.tasks) ? m.tasks : [],
232
+ })),
233
+ workflowState: p.state != null ? bucketForState(p.state, config) : null,
234
+ priority: p.priority != null ? p.priority : null,
235
+ labels: Array.isArray(p.labels) ? p.labels : [],
236
+ }
237
+ return toFieldSet(extracted, config)
238
+ }
239
+
240
+ module.exports = {
241
+ normalizeLocal,
242
+ normalizeRemote,
243
+ readSnapshot,
244
+ parseFrontmatter,
245
+ parseSections,
246
+ parsePhaseIndex,
247
+ canonicalRemoteStatus,
248
+ bucketForState,
249
+ }
@@ -0,0 +1,84 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * `pull` — Linear → 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 } = require('./write.js')
22
+ const { frontmatterPatchFor } = require('./apply.js')
23
+
24
+ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
25
+ const local = normalizeLocal(snapshotDir, config)
26
+ const remoteRaw = await adapter.readProject(projectId)
27
+ if (!remoteRaw) {
28
+ return { ok: false, error: `Linear project not found: ${projectId}` }
29
+ }
30
+ const remote = normalizeRemote(remoteRaw, config)
31
+ const base = readBase(dir, identifier, config)
32
+ const fields = classify(local, remote, base, config)
33
+
34
+ const conflicts = fields.filter((f) => f.status === 'conflict').map((f) => f.field)
35
+ if (conflicts.length && !force) {
36
+ return {
37
+ ok: false,
38
+ blocked: true,
39
+ reason: 'conflict',
40
+ conflicts,
41
+ message: `pull refused — ${conflicts.length} field(s) changed on both sides: ` +
42
+ `${conflicts.join(', ')}. Resolve locally or re-run with --force (remote wins).`,
43
+ }
44
+ }
45
+
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
+ // --force overwrites local edits — back the local side up first.
55
+ let backupPath = null
56
+ if (force) {
57
+ backupPath = backup('local', dir, identifier, config, { timestamp, data: local })
58
+ }
59
+
60
+ // Apply frontmatter-mapped fields + stamp the sync.
61
+ if (applied.length || timestamp) {
62
+ writeFrontmatter(snapshotDir, config, { ...patch, last_synced_at: timestamp })
63
+ }
64
+
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 }
68
+ for (const field of applied) newBase[field] = remote[field]
69
+ newBase.__meta = { updatedAt: remoteRaw.updatedAt || null, syncedAt: timestamp }
70
+ const basePath = writeBase(dir, identifier, config, newBase)
71
+
72
+ return {
73
+ ok: true,
74
+ blocked: false,
75
+ applied,
76
+ deferred,
77
+ conflictsForced: force ? conflicts : [],
78
+ backupPath,
79
+ basePath,
80
+ pulled: pullFields.map((f) => f.field),
81
+ }
82
+ }
83
+
84
+ module.exports = { pull }
@@ -0,0 +1,106 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * `push` — repo → Linear, three-way aware and ownership-respecting.
5
+ *
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`.
13
+ *
14
+ * Pure orchestration over an injected `adapter` (readProject + updateProject) and
15
+ * injected `timestamp`. Tests drive it 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 } = require('./write.js')
22
+
23
+ async function push({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
24
+ const local = normalizeLocal(snapshotDir, config)
25
+ const remoteRaw = await adapter.readProject(projectId)
26
+ if (!remoteRaw) {
27
+ return { ok: false, error: `Linear project not found: ${projectId}` }
28
+ }
29
+ const remote = normalizeRemote(remoteRaw, config)
30
+ const base = readBase(dir, identifier, config)
31
+ const baseStamp = base && base.__meta ? base.__meta.updatedAt : null
32
+ const fields = classify(local, remote, base, config)
33
+
34
+ // Remote moved past base if the classifier sees remote-side divergence OR the
35
+ // recorded updatedAt no longer matches (a change we can't even see as a field).
36
+ const remoteDivergedFields = fields
37
+ .filter((f) => f.raw === 'remote-only' || f.raw === 'conflict')
38
+ .map((f) => f.field)
39
+ const stampMoved = baseStamp != null && remoteRaw.updatedAt !== baseStamp
40
+ const moved = remoteDivergedFields.length > 0 || stampMoved
41
+
42
+ if (moved && !force) {
43
+ return {
44
+ ok: false,
45
+ blocked: true,
46
+ reason: 'remote-moved',
47
+ movedFields: remoteDivergedFields,
48
+ message:
49
+ 'push refused — Linear moved since the last sync' +
50
+ (remoteDivergedFields.length ? ` (${remoteDivergedFields.join(', ')})` : '') +
51
+ '. Pull first, or re-run with --force (local wins).',
52
+ }
53
+ }
54
+
55
+ const pushFields = fields.filter((f) => f.pushable)
56
+ if (!pushFields.length && !force) {
57
+ return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
58
+ }
59
+
60
+ // Optimistic concurrency: re-read immediately before writing to catch a racer.
61
+ const remoteRaw2 = await adapter.readProject(projectId)
62
+ if (remoteRaw2 && remoteRaw2.updatedAt !== remoteRaw.updatedAt && !force) {
63
+ return {
64
+ ok: false,
65
+ blocked: true,
66
+ reason: 'concurrent-write',
67
+ message: 'push refused — Linear changed during the push. Pull first, or --force.',
68
+ }
69
+ }
70
+
71
+ // --force clobbers the remote side — back it up first.
72
+ let backupPath = null
73
+ if (force) {
74
+ backupPath = backup('remote', dir, identifier, config, { timestamp, data: remoteRaw2 || remoteRaw })
75
+ }
76
+
77
+ const updates = {}
78
+ for (const f of pushFields) updates[f.field] = local[f.field]
79
+ const updated = (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
80
+ const updatedRemote = normalizeRemote(updated, config)
81
+
82
+ // Reconciled base: local is the source of truth for the fields we pushed (and
83
+ // for unchanged/local-only fields); pull-owned fields keep Linear's value so
84
+ // they don't read as pending next time.
85
+ const newBase = { ...local }
86
+ for (const [field, own] of Object.entries(config.sync.fieldOwnership)) {
87
+ if (own === 'pull') newBase[field] = updatedRemote[field]
88
+ }
89
+ newBase.__meta = { updatedAt: updated.updatedAt || null, syncedAt: timestamp }
90
+ const basePath = writeBase(dir, identifier, config, newBase)
91
+
92
+ if (timestamp) writeFrontmatter(snapshotDir, config, { last_synced_at: timestamp })
93
+
94
+ return {
95
+ ok: true,
96
+ blocked: false,
97
+ written: pushFields.map((f) => f.field),
98
+ skipped: fields
99
+ .filter((f) => !f.pushable && f.status !== 'unchanged')
100
+ .map((f) => f.field),
101
+ backupPath,
102
+ basePath,
103
+ }
104
+ }
105
+
106
+ module.exports = { push }
@@ -0,0 +1,86 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Local snapshot writes for pull (Linear → repo).
5
+ *
6
+ * Phase 2 applies **frontmatter-mapped** pulled fields — the `pull`-owned data
7
+ * Linear 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.
12
+ *
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.
17
+ */
18
+
19
+ const fs = require('node:fs')
20
+ const path = require('node:path')
21
+
22
+ // Serialize a JS value as a YAML-ish frontmatter scalar. null/undefined → the
23
+ // key is dropped (caller shouldn't pass those).
24
+ function serialize(value) {
25
+ if (Array.isArray(value)) return JSON.stringify(value)
26
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
27
+ return JSON.stringify(String(value)) // quoted string
28
+ }
29
+
30
+ // Split `---\n…\n---\n` frontmatter off the top. Returns { fmLines, body, had }.
31
+ function splitFrontmatter(raw) {
32
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(raw)
33
+ if (!m) return { fmLines: [], body: raw, had: false }
34
+ return { fmLines: m[1].split('\n'), body: raw.slice(m[0].length), had: true }
35
+ }
36
+
37
+ // Apply a key→value patch onto frontmatter lines, preserving order.
38
+ function patchFrontmatterLines(lines, patch) {
39
+ const keys = new Set(Object.keys(patch))
40
+ const out = []
41
+ const seen = new Set()
42
+ for (const line of lines) {
43
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
44
+ if (kv && keys.has(kv[1])) {
45
+ out.push(`${kv[1]}: ${serialize(patch[kv[1]])}`)
46
+ seen.add(kv[1])
47
+ } else {
48
+ out.push(line)
49
+ }
50
+ }
51
+ // Append any new keys not already present.
52
+ for (const key of Object.keys(patch)) {
53
+ if (!seen.has(key)) out.push(`${key}: ${serialize(patch[key])}`)
54
+ }
55
+ return out
56
+ }
57
+
58
+ /**
59
+ * Update `00-overview.md` frontmatter under `snapshotDir` with `patch`
60
+ * (key → value; nullish values are skipped). Returns the list of keys written.
61
+ */
62
+ function writeFrontmatter(snapshotDir, config, patch) {
63
+ const overviewFile = (config && config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
64
+ const file = path.join(snapshotDir, overviewFile)
65
+ const raw = fs.readFileSync(file, 'utf-8')
66
+
67
+ const clean = {}
68
+ for (const [k, v] of Object.entries(patch)) {
69
+ if (v !== null && v !== undefined) clean[k] = v
70
+ }
71
+ if (!Object.keys(clean).length) return []
72
+
73
+ const { fmLines, body, had } = splitFrontmatter(raw)
74
+ const patched = patchFrontmatterLines(fmLines, clean)
75
+ const frontmatter = `---\n${patched.join('\n')}\n---\n`
76
+ const next = had ? frontmatter + body : frontmatter + '\n' + raw
77
+
78
+ fs.writeFileSync(file, next, 'utf-8')
79
+ return Object.keys(clean)
80
+ }
81
+
82
+ module.exports = {
83
+ writeFrontmatter,
84
+ splitFrontmatter,
85
+ serialize,
86
+ }