@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.
- package/README.md +56 -0
- package/assets/claude-md-section.md +39 -0
- package/assets/core/env.config.json.example +28 -0
- package/assets/core/env.config.md +99 -0
- package/assets/core/linear.config.json.example +39 -0
- package/assets/core/linear.config.md +121 -0
- package/assets/rules/spec-planning.md +152 -0
- package/assets/skills/spec/SKILL.md +232 -0
- package/assets/skills/spec-bug/SKILL.md +110 -0
- package/assets/skills/spec-cancel/SKILL.md +61 -0
- package/assets/skills/spec-complete/SKILL.md +87 -0
- package/assets/skills/spec-env/SKILL.md +63 -0
- package/assets/skills/spec-env-down/SKILL.md +64 -0
- package/assets/skills/spec-go/SKILL.md +134 -0
- package/assets/skills/spec-init/SKILL.md +84 -0
- package/assets/skills/spec-pull/SKILL.md +46 -0
- package/assets/skills/spec-push/SKILL.md +53 -0
- package/assets/skills/spec-ready/SKILL.md +50 -0
- package/assets/skills/spec-review/SKILL.md +69 -0
- package/assets/skills/spec-status/SKILL.md +46 -0
- package/bin/skitterspec-linear.js +26 -0
- package/package.json +38 -0
- package/src/cli.js +495 -0
- package/src/deprecate.js +138 -0
- package/src/env/config.js +165 -0
- package/src/env/integrate.js +46 -0
- package/src/env/provision.js +76 -0
- package/src/env/registry.js +95 -0
- package/src/env/render.js +26 -0
- package/src/env/resolve.js +202 -0
- package/src/env/teardown.js +109 -0
- package/src/env/trust.js +87 -0
- package/src/init.js +311 -0
- package/src/prompts.js +56 -0
- package/src/vendor/linear/cli-sync.js +256 -0
- package/src/vendor/linear/config.js +198 -0
- package/src/vendor/linear/mcp.js +112 -0
- package/src/vendor/sync-core/index.js +35 -0
- package/src/vendor/sync-core/src/apply.js +66 -0
- package/src/vendor/sync-core/src/base.js +83 -0
- package/src/vendor/sync-core/src/compare.js +99 -0
- package/src/vendor/sync-core/src/normalize.js +249 -0
- package/src/vendor/sync-core/src/pull.js +84 -0
- package/src/vendor/sync-core/src/push.js +106 -0
- 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 }
|