@skitterbyte/skitterspec-linear 3.4.0 → 5.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/README.md +45 -5
- package/assets/core/SETUP.md +11 -5
- package/assets/core/env.config.json.example +3 -0
- package/assets/core/env.config.md +9 -0
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +38 -1
- package/assets/rules/spec-planning.md +14 -0
- package/assets/skills/spec-complete/SKILL.md +6 -0
- package/assets/skills/spec-connect/SKILL.md +6 -0
- package/assets/skills/spec-go/SKILL.md +3 -1
- package/assets/skills/spec-init/SKILL.md +8 -0
- package/assets/skills/spec-live/SKILL.md +70 -0
- package/assets/skills/spec-pull/SKILL.md +4 -1
- package/assets/skills/spec-push/SKILL.md +21 -0
- package/package.json +1 -1
- package/src/cli.js +377 -29
- package/src/env/config.js +12 -1
- package/src/env/live.js +348 -0
- package/src/env/resolve.js +25 -0
- package/src/init.js +241 -6
- package/src/prompts.js +37 -1
- package/src/vendor/linear/cli-sync.js +32 -1
- package/src/vendor/linear/config.js +22 -0
- package/src/vendor/linear/mcp.js +17 -0
- package/src/vendor/sync-core/src/compare.js +116 -0
- package/src/vendor/sync-core/src/normalize.js +92 -26
- package/src/vendor/sync-core/src/pull.js +47 -16
- package/src/vendor/sync-core/src/push.js +48 -9
- package/src/vendor/sync-core/src/write.js +247 -0
|
@@ -32,13 +32,17 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
32
32
|
|
|
33
33
|
// Remote moved past base only if a *co-authored* (`both`) field diverged on the
|
|
34
34
|
// remote side — that's the case the repo can't safely overwrite without a pull.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// a
|
|
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.
|
|
39
40
|
const remoteDivergedFields = fields
|
|
40
|
-
.filter((f) => f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
|
|
41
|
+
.filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
|
|
41
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
|
+
}
|
|
42
46
|
const moved = remoteDivergedFields.length > 0
|
|
43
47
|
|
|
44
48
|
if (moved && !force) {
|
|
@@ -54,8 +58,37 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
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) {
|
|
59
92
|
return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
|
|
60
93
|
}
|
|
61
94
|
|
|
@@ -78,7 +111,9 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
78
111
|
|
|
79
112
|
const updates = {}
|
|
80
113
|
for (const f of pushFields) updates[f.field] = local[f.field]
|
|
81
|
-
const updated =
|
|
114
|
+
const updated = Object.keys(updates).length
|
|
115
|
+
? (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
|
|
116
|
+
: remoteRaw2 || remoteRaw
|
|
82
117
|
const updatedRemote = normalizeRemote(updated, config)
|
|
83
118
|
|
|
84
119
|
// Reconciled base: local is the source of truth for the fields we pushed (and
|
|
@@ -97,8 +132,12 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
97
132
|
ok: true,
|
|
98
133
|
blocked: false,
|
|
99
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 } : {}),
|
|
100
139
|
skipped: fields
|
|
101
|
-
.filter((f) => !f.pushable && f.status !== 'unchanged')
|
|
140
|
+
.filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
|
|
102
141
|
.map((f) => f.field),
|
|
103
142
|
backupPath,
|
|
104
143
|
basePath,
|
|
@@ -79,8 +79,255 @@ function writeFrontmatter(snapshotDir, config, patch) {
|
|
|
79
79
|
return Object.keys(clean)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// --- phase-file denormalizer (keyed milestone pull) ------------------------
|
|
83
|
+
//
|
|
84
|
+
// Writes pulled milestone edits back into the *body* — the phase files — which
|
|
85
|
+
// the frontmatter writer above never touches. An edit updates the matching phase
|
|
86
|
+
// file (by its linear_milestone_id) in place, leaving everything else
|
|
87
|
+
// byte-untouched; a Linear-only milestone becomes a new phase file. Removals are
|
|
88
|
+
// never applied here (report-only, Decision 7).
|
|
89
|
+
|
|
90
|
+
// Phase files in a snapshot dir (01-*.md …), execution order.
|
|
91
|
+
function listPhaseFiles(snapshotDir) {
|
|
92
|
+
try {
|
|
93
|
+
return fs
|
|
94
|
+
.readdirSync(snapshotDir)
|
|
95
|
+
.filter((f) => /^\d\d-.*\.md$/.test(f) && !f.startsWith('00-'))
|
|
96
|
+
.sort()
|
|
97
|
+
} catch {
|
|
98
|
+
return []
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The linear_milestone_id recorded in a phase file's frontmatter, or null.
|
|
103
|
+
function phaseMilestoneId(raw) {
|
|
104
|
+
const { fmLines } = splitFrontmatter(raw)
|
|
105
|
+
for (const line of fmLines) {
|
|
106
|
+
const m = /^linear_milestone_id:\s*(.*)$/.exec(line)
|
|
107
|
+
if (m) return m[1].trim().replace(/^["']|["']$/g, '') || null
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Find the phase file linked to a milestone id, or null.
|
|
113
|
+
function findPhaseFileByMilestoneId(snapshotDir, id) {
|
|
114
|
+
const want = String(id)
|
|
115
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
116
|
+
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
117
|
+
if (phaseMilestoneId(raw) === want) return file
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Find the phase file whose h1 title matches `name` (used to link a freshly
|
|
123
|
+
// created milestone back to the phase it came from, before it has an id).
|
|
124
|
+
function findPhaseFileByTitle(snapshotDir, name) {
|
|
125
|
+
const want = String(name).trim()
|
|
126
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
127
|
+
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
128
|
+
const h1 = /^#\s+(.*)$/m.exec(splitFrontmatter(raw).body)
|
|
129
|
+
if (!h1) continue
|
|
130
|
+
const title = h1[1]
|
|
131
|
+
.replace(/\s*[⬜🔄✅]\s*$/u, '')
|
|
132
|
+
.replace(/^Phase\s+\d+\s*[—–-]\s*/i, '')
|
|
133
|
+
.trim()
|
|
134
|
+
if (title === want) return file
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Update a phase file's title (h1, preserving the "Phase N — " prefix + status
|
|
140
|
+
// emoji) and its `**Goal:**` line, leaving everything else untouched.
|
|
141
|
+
function writeMilestoneFields(snapshotDir, file, { name, goal }) {
|
|
142
|
+
const p = path.join(snapshotDir, file)
|
|
143
|
+
let raw = fs.readFileSync(p, 'utf-8')
|
|
144
|
+
if (name != null) {
|
|
145
|
+
raw = raw.replace(/^(#[ \t]+)(.*)$/m, (_full, hash, rest) => {
|
|
146
|
+
const pm = /^(Phase\s+\d+\s*[—–-]\s*)(.*?)(\s*[⬜🔄✅])?\s*$/.exec(rest)
|
|
147
|
+
return pm ? `${hash}${pm[1]}${name}${pm[3] || ''}` : `${hash}${name}`
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
if (goal != null && /^\*\*Goal:\*\*/m.test(raw)) {
|
|
151
|
+
raw = raw.replace(/^(\*\*Goal:\*\*[ \t]*).*$/m, `$1${goal}`)
|
|
152
|
+
}
|
|
153
|
+
fs.writeFileSync(p, raw, 'utf-8')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Add/update linear_milestone_id in a phase file's frontmatter (in place).
|
|
157
|
+
function stampMilestoneId(snapshotDir, file, id) {
|
|
158
|
+
const p = path.join(snapshotDir, file)
|
|
159
|
+
const raw = fs.readFileSync(p, 'utf-8')
|
|
160
|
+
const { fmLines, body, had } = splitFrontmatter(raw)
|
|
161
|
+
const patched = patchFrontmatterLines(fmLines, { linear_milestone_id: String(id) })
|
|
162
|
+
const fm = `---\n${patched.join('\n')}\n---\n`
|
|
163
|
+
fs.writeFileSync(p, had ? fm + body : fm + '\n' + raw, 'utf-8')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const slugify = (name) =>
|
|
167
|
+
String(name || 'phase')
|
|
168
|
+
.toLowerCase()
|
|
169
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
170
|
+
.replace(/^-+|-+$/g, '')
|
|
171
|
+
.slice(0, 40) || 'phase'
|
|
172
|
+
|
|
173
|
+
// Next phase number (max existing + 1).
|
|
174
|
+
function nextPhaseNumber(snapshotDir) {
|
|
175
|
+
const nums = listPhaseFiles(snapshotDir)
|
|
176
|
+
.map((f) => parseInt(f.slice(0, 2), 10))
|
|
177
|
+
.filter(Number.isFinite)
|
|
178
|
+
return nums.length ? Math.max(...nums) + 1 : 1
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Create a new phase file for a Linear-only milestone. Returns the filename.
|
|
182
|
+
function createPhaseFileForMilestone(snapshotDir, { id, name, goal }) {
|
|
183
|
+
const n = nextPhaseNumber(snapshotDir)
|
|
184
|
+
const file = `${String(n).padStart(2, '0')}-${slugify(name)}.md`
|
|
185
|
+
const content =
|
|
186
|
+
`---\nlinear_milestone_id: ${JSON.stringify(String(id))}\n---\n\n` +
|
|
187
|
+
`# Phase ${n} — ${name || 'Untitled'} ⬜\n\n` +
|
|
188
|
+
`> Spec: [00-overview.md](00-overview.md) · **Status:** Not started\n\n` +
|
|
189
|
+
`**Goal:** ${goal || ''}\n\n## Tasks\n\n- [ ] (pulled from Linear — flesh out)\n`
|
|
190
|
+
fs.writeFileSync(path.join(snapshotDir, file), content, 'utf-8')
|
|
191
|
+
return file
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Apply a pull's keyed milestone item outcomes to the phase files.
|
|
196
|
+
* @param items classifyItems output for the milestones field.
|
|
197
|
+
* @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
|
|
198
|
+
*/
|
|
199
|
+
function applyMilestonesPull(snapshotDir, items) {
|
|
200
|
+
const applied = []
|
|
201
|
+
const created = []
|
|
202
|
+
const reported = []
|
|
203
|
+
for (const it of items || []) {
|
|
204
|
+
if (it.report) {
|
|
205
|
+
reported.push(it.id)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (!it.pullable || !it.remote) continue
|
|
209
|
+
if (it.status === 'added') {
|
|
210
|
+
const file = createPhaseFileForMilestone(snapshotDir, it.remote)
|
|
211
|
+
created.push({ id: it.id, file })
|
|
212
|
+
} else if (it.status === 'edited' || it.status === 'conflict') {
|
|
213
|
+
const file = findPhaseFileByMilestoneId(snapshotDir, it.id)
|
|
214
|
+
if (file) {
|
|
215
|
+
writeMilestoneFields(snapshotDir, file, it.remote)
|
|
216
|
+
applied.push(it.id)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { applied, created, reported }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- task-line denormalizer (keyed issue pull) -----------------------------
|
|
224
|
+
//
|
|
225
|
+
// Tasks live as checkbox lines inside phase files. A pulled issue edit rewrites
|
|
226
|
+
// the matching line (by its inline id) in place; a Linear-only issue appends a
|
|
227
|
+
// new task line; a created issue's id is stamped inline. Removals report-only.
|
|
228
|
+
|
|
229
|
+
const TASK_RE = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/
|
|
230
|
+
const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
|
|
231
|
+
|
|
232
|
+
// Render a task line from an item.
|
|
233
|
+
function taskLine(indent, { id, text, done }) {
|
|
234
|
+
return `${indent}- [${done ? 'x' : ' '}] ${text}${id ? ` (${id})` : ''}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Update the task line carrying inline id `id` (text + checkbox), in place.
|
|
238
|
+
function updateTaskLine(snapshotDir, id, { text, done }) {
|
|
239
|
+
const want = String(id)
|
|
240
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
241
|
+
const p = path.join(snapshotDir, file)
|
|
242
|
+
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
243
|
+
for (let i = 0; i < lines.length; i++) {
|
|
244
|
+
const m = TASK_RE.exec(lines[i])
|
|
245
|
+
if (!m) continue
|
|
246
|
+
const idm = INLINE_ID_RE.exec(m[3])
|
|
247
|
+
if (idm && idm[1] === want) {
|
|
248
|
+
lines[i] = taskLine(m[1], { id: want, text, done })
|
|
249
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Append a task line for a Linear-only issue after the last existing task line
|
|
258
|
+
// (falls back to end of the last phase file). Returns the file it landed in.
|
|
259
|
+
function addTaskLine(snapshotDir, item) {
|
|
260
|
+
const files = listPhaseFiles(snapshotDir)
|
|
261
|
+
const file = files[files.length - 1]
|
|
262
|
+
if (!file) return null
|
|
263
|
+
const p = path.join(snapshotDir, file)
|
|
264
|
+
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
265
|
+
let lastTask = -1
|
|
266
|
+
for (let i = 0; i < lines.length; i++) if (TASK_RE.test(lines[i])) lastTask = i
|
|
267
|
+
const line = taskLine('', item)
|
|
268
|
+
if (lastTask >= 0) lines.splice(lastTask + 1, 0, line)
|
|
269
|
+
else lines.push(line)
|
|
270
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
271
|
+
return file
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Stamp an inline id onto the (idless) task line whose text matches — used after
|
|
275
|
+
// the skill creates an issue for a new local task.
|
|
276
|
+
function stampIssueId(snapshotDir, text, id) {
|
|
277
|
+
const want = String(text).trim()
|
|
278
|
+
for (const file of listPhaseFiles(snapshotDir)) {
|
|
279
|
+
const p = path.join(snapshotDir, file)
|
|
280
|
+
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
281
|
+
for (let i = 0; i < lines.length; i++) {
|
|
282
|
+
const m = TASK_RE.exec(lines[i])
|
|
283
|
+
if (!m || INLINE_ID_RE.test(m[3])) continue
|
|
284
|
+
if (m[3].trim() === want) {
|
|
285
|
+
lines[i] = `${m[1]}- [${m[2].toLowerCase() === 'x' ? 'x' : ' '}] ${want} (${id})`
|
|
286
|
+
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
287
|
+
return file
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Apply a pull's keyed task item outcomes to the phase files' task lines.
|
|
296
|
+
* @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
|
|
297
|
+
*/
|
|
298
|
+
function applyTasksPull(snapshotDir, items) {
|
|
299
|
+
const applied = []
|
|
300
|
+
const created = []
|
|
301
|
+
const reported = []
|
|
302
|
+
for (const it of items || []) {
|
|
303
|
+
if (it.report) {
|
|
304
|
+
reported.push(it.id)
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (!it.pullable || !it.remote) continue
|
|
308
|
+
if (it.status === 'added') {
|
|
309
|
+
const file = addTaskLine(snapshotDir, it.remote)
|
|
310
|
+
if (file) created.push({ id: it.id, file })
|
|
311
|
+
} else if (it.status === 'edited' || it.status === 'conflict') {
|
|
312
|
+
if (updateTaskLine(snapshotDir, it.id, it.remote)) applied.push(it.id)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { applied, created, reported }
|
|
316
|
+
}
|
|
317
|
+
|
|
82
318
|
module.exports = {
|
|
83
319
|
writeFrontmatter,
|
|
84
320
|
splitFrontmatter,
|
|
85
321
|
serialize,
|
|
322
|
+
listPhaseFiles,
|
|
323
|
+
findPhaseFileByMilestoneId,
|
|
324
|
+
findPhaseFileByTitle,
|
|
325
|
+
writeMilestoneFields,
|
|
326
|
+
stampMilestoneId,
|
|
327
|
+
createPhaseFileForMilestone,
|
|
328
|
+
applyMilestonesPull,
|
|
329
|
+
updateTaskLine,
|
|
330
|
+
addTaskLine,
|
|
331
|
+
stampIssueId,
|
|
332
|
+
applyTasksPull,
|
|
86
333
|
}
|