@skitterbyte/skitterspec 0.1.0 → 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 +97 -2
- package/assets/claude-md-section.md +11 -0
- package/assets/core/env.config.json.example +24 -0
- package/assets/core/env.config.md +83 -0
- package/assets/core/linear.config.json.example +39 -0
- package/assets/core/linear.config.md +121 -0
- package/assets/rules/spec-planning.md +29 -11
- package/assets/skills/spec/SKILL.md +61 -13
- package/assets/skills/spec-bug/SKILL.md +0 -4
- package/assets/skills/spec-cancel/SKILL.md +8 -3
- package/assets/skills/spec-complete/SKILL.md +10 -10
- package/assets/skills/spec-env/SKILL.md +57 -0
- package/assets/skills/spec-env-down/SKILL.md +56 -0
- package/assets/skills/spec-go/SKILL.md +39 -3
- package/assets/skills/spec-init/SKILL.md +0 -8
- 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 +0 -2
- package/assets/skills/spec-review/SKILL.md +2 -2
- package/assets/skills/spec-status/SKILL.md +46 -0
- package/bin/skitterspec.js +0 -0
- package/package.json +6 -6
- package/src/cli.js +497 -2
- package/src/env/config.js +152 -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 +184 -0
- package/src/env/teardown.js +94 -0
- package/src/init.js +82 -27
- package/src/prompts.js +23 -12
- package/src/sync/apply.js +66 -0
- package/src/sync/base.js +83 -0
- package/src/sync/compare.js +99 -0
- package/src/sync/config.js +198 -0
- package/src/sync/mcp.js +112 -0
- package/src/sync/normalize.js +249 -0
- package/src/sync/pull.js +84 -0
- package/src/sync/push.js +106 -0
- package/src/sync/write.js +86 -0
package/src/cli.js
CHANGED
|
@@ -2,8 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
|
+
const { execFileSync } = require('child_process')
|
|
5
6
|
const { init } = require('./init.js')
|
|
6
7
|
const { loadConfig } = require('./config.js')
|
|
8
|
+
const { loadEnvConfig } = require('./env/config.js')
|
|
9
|
+
const {
|
|
10
|
+
readRegistry,
|
|
11
|
+
writeRegistry,
|
|
12
|
+
allocateSlot,
|
|
13
|
+
freeSlot,
|
|
14
|
+
portOffset,
|
|
15
|
+
} = require('./env/registry.js')
|
|
16
|
+
const { resolveSpec } = require('./env/resolve.js')
|
|
17
|
+
const { planUp } = require('./env/provision.js')
|
|
18
|
+
const { planDown } = require('./env/teardown.js')
|
|
19
|
+
const { findSpecFolder } = require('./env/resolve.js')
|
|
20
|
+
const { loadLinearConfig } = require('./sync/config.js')
|
|
21
|
+
const { normalizeLocal, normalizeRemote, readSnapshot } = require('./sync/normalize.js')
|
|
22
|
+
const { classify } = require('./sync/compare.js')
|
|
23
|
+
const { readBase } = require('./sync/base.js')
|
|
24
|
+
const { pull } = require('./sync/pull.js')
|
|
25
|
+
const { push } = require('./sync/push.js')
|
|
7
26
|
|
|
8
27
|
const pkg = require('../package.json')
|
|
9
28
|
|
|
@@ -14,6 +33,18 @@ Usage:
|
|
|
14
33
|
changelog/release-note tooling into a project
|
|
15
34
|
skitterspec update [dir] Re-copy skills + rule + scripts (overwrites), leaves
|
|
16
35
|
specs/ and skitterspec.config.json alone
|
|
36
|
+
skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
|
|
37
|
+
specs/.core/env.config.json). Subcommands:
|
|
38
|
+
up <spec> plan a worktree + Docker stack + opener
|
|
39
|
+
down <spec> tear down (guards; --keep-volumes, --force)
|
|
40
|
+
status list provisioned specs + port blocks
|
|
41
|
+
resolve <spec> print resolved slug/type/branch/paths
|
|
42
|
+
skitterspec spec-sync <cmd> Linear hybrid-sync engine (opt-in; needs
|
|
43
|
+
specs/.core/linear.config.json). Subcommands:
|
|
44
|
+
normalize <spec> print the normalized field set (JSON)
|
|
45
|
+
status <spec> per-field divergence vs base (read-only)
|
|
46
|
+
pull <spec> Linear->local (--force, --remote file)
|
|
47
|
+
push <spec> local->Linear (--force, --remote file)
|
|
17
48
|
skitterspec --help Show this help
|
|
18
49
|
skitterspec --version Print version
|
|
19
50
|
|
|
@@ -30,6 +61,8 @@ Release-tooling options (init) — drive setup non-interactively:
|
|
|
30
61
|
--releases-file=NAME Release-notes filename (default RELEASES.md)
|
|
31
62
|
--product-name=NAME Product name shown in the release-notes header
|
|
32
63
|
--version-hook / --no-version-hook Wire (or skip) the npm "version" hook
|
|
64
|
+
--isolation / --no-isolation Enable/skip per-spec isolation (a git
|
|
65
|
+
worktree per spec; writes env.config.json)
|
|
33
66
|
|
|
34
67
|
Examples:
|
|
35
68
|
npx @skitterbyte/skitterspec init
|
|
@@ -50,6 +83,7 @@ function parse(argv) {
|
|
|
50
83
|
releasesFile: undefined,
|
|
51
84
|
productName: undefined,
|
|
52
85
|
versionHook: undefined,
|
|
86
|
+
isolation: undefined,
|
|
53
87
|
}
|
|
54
88
|
const positional = []
|
|
55
89
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -57,6 +91,8 @@ function parse(argv) {
|
|
|
57
91
|
if (a === '--force') opts.force = true
|
|
58
92
|
else if (a === '--no-claude-md') opts.claudeMd = false
|
|
59
93
|
else if (a === '--yes' || a === '-y') opts.yes = true
|
|
94
|
+
else if (a === '--isolation') opts.isolation = true
|
|
95
|
+
else if (a === '--no-isolation') opts.isolation = false
|
|
60
96
|
else if (a === '--changelog') opts.changelog = true
|
|
61
97
|
else if (a === '--no-changelog') opts.changelog = false
|
|
62
98
|
else if (a === '--releases') opts.releases = true
|
|
@@ -92,6 +128,450 @@ function resolveRelease(existing, opts) {
|
|
|
92
128
|
}
|
|
93
129
|
}
|
|
94
130
|
|
|
131
|
+
// --- spec-env: per-spec isolation engine (Phase 1: status + resolve) --------
|
|
132
|
+
|
|
133
|
+
// Print provisioned specs, their slots, and port blocks from the registry.
|
|
134
|
+
function specEnvStatus(dir, config) {
|
|
135
|
+
const registry = readRegistry(dir, config)
|
|
136
|
+
const names = Object.keys(registry.slots)
|
|
137
|
+
if (!names.length) {
|
|
138
|
+
process.stdout.write('spec-env: no provisioned specs.\n')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
process.stdout.write('Provisioned specs:\n')
|
|
142
|
+
names
|
|
143
|
+
.sort((a, b) => registry.slots[a] - registry.slots[b])
|
|
144
|
+
.forEach((name) => {
|
|
145
|
+
const slot = registry.slots[name]
|
|
146
|
+
const off = portOffset(slot, config)
|
|
147
|
+
const hi = off + config.docker.portsPerSpec - 1
|
|
148
|
+
process.stdout.write(` ${name} slot ${slot} ports ${off}-${hi}\n`)
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Provision: allocate the slot, persist the registry, and print the plan the
|
|
153
|
+
// /spec-env skill executes (git worktree add, docker compose up, .env, opener).
|
|
154
|
+
function specEnvUp(dir, config, specArg) {
|
|
155
|
+
if (!specArg) {
|
|
156
|
+
process.stdout.write('Usage: skitterspec spec-env up <spec>\n')
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const spec = resolveSpec(specArg, dir, config)
|
|
160
|
+
const wantsDocker = spec.stack === 'docker' && config.docker.enabled
|
|
161
|
+
|
|
162
|
+
// Slot allocation is Docker-only: a worktree-only spec never touches the
|
|
163
|
+
// registry (no slot, no port block). Its re-run signal is the worktree already
|
|
164
|
+
// existing on disk (attach the branch, don't `-b`); a Docker spec's is its slot.
|
|
165
|
+
let slot = null
|
|
166
|
+
let attached
|
|
167
|
+
if (wantsDocker) {
|
|
168
|
+
const before = readRegistry(dir, config)
|
|
169
|
+
attached = Object.prototype.hasOwnProperty.call(before.slots, spec.folder)
|
|
170
|
+
const alloc = allocateSlot(before, spec.folder)
|
|
171
|
+
slot = alloc.slot
|
|
172
|
+
writeRegistry(dir, config, alloc.registry) // the engine's only write (Docker path)
|
|
173
|
+
} else {
|
|
174
|
+
attached = fs.existsSync(spec.worktreePath)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const plan = planUp(spec, { slot, attached }, config)
|
|
178
|
+
|
|
179
|
+
const out = []
|
|
180
|
+
out.push(`spec-env up: ${spec.folder} ${attached ? '(attached — existing)' : '(provisioned)'}`)
|
|
181
|
+
out.push('')
|
|
182
|
+
out.push(` worktree: ${plan.worktreePath}`)
|
|
183
|
+
out.push(` branch: ${plan.branch}`)
|
|
184
|
+
if (plan.slot !== null) {
|
|
185
|
+
const hi = plan.portOffset + config.docker.portsPerSpec - 1
|
|
186
|
+
out.push(` project: ${plan.projectName}`)
|
|
187
|
+
out.push(` slot: ${plan.slot} (ports ${plan.portOffset}-${hi})`)
|
|
188
|
+
} else {
|
|
189
|
+
out.push(' stack: worktree-only (no docker, no port block)')
|
|
190
|
+
}
|
|
191
|
+
out.push('')
|
|
192
|
+
out.push(' run these:')
|
|
193
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
194
|
+
if (plan.openCommand) out.push(` ${plan.openCommand}`)
|
|
195
|
+
if (plan.envContents) {
|
|
196
|
+
out.push('')
|
|
197
|
+
out.push(` write ${config.docker.envFile} in the worktree:`)
|
|
198
|
+
for (const line of plan.envContents.replace(/\n$/, '').split('\n')) {
|
|
199
|
+
out.push(` ${line}`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Query a worktree's git state (side-effecting — kept in the CLI, not the pure
|
|
206
|
+
// planner). A missing worktree → nothing to lose (dirty:false, unpushed:false).
|
|
207
|
+
function worktreeGitState(worktreePath) {
|
|
208
|
+
if (!fs.existsSync(worktreePath)) return { dirty: false, unpushed: false }
|
|
209
|
+
const git = (argv) =>
|
|
210
|
+
execFileSync('git', ['-C', worktreePath, ...argv], {
|
|
211
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
212
|
+
})
|
|
213
|
+
.toString()
|
|
214
|
+
.trim()
|
|
215
|
+
|
|
216
|
+
let dirty = false
|
|
217
|
+
try {
|
|
218
|
+
dirty = git(['status', '--porcelain']).length > 0
|
|
219
|
+
} catch {
|
|
220
|
+
dirty = false
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let unpushed = false
|
|
224
|
+
try {
|
|
225
|
+
// commits on HEAD's upstream branch not yet pushed
|
|
226
|
+
unpushed = Number(git(['rev-list', '--count', '@{u}..HEAD'])) > 0
|
|
227
|
+
} catch {
|
|
228
|
+
// no upstream configured → any commit on HEAD not on a remote counts
|
|
229
|
+
try {
|
|
230
|
+
unpushed = git(['log', '--oneline', 'HEAD', '--not', '--remotes']).length > 0
|
|
231
|
+
} catch {
|
|
232
|
+
unpushed = false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { dirty, unpushed }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A deterministic-enough compact timestamp for backup filenames (CLI-only; the
|
|
239
|
+
// pure planner receives this as input so it stays testable).
|
|
240
|
+
function compactTimestamp() {
|
|
241
|
+
return new Date()
|
|
242
|
+
.toISOString()
|
|
243
|
+
.replace(/[-:]/g, '')
|
|
244
|
+
.replace(/\.\d+Z$/, '')
|
|
245
|
+
.replace('T', '-')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Teardown: evaluate guards, print the plan, free the slot. Idempotent no-op
|
|
249
|
+
// when the spec was never provisioned / already torn down.
|
|
250
|
+
function specEnvDown(dir, config, specArg, flags) {
|
|
251
|
+
if (!specArg) {
|
|
252
|
+
process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
const spec = resolveSpec(specArg, dir, config)
|
|
256
|
+
|
|
257
|
+
// A worktree-only spec never held a slot but its worktree still needs removing,
|
|
258
|
+
// so "nothing to do" means neither a slot nor a worktree exists.
|
|
259
|
+
const registry = readRegistry(dir, config)
|
|
260
|
+
const hasSlot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
|
|
261
|
+
if (!hasSlot && !fs.existsSync(spec.worktreePath)) {
|
|
262
|
+
process.stdout.write(`spec-env down: ${spec.folder} is not provisioned — nothing to do.\n`)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const worktreeState = worktreeGitState(spec.worktreePath)
|
|
267
|
+
const plan = planDown(spec, config, flags, { worktreeState, timestamp: compactTimestamp() })
|
|
268
|
+
|
|
269
|
+
if (plan.blocked) {
|
|
270
|
+
process.stdout.write(
|
|
271
|
+
`spec-env down: blocked — ${plan.reason}.\n` +
|
|
272
|
+
'Re-run with --force to tear down anyway (destroys the worktree).\n',
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Free the slot (the engine's only write on down) — only if one was held; a
|
|
278
|
+
// worktree-only teardown never touches the registry.
|
|
279
|
+
if (hasSlot) {
|
|
280
|
+
writeRegistry(dir, config, freeSlot(registry, spec.folder))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const out = []
|
|
284
|
+
out.push(`spec-env down: ${spec.folder}${hasSlot ? ' (slot freed)' : ''}`)
|
|
285
|
+
out.push('')
|
|
286
|
+
out.push(` worktree: ${spec.worktreePath}`)
|
|
287
|
+
out.push(` volumes: ${plan.volumesDropped ? 'dropped' : 'kept'}`)
|
|
288
|
+
if (plan.backupPath) out.push(` backup: ${plan.backupPath}`)
|
|
289
|
+
else if (plan.volumesDropped) out.push(' backup: none (no docker.backupCommand set)')
|
|
290
|
+
out.push('')
|
|
291
|
+
out.push(' run these:')
|
|
292
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
293
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Print the resolved identity/coordinates for a single spec.
|
|
297
|
+
function specEnvResolve(dir, config, specArg) {
|
|
298
|
+
if (!specArg) {
|
|
299
|
+
process.stdout.write('Usage: skitterspec spec-env resolve <spec>\n')
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const r = resolveSpec(specArg, dir, config)
|
|
303
|
+
process.stdout.write(
|
|
304
|
+
`spec: ${r.folder} (${r.bucket})\n` +
|
|
305
|
+
`type/slug: ${r.type} / ${r.slug}\n` +
|
|
306
|
+
`branch: ${r.branch}\n` +
|
|
307
|
+
`worktree: ${r.worktreePath}\n` +
|
|
308
|
+
`project: ${r.projectName}\n`,
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
|
|
313
|
+
// message when the feature isn't enabled (no specs/.core/env.config.json).
|
|
314
|
+
function specEnv(rest) {
|
|
315
|
+
const [sub, ...args] = rest
|
|
316
|
+
let dir = process.cwd()
|
|
317
|
+
const positional = []
|
|
318
|
+
const flags = { keepVolumes: false, force: false }
|
|
319
|
+
for (let i = 0; i < args.length; i++) {
|
|
320
|
+
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
321
|
+
else if (args[i] === '--keep-volumes') flags.keepVolumes = true
|
|
322
|
+
else if (args[i] === '--force') flags.force = true
|
|
323
|
+
else positional.push(args[i])
|
|
324
|
+
}
|
|
325
|
+
dir = path.resolve(dir)
|
|
326
|
+
|
|
327
|
+
const { config, present } = loadEnvConfig(dir)
|
|
328
|
+
if (!present) {
|
|
329
|
+
process.stdout.write(
|
|
330
|
+
'spec-env: isolation not enabled (no specs/.core/env.config.json).\n' +
|
|
331
|
+
'Opt in by copying specs/.core/env.config.json.example → env.config.json.\n',
|
|
332
|
+
)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
switch (sub) {
|
|
337
|
+
case 'up':
|
|
338
|
+
specEnvUp(dir, config, positional[0])
|
|
339
|
+
break
|
|
340
|
+
case 'down':
|
|
341
|
+
specEnvDown(dir, config, positional[0], flags)
|
|
342
|
+
break
|
|
343
|
+
case 'status':
|
|
344
|
+
specEnvStatus(dir, config)
|
|
345
|
+
break
|
|
346
|
+
case 'resolve':
|
|
347
|
+
specEnvResolve(dir, config, positional[0])
|
|
348
|
+
break
|
|
349
|
+
default:
|
|
350
|
+
process.stdout.write(
|
|
351
|
+
'Usage: skitterspec spec-env <up|down|status|resolve> [spec] [--keep-volumes] [--force]\n',
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- spec-sync: Linear hybrid-sync engine seam (Phase 1: normalize + status) -
|
|
357
|
+
|
|
358
|
+
// Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
|
|
359
|
+
// under specs/** (preferred) or a literal path to a snapshot directory.
|
|
360
|
+
function resolveSnapshotDir(specArg, dir) {
|
|
361
|
+
const found = findSpecFolder(specArg, dir)
|
|
362
|
+
if (found) return found.path
|
|
363
|
+
const literal = path.resolve(dir, specArg)
|
|
364
|
+
if (fs.existsSync(literal) && fs.statSync(literal).isDirectory()) return literal
|
|
365
|
+
return null
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The identifier keying the base sidecar: the spec's linear_identifier if set,
|
|
369
|
+
// else its folder name (so the engine is usable before a spec is linked).
|
|
370
|
+
function specIdentifier(snapshotDir, config) {
|
|
371
|
+
try {
|
|
372
|
+
const { frontmatter } = readSnapshot(snapshotDir, config)
|
|
373
|
+
if (frontmatter.linear_identifier) return String(frontmatter.linear_identifier)
|
|
374
|
+
} catch {
|
|
375
|
+
/* fall through to folder name */
|
|
376
|
+
}
|
|
377
|
+
return path.basename(snapshotDir)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// `spec-sync normalize <spec>` — print the normalized local field set as JSON.
|
|
381
|
+
function specSyncNormalize(dir, config, specArg) {
|
|
382
|
+
if (!specArg) {
|
|
383
|
+
process.stdout.write('Usage: skitterspec spec-sync normalize <spec>\n')
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
387
|
+
if (!snapshotDir) {
|
|
388
|
+
process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
const local = normalizeLocal(snapshotDir, config)
|
|
392
|
+
process.stdout.write(JSON.stringify(local, null, 2) + '\n')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// `spec-sync status <spec> [--remote file]` — read-only per-field divergence
|
|
396
|
+
// (git status analog). With `--remote` (a Linear Project projection, supplied by
|
|
397
|
+
// the /spec-status skill via MCP) it reports true three-way divergence; without
|
|
398
|
+
// it, it compares local vs the committed base only (what changed locally since
|
|
399
|
+
// the last sync).
|
|
400
|
+
function specSyncStatus(dir, config, specArg, flags = {}) {
|
|
401
|
+
if (!specArg) {
|
|
402
|
+
process.stdout.write('Usage: skitterspec spec-sync status <spec> [--remote file]\n')
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
406
|
+
if (!snapshotDir) {
|
|
407
|
+
process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
411
|
+
const local = normalizeLocal(snapshotDir, config)
|
|
412
|
+
const base = readBase(dir, identifier, config)
|
|
413
|
+
|
|
414
|
+
let remote = base // no remote → compare local vs base
|
|
415
|
+
let haveRemote = false
|
|
416
|
+
if (flags.remote && fs.existsSync(flags.remote)) {
|
|
417
|
+
remote = normalizeRemote(JSON.parse(fs.readFileSync(flags.remote, 'utf-8')), config)
|
|
418
|
+
haveRemote = true
|
|
419
|
+
}
|
|
420
|
+
const fields = classify(local, remote, base, config)
|
|
421
|
+
|
|
422
|
+
const out = []
|
|
423
|
+
out.push(`spec-sync status: ${identifier}${base ? '' : ' (no base yet — never synced)'}`)
|
|
424
|
+
if (!haveRemote) out.push(' (no --remote given — compared local vs base only)')
|
|
425
|
+
const changed = fields.filter((f) => f.status !== 'unchanged')
|
|
426
|
+
if (!changed.length) {
|
|
427
|
+
out.push(haveRemote ? ' in sync — local, Linear, and base agree' : ' nothing to sync — local matches base')
|
|
428
|
+
} else {
|
|
429
|
+
for (const f of changed) {
|
|
430
|
+
const dir_ = f.pushable && f.pullable ? 'push+pull' : f.pushable ? 'push' : f.pullable ? 'pull' : '—'
|
|
431
|
+
out.push(` ${f.status.padEnd(12)} ${f.field.padEnd(18)} (${f.ownership}, ${dir_})`)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The linked Linear project id for a spec (frontmatter linear_project_id), else
|
|
438
|
+
// its identifier — enough for the file adapter / a single-project remote file.
|
|
439
|
+
function specProjectId(snapshotDir, config) {
|
|
440
|
+
try {
|
|
441
|
+
const { frontmatter } = readSnapshot(snapshotDir, config)
|
|
442
|
+
if (frontmatter.linear_project_id) return String(frontmatter.linear_project_id)
|
|
443
|
+
if (frontmatter.linear_identifier) return String(frontmatter.linear_identifier)
|
|
444
|
+
} catch {
|
|
445
|
+
/* fall through */
|
|
446
|
+
}
|
|
447
|
+
return path.basename(snapshotDir)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// A file-backed MCP adapter: reads the remote Project projection from a JSON file
|
|
451
|
+
// and (on push) writes the merged result to `outPath` (default: the same file).
|
|
452
|
+
// This lets `spec-sync push|pull` run the engine deterministically from the CLI /
|
|
453
|
+
// CI. Live MCP-backed sync goes through the /spec-push · /spec-pull skills, which
|
|
454
|
+
// supply the real adapter (Phase 3). `stamp` bumps updatedAt on write.
|
|
455
|
+
function fileAdapter(remotePath, outPath, stamp) {
|
|
456
|
+
const readRemote = () => JSON.parse(fs.readFileSync(remotePath, 'utf-8'))
|
|
457
|
+
return {
|
|
458
|
+
async readProject() {
|
|
459
|
+
return fs.existsSync(remotePath) ? readRemote() : null
|
|
460
|
+
},
|
|
461
|
+
async updateProject(id, updates) {
|
|
462
|
+
const merged = { ...readRemote(), ...updates, updatedAt: `${stamp}-pushed` }
|
|
463
|
+
if (outPath) fs.writeFileSync(outPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8')
|
|
464
|
+
return merged
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Print a git-like summary of a pull/push engine result.
|
|
470
|
+
function printSyncResult(kind, result) {
|
|
471
|
+
const out = []
|
|
472
|
+
if (result.ok === false && !result.blocked) {
|
|
473
|
+
out.push(`spec-sync ${kind}: error — ${result.error}`)
|
|
474
|
+
} else if (result.blocked) {
|
|
475
|
+
out.push(`spec-sync ${kind}: refused — ${result.message}`)
|
|
476
|
+
} else {
|
|
477
|
+
out.push(`spec-sync ${kind}: ok`)
|
|
478
|
+
if (kind === 'pull') {
|
|
479
|
+
if (result.applied.length) out.push(` applied: ${result.applied.join(', ')}`)
|
|
480
|
+
if (result.deferred.length) out.push(` deferred: ${result.deferred.join(', ')} (body write-back — manual)`)
|
|
481
|
+
if (!result.applied.length && !result.deferred.length) out.push(' nothing to pull — up to date')
|
|
482
|
+
} else {
|
|
483
|
+
if (result.written && result.written.length) out.push(` written: ${result.written.join(', ')}`)
|
|
484
|
+
if (result.skipped && result.skipped.length) out.push(` skipped: ${result.skipped.join(', ')} (not pushable)`)
|
|
485
|
+
if (result.note) out.push(` ${result.note}`)
|
|
486
|
+
}
|
|
487
|
+
if (result.backupPath) out.push(` backup: ${result.backupPath}`)
|
|
488
|
+
if (result.basePath) out.push(` base: ${result.basePath}`)
|
|
489
|
+
}
|
|
490
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// `spec-sync push|pull <spec> [--force] [--remote file] [--out file]`.
|
|
494
|
+
async function specSyncPushPull(kind, dir, config, specArg, flags) {
|
|
495
|
+
if (!specArg) {
|
|
496
|
+
process.stdout.write(`Usage: skitterspec spec-sync ${kind} <spec> [--force] [--remote file] [--out file]\n`)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
500
|
+
if (!snapshotDir) {
|
|
501
|
+
process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
if (!flags.remote) {
|
|
505
|
+
process.stdout.write(
|
|
506
|
+
`spec-sync ${kind}: live Linear sync runs through the /spec-${kind} skill, which ` +
|
|
507
|
+
'connects the Linear MCP server.\n' +
|
|
508
|
+
`For a local run, pass --remote <project.json> (a Linear Project projection).\n`,
|
|
509
|
+
)
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
const identifier = specIdentifier(snapshotDir, config)
|
|
513
|
+
const projectId = specProjectId(snapshotDir, config)
|
|
514
|
+
const stamp = compactTimestamp()
|
|
515
|
+
const adapter = fileAdapter(flags.remote, flags.out, stamp)
|
|
516
|
+
const run = kind === 'pull' ? pull : push
|
|
517
|
+
const result = await run({
|
|
518
|
+
dir,
|
|
519
|
+
snapshotDir,
|
|
520
|
+
identifier,
|
|
521
|
+
projectId,
|
|
522
|
+
adapter,
|
|
523
|
+
config,
|
|
524
|
+
force: flags.force,
|
|
525
|
+
timestamp: new Date().toISOString(),
|
|
526
|
+
})
|
|
527
|
+
printSyncResult(kind, result)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Dispatch `skitterspec spec-sync <sub> [spec] [flags]`. No-ops with a clear
|
|
531
|
+
// message when Linear sync isn't enabled (no specs/.core/linear.config.json).
|
|
532
|
+
async function specSync(rest) {
|
|
533
|
+
const [sub, ...args] = rest
|
|
534
|
+
let dir = process.cwd()
|
|
535
|
+
const positional = []
|
|
536
|
+
const flags = { force: false, remote: null, out: null }
|
|
537
|
+
for (let i = 0; i < args.length; i++) {
|
|
538
|
+
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
539
|
+
else if (args[i] === '--force') flags.force = true
|
|
540
|
+
else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
|
|
541
|
+
else if (args[i] === '--out') flags.out = path.resolve(args[++i])
|
|
542
|
+
else positional.push(args[i])
|
|
543
|
+
}
|
|
544
|
+
dir = path.resolve(dir)
|
|
545
|
+
|
|
546
|
+
const { config, present } = loadLinearConfig(dir)
|
|
547
|
+
if (!present) {
|
|
548
|
+
process.stdout.write(
|
|
549
|
+
'spec-sync: Linear sync not enabled (no specs/.core/linear.config.json).\n' +
|
|
550
|
+
'Opt in by copying specs/.core/linear.config.json.example → linear.config.json.\n',
|
|
551
|
+
)
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
switch (sub) {
|
|
556
|
+
case 'normalize':
|
|
557
|
+
specSyncNormalize(dir, config, positional[0])
|
|
558
|
+
break
|
|
559
|
+
case 'status':
|
|
560
|
+
specSyncStatus(dir, config, positional[0], flags)
|
|
561
|
+
break
|
|
562
|
+
case 'pull':
|
|
563
|
+
await specSyncPushPull('pull', dir, config, positional[0], flags)
|
|
564
|
+
break
|
|
565
|
+
case 'push':
|
|
566
|
+
await specSyncPushPull('push', dir, config, positional[0], flags)
|
|
567
|
+
break
|
|
568
|
+
default:
|
|
569
|
+
process.stdout.write(
|
|
570
|
+
'Usage: skitterspec spec-sync <normalize|status|pull|push> <spec> [--force] [--remote file] [--out file]\n',
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
95
575
|
async function run(argv) {
|
|
96
576
|
if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {
|
|
97
577
|
process.stdout.write(HELP)
|
|
@@ -103,6 +583,17 @@ async function run(argv) {
|
|
|
103
583
|
}
|
|
104
584
|
|
|
105
585
|
const [cmd, ...rest] = argv
|
|
586
|
+
|
|
587
|
+
if (cmd === 'spec-env') {
|
|
588
|
+
specEnv(rest)
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (cmd === 'spec-sync') {
|
|
593
|
+
await specSync(rest)
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
|
|
106
597
|
const { opts, positional } = parse(rest)
|
|
107
598
|
const dir = path.resolve(opts.dir || positional[0] || process.cwd())
|
|
108
599
|
|
|
@@ -110,15 +601,19 @@ async function run(argv) {
|
|
|
110
601
|
case 'init': {
|
|
111
602
|
const existing = loadConfig(dir)
|
|
112
603
|
let release = resolveRelease(existing, opts)
|
|
604
|
+
// Isolation defaults OFF; a flag or an interactive "yes" opts in.
|
|
605
|
+
let isolation = opts.isolation === true
|
|
113
606
|
|
|
114
607
|
const interactive = Boolean(process.stdin.isTTY) && !opts.yes
|
|
115
608
|
if (interactive) {
|
|
116
609
|
const { promptSetup } = require('./prompts.js')
|
|
117
610
|
const pkgExists = fs.existsSync(path.join(dir, 'package.json'))
|
|
118
|
-
|
|
611
|
+
const result = await promptSetup({ seed: release, pkgExists, isolationSeed: isolation })
|
|
612
|
+
release = result.release
|
|
613
|
+
isolation = result.isolation
|
|
119
614
|
}
|
|
120
615
|
|
|
121
|
-
await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', release })
|
|
616
|
+
await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', release, isolation })
|
|
122
617
|
break
|
|
123
618
|
}
|
|
124
619
|
case 'update':
|