@skitterbyte/skitterspec-linear 7.0.2 → 8.0.1
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 +23 -25
- package/assets/core/SETUP.md +46 -51
- package/assets/core/linear.config.json.example +6 -7
- package/assets/core/linear.config.md +47 -77
- package/assets/rules/spec-planning.md +9 -7
- package/assets/skills/spec/SKILL.md +2 -2
- package/assets/skills/spec-go/SKILL.md +16 -18
- package/assets/skills/spec-push/SKILL.md +53 -47
- package/assets/skills/spec-status/SKILL.md +31 -26
- package/package.json +2 -2
- package/src/init.js +25 -0
- package/src/vendor/linear/cli-sanitise.js +0 -0
- package/src/vendor/linear/cli-sync.js +123 -204
- package/src/vendor/linear/config.js +18 -14
- package/src/vendor/sync-core/index.js +23 -19
- package/src/vendor/sync-core/src/base.js +8 -10
- package/src/vendor/sync-core/src/compare.js +83 -174
- package/src/vendor/sync-core/src/normalize.js +84 -80
- package/src/vendor/sync-core/src/push.js +39 -133
- package/src/vendor/sync-core/src/sanitise.js +4 -8
- package/src/vendor/sync-core/src/task-block.js +13 -1
- package/src/vendor/sync-core/src/write.js +15 -205
- package/assets/skills/spec-pull/SKILL.md +0 -49
- package/src/vendor/sync-core/src/apply.js +0 -66
- package/src/vendor/sync-core/src/pull.js +0 -115
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `spec-sync` CLI handler — the Linear
|
|
4
|
+
* `spec-sync` CLI handler — the Linear one-way sync engine seam.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Ships only with the Linear provider package, so the base
|
|
7
|
+
* (`@skitterbyte/skitterspec-common`) knows nothing about tracker sync. The repo
|
|
8
|
+
* is the source of truth; Linear is a generated mirror. This drives the
|
|
9
|
+
* provider-neutral engine (`@skitterbyte/skitterspec-sync-core`):
|
|
10
|
+
*
|
|
11
|
+
* spec-sync normalize <spec> print the local projection (JSON)
|
|
12
|
+
* spec-sync push <spec> print the create/update PLAN the skill applies
|
|
13
|
+
* spec-sync record <spec> write the last-pushed snapshot (after apply)
|
|
14
|
+
* spec-sync status <spec> read-only drift report (never writes)
|
|
15
|
+
*
|
|
16
|
+
* The `/spec-push` skill: `push` → apply the plan over MCP → stamp returned ids
|
|
17
|
+
* into the repo → `record`. There is no pull — Linear is not read for content.
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
20
|
const fs = require('node:fs')
|
|
@@ -16,26 +23,19 @@ const path = require('node:path')
|
|
|
16
23
|
const { findSpecFolder } = require('../../env/resolve.js')
|
|
17
24
|
const {
|
|
18
25
|
normalizeLocal,
|
|
19
|
-
normalizeRemote,
|
|
20
26
|
readSnapshot,
|
|
21
|
-
classify,
|
|
22
27
|
readBase,
|
|
23
|
-
pull,
|
|
24
28
|
push,
|
|
29
|
+
recordPush,
|
|
30
|
+
projectionOf,
|
|
31
|
+
planChanges,
|
|
32
|
+
isEmptyPlan,
|
|
33
|
+
remoteWorkflowState,
|
|
34
|
+
validateStates,
|
|
25
35
|
} = require('../sync-core')
|
|
26
36
|
|
|
27
37
|
const { loadLinearConfig } = require('./config.js')
|
|
28
38
|
|
|
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
39
|
// Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
|
|
40
40
|
// under specs/** (preferred) or a literal path to a snapshot directory.
|
|
41
41
|
function resolveSnapshotDir(specArg, dir) {
|
|
@@ -46,8 +46,8 @@ function resolveSnapshotDir(specArg, dir) {
|
|
|
46
46
|
return null
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// The identifier keying the
|
|
50
|
-
// else its folder name (so the engine is usable before a spec is linked).
|
|
49
|
+
// The identifier keying the snapshot sidecar: the spec's linear_identifier if
|
|
50
|
+
// set, else its folder name (so the engine is usable before a spec is linked).
|
|
51
51
|
function specIdentifier(snapshotDir, config) {
|
|
52
52
|
try {
|
|
53
53
|
const { frontmatter } = readSnapshot(snapshotDir, config)
|
|
@@ -58,229 +58,148 @@ function specIdentifier(snapshotDir, config) {
|
|
|
58
58
|
return path.basename(snapshotDir)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!specArg) {
|
|
64
|
-
process.stdout.write('Usage: skitterspec spec-sync normalize <spec>\n')
|
|
65
|
-
return
|
|
66
|
-
}
|
|
61
|
+
function resolveOrExit(specArg, dir, out) {
|
|
62
|
+
if (!specArg) return null
|
|
67
63
|
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
68
64
|
if (!snapshotDir) {
|
|
69
|
-
|
|
70
|
-
return
|
|
65
|
+
out.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
66
|
+
return null
|
|
71
67
|
}
|
|
72
|
-
|
|
73
|
-
process.stdout.write(JSON.stringify(local, null, 2) + '\n')
|
|
68
|
+
return snapshotDir
|
|
74
69
|
}
|
|
75
70
|
|
|
76
|
-
// `spec-sync
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
|
71
|
+
// `spec-sync normalize <spec>` — print the local projection as JSON.
|
|
72
|
+
function specSyncNormalize(dir, config, specArg, out) {
|
|
73
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
74
|
+
if (!snapshotDir) return
|
|
75
|
+
out.write(JSON.stringify(projectionOf(snapshotDir, config), null, 2) + '\n')
|
|
76
|
+
}
|
|
94
77
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
78
|
+
// `spec-sync push <spec> [--json]` — print the create/update PLAN diffed against
|
|
79
|
+
// the last-pushed snapshot. Machine-readable by default; the /spec-push skill
|
|
80
|
+
// applies it over MCP then calls `record`.
|
|
81
|
+
function specSyncPush(dir, config, specArg, flags, out) {
|
|
82
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
83
|
+
if (!snapshotDir) return
|
|
84
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
85
|
+
const r = push({ dir, snapshotDir, identifier, config })
|
|
86
|
+
if (flags.json || !out.isTTY) {
|
|
87
|
+
out.write(JSON.stringify(r.plan, null, 2) + '\n')
|
|
88
|
+
return
|
|
100
89
|
}
|
|
101
|
-
const
|
|
90
|
+
const p = r.plan
|
|
91
|
+
const lines = [`spec-sync push: ${identifier}`]
|
|
92
|
+
if (r.empty) lines.push(' nothing to push — mirror matches the last push')
|
|
93
|
+
else {
|
|
94
|
+
if (p.project) lines.push(' project: description/status')
|
|
95
|
+
if (p.milestones.create.length) lines.push(` milestones create: ${p.milestones.create.map((m) => m.name).join(', ')}`)
|
|
96
|
+
if (p.milestones.update.length) lines.push(` milestones update: ${p.milestones.update.map((m) => m.id).join(', ')}`)
|
|
97
|
+
if (p.issues.create.length) lines.push(` issues create: ${p.issues.create.length}`)
|
|
98
|
+
if (p.issues.update.length) lines.push(` issues update: ${p.issues.update.map((i) => i.id).join(', ')}`)
|
|
99
|
+
lines.push(' (run with --json for the full plan the skill applies)')
|
|
100
|
+
}
|
|
101
|
+
out.write(lines.join('\n') + '\n')
|
|
102
|
+
}
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
if (!
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
// 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
|
-
}
|
|
126
|
-
process.stdout.write(out.join('\n') + '\n')
|
|
104
|
+
// `spec-sync record <spec>` — write the last-pushed snapshot from the CURRENT
|
|
105
|
+
// files. The skill calls this AFTER applying the plan and stamping new ids.
|
|
106
|
+
function specSyncRecord(dir, config, specArg, out) {
|
|
107
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
108
|
+
if (!snapshotDir) return
|
|
109
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
110
|
+
const file = recordPush({ dir, snapshotDir, identifier, config })
|
|
111
|
+
out.write(`spec-sync record: snapshot written → ${path.relative(dir, file)}\n`)
|
|
127
112
|
}
|
|
128
113
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
114
|
+
// `spec-sync status <spec> [--remote file] [--workspace-states file]` — read-only
|
|
115
|
+
// drift report. Never writes. Reports: (a) whether the spec changed since the last
|
|
116
|
+
// push (there is something to push), and (b) with --remote, whether Linear's
|
|
117
|
+
// workflow-state differs from the spec's. With --workspace-states, validates the
|
|
118
|
+
// configured state names and fails loudly on a typo Linear would silently no-op.
|
|
119
|
+
function specSyncStatus(dir, config, specArg, flags, out) {
|
|
120
|
+
const snapshotDir = resolveOrExit(specArg, dir, out)
|
|
121
|
+
if (!snapshotDir) return
|
|
122
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
123
|
+
const lines = [`spec-sync status: ${identifier}`]
|
|
124
|
+
|
|
125
|
+
if (flags.workspaceStates && fs.existsSync(flags.workspaceStates)) {
|
|
126
|
+
const names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
|
|
127
|
+
const missing = validateStates(config, Array.isArray(names) ? names : [])
|
|
128
|
+
if (missing.length) {
|
|
129
|
+
out.write(
|
|
130
|
+
`spec-sync status: ERROR — configured state name(s) not in the workspace: ${missing.join(', ')}. ` +
|
|
131
|
+
`Linear silently ignores an unknown project status; fix specs/.core/linear.config.json.\n`,
|
|
132
|
+
)
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
lines.push(' states: all configured names exist in the workspace')
|
|
138
136
|
}
|
|
139
|
-
return path.basename(snapshotDir)
|
|
140
|
-
}
|
|
141
137
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return fs.existsSync(remotePath) ? readRemote() : null
|
|
152
|
-
},
|
|
153
|
-
async updateProject(id, updates) {
|
|
154
|
-
const merged = { ...readRemote(), ...updates, updatedAt: `${stamp}-pushed` }
|
|
155
|
-
if (outPath) fs.writeFileSync(outPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8')
|
|
156
|
-
return merged
|
|
157
|
-
},
|
|
138
|
+
const projection = projectionOf(snapshotDir, config)
|
|
139
|
+
const snapshot = readBase(dir, identifier, config)
|
|
140
|
+
const plan = planChanges(projection, snapshot)
|
|
141
|
+
if (!snapshot) lines.push(' push: never pushed — everything is pending')
|
|
142
|
+
else if (isEmptyPlan(plan)) lines.push(' push: up to date — nothing changed since the last push')
|
|
143
|
+
else {
|
|
144
|
+
const n = plan.milestones.create.length + plan.issues.create.length
|
|
145
|
+
const u = plan.milestones.update.length + plan.issues.update.length
|
|
146
|
+
lines.push(` push: pending — ${n} to create, ${u} to update${plan.project ? ', project changed' : ''}`)
|
|
158
147
|
}
|
|
159
|
-
}
|
|
160
148
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
out.push(`spec-sync ${kind}: refused — ${result.message}`)
|
|
168
|
-
} else {
|
|
169
|
-
out.push(`spec-sync ${kind}: ok`)
|
|
170
|
-
if (kind === 'pull') {
|
|
171
|
-
const keyedApplied = result.keyedApplied || []
|
|
172
|
-
const keyedCreated = result.keyedCreated || []
|
|
173
|
-
const keyedReported = result.keyedReported || []
|
|
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)`)
|
|
178
|
-
if (result.deferred.length) out.push(` deferred: ${result.deferred.join(', ')} (body write-back — manual)`)
|
|
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
|
-
}
|
|
149
|
+
if (flags.remote && fs.existsSync(flags.remote)) {
|
|
150
|
+
const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
|
|
151
|
+
const rState = remoteWorkflowState(remote, config)
|
|
152
|
+
const lState = projection.status
|
|
153
|
+
if (rState && lState && rState !== lState) {
|
|
154
|
+
lines.push(` drift: Linear workflow-state is "${rState}" but the spec is "${lState}" (repo wins on next push)`)
|
|
188
155
|
} else {
|
|
189
|
-
|
|
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)`)
|
|
196
|
-
if (result.skipped && result.skipped.length) out.push(` skipped: ${result.skipped.join(', ')} (not pushable)`)
|
|
197
|
-
if (result.note) out.push(` ${result.note}`)
|
|
156
|
+
lines.push(' drift: none — Linear workflow-state matches the spec')
|
|
198
157
|
}
|
|
199
|
-
if (result.backupPath) out.push(` backup: ${result.backupPath}`)
|
|
200
|
-
if (result.basePath) out.push(` base: ${result.basePath}`)
|
|
201
158
|
}
|
|
202
|
-
process.stdout.write(out.join('\n') + '\n')
|
|
203
|
-
}
|
|
204
159
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (!specArg) {
|
|
208
|
-
process.stdout.write(`Usage: skitterspec spec-sync ${kind} <spec> [--force] [--remote file] [--out file]\n`)
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
212
|
-
if (!snapshotDir) {
|
|
213
|
-
process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
if (!flags.remote) {
|
|
217
|
-
process.stdout.write(
|
|
218
|
-
`spec-sync ${kind}: live Linear sync runs through the /spec-${kind} skill, which ` +
|
|
219
|
-
'connects the Linear MCP server.\n' +
|
|
220
|
-
`For a local run, pass --remote <project.json> (a Linear Project projection).\n`,
|
|
221
|
-
)
|
|
222
|
-
return
|
|
223
|
-
}
|
|
224
|
-
const identifier = specIdentifier(snapshotDir, config)
|
|
225
|
-
const projectId = specProjectId(snapshotDir, config)
|
|
226
|
-
const stamp = compactTimestamp()
|
|
227
|
-
const adapter = fileAdapter(flags.remote, flags.out, stamp)
|
|
228
|
-
const run = kind === 'pull' ? pull : push
|
|
229
|
-
const result = await run({
|
|
230
|
-
dir,
|
|
231
|
-
snapshotDir,
|
|
232
|
-
identifier,
|
|
233
|
-
projectId,
|
|
234
|
-
adapter,
|
|
235
|
-
config,
|
|
236
|
-
force: flags.force,
|
|
237
|
-
timestamp: new Date().toISOString(),
|
|
238
|
-
})
|
|
239
|
-
printSyncResult(kind, result)
|
|
160
|
+
out.write(lines.join('\n') + '\n')
|
|
161
|
+
return 0
|
|
240
162
|
}
|
|
241
163
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
async function specSync(rest) {
|
|
164
|
+
async function specSync(rest, io = {}) {
|
|
165
|
+
const out = io.out || process.stdout
|
|
245
166
|
const [sub, ...args] = rest
|
|
246
|
-
let dir = process.cwd()
|
|
167
|
+
let dir = io.cwd || process.cwd()
|
|
247
168
|
const positional = []
|
|
248
|
-
const flags = {
|
|
169
|
+
const flags = { json: false, remote: null, workspaceStates: null }
|
|
249
170
|
for (let i = 0; i < args.length; i++) {
|
|
250
171
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
251
|
-
else if (args[i] === '--
|
|
172
|
+
else if (args[i] === '--json') flags.json = true
|
|
252
173
|
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
253
|
-
else if (args[i] === '--
|
|
174
|
+
else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
|
|
254
175
|
else positional.push(args[i])
|
|
255
176
|
}
|
|
256
177
|
dir = path.resolve(dir)
|
|
257
178
|
|
|
258
179
|
const { config, present } = loadLinearConfig(dir)
|
|
259
180
|
if (!present) {
|
|
260
|
-
|
|
181
|
+
out.write(
|
|
261
182
|
'spec-sync: Linear sync not enabled (no specs/.core/linear.config.json).\n' +
|
|
262
183
|
'Opt in by copying specs/.core/linear.config.json.example → linear.config.json.\n',
|
|
263
184
|
)
|
|
264
|
-
return
|
|
185
|
+
return 0
|
|
265
186
|
}
|
|
266
187
|
|
|
267
188
|
switch (sub) {
|
|
268
189
|
case 'normalize':
|
|
269
|
-
specSyncNormalize(dir, config, positional[0])
|
|
270
|
-
|
|
271
|
-
case 'status':
|
|
272
|
-
specSyncStatus(dir, config, positional[0], flags)
|
|
273
|
-
break
|
|
274
|
-
case 'pull':
|
|
275
|
-
await specSyncPushPull('pull', dir, config, positional[0], flags)
|
|
276
|
-
break
|
|
190
|
+
specSyncNormalize(dir, config, positional[0], out)
|
|
191
|
+
return 0
|
|
277
192
|
case 'push':
|
|
278
|
-
|
|
279
|
-
|
|
193
|
+
specSyncPush(dir, config, positional[0], flags, out)
|
|
194
|
+
return 0
|
|
195
|
+
case 'record':
|
|
196
|
+
specSyncRecord(dir, config, positional[0], out)
|
|
197
|
+
return 0
|
|
198
|
+
case 'status':
|
|
199
|
+
return specSyncStatus(dir, config, positional[0], flags, out) || 0
|
|
280
200
|
default:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
)
|
|
201
|
+
out.write('Usage: skitterspec spec-sync <normalize|push|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n')
|
|
202
|
+
return 0
|
|
284
203
|
}
|
|
285
204
|
}
|
|
286
205
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Config loader for the Linear
|
|
5
|
-
*
|
|
4
|
+
* Config loader for the one-way Linear sync feature (`/spec-status`, `/spec-push`
|
|
5
|
+
* and the Linear-aware paths of `/spec` and `/spec-go`).
|
|
6
6
|
*
|
|
7
7
|
* Reads `specs/.core/linear.config.json` from the project root and normalises it
|
|
8
8
|
* over frozen defaults. The feature is strictly opt-in: when the file is absent
|
|
@@ -39,28 +39,32 @@ const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
|
|
|
39
39
|
const DEFAULT_CONFIG = Object.freeze({
|
|
40
40
|
linear: Object.freeze({ teamKey: '', teamId: '', initiativeId: '' }),
|
|
41
41
|
mapping: Object.freeze({ specFolder: 'project', phases: 'milestone', tasks: 'issue' }),
|
|
42
|
+
// Linear PROJECT statuses (mapping.specFolder is `project`) — NOT issue-status
|
|
43
|
+
// names. Linear silently no-ops save_project on an unknown status (200,
|
|
44
|
+
// unchanged), so these must match the workspace exactly; `validateStates`
|
|
45
|
+
// checks them at push/status time.
|
|
42
46
|
states: Object.freeze({
|
|
43
47
|
backlog: 'Backlog',
|
|
44
48
|
'in-progress': 'In Progress',
|
|
45
|
-
complete: '
|
|
46
|
-
cancelled: '
|
|
49
|
+
complete: 'Completed',
|
|
50
|
+
cancelled: 'Canceled',
|
|
47
51
|
}),
|
|
48
52
|
snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
|
|
49
53
|
branch: Object.freeze({ pattern: '{type}/{slug}' }),
|
|
50
54
|
sync: Object.freeze({
|
|
51
55
|
baseDir: 'specs/.core/linear-base',
|
|
52
56
|
backupDir: 'specs/.core/linear-backups',
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
57
|
+
// One-way (repo → Linear): the projection field set the repo owns and pushes
|
|
58
|
+
// — the project `description`, `milestones` (one per phase), `tasks` (one
|
|
59
|
+
// issue each), and the lifecycle `workflowState`. There is no pull. Priority,
|
|
60
|
+
// labels, cycles and comments are Linear-native triage — deliberately NOT in
|
|
61
|
+
// the set, so the PM's triage is never touched. The `push` marker is retained
|
|
62
|
+
// for shape; any key you add joins the pushed projection.
|
|
59
63
|
fieldOwnership: Object.freeze({
|
|
60
|
-
description: '
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
description: 'push',
|
|
65
|
+
milestones: 'push',
|
|
66
|
+
tasks: 'push',
|
|
67
|
+
workflowState: 'push',
|
|
64
68
|
}),
|
|
65
69
|
localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
|
|
66
70
|
// Fields that are keyed collections (arrays of objects with a stable id),
|
|
@@ -1,37 +1,41 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Provider-neutral
|
|
4
|
+
* Provider-neutral one-way sync engine (repo → tracker).
|
|
5
5
|
*
|
|
6
|
-
* Every function
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Every function is parameterised by a plain `config` object — it knows nothing
|
|
7
|
+
* about any specific tracker. The repo is the source of truth: the engine builds
|
|
8
|
+
* a local projection, diffs it against a committed last-pushed snapshot
|
|
9
|
+
* (`planChanges`), and returns a create/update plan the provider skill applies
|
|
10
|
+
* over its API. No remote content is read or merged.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
const { normalizeLocal,
|
|
13
|
-
const {
|
|
14
|
-
const { readBase, writeBase
|
|
15
|
-
const {
|
|
16
|
-
const {
|
|
17
|
-
const { writeFrontmatter } = require('./src/write.js')
|
|
18
|
-
const { frontmatterPatchFor, localWorkflowState } = require('./src/apply.js')
|
|
13
|
+
const { normalizeLocal, readSnapshot, remoteWorkflowState, titleFromText, validateStates } = require('./src/normalize.js')
|
|
14
|
+
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
|
|
15
|
+
const { readBase, writeBase } = require('./src/base.js')
|
|
16
|
+
const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
17
|
+
const { writeFrontmatter, stampMilestoneId, stampIssueId, findPhaseFileByTitle } = require('./src/write.js')
|
|
19
18
|
const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
|
|
20
19
|
|
|
21
20
|
module.exports = {
|
|
22
21
|
normalizeLocal,
|
|
23
|
-
normalizeRemote,
|
|
24
22
|
readSnapshot,
|
|
25
|
-
|
|
23
|
+
projectionOf,
|
|
24
|
+
planChanges,
|
|
25
|
+
snapshotOf,
|
|
26
|
+
isEmptyPlan,
|
|
27
|
+
push,
|
|
28
|
+
recordPush,
|
|
29
|
+
remoteWorkflowState,
|
|
30
|
+
titleFromText,
|
|
31
|
+
validateStates,
|
|
26
32
|
hashField,
|
|
27
33
|
stableStringify,
|
|
28
34
|
readBase,
|
|
29
35
|
writeBase,
|
|
30
|
-
backup,
|
|
31
|
-
pull,
|
|
32
|
-
push,
|
|
33
36
|
writeFrontmatter,
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
stampMilestoneId,
|
|
38
|
+
stampIssueId,
|
|
39
|
+
findPhaseFileByTitle,
|
|
36
40
|
sanitizeSpecMarkdown,
|
|
37
41
|
}
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The committed
|
|
4
|
+
* The committed **last-pushed snapshot** sidecar.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* `{sync.baseDir}/{identifier}.base.json
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
6
|
+
* One-way sync (repo → Linear) records what it last pushed per spec at
|
|
7
|
+
* `{sync.baseDir}/{identifier}.base.json`, committed so each worktree carries its
|
|
8
|
+
* own snapshot. The snapshot is a set of content hashes
|
|
9
|
+
* (`{ project, milestones: {id:hash}, issues: {id:hash} }`, see compare.js
|
|
10
|
+
* `snapshotOf`); `planChanges` diffs the current projection against it to decide
|
|
11
|
+
* create/update/skip — no remote read. After a successful push the engine
|
|
12
|
+
* rewrites it (`writeBase`). Generic JSON read/write; the shape is the caller's.
|
|
15
13
|
*/
|
|
16
14
|
|
|
17
15
|
const fs = require('node:fs')
|