@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
package/src/prompts.js
CHANGED
|
@@ -53,4 +53,40 @@ async function confirmRemoveReleaseTooling() {
|
|
|
53
53
|
return Boolean(ans.remove)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Interactive choice when `init` finds an already-set-up repo. Returns one of
|
|
58
|
+
* `leave` | `resync` | `reset`. Defaults to (and cancels to) `leave` — the safe
|
|
59
|
+
* no-op — and asks a second confirm before `reset` (destructive to managed files).
|
|
60
|
+
*/
|
|
61
|
+
async function promptExistingSetup() {
|
|
62
|
+
const prompts = require('prompts')
|
|
63
|
+
const { action } = await prompts(
|
|
64
|
+
{
|
|
65
|
+
type: 'select',
|
|
66
|
+
name: 'action',
|
|
67
|
+
message: 'This project already has skitterspec set up. What would you like to do?',
|
|
68
|
+
initial: 0,
|
|
69
|
+
choices: [
|
|
70
|
+
{ title: 'Leave alone — make no changes', value: 'leave' },
|
|
71
|
+
{ title: 'Resync — update managed files to the latest, keep my edits', value: 'resync' },
|
|
72
|
+
{ title: 'Start again — reset the scaffolding (your specs & config are kept)', value: 'reset' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{ onCancel: () => {} },
|
|
76
|
+
)
|
|
77
|
+
if (action === 'reset') {
|
|
78
|
+
const { confirm } = await prompts(
|
|
79
|
+
{
|
|
80
|
+
type: 'confirm',
|
|
81
|
+
name: 'confirm',
|
|
82
|
+
message: 'Start again overwrites managed skills/rules. Your specs and config are untouched. Continue?',
|
|
83
|
+
initial: false,
|
|
84
|
+
},
|
|
85
|
+
{ onCancel: () => false },
|
|
86
|
+
)
|
|
87
|
+
if (!confirm) return 'leave'
|
|
88
|
+
}
|
|
89
|
+
return action || 'leave'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { promptSetup, confirmRemoveReleaseTooling, promptExistingSetup }
|
|
@@ -112,6 +112,17 @@ function specSyncStatus(dir, config, specArg, flags = {}) {
|
|
|
112
112
|
out.push(` ${f.status.padEnd(12)} ${f.field.padEnd(18)} (${f.ownership}, ${dir_})`)
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
+
// Deletions are never auto-applied (Decision 7) — surface them for the operator
|
|
116
|
+
// to resolve by hand: a removed keyed item on either side.
|
|
117
|
+
const removed = []
|
|
118
|
+
for (const f of fields) {
|
|
119
|
+
if (!f.keyed) continue
|
|
120
|
+
for (const it of f.items) if (it.report) removed.push(`${f.field}#${it.id} (removed in ${it.side})`)
|
|
121
|
+
}
|
|
122
|
+
if (removed.length) {
|
|
123
|
+
out.push(' needs manual resolution — removed, not auto-applied:')
|
|
124
|
+
for (const r of removed) out.push(` ${r}`)
|
|
125
|
+
}
|
|
115
126
|
process.stdout.write(out.join('\n') + '\n')
|
|
116
127
|
}
|
|
117
128
|
|
|
@@ -157,11 +168,31 @@ function printSyncResult(kind, result) {
|
|
|
157
168
|
} else {
|
|
158
169
|
out.push(`spec-sync ${kind}: ok`)
|
|
159
170
|
if (kind === 'pull') {
|
|
171
|
+
const keyedApplied = result.keyedApplied || []
|
|
172
|
+
const keyedCreated = result.keyedCreated || []
|
|
173
|
+
const keyedReported = result.keyedReported || []
|
|
160
174
|
if (result.applied.length) out.push(` applied: ${result.applied.join(', ')}`)
|
|
175
|
+
if (keyedApplied.length) out.push(` updated: ${keyedApplied.join(', ')} (phase files)`)
|
|
176
|
+
if (keyedCreated.length) out.push(` created: ${keyedCreated.map((c) => c.file).join(', ')}`)
|
|
177
|
+
if (keyedReported.length) out.push(` removed: ${keyedReported.join(', ')} (in Linear — resolve manually)`)
|
|
161
178
|
if (result.deferred.length) out.push(` deferred: ${result.deferred.join(', ')} (body write-back — manual)`)
|
|
162
|
-
if (
|
|
179
|
+
if (
|
|
180
|
+
!result.applied.length &&
|
|
181
|
+
!keyedApplied.length &&
|
|
182
|
+
!keyedCreated.length &&
|
|
183
|
+
!keyedReported.length &&
|
|
184
|
+
!result.deferred.length
|
|
185
|
+
) {
|
|
186
|
+
out.push(' nothing to pull — up to date')
|
|
187
|
+
}
|
|
163
188
|
} else {
|
|
164
189
|
if (result.written && result.written.length) out.push(` written: ${result.written.join(', ')}`)
|
|
190
|
+
const mp = result.milestonesPush
|
|
191
|
+
if (mp && mp.create.length) out.push(` milestones create: ${mp.create.map((m) => m.name).join(', ')} (skill applies via MCP)`)
|
|
192
|
+
if (mp && mp.update.length) out.push(` milestones update: ${mp.update.map((m) => m.id).join(', ')} (skill applies via MCP)`)
|
|
193
|
+
const ip = result.issuesPush
|
|
194
|
+
if (ip && ip.create.length) out.push(` issues create: ${ip.create.length} (skill applies via MCP)`)
|
|
195
|
+
if (ip && ip.update.length) out.push(` issues update: ${ip.update.map((i) => i.id).join(', ')} (skill applies via MCP)`)
|
|
165
196
|
if (result.skipped && result.skipped.length) out.push(` skipped: ${result.skipped.join(', ')} (not pushable)`)
|
|
166
197
|
if (result.note) out.push(` ${result.note}`)
|
|
167
198
|
}
|
|
@@ -63,6 +63,11 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
63
63
|
labels: 'pull',
|
|
64
64
|
}),
|
|
65
65
|
localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
|
|
66
|
+
// Fields that are keyed collections (arrays of objects with a stable id),
|
|
67
|
+
// compared/merged per item rather than as one opaque value. Map field name →
|
|
68
|
+
// the item's id property. Empty by default — a workspace opts a field in
|
|
69
|
+
// (e.g. { milestones: "id", tasks: "id" }) once the body round-trip is wired.
|
|
70
|
+
keyedFields: Object.freeze({}),
|
|
66
71
|
}),
|
|
67
72
|
})
|
|
68
73
|
|
|
@@ -83,6 +88,7 @@ function defaults() {
|
|
|
83
88
|
backupDir: DEFAULT_CONFIG.sync.backupDir,
|
|
84
89
|
fieldOwnership: { ...DEFAULT_CONFIG.sync.fieldOwnership },
|
|
85
90
|
localOnlySections: [...DEFAULT_CONFIG.sync.localOnlySections],
|
|
91
|
+
keyedFields: { ...DEFAULT_CONFIG.sync.keyedFields },
|
|
86
92
|
},
|
|
87
93
|
}
|
|
88
94
|
}
|
|
@@ -115,6 +121,21 @@ function mergeFieldOwnership(base, parsed) {
|
|
|
115
121
|
}
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
// Merge (and validate) sync.keyedFields. Each value is the item's id property
|
|
125
|
+
// name (a non-empty string); a field listed here is compared per item.
|
|
126
|
+
function mergeKeyedFields(base, parsed) {
|
|
127
|
+
if (!isObject(parsed)) return
|
|
128
|
+
for (const [field, idKey] of Object.entries(parsed)) {
|
|
129
|
+
if (typeof idKey !== 'string' || !idKey.trim()) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Invalid ${CONFIG_FILE}: sync.keyedFields.${field} = ${JSON.stringify(idKey)} ` +
|
|
132
|
+
'(expected the item id property name, a non-empty string)',
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
base[field] = idKey.trim()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
118
139
|
/**
|
|
119
140
|
* Merge a parsed config over the defaults. Only known keys are copied (unknown
|
|
120
141
|
* keys ignored for forward-compat). Nested objects are merged field-by-field.
|
|
@@ -152,6 +173,7 @@ function mergeConfig(base, parsed) {
|
|
|
152
173
|
assign(base.sync, parsed.sync, 'baseDir', 'string')
|
|
153
174
|
assign(base.sync, parsed.sync, 'backupDir', 'string')
|
|
154
175
|
mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
|
|
176
|
+
mergeKeyedFields(base.sync.keyedFields, parsed.sync.keyedFields)
|
|
155
177
|
if (Array.isArray(parsed.sync.localOnlySections)) {
|
|
156
178
|
base.sync.localOnlySections = parsed.sync.localOnlySections
|
|
157
179
|
.filter((s) => typeof s === 'string' && s.trim())
|
package/src/vendor/linear/mcp.js
CHANGED
|
@@ -102,6 +102,12 @@ function makeAdapter(callTool, resolved) {
|
|
|
102
102
|
async updateProject(id, updates) {
|
|
103
103
|
return callTool(need('projectUpdate'), { id, ...updates })
|
|
104
104
|
},
|
|
105
|
+
// List a project's milestones (the pull read side). Most Linear reads also
|
|
106
|
+
// return milestones inline on the project via includeMilestones — this is the
|
|
107
|
+
// explicit list op for callers that need it on its own.
|
|
108
|
+
async listMilestones(projectId) {
|
|
109
|
+
return callTool(need('milestoneList'), { project: projectId })
|
|
110
|
+
},
|
|
105
111
|
// `save_milestone` requires the owning `project`; upserts on `id`.
|
|
106
112
|
async createMilestone(projectId, milestone) {
|
|
107
113
|
return callTool(need('milestoneCreate'), { project: projectId, ...milestone })
|
|
@@ -109,6 +115,17 @@ function makeAdapter(callTool, resolved) {
|
|
|
109
115
|
async updateMilestone(projectId, id, updates) {
|
|
110
116
|
return callTool(need('milestoneUpdate'), { project: projectId, id, ...updates })
|
|
111
117
|
},
|
|
118
|
+
// Issues (tasks). List the project's issues (pull read side); `save_issue`
|
|
119
|
+
// upserts on `id`, attached to the project (and optionally a milestone).
|
|
120
|
+
async listIssues(projectId) {
|
|
121
|
+
return callTool(need('issueList'), { project: projectId })
|
|
122
|
+
},
|
|
123
|
+
async createIssue(projectId, issue) {
|
|
124
|
+
return callTool(need('issueCreate'), { project: projectId, ...issue })
|
|
125
|
+
},
|
|
126
|
+
async updateIssue(id, updates) {
|
|
127
|
+
return callTool(need('issueUpdate'), { id, ...updates })
|
|
128
|
+
},
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
131
|
|
|
@@ -52,6 +52,94 @@ function rawStatus(localH, remoteH, baseH) {
|
|
|
52
52
|
return 'conflict'
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// --- keyed collections (per-item three-way) --------------------------------
|
|
56
|
+
|
|
57
|
+
// Index an array of items by their `idKey` value (stringified). Non-arrays and
|
|
58
|
+
// items missing the id are skipped — they can't participate in a keyed merge.
|
|
59
|
+
function indexById(arr, idKey) {
|
|
60
|
+
const map = new Map()
|
|
61
|
+
if (Array.isArray(arr)) {
|
|
62
|
+
for (const item of arr) {
|
|
63
|
+
if (item && typeof item === 'object' && item[idKey] != null) {
|
|
64
|
+
map.set(String(item[idKey]), item)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return map
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Signature of an item under a given id: its content hash with the id stripped
|
|
72
|
+
// (so an id-only change / reorder is not a content edit), or 'ABSENT' when the
|
|
73
|
+
// id isn't present on that side. 'ABSENT' never collides with a hex hash.
|
|
74
|
+
function itemSignature(map, id, idKey) {
|
|
75
|
+
if (!map.has(id)) return 'ABSENT'
|
|
76
|
+
const { [idKey]: _omit, ...content } = map.get(id)
|
|
77
|
+
return hashField(content)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Raw per-item three-way: which side moved off base, and how (added/edited/
|
|
81
|
+
// removed). Mirrors rawStatus but tracks presence so add vs edit vs remove is
|
|
82
|
+
// distinguishable.
|
|
83
|
+
function itemRaw(localSig, remoteSig, baseSig) {
|
|
84
|
+
const localChanged = localSig !== baseSig
|
|
85
|
+
const remoteChanged = remoteSig !== baseSig
|
|
86
|
+
if (!localChanged && !remoteChanged) return { raw: 'unchanged', side: null }
|
|
87
|
+
const kind = (sig, base) => (base === 'ABSENT' ? 'added' : sig === 'ABSENT' ? 'removed' : 'edited')
|
|
88
|
+
if (localChanged && !remoteChanged) return { raw: kind(localSig, baseSig), side: 'local' }
|
|
89
|
+
if (!localChanged && remoteChanged) return { raw: kind(remoteSig, baseSig), side: 'remote' }
|
|
90
|
+
if (localSig === remoteSig) return { raw: 'unchanged', side: null } // both converged
|
|
91
|
+
return { raw: 'conflict', side: null }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Collapse a raw per-item outcome through ownership into an effective status +
|
|
95
|
+
// push/pull flags. Removals are report-only in v1 (surfaced, never auto-applied).
|
|
96
|
+
function collapseItem({ raw, side }, ownership) {
|
|
97
|
+
const canPush = ownership === 'both' || ownership === 'push'
|
|
98
|
+
const canPull = ownership === 'both' || ownership === 'pull'
|
|
99
|
+
if (raw === 'unchanged') return { status: 'unchanged', side: null, pushable: false, pullable: false, report: false }
|
|
100
|
+
if (raw === 'removed') return { status: 'removed', side, pushable: false, pullable: false, report: true }
|
|
101
|
+
if (raw === 'conflict') {
|
|
102
|
+
if (ownership === 'push') return { status: 'edited', side: 'local', pushable: true, pullable: false, report: false }
|
|
103
|
+
if (ownership === 'pull') return { status: 'edited', side: 'remote', pushable: false, pullable: true, report: false }
|
|
104
|
+
return { status: 'conflict', side: null, pushable: true, pullable: true, report: false }
|
|
105
|
+
}
|
|
106
|
+
// added / edited
|
|
107
|
+
if (side === 'local') return { status: raw, side, pushable: canPush, pullable: false, report: false }
|
|
108
|
+
return { status: raw, side, pushable: false, pullable: canPull, report: false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Classify a keyed collection field item-by-item. Returns one entry per id seen
|
|
113
|
+
* across local/remote/base, each with its effective status and push/pull flags,
|
|
114
|
+
* plus the local/remote item values so a caller can apply the change.
|
|
115
|
+
*/
|
|
116
|
+
function classifyItems(local, remote, base, ownership, idKey) {
|
|
117
|
+
const lMap = indexById(local, idKey)
|
|
118
|
+
const rMap = indexById(remote, idKey)
|
|
119
|
+
const bMap = indexById(base, idKey)
|
|
120
|
+
const ids = new Set([...lMap.keys(), ...rMap.keys(), ...bMap.keys()])
|
|
121
|
+
const items = []
|
|
122
|
+
for (const id of ids) {
|
|
123
|
+
const raw = itemRaw(
|
|
124
|
+
itemSignature(lMap, id, idKey),
|
|
125
|
+
itemSignature(rMap, id, idKey),
|
|
126
|
+
itemSignature(bMap, id, idKey),
|
|
127
|
+
)
|
|
128
|
+
const c = collapseItem(raw, ownership)
|
|
129
|
+
items.push({
|
|
130
|
+
id,
|
|
131
|
+
status: c.status,
|
|
132
|
+
side: c.side,
|
|
133
|
+
pushable: c.pushable,
|
|
134
|
+
pullable: c.pullable,
|
|
135
|
+
report: c.report,
|
|
136
|
+
local: lMap.get(id) || null,
|
|
137
|
+
remote: rMap.get(id) || null,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
return items
|
|
141
|
+
}
|
|
142
|
+
|
|
55
143
|
// Collapse the raw status through ownership into an effective status + flags.
|
|
56
144
|
function collapse(raw, ownership) {
|
|
57
145
|
const canPush = ownership === 'both' || ownership === 'push'
|
|
@@ -78,9 +166,36 @@ function collapse(raw, ownership) {
|
|
|
78
166
|
*/
|
|
79
167
|
function classify(local, remote, base, config) {
|
|
80
168
|
const ownership = config.sync.fieldOwnership
|
|
169
|
+
const keyed = (config.sync && config.sync.keyedFields) || {}
|
|
81
170
|
const baseObj = base || {}
|
|
82
171
|
return Object.keys(ownership).map((field) => {
|
|
83
172
|
const own = ownership[field]
|
|
173
|
+
const idKey = keyed[field]
|
|
174
|
+
|
|
175
|
+
// Keyed collection: per-item three-way. Field-level flags aggregate the
|
|
176
|
+
// items so existing summary code still sees "does this field have work?".
|
|
177
|
+
if (idKey) {
|
|
178
|
+
const items = classifyItems(
|
|
179
|
+
local ? local[field] : null,
|
|
180
|
+
remote ? remote[field] : null,
|
|
181
|
+
field in baseObj ? baseObj[field] : null,
|
|
182
|
+
own,
|
|
183
|
+
idKey,
|
|
184
|
+
)
|
|
185
|
+
const active = items.filter((i) => i.status !== 'unchanged')
|
|
186
|
+
return {
|
|
187
|
+
field,
|
|
188
|
+
ownership: own,
|
|
189
|
+
keyed: true,
|
|
190
|
+
idKey,
|
|
191
|
+
items,
|
|
192
|
+
status: active.length ? 'items-changed' : 'unchanged',
|
|
193
|
+
pushable: items.some((i) => i.pushable),
|
|
194
|
+
pullable: items.some((i) => i.pullable),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Scalar / whole-field (behaviour unchanged).
|
|
84
199
|
const localH = hashField(local ? local[field] : null)
|
|
85
200
|
const remoteH = hashField(remote ? remote[field] : null)
|
|
86
201
|
const baseH = hashField(field in baseObj ? baseObj[field] : null)
|
|
@@ -92,6 +207,7 @@ function classify(local, remote, base, config) {
|
|
|
92
207
|
|
|
93
208
|
module.exports = {
|
|
94
209
|
classify,
|
|
210
|
+
classifyItems,
|
|
95
211
|
hashField,
|
|
96
212
|
stableStringify,
|
|
97
213
|
rawStatus,
|
|
@@ -121,7 +121,37 @@ function parsePhaseIndex(phasesSection) {
|
|
|
121
121
|
return rows
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
//
|
|
124
|
+
// The phase's descriptive title: the h1 with the "Phase N — " prefix and any
|
|
125
|
+
// trailing status emoji stripped (so it matches its Linear Milestone name).
|
|
126
|
+
function phaseTitle(body) {
|
|
127
|
+
const h1 = /^#\s+(.*)$/m.exec(body)
|
|
128
|
+
if (!h1) return null
|
|
129
|
+
const t = h1[1]
|
|
130
|
+
.replace(/\s*[⬜🔄✅]\s*$/u, '')
|
|
131
|
+
.replace(/^Phase\s+\d+\s*[—–-]\s*/i, '')
|
|
132
|
+
.trim()
|
|
133
|
+
return t || null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Parse a task line (already stripped of its leading "- ") into a keyed item:
|
|
137
|
+
// its checkbox state, its text, and the inline Linear issue identifier if present
|
|
138
|
+
// (`… (SKI-123)`). Returns null for a non-task line.
|
|
139
|
+
function parseTaskLine(line) {
|
|
140
|
+
const m = /^\[([ xX])\]\s*(.*)$/.exec(line)
|
|
141
|
+
if (!m) return null
|
|
142
|
+
const done = m[1].toLowerCase() === 'x'
|
|
143
|
+
let text = m[2].trim()
|
|
144
|
+
let id = null
|
|
145
|
+
const idm = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/.exec(text)
|
|
146
|
+
if (idm) {
|
|
147
|
+
id = idm[1]
|
|
148
|
+
text = text.slice(0, idm.index).trim()
|
|
149
|
+
}
|
|
150
|
+
return { id, text, done }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
|
|
154
|
+
// linked milestone id (from optional frontmatter), title, goal and tasks.
|
|
125
155
|
function readPhaseFiles(snapshotDir) {
|
|
126
156
|
let entries
|
|
127
157
|
try {
|
|
@@ -134,11 +164,19 @@ function readPhaseFiles(snapshotDir) {
|
|
|
134
164
|
.sort()
|
|
135
165
|
.map((file) => {
|
|
136
166
|
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
137
|
-
const
|
|
138
|
-
const
|
|
167
|
+
const { data, body } = parseFrontmatter(raw)
|
|
168
|
+
const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(body) || [])[1] || ''
|
|
169
|
+
const tasks = (body.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
|
|
139
170
|
t.replace(/^-\s*/, '').trim(),
|
|
140
171
|
)
|
|
141
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
phase: file.replace(/\.md$/, ''),
|
|
174
|
+
file,
|
|
175
|
+
id: data.linear_milestone_id != null ? String(data.linear_milestone_id) : null,
|
|
176
|
+
name: phaseTitle(body),
|
|
177
|
+
goal: goal.trim(),
|
|
178
|
+
tasks,
|
|
179
|
+
}
|
|
142
180
|
})
|
|
143
181
|
}
|
|
144
182
|
|
|
@@ -170,9 +208,11 @@ function readSnapshot(snapshotDir, config) {
|
|
|
170
208
|
}
|
|
171
209
|
|
|
172
210
|
// Build the pushed description: the overview prose with local-only sections
|
|
173
|
-
// removed. Keeps the title line for context.
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
// removed. Keeps the title line for context. `extraSkip` drops additional
|
|
212
|
+
// sections (e.g. "Phases" once milestones sync as first-class Linear objects, so
|
|
213
|
+
// the phase list isn't duplicated in the description — Decision 5).
|
|
214
|
+
function buildDescription(title, sections, localOnlySections, extraSkip = []) {
|
|
215
|
+
const skip = new Set([...(localOnlySections || []), ...extraSkip])
|
|
176
216
|
const parts = []
|
|
177
217
|
if (title) parts.push(`# ${title}`)
|
|
178
218
|
for (const [heading, content] of Object.entries(sections)) {
|
|
@@ -187,9 +227,24 @@ function buildDescription(title, sections, localOnlySections) {
|
|
|
187
227
|
*/
|
|
188
228
|
function normalizeLocal(snapshotDir, config) {
|
|
189
229
|
const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
|
|
230
|
+
const milestonesKeyed = !!(config.sync.keyedFields && config.sync.keyedFields.milestones)
|
|
190
231
|
const extracted = {
|
|
191
|
-
description: buildDescription(
|
|
192
|
-
|
|
232
|
+
description: buildDescription(
|
|
233
|
+
title,
|
|
234
|
+
sections,
|
|
235
|
+
config.sync.localOnlySections,
|
|
236
|
+
milestonesKeyed ? ['Phases'] : [],
|
|
237
|
+
),
|
|
238
|
+
// Keyed milestone items: a phase's linked milestone id (or null when
|
|
239
|
+
// unlinked), its title and goal. Unlinked phases carry id:null and are
|
|
240
|
+
// skipped by the keyed compare until they're linked.
|
|
241
|
+
milestones: phases
|
|
242
|
+
.filter((p) => p.name)
|
|
243
|
+
.map((p) => ({ id: p.id, name: p.name, goal: p.goal })),
|
|
244
|
+
// Keyed task items across all phases: inline issue id (or null), text, done.
|
|
245
|
+
// Deliberately just {id,text,done} so it hashes equal to a remote issue — the
|
|
246
|
+
// owning phase is recovered at push time by locating the task line.
|
|
247
|
+
tasks: phases.flatMap((p) => p.tasks.map(parseTaskLine).filter(Boolean)),
|
|
193
248
|
phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
|
|
194
249
|
acceptanceCriteria: sections['Acceptance criteria'] || null,
|
|
195
250
|
taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
|
|
@@ -251,25 +306,19 @@ function remoteLabels(labels) {
|
|
|
251
306
|
.filter((n) => n != null)
|
|
252
307
|
}
|
|
253
308
|
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (pct >= 100) return 'done'
|
|
263
|
-
if (pct > 0) return 'in-progress'
|
|
264
|
-
}
|
|
265
|
-
return 'not-started'
|
|
266
|
-
}
|
|
267
|
-
return 'not-started'
|
|
309
|
+
// Whether a remote Linear issue is complete. The real MCP shape is a flat
|
|
310
|
+
// `statusType` ("completed") — accept the legacy `state.type` and `completedAt`
|
|
311
|
+
// / bare `done` too (fixtures, older shapes).
|
|
312
|
+
function remoteIssueDone(iss) {
|
|
313
|
+
if (iss.done === true) return true
|
|
314
|
+
const type = iss.statusType != null ? iss.statusType : iss.state && iss.state.type
|
|
315
|
+
if (type != null) return String(type).toLowerCase() === 'completed'
|
|
316
|
+
return iss.completedAt != null
|
|
268
317
|
}
|
|
269
318
|
|
|
270
319
|
/**
|
|
271
|
-
* Normalize a remote Project projection (from the
|
|
272
|
-
*
|
|
320
|
+
* Normalize a remote Project projection (from the MCP adapter, or a fixture)
|
|
321
|
+
* into the same field set as `normalizeLocal`.
|
|
273
322
|
*/
|
|
274
323
|
function normalizeRemote(project, config) {
|
|
275
324
|
const p = project || {}
|
|
@@ -277,15 +326,31 @@ function normalizeRemote(project, config) {
|
|
|
277
326
|
const stateName = remoteStateName(p)
|
|
278
327
|
const extracted = {
|
|
279
328
|
description: p.description != null ? canonicalizeMarkdown(p.description) : null,
|
|
329
|
+
// Keyed milestone items mirroring normalizeLocal: id, title, goal (the Linear
|
|
330
|
+
// milestone's description). Progress is Linear-derived and not synced.
|
|
280
331
|
milestones: milestones.map((m) => ({
|
|
332
|
+
id: m.id != null ? String(m.id) : null,
|
|
281
333
|
name: m.name,
|
|
282
|
-
|
|
334
|
+
// A milestone's description mirrors the phase's `**Goal:**` line; strip the
|
|
335
|
+
// label so it hashes equal to the local goal (which readPhaseFiles already
|
|
336
|
+
// captures without it).
|
|
337
|
+
goal: (m.description != null ? m.description : '')
|
|
338
|
+
.replace(/^\s*\*\*Goal:\*\*\s*/, '')
|
|
339
|
+
.trim(),
|
|
283
340
|
})),
|
|
284
341
|
phaseBodies: milestones.map((m) => ({
|
|
285
342
|
phase: m.name,
|
|
286
343
|
goal: (m.description != null ? m.description : '').trim(),
|
|
287
344
|
})),
|
|
288
345
|
acceptanceCriteria: p.acceptanceCriteria != null ? p.acceptanceCriteria : null,
|
|
346
|
+
// Keyed task items from the project's issues: keyed by the human identifier
|
|
347
|
+
// (SKI-123, what the inline task-line id carries — the Linear MCP returns it as
|
|
348
|
+
// the issue's `id`), text ← title, done ← a completed-type workflow state.
|
|
349
|
+
tasks: (Array.isArray(p.issues) ? p.issues : []).map((iss) => ({
|
|
350
|
+
id: iss.identifier != null ? String(iss.identifier) : iss.id != null ? String(iss.id) : null,
|
|
351
|
+
text: iss.title != null ? iss.title : '',
|
|
352
|
+
done: remoteIssueDone(iss),
|
|
353
|
+
})),
|
|
289
354
|
taskBreakdown: milestones.map((m) => ({
|
|
290
355
|
phase: m.name,
|
|
291
356
|
tasks: Array.isArray(m.tasks) ? m.tasks : [],
|
|
@@ -304,6 +369,7 @@ module.exports = {
|
|
|
304
369
|
parseFrontmatter,
|
|
305
370
|
parseSections,
|
|
306
371
|
parsePhaseIndex,
|
|
372
|
+
parseTaskLine,
|
|
307
373
|
canonicalRemoteStatus,
|
|
308
374
|
canonicalizeMarkdown,
|
|
309
375
|
bucketForState,
|
|
@@ -18,9 +18,23 @@
|
|
|
18
18
|
const { normalizeLocal, normalizeRemote } = require('./normalize.js')
|
|
19
19
|
const { classify } = require('./compare.js')
|
|
20
20
|
const { readBase, writeBase, backup } = require('./base.js')
|
|
21
|
-
const { writeFrontmatter } = require('./write.js')
|
|
21
|
+
const { writeFrontmatter, applyMilestonesPull, applyTasksPull } = require('./write.js')
|
|
22
22
|
const { frontmatterPatchFor } = require('./apply.js')
|
|
23
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
|
+
|
|
24
38
|
async function pull({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
|
|
25
39
|
const local = normalizeLocal(snapshotDir, config)
|
|
26
40
|
const remoteRaw = await adapter.readProject(projectId)
|
|
@@ -31,40 +45,54 @@ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
31
45
|
const base = readBase(dir, identifier, config)
|
|
32
46
|
const fields = classify(local, remote, base, config)
|
|
33
47
|
|
|
34
|
-
const conflicts = fields
|
|
48
|
+
const conflicts = collectConflicts(fields)
|
|
35
49
|
if (conflicts.length && !force) {
|
|
36
50
|
return {
|
|
37
51
|
ok: false,
|
|
38
52
|
blocked: true,
|
|
39
53
|
reason: 'conflict',
|
|
40
54
|
conflicts,
|
|
41
|
-
message: `pull refused — ${conflicts.length}
|
|
55
|
+
message: `pull refused — ${conflicts.length} unit(s) changed on both sides: ` +
|
|
42
56
|
`${conflicts.join(', ')}. Resolve locally or re-run with --force (remote wins).`,
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
59
|
|
|
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
60
|
// --force overwrites local edits — back the local side up first.
|
|
55
61
|
let backupPath = null
|
|
56
62
|
if (force) {
|
|
57
63
|
backupPath = backup('local', dir, identifier, config, { timestamp, data: local })
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
//
|
|
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
|
+
|
|
61
86
|
if (applied.length || timestamp) {
|
|
62
87
|
writeFrontmatter(snapshotDir, config, { ...patch, last_synced_at: timestamp })
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
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 }
|
|
68
96
|
for (const field of applied) newBase[field] = remote[field]
|
|
69
97
|
newBase.__meta = { updatedAt: remoteRaw.updatedAt || null, syncedAt: timestamp }
|
|
70
98
|
const basePath = writeBase(dir, identifier, config, newBase)
|
|
@@ -74,10 +102,13 @@ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
74
102
|
blocked: false,
|
|
75
103
|
applied,
|
|
76
104
|
deferred,
|
|
105
|
+
keyedApplied,
|
|
106
|
+
keyedCreated,
|
|
107
|
+
keyedReported,
|
|
77
108
|
conflictsForced: force ? conflicts : [],
|
|
78
109
|
backupPath,
|
|
79
110
|
basePath,
|
|
80
|
-
pulled:
|
|
111
|
+
pulled: [...scalarPull.map((f) => f.field), ...keyedApplied],
|
|
81
112
|
}
|
|
82
113
|
}
|
|
83
114
|
|