@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.
Files changed (40) hide show
  1. package/README.md +97 -2
  2. package/assets/claude-md-section.md +11 -0
  3. package/assets/core/env.config.json.example +24 -0
  4. package/assets/core/env.config.md +83 -0
  5. package/assets/core/linear.config.json.example +39 -0
  6. package/assets/core/linear.config.md +121 -0
  7. package/assets/rules/spec-planning.md +29 -11
  8. package/assets/skills/spec/SKILL.md +61 -13
  9. package/assets/skills/spec-bug/SKILL.md +0 -4
  10. package/assets/skills/spec-cancel/SKILL.md +8 -3
  11. package/assets/skills/spec-complete/SKILL.md +10 -10
  12. package/assets/skills/spec-env/SKILL.md +57 -0
  13. package/assets/skills/spec-env-down/SKILL.md +56 -0
  14. package/assets/skills/spec-go/SKILL.md +39 -3
  15. package/assets/skills/spec-init/SKILL.md +0 -8
  16. package/assets/skills/spec-pull/SKILL.md +46 -0
  17. package/assets/skills/spec-push/SKILL.md +53 -0
  18. package/assets/skills/spec-ready/SKILL.md +0 -2
  19. package/assets/skills/spec-review/SKILL.md +2 -2
  20. package/assets/skills/spec-status/SKILL.md +46 -0
  21. package/bin/skitterspec.js +0 -0
  22. package/package.json +6 -6
  23. package/src/cli.js +497 -2
  24. package/src/env/config.js +152 -0
  25. package/src/env/provision.js +76 -0
  26. package/src/env/registry.js +95 -0
  27. package/src/env/render.js +26 -0
  28. package/src/env/resolve.js +184 -0
  29. package/src/env/teardown.js +94 -0
  30. package/src/init.js +82 -27
  31. package/src/prompts.js +23 -12
  32. package/src/sync/apply.js +66 -0
  33. package/src/sync/base.js +83 -0
  34. package/src/sync/compare.js +99 -0
  35. package/src/sync/config.js +198 -0
  36. package/src/sync/mcp.js +112 -0
  37. package/src/sync/normalize.js +249 -0
  38. package/src/sync/pull.js +84 -0
  39. package/src/sync/push.js +106 -0
  40. 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
- release = await promptSetup({ seed: release, pkgExists })
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':