@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,256 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `spec-sync` CLI handler — the Linear hybrid-sync engine seam.
|
|
5
|
+
*
|
|
6
|
+
* Extracted out of the base CLI: this ships only with the Linear provider package,
|
|
7
|
+
* so the base (`@skitterbyte/skitterspec-common`) knows nothing about tracker sync.
|
|
8
|
+
* It drives the provider-neutral engine (`@skitterbyte/skitterspec-sync-core`) with
|
|
9
|
+
* the Linear config loader (`./config.js`) and a file-backed adapter; live
|
|
10
|
+
* MCP-backed sync goes through the /spec-status · /spec-pull · /spec-push skills.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
|
|
16
|
+
const { findSpecFolder } = require('../../env/resolve.js')
|
|
17
|
+
const {
|
|
18
|
+
normalizeLocal,
|
|
19
|
+
normalizeRemote,
|
|
20
|
+
readSnapshot,
|
|
21
|
+
classify,
|
|
22
|
+
readBase,
|
|
23
|
+
pull,
|
|
24
|
+
push,
|
|
25
|
+
} = require('../sync-core')
|
|
26
|
+
|
|
27
|
+
const { loadLinearConfig } = require('./config.js')
|
|
28
|
+
|
|
29
|
+
// A compact, filesystem-safe timestamp (e.g. 20260714-030405) for backup/adapter
|
|
30
|
+
// stamps. Inlined so this handler needs nothing from the base CLI.
|
|
31
|
+
function compactTimestamp() {
|
|
32
|
+
return new Date()
|
|
33
|
+
.toISOString()
|
|
34
|
+
.replace(/[-:]/g, '')
|
|
35
|
+
.replace(/\.\d+Z$/, '')
|
|
36
|
+
.replace('T', '-')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
|
|
40
|
+
// under specs/** (preferred) or a literal path to a snapshot directory.
|
|
41
|
+
function resolveSnapshotDir(specArg, dir) {
|
|
42
|
+
const found = findSpecFolder(specArg, dir)
|
|
43
|
+
if (found) return found.path
|
|
44
|
+
const literal = path.resolve(dir, specArg)
|
|
45
|
+
if (fs.existsSync(literal) && fs.statSync(literal).isDirectory()) return literal
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The identifier keying the base sidecar: the spec's linear_identifier if set,
|
|
50
|
+
// else its folder name (so the engine is usable before a spec is linked).
|
|
51
|
+
function specIdentifier(snapshotDir, config) {
|
|
52
|
+
try {
|
|
53
|
+
const { frontmatter } = readSnapshot(snapshotDir, config)
|
|
54
|
+
if (frontmatter.linear_identifier) return String(frontmatter.linear_identifier)
|
|
55
|
+
} catch {
|
|
56
|
+
/* fall through to folder name */
|
|
57
|
+
}
|
|
58
|
+
return path.basename(snapshotDir)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `spec-sync normalize <spec>` — print the normalized local field set as JSON.
|
|
62
|
+
function specSyncNormalize(dir, config, specArg) {
|
|
63
|
+
if (!specArg) {
|
|
64
|
+
process.stdout.write('Usage: skitterspec spec-sync normalize <spec>\n')
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
68
|
+
if (!snapshotDir) {
|
|
69
|
+
process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const local = normalizeLocal(snapshotDir, config)
|
|
73
|
+
process.stdout.write(JSON.stringify(local, null, 2) + '\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `spec-sync status <spec> [--remote file]` — read-only per-field divergence
|
|
77
|
+
// (git status analog). With `--remote` (a Linear Project projection, supplied by
|
|
78
|
+
// the /spec-status skill via MCP) it reports true three-way divergence; without
|
|
79
|
+
// it, it compares local vs the committed base only (what changed locally since
|
|
80
|
+
// the last sync).
|
|
81
|
+
function specSyncStatus(dir, config, specArg, flags = {}) {
|
|
82
|
+
if (!specArg) {
|
|
83
|
+
process.stdout.write('Usage: skitterspec spec-sync status <spec> [--remote file]\n')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
87
|
+
if (!snapshotDir) {
|
|
88
|
+
process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
92
|
+
const local = normalizeLocal(snapshotDir, config)
|
|
93
|
+
const base = readBase(dir, identifier, config)
|
|
94
|
+
|
|
95
|
+
let remote = base // no remote → compare local vs base
|
|
96
|
+
let haveRemote = false
|
|
97
|
+
if (flags.remote && fs.existsSync(flags.remote)) {
|
|
98
|
+
remote = normalizeRemote(JSON.parse(fs.readFileSync(flags.remote, 'utf-8')), config)
|
|
99
|
+
haveRemote = true
|
|
100
|
+
}
|
|
101
|
+
const fields = classify(local, remote, base, config)
|
|
102
|
+
|
|
103
|
+
const out = []
|
|
104
|
+
out.push(`spec-sync status: ${identifier}${base ? '' : ' (no base yet — never synced)'}`)
|
|
105
|
+
if (!haveRemote) out.push(' (no --remote given — compared local vs base only)')
|
|
106
|
+
const changed = fields.filter((f) => f.status !== 'unchanged')
|
|
107
|
+
if (!changed.length) {
|
|
108
|
+
out.push(haveRemote ? ' in sync — local, Linear, and base agree' : ' nothing to sync — local matches base')
|
|
109
|
+
} else {
|
|
110
|
+
for (const f of changed) {
|
|
111
|
+
const dir_ = f.pushable && f.pullable ? 'push+pull' : f.pushable ? 'push' : f.pullable ? 'pull' : '—'
|
|
112
|
+
out.push(` ${f.status.padEnd(12)} ${f.field.padEnd(18)} (${f.ownership}, ${dir_})`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The linked Linear project id for a spec (frontmatter linear_project_id), else
|
|
119
|
+
// its identifier — enough for the file adapter / a single-project remote file.
|
|
120
|
+
function specProjectId(snapshotDir, config) {
|
|
121
|
+
try {
|
|
122
|
+
const { frontmatter } = readSnapshot(snapshotDir, config)
|
|
123
|
+
if (frontmatter.linear_project_id) return String(frontmatter.linear_project_id)
|
|
124
|
+
if (frontmatter.linear_identifier) return String(frontmatter.linear_identifier)
|
|
125
|
+
} catch {
|
|
126
|
+
/* fall through */
|
|
127
|
+
}
|
|
128
|
+
return path.basename(snapshotDir)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A file-backed MCP adapter: reads the remote Project projection from a JSON file
|
|
132
|
+
// and (on push) writes the merged result to `outPath` (default: the same file).
|
|
133
|
+
// This lets `spec-sync push|pull` run the engine deterministically from the CLI /
|
|
134
|
+
// CI. Live MCP-backed sync goes through the /spec-push · /spec-pull skills, which
|
|
135
|
+
// supply the real adapter. `stamp` bumps updatedAt on write.
|
|
136
|
+
function fileAdapter(remotePath, outPath, stamp) {
|
|
137
|
+
const readRemote = () => JSON.parse(fs.readFileSync(remotePath, 'utf-8'))
|
|
138
|
+
return {
|
|
139
|
+
async readProject() {
|
|
140
|
+
return fs.existsSync(remotePath) ? readRemote() : null
|
|
141
|
+
},
|
|
142
|
+
async updateProject(id, updates) {
|
|
143
|
+
const merged = { ...readRemote(), ...updates, updatedAt: `${stamp}-pushed` }
|
|
144
|
+
if (outPath) fs.writeFileSync(outPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8')
|
|
145
|
+
return merged
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Print a git-like summary of a pull/push engine result.
|
|
151
|
+
function printSyncResult(kind, result) {
|
|
152
|
+
const out = []
|
|
153
|
+
if (result.ok === false && !result.blocked) {
|
|
154
|
+
out.push(`spec-sync ${kind}: error — ${result.error}`)
|
|
155
|
+
} else if (result.blocked) {
|
|
156
|
+
out.push(`spec-sync ${kind}: refused — ${result.message}`)
|
|
157
|
+
} else {
|
|
158
|
+
out.push(`spec-sync ${kind}: ok`)
|
|
159
|
+
if (kind === 'pull') {
|
|
160
|
+
if (result.applied.length) out.push(` applied: ${result.applied.join(', ')}`)
|
|
161
|
+
if (result.deferred.length) out.push(` deferred: ${result.deferred.join(', ')} (body write-back — manual)`)
|
|
162
|
+
if (!result.applied.length && !result.deferred.length) out.push(' nothing to pull — up to date')
|
|
163
|
+
} else {
|
|
164
|
+
if (result.written && result.written.length) out.push(` written: ${result.written.join(', ')}`)
|
|
165
|
+
if (result.skipped && result.skipped.length) out.push(` skipped: ${result.skipped.join(', ')} (not pushable)`)
|
|
166
|
+
if (result.note) out.push(` ${result.note}`)
|
|
167
|
+
}
|
|
168
|
+
if (result.backupPath) out.push(` backup: ${result.backupPath}`)
|
|
169
|
+
if (result.basePath) out.push(` base: ${result.basePath}`)
|
|
170
|
+
}
|
|
171
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// `spec-sync push|pull <spec> [--force] [--remote file] [--out file]`.
|
|
175
|
+
async function specSyncPushPull(kind, dir, config, specArg, flags) {
|
|
176
|
+
if (!specArg) {
|
|
177
|
+
process.stdout.write(`Usage: skitterspec spec-sync ${kind} <spec> [--force] [--remote file] [--out file]\n`)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
181
|
+
if (!snapshotDir) {
|
|
182
|
+
process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!flags.remote) {
|
|
186
|
+
process.stdout.write(
|
|
187
|
+
`spec-sync ${kind}: live Linear sync runs through the /spec-${kind} skill, which ` +
|
|
188
|
+
'connects the Linear MCP server.\n' +
|
|
189
|
+
`For a local run, pass --remote <project.json> (a Linear Project projection).\n`,
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
194
|
+
const projectId = specProjectId(snapshotDir, config)
|
|
195
|
+
const stamp = compactTimestamp()
|
|
196
|
+
const adapter = fileAdapter(flags.remote, flags.out, stamp)
|
|
197
|
+
const run = kind === 'pull' ? pull : push
|
|
198
|
+
const result = await run({
|
|
199
|
+
dir,
|
|
200
|
+
snapshotDir,
|
|
201
|
+
identifier,
|
|
202
|
+
projectId,
|
|
203
|
+
adapter,
|
|
204
|
+
config,
|
|
205
|
+
force: flags.force,
|
|
206
|
+
timestamp: new Date().toISOString(),
|
|
207
|
+
})
|
|
208
|
+
printSyncResult(kind, result)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Dispatch `skitterspec spec-sync <sub> [spec] [flags]`. No-ops with a clear
|
|
212
|
+
// message when Linear sync isn't enabled (no specs/.core/linear.config.json).
|
|
213
|
+
async function specSync(rest) {
|
|
214
|
+
const [sub, ...args] = rest
|
|
215
|
+
let dir = process.cwd()
|
|
216
|
+
const positional = []
|
|
217
|
+
const flags = { force: false, remote: null, out: null }
|
|
218
|
+
for (let i = 0; i < args.length; i++) {
|
|
219
|
+
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
220
|
+
else if (args[i] === '--force') flags.force = true
|
|
221
|
+
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
222
|
+
else if (args[i] === '--out') flags.out = path.resolve(args[++i])
|
|
223
|
+
else positional.push(args[i])
|
|
224
|
+
}
|
|
225
|
+
dir = path.resolve(dir)
|
|
226
|
+
|
|
227
|
+
const { config, present } = loadLinearConfig(dir)
|
|
228
|
+
if (!present) {
|
|
229
|
+
process.stdout.write(
|
|
230
|
+
'spec-sync: Linear sync not enabled (no specs/.core/linear.config.json).\n' +
|
|
231
|
+
'Opt in by copying specs/.core/linear.config.json.example → linear.config.json.\n',
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
switch (sub) {
|
|
237
|
+
case 'normalize':
|
|
238
|
+
specSyncNormalize(dir, config, positional[0])
|
|
239
|
+
break
|
|
240
|
+
case 'status':
|
|
241
|
+
specSyncStatus(dir, config, positional[0], flags)
|
|
242
|
+
break
|
|
243
|
+
case 'pull':
|
|
244
|
+
await specSyncPushPull('pull', dir, config, positional[0], flags)
|
|
245
|
+
break
|
|
246
|
+
case 'push':
|
|
247
|
+
await specSyncPushPull('push', dir, config, positional[0], flags)
|
|
248
|
+
break
|
|
249
|
+
default:
|
|
250
|
+
process.stdout.write(
|
|
251
|
+
'Usage: skitterspec spec-sync <normalize|status|pull|push> <spec> [--force] [--remote file] [--out file]\n',
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { specSync }
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Config loader for the Linear hybrid-sync feature (`/spec-status`, `/spec-pull`,
|
|
5
|
+
* `/spec-push` and the Linear-aware paths of `/spec` and `/spec-go`).
|
|
6
|
+
*
|
|
7
|
+
* Reads `specs/.core/linear.config.json` from the project root and normalises it
|
|
8
|
+
* over frozen defaults. The feature is strictly opt-in: when the file is absent
|
|
9
|
+
* the loader never throws — it returns the defaults with `present:false`, which
|
|
10
|
+
* every caller treats as "Linear sync unused".
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the shape/idiom of `src/env/config.js` (frozen defaults, merge known
|
|
13
|
+
* keys only, forward-compatible on unknown keys). Zero-dependency. The one place
|
|
14
|
+
* it is stricter: a `sync.fieldOwnership` value outside `both|pull|push` is a
|
|
15
|
+
* hard error — the engine's whole safety model rests on those enums.
|
|
16
|
+
*
|
|
17
|
+
* Shape (see assets/core/linear.config.md for field docs):
|
|
18
|
+
* {
|
|
19
|
+
* linear: { teamKey, teamId, initiativeId },
|
|
20
|
+
* mapping: { specFolder, phases, tasks },
|
|
21
|
+
* states: { backlog, "in-progress", complete, cancelled },
|
|
22
|
+
* snapshot: { overviewFile },
|
|
23
|
+
* branch: { pattern },
|
|
24
|
+
* sync: {
|
|
25
|
+
* baseDir, backupDir,
|
|
26
|
+
* fieldOwnership: { <field>: "both" | "pull" | "push" },
|
|
27
|
+
* localOnlySections: string[]
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const { readFileSync } = require('node:fs')
|
|
33
|
+
const { join } = require('node:path')
|
|
34
|
+
|
|
35
|
+
const CONFIG_FILE = join('specs', '.core', 'linear.config.json')
|
|
36
|
+
|
|
37
|
+
const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
|
|
38
|
+
|
|
39
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
40
|
+
linear: Object.freeze({ teamKey: '', teamId: '', initiativeId: '' }),
|
|
41
|
+
mapping: Object.freeze({ specFolder: 'project', phases: 'milestone', tasks: 'issue' }),
|
|
42
|
+
states: Object.freeze({
|
|
43
|
+
backlog: 'Backlog',
|
|
44
|
+
'in-progress': 'In Progress',
|
|
45
|
+
complete: 'Done',
|
|
46
|
+
cancelled: 'Cancelled',
|
|
47
|
+
}),
|
|
48
|
+
snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
|
|
49
|
+
branch: Object.freeze({ pattern: '{type}/{slug}' }),
|
|
50
|
+
sync: Object.freeze({
|
|
51
|
+
baseDir: 'specs/.core/linear-base',
|
|
52
|
+
backupDir: 'specs/.core/linear-backups',
|
|
53
|
+
fieldOwnership: Object.freeze({
|
|
54
|
+
description: 'both',
|
|
55
|
+
milestones: 'both',
|
|
56
|
+
phaseBodies: 'both',
|
|
57
|
+
acceptanceCriteria: 'both',
|
|
58
|
+
taskBreakdown: 'both',
|
|
59
|
+
workflowState: 'pull',
|
|
60
|
+
priority: 'pull',
|
|
61
|
+
labels: 'pull',
|
|
62
|
+
}),
|
|
63
|
+
localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
|
|
64
|
+
}),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
function isObject(value) {
|
|
68
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A fresh, deeply-mutable copy of the defaults to merge onto.
|
|
72
|
+
function defaults() {
|
|
73
|
+
return {
|
|
74
|
+
linear: { ...DEFAULT_CONFIG.linear },
|
|
75
|
+
mapping: { ...DEFAULT_CONFIG.mapping },
|
|
76
|
+
states: { ...DEFAULT_CONFIG.states },
|
|
77
|
+
snapshot: { ...DEFAULT_CONFIG.snapshot },
|
|
78
|
+
branch: { ...DEFAULT_CONFIG.branch },
|
|
79
|
+
sync: {
|
|
80
|
+
baseDir: DEFAULT_CONFIG.sync.baseDir,
|
|
81
|
+
backupDir: DEFAULT_CONFIG.sync.backupDir,
|
|
82
|
+
fieldOwnership: { ...DEFAULT_CONFIG.sync.fieldOwnership },
|
|
83
|
+
localOnlySections: [...DEFAULT_CONFIG.sync.localOnlySections],
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Copy a typed field from parsed[key] onto base[key] when it matches `type`.
|
|
89
|
+
// Strings are trimmed and must be non-empty to override; `string?` may be empty.
|
|
90
|
+
function assign(base, parsed, key, type) {
|
|
91
|
+
const v = parsed[key]
|
|
92
|
+
if (type === 'string') {
|
|
93
|
+
if (typeof v === 'string' && v.trim()) base[key] = v.trim()
|
|
94
|
+
} else if (type === 'string?') {
|
|
95
|
+
if (typeof v === 'string') base[key] = v
|
|
96
|
+
} else if (type === 'boolean') {
|
|
97
|
+
if (typeof v === 'boolean') base[key] = v
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Merge (and validate) sync.fieldOwnership. Any key the caller lists joins the
|
|
102
|
+
// compared field set; the value MUST be one of both|pull|push.
|
|
103
|
+
function mergeFieldOwnership(base, parsed) {
|
|
104
|
+
if (!isObject(parsed)) return
|
|
105
|
+
for (const [field, dir] of Object.entries(parsed)) {
|
|
106
|
+
if (!OWNERSHIP.includes(dir)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Invalid ${CONFIG_FILE}: sync.fieldOwnership.${field} = ${JSON.stringify(dir)} ` +
|
|
109
|
+
`(expected one of ${OWNERSHIP.join('|')})`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
base[field] = dir
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Merge a parsed config over the defaults. Only known keys are copied (unknown
|
|
118
|
+
* keys ignored for forward-compat). Nested objects are merged field-by-field.
|
|
119
|
+
*/
|
|
120
|
+
function mergeConfig(base, parsed) {
|
|
121
|
+
if (!isObject(parsed)) return base
|
|
122
|
+
|
|
123
|
+
if (isObject(parsed.linear)) {
|
|
124
|
+
assign(base.linear, parsed.linear, 'teamKey', 'string?')
|
|
125
|
+
assign(base.linear, parsed.linear, 'teamId', 'string?')
|
|
126
|
+
assign(base.linear, parsed.linear, 'initiativeId', 'string?')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isObject(parsed.mapping)) {
|
|
130
|
+
assign(base.mapping, parsed.mapping, 'specFolder', 'string')
|
|
131
|
+
assign(base.mapping, parsed.mapping, 'phases', 'string')
|
|
132
|
+
assign(base.mapping, parsed.mapping, 'tasks', 'string')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (isObject(parsed.states)) {
|
|
136
|
+
for (const key of Object.keys(base.states)) {
|
|
137
|
+
assign(base.states, parsed.states, key, 'string')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isObject(parsed.snapshot)) {
|
|
142
|
+
assign(base.snapshot, parsed.snapshot, 'overviewFile', 'string')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isObject(parsed.branch)) {
|
|
146
|
+
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isObject(parsed.sync)) {
|
|
150
|
+
assign(base.sync, parsed.sync, 'baseDir', 'string')
|
|
151
|
+
assign(base.sync, parsed.sync, 'backupDir', 'string')
|
|
152
|
+
mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
|
|
153
|
+
if (Array.isArray(parsed.sync.localOnlySections)) {
|
|
154
|
+
base.sync.localOnlySections = parsed.sync.localOnlySections
|
|
155
|
+
.filter((s) => typeof s === 'string' && s.trim())
|
|
156
|
+
.map((s) => s.trim())
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return base
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Load and normalise `specs/.core/linear.config.json` from `dir` (default cwd).
|
|
165
|
+
* Returns `{ config, present }`:
|
|
166
|
+
* - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
|
|
167
|
+
* - present → `{ config: merged, present: true }`
|
|
168
|
+
* Malformed JSON or a bad `fieldOwnership` enum → throws a clear Error.
|
|
169
|
+
*/
|
|
170
|
+
function loadLinearConfig(dir = process.cwd()) {
|
|
171
|
+
const base = defaults()
|
|
172
|
+
const file = join(dir, CONFIG_FILE)
|
|
173
|
+
|
|
174
|
+
let raw
|
|
175
|
+
try {
|
|
176
|
+
raw = readFileSync(file, 'utf-8')
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error.code === 'ENOENT') return { config: base, present: false }
|
|
179
|
+
throw error
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let parsed
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(raw)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { config: mergeConfig(base, parsed), present: true }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
loadLinearConfig,
|
|
194
|
+
mergeConfig,
|
|
195
|
+
DEFAULT_CONFIG,
|
|
196
|
+
CONFIG_FILE,
|
|
197
|
+
OWNERSHIP,
|
|
198
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Linear MCP boundary — the one place that knows concrete Linear tool names.
|
|
5
|
+
*
|
|
6
|
+
* `discoverLinear(tools)` resolves the operations the sync needs (read/update a
|
|
7
|
+
* Project, list/create/update Milestones + Issues) against the *connected*
|
|
8
|
+
* server's advertised tool list at runtime, rather than hardcoding names that
|
|
9
|
+
* drift. If Linear isn't connected (empty / zero-match tool list) it returns a
|
|
10
|
+
* clean `{ ok:false, error }` so the caller can stop and do nothing destructive.
|
|
11
|
+
*
|
|
12
|
+
* `makeAdapter(callTool, resolved)` wraps a generic `callTool(name, args)` (the
|
|
13
|
+
* skill's MCP invoker) into the typed async operations push/pull consume. Tests
|
|
14
|
+
* inject a fake adapter directly (an in-memory Project), so the engine stays
|
|
15
|
+
* offline and deterministic; production wires `callTool` to the real MCP server.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Canonical operations, and the regexes that match a Linear MCP tool name to
|
|
19
|
+
// each. Ordered patterns: first match wins. Verified against the connected
|
|
20
|
+
// Linear MCP server during build (resolves the overview's Open questions).
|
|
21
|
+
const MATCHERS = {
|
|
22
|
+
projectRead: [/get_?project\b/i, /read_?project/i, /project_?get/i],
|
|
23
|
+
projectUpdate: [/update_?project/i, /project_?update/i],
|
|
24
|
+
projectCreate: [/create_?project/i, /project_?create/i],
|
|
25
|
+
milestoneList: [/list_?.*milestone/i, /milestones?_?list/i, /get_?.*milestones?/i],
|
|
26
|
+
milestoneCreate: [/create_?.*milestone/i, /milestone_?create/i],
|
|
27
|
+
milestoneUpdate: [/update_?.*milestone/i, /milestone_?update/i],
|
|
28
|
+
issueList: [/list_?issues?/i, /issues?_?list/i, /get_?issues?/i],
|
|
29
|
+
issueCreate: [/create_?issue/i, /issue_?create/i],
|
|
30
|
+
issueUpdate: [/update_?issue/i, /issue_?update/i],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The minimum the push/pull engine can't run without. Milestone/issue ops are
|
|
34
|
+
// optional in Phase 2 (project description round-trips first).
|
|
35
|
+
const REQUIRED = ['projectRead', 'projectUpdate']
|
|
36
|
+
|
|
37
|
+
// Normalise a tools argument (array of strings or {name} objects) to names.
|
|
38
|
+
function toolNames(tools) {
|
|
39
|
+
if (!Array.isArray(tools)) return []
|
|
40
|
+
return tools
|
|
41
|
+
.map((t) => (typeof t === 'string' ? t : t && typeof t === 'object' ? t.name : null))
|
|
42
|
+
.filter((n) => typeof n === 'string' && n)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve Linear operations against the connected server's tool list.
|
|
47
|
+
* @returns {{ok:true, tools:Record<string,string>}} on success, or
|
|
48
|
+
* {{ok:false, error:string, resolved?:object, missing?:string[]}}.
|
|
49
|
+
*/
|
|
50
|
+
function discoverLinear(tools) {
|
|
51
|
+
const names = toolNames(tools)
|
|
52
|
+
if (!names.length) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: 'Linear not connected — connect the `linear` MCP server, then retry.',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const resolved = {}
|
|
60
|
+
for (const [op, patterns] of Object.entries(MATCHERS)) {
|
|
61
|
+
const hit = names.find((n) => patterns.some((p) => p.test(n)))
|
|
62
|
+
if (hit) resolved[op] = hit
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const missing = REQUIRED.filter((op) => !resolved[op])
|
|
66
|
+
if (missing.length) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error:
|
|
70
|
+
`Linear MCP is connected but missing required tools: ${missing.join(', ')}. ` +
|
|
71
|
+
'Check the linear server exposes project read + update.',
|
|
72
|
+
resolved,
|
|
73
|
+
missing,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: true, tools: resolved }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wrap a generic `callTool(name, args) → Promise<result>` into the typed ops the
|
|
82
|
+
* engine uses. `resolved` is `discoverLinear(...).tools`.
|
|
83
|
+
*/
|
|
84
|
+
function makeAdapter(callTool, resolved) {
|
|
85
|
+
const need = (op) => {
|
|
86
|
+
const name = resolved[op]
|
|
87
|
+
if (!name) throw new Error(`Linear MCP op not available: ${op}`)
|
|
88
|
+
return name
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
async readProject(id) {
|
|
92
|
+
return callTool(need('projectRead'), { id })
|
|
93
|
+
},
|
|
94
|
+
async updateProject(id, updates) {
|
|
95
|
+
return callTool(need('projectUpdate'), { id, ...updates })
|
|
96
|
+
},
|
|
97
|
+
async createMilestone(projectId, milestone) {
|
|
98
|
+
return callTool(need('milestoneCreate'), { projectId, ...milestone })
|
|
99
|
+
},
|
|
100
|
+
async updateMilestone(id, updates) {
|
|
101
|
+
return callTool(need('milestoneUpdate'), { id, ...updates })
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
discoverLinear,
|
|
108
|
+
makeAdapter,
|
|
109
|
+
toolNames,
|
|
110
|
+
MATCHERS,
|
|
111
|
+
REQUIRED,
|
|
112
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Provider-neutral spec↔tracker sync engine.
|
|
5
|
+
*
|
|
6
|
+
* Every function here is parameterised by a plain `config` object and an injected
|
|
7
|
+
* `adapter` — it knows nothing about any specific tracker or provider. A
|
|
8
|
+
* provider package supplies the config shape, the frontmatter key mapping, and the
|
|
9
|
+
* adapter that talks to its API; this core does the three-way merge.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { normalizeLocal, normalizeRemote, readSnapshot } = require('./src/normalize.js')
|
|
13
|
+
const { classify, hashField, stableStringify } = require('./src/compare.js')
|
|
14
|
+
const { readBase, writeBase, backup } = require('./src/base.js')
|
|
15
|
+
const { pull } = require('./src/pull.js')
|
|
16
|
+
const { push } = require('./src/push.js')
|
|
17
|
+
const { writeFrontmatter } = require('./src/write.js')
|
|
18
|
+
const { frontmatterPatchFor, localWorkflowState } = require('./src/apply.js')
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
normalizeLocal,
|
|
22
|
+
normalizeRemote,
|
|
23
|
+
readSnapshot,
|
|
24
|
+
classify,
|
|
25
|
+
hashField,
|
|
26
|
+
stableStringify,
|
|
27
|
+
readBase,
|
|
28
|
+
writeBase,
|
|
29
|
+
backup,
|
|
30
|
+
pull,
|
|
31
|
+
push,
|
|
32
|
+
writeFrontmatter,
|
|
33
|
+
frontmatterPatchFor,
|
|
34
|
+
localWorkflowState,
|
|
35
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|