@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.
- package/assets/core/SETUP.md +46 -51
- package/assets/core/linear.config.json.example +2 -2
- package/assets/core/linear.config.md +9 -7
- package/assets/rules/spec-planning.md +13 -7
- package/assets/skills/spec-go/SKILL.md +12 -13
- package/assets/skills/spec-push/SKILL.md +53 -47
- package/assets/skills/spec-status/SKILL.md +31 -26
- package/bin/skitterspec-linear.js +5 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sanitise.js +0 -0
- package/src/vendor/linear/cli-sync.js +123 -204
- package/src/vendor/linear/config.js +14 -6
- package/src/vendor/sync-core/index.js +25 -19
- package/src/vendor/sync-core/src/base.js +8 -10
- package/src/vendor/sync-core/src/compare.js +83 -174
- package/src/vendor/sync-core/src/normalize.js +186 -82
- package/src/vendor/sync-core/src/push.js +39 -133
- package/src/vendor/sync-core/src/sanitise.js +143 -0
- package/src/vendor/sync-core/src/task-block.js +104 -11
- package/src/vendor/sync-core/src/write.js +15 -205
- package/assets/skills/spec-pull/SKILL.md +0 -49
- package/src/vendor/sync-core/src/apply.js +0 -66
- package/src/vendor/sync-core/src/pull.js +0 -115
|
@@ -1,32 +1,25 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
109
|
-
|
|
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 }
|