@skitterbyte/skitterspec-linear 1.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.
Files changed (45) hide show
  1. package/README.md +56 -0
  2. package/assets/claude-md-section.md +39 -0
  3. package/assets/core/env.config.json.example +28 -0
  4. package/assets/core/env.config.md +99 -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 +152 -0
  8. package/assets/skills/spec/SKILL.md +232 -0
  9. package/assets/skills/spec-bug/SKILL.md +110 -0
  10. package/assets/skills/spec-cancel/SKILL.md +61 -0
  11. package/assets/skills/spec-complete/SKILL.md +87 -0
  12. package/assets/skills/spec-env/SKILL.md +63 -0
  13. package/assets/skills/spec-env-down/SKILL.md +64 -0
  14. package/assets/skills/spec-go/SKILL.md +134 -0
  15. package/assets/skills/spec-init/SKILL.md +84 -0
  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 +50 -0
  19. package/assets/skills/spec-review/SKILL.md +69 -0
  20. package/assets/skills/spec-status/SKILL.md +46 -0
  21. package/bin/skitterspec-linear.js +26 -0
  22. package/package.json +38 -0
  23. package/src/cli.js +495 -0
  24. package/src/deprecate.js +138 -0
  25. package/src/env/config.js +165 -0
  26. package/src/env/integrate.js +46 -0
  27. package/src/env/provision.js +76 -0
  28. package/src/env/registry.js +95 -0
  29. package/src/env/render.js +26 -0
  30. package/src/env/resolve.js +202 -0
  31. package/src/env/teardown.js +109 -0
  32. package/src/env/trust.js +87 -0
  33. package/src/init.js +311 -0
  34. package/src/prompts.js +56 -0
  35. package/src/vendor/linear/cli-sync.js +256 -0
  36. package/src/vendor/linear/config.js +198 -0
  37. package/src/vendor/linear/mcp.js +112 -0
  38. package/src/vendor/sync-core/index.js +35 -0
  39. package/src/vendor/sync-core/src/apply.js +66 -0
  40. package/src/vendor/sync-core/src/base.js +83 -0
  41. package/src/vendor/sync-core/src/compare.js +99 -0
  42. package/src/vendor/sync-core/src/normalize.js +249 -0
  43. package/src/vendor/sync-core/src/pull.js +84 -0
  44. package/src/vendor/sync-core/src/push.js +106 -0
  45. package/src/vendor/sync-core/src/write.js +86 -0
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The committed base sidecar + the backup-before-force reflog.
5
+ *
6
+ * The base is the last-synced snapshot per spec, stored at
7
+ * `{sync.baseDir}/{identifier}.base.json` and committed so each worktree carries
8
+ * its own base and the three-way divergence check stays accurate. After any
9
+ * successful pull/push/force the engine rewrites it (`writeBase`).
10
+ *
11
+ * `backup(side, …)` lands the about-to-be-clobbered side under `{sync.backupDir}`
12
+ * BEFORE a `--force` overwrites it — force never destroys without first writing a
13
+ * copy. The filename carries a caller-supplied timestamp (the engine takes no
14
+ * Date.now(), for reproducible tests) and is made collision-safe with a counter.
15
+ */
16
+
17
+ const fs = require('node:fs')
18
+ const path = require('node:path')
19
+
20
+ function baseFile(dir, identifier, config) {
21
+ return path.join(dir, config.sync.baseDir, `${identifier}.base.json`)
22
+ }
23
+
24
+ /**
25
+ * Read a spec's committed base. Returns the parsed object, or `null` when no base
26
+ * exists yet (never synced) — the compare treats null as "no prior state".
27
+ */
28
+ function readBase(dir, identifier, config) {
29
+ const file = baseFile(dir, identifier, config)
30
+ let raw
31
+ try {
32
+ raw = fs.readFileSync(file, 'utf-8')
33
+ } catch (error) {
34
+ if (error.code === 'ENOENT') return null
35
+ throw error
36
+ }
37
+ try {
38
+ return JSON.parse(raw)
39
+ } catch (error) {
40
+ throw new Error(`Invalid base ${path.relative(dir, file)}: ${error.message}`)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Rewrite a spec's committed base with the freshly-synced field set. Creates
46
+ * `{sync.baseDir}` if needed. Returns the absolute path written.
47
+ */
48
+ function writeBase(dir, identifier, config, data) {
49
+ const file = baseFile(dir, identifier, config)
50
+ fs.mkdirSync(path.dirname(file), { recursive: true })
51
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
52
+ return file
53
+ }
54
+
55
+ /**
56
+ * Back up the about-to-be-clobbered `side` ('local' | 'remote') into
57
+ * `{sync.backupDir}` before a --force. `timestamp` is caller-supplied (the engine
58
+ * never reads the clock); the name is made collision-safe with a `-N` counter.
59
+ * Returns the absolute path written, or null when `data` is nullish (nothing to
60
+ * back up — e.g. forcing a pull with no prior remote).
61
+ */
62
+ function backup(side, dir, identifier, config, { timestamp, data }) {
63
+ if (data == null) return null
64
+ const backupRoot = path.join(dir, config.sync.backupDir)
65
+ fs.mkdirSync(backupRoot, { recursive: true })
66
+
67
+ const stem = `${identifier}.${side}.${timestamp}`
68
+ let file = path.join(backupRoot, `${stem}.json`)
69
+ let n = 1
70
+ while (fs.existsSync(file)) {
71
+ file = path.join(backupRoot, `${stem}-${n}.json`)
72
+ n += 1
73
+ }
74
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
75
+ return file
76
+ }
77
+
78
+ module.exports = {
79
+ readBase,
80
+ writeBase,
81
+ backup,
82
+ baseFile,
83
+ }
@@ -0,0 +1,99 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The three-way compare at the heart of the hybrid sync.
5
+ *
6
+ * `classify(local, remote, base, config)` compares each configured field across
7
+ * the local snapshot, the remote (remote) projection, and the committed base
8
+ * (the last-synced state). Per field it returns a raw three-way `status`
9
+ * (unchanged / local-only / remote-only / conflict), then collapses it through
10
+ * the field's ownership (`both|pull|push`) into effective `pushable` / `pullable`
11
+ * flags. Ownership is what makes most "both sides differ" cases *not* a real
12
+ * conflict:
13
+ * - a `pull` field never pushes (remote wins) → conflict collapses to remote-only
14
+ * - a `push` field never pulls (repo wins) → conflict collapses to local-only
15
+ * - only a `both` field where both sides moved off base is a true `conflict`.
16
+ *
17
+ * Pure and deterministic: field identity is a stable content hash (sorted-key
18
+ * JSON → SHA-1), so `null`, `undefined`, and a missing base all compare equal,
19
+ * and object key order never causes a false diff. No Date.now()/Math.random().
20
+ */
21
+
22
+ const { createHash } = require('node:crypto')
23
+
24
+ // Deterministic JSON: object keys sorted recursively; array order preserved
25
+ // (order is meaningful for milestones/tasks). undefined normalises to null.
26
+ function stableStringify(value) {
27
+ if (value === undefined || value === null) return 'null'
28
+ if (Array.isArray(value)) {
29
+ return '[' + value.map(stableStringify).join(',') + ']'
30
+ }
31
+ if (typeof value === 'object') {
32
+ const keys = Object.keys(value).sort()
33
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'
34
+ }
35
+ return JSON.stringify(value)
36
+ }
37
+
38
+ // Stable content hash of a single field value.
39
+ function hashField(value) {
40
+ return createHash('sha1').update(stableStringify(value)).digest('hex')
41
+ }
42
+
43
+ // Raw three-way status from the three hashes.
44
+ function rawStatus(localH, remoteH, baseH) {
45
+ const localChanged = localH !== baseH
46
+ const remoteChanged = remoteH !== baseH
47
+ if (!localChanged && !remoteChanged) return 'unchanged'
48
+ if (localChanged && !remoteChanged) return 'local-only'
49
+ if (!localChanged && remoteChanged) return 'remote-only'
50
+ // both moved off base — but they may have converged on the same value.
51
+ if (localH === remoteH) return 'unchanged'
52
+ return 'conflict'
53
+ }
54
+
55
+ // Collapse the raw status through ownership into an effective status + flags.
56
+ function collapse(raw, ownership) {
57
+ const canPush = ownership === 'both' || ownership === 'push'
58
+ const canPull = ownership === 'both' || ownership === 'pull'
59
+
60
+ if (raw === 'unchanged') return { status: 'unchanged', pushable: false, pullable: false }
61
+ if (raw === 'local-only') return { status: 'local-only', pushable: canPush, pullable: false }
62
+ if (raw === 'remote-only') return { status: 'remote-only', pushable: false, pullable: canPull }
63
+
64
+ // conflict: both sides diverged off base.
65
+ if (ownership === 'push') return { status: 'local-only', pushable: true, pullable: false }
66
+ if (ownership === 'pull') return { status: 'remote-only', pushable: false, pullable: true }
67
+ return { status: 'conflict', pushable: true, pullable: true }
68
+ }
69
+
70
+ /**
71
+ * Classify every field in `config.sync.fieldOwnership`.
72
+ *
73
+ * @param {object} local normalized local snapshot (normalizeLocal output)
74
+ * @param {object} remote normalized remote projection (normalizeRemote output)
75
+ * @param {object|null} base the committed base (same shape) or null (never synced)
76
+ * @returns {Array<{field, ownership, raw, status, pushable, pullable}>}
77
+ * one entry per configured field, in config order.
78
+ */
79
+ function classify(local, remote, base, config) {
80
+ const ownership = config.sync.fieldOwnership
81
+ const baseObj = base || {}
82
+ return Object.keys(ownership).map((field) => {
83
+ const own = ownership[field]
84
+ const localH = hashField(local ? local[field] : null)
85
+ const remoteH = hashField(remote ? remote[field] : null)
86
+ const baseH = hashField(field in baseObj ? baseObj[field] : null)
87
+ const raw = rawStatus(localH, remoteH, baseH)
88
+ const { status, pushable, pullable } = collapse(raw, own)
89
+ return { field, ownership: own, raw, status, pushable, pullable }
90
+ })
91
+ }
92
+
93
+ module.exports = {
94
+ classify,
95
+ hashField,
96
+ stableStringify,
97
+ rawStatus,
98
+ collapse,
99
+ }
@@ -0,0 +1,249 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Normalize a remote 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 remote.
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 remote 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 remote 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 remote 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` — 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 } = 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: `remote 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 → remote, 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: `remote 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 — remote 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 — remote 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 remote'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 }