@skitterbyte/skitterspec 1.0.1 → 2.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 (42) hide show
  1. package/README.md +27 -244
  2. package/assets/claude-md-section.md +0 -6
  3. package/assets/core/env.config.json.example +5 -1
  4. package/assets/core/env.config.md +21 -5
  5. package/assets/rules/spec-planning.md +14 -10
  6. package/assets/skills/spec/SKILL.md +11 -38
  7. package/assets/skills/spec-complete/SKILL.md +31 -4
  8. package/assets/skills/spec-env/SKILL.md +6 -0
  9. package/assets/skills/spec-env-down/SKILL.md +16 -8
  10. package/assets/skills/spec-go/SKILL.md +15 -17
  11. package/package.json +6 -11
  12. package/src/cli.js +174 -318
  13. package/src/deprecate.js +138 -0
  14. package/src/env/config.js +17 -4
  15. package/src/env/integrate.js +46 -0
  16. package/src/env/resolve.js +54 -45
  17. package/src/env/teardown.js +19 -4
  18. package/src/env/trust.js +87 -0
  19. package/src/init.js +78 -170
  20. package/src/prompts.js +26 -63
  21. package/LICENSE +0 -21
  22. package/assets/core/linear.config.json.example +0 -39
  23. package/assets/core/linear.config.md +0 -121
  24. package/assets/rules/commit-messages.md +0 -85
  25. package/assets/scripts/generate-changelog.js +0 -274
  26. package/assets/scripts/generate-releases.js +0 -360
  27. package/assets/scripts/lib/config.js +0 -127
  28. package/assets/scripts/lib/git-commits.js +0 -265
  29. package/assets/skills/commit/SKILL.md +0 -28
  30. package/assets/skills/spec-pull/SKILL.md +0 -46
  31. package/assets/skills/spec-push/SKILL.md +0 -53
  32. package/assets/skills/spec-status/SKILL.md +0 -46
  33. package/src/config.js +0 -13
  34. package/src/sync/apply.js +0 -66
  35. package/src/sync/base.js +0 -83
  36. package/src/sync/compare.js +0 -99
  37. package/src/sync/config.js +0 -198
  38. package/src/sync/mcp.js +0 -112
  39. package/src/sync/normalize.js +0 -249
  40. package/src/sync/pull.js +0 -84
  41. package/src/sync/push.js +0 -106
  42. package/src/sync/write.js +0 -86
package/src/cli.js CHANGED
@@ -4,7 +4,11 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
  const { execFileSync } = require('child_process')
6
6
  const { init } = require('./init.js')
7
- const { loadConfig } = require('./config.js')
7
+ const {
8
+ detectReleaseTooling,
9
+ removeReleaseTooling,
10
+ releaseToolingNotice,
11
+ } = require('./deprecate.js')
8
12
  const { loadEnvConfig } = require('./env/config.js')
9
13
  const {
10
14
  readRegistry,
@@ -13,38 +17,28 @@ const {
13
17
  freeSlot,
14
18
  portOffset,
15
19
  } = require('./env/registry.js')
16
- const { resolveSpec } = require('./env/resolve.js')
20
+ const { resolveSpec, resolveBaseBranch } = require('./env/resolve.js')
21
+ const { ensureWorktreeDirTrusted } = require('./env/trust.js')
17
22
  const { planUp } = require('./env/provision.js')
18
23
  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')
24
+ const { planIntegrate } = require('./env/integrate.js')
26
25
 
27
26
  const pkg = require('../package.json')
28
27
 
29
28
  const HELP = `skitterspec — spec-driven-development for Claude Code
30
29
 
31
30
  Usage:
32
- skitterspec init [dir] Install skills, rule, specs/ folders, and (optionally)
33
- changelog/release-note tooling into a project
34
- skitterspec update [dir] Re-copy skills + rule + scripts (overwrites), leaves
35
- specs/ and skitterspec.config.json alone
31
+ skitterspec init [dir] Install the spec lifecycle skills, rule, and specs/
32
+ folders into a project
33
+ skitterspec update [dir] Re-copy skills + rule (overwrites), leaves specs/
34
+ and specs/.core/ config alone
36
35
  skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
37
36
  specs/.core/env.config.json). Subcommands:
38
37
  up <spec> plan a worktree + Docker stack + opener
39
38
  down <spec> tear down (guards; --keep-volumes, --force)
39
+ integrate <spec> plan rebase + fast-forward onto the base branch
40
40
  status list provisioned specs + port blocks
41
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)
48
42
  skitterspec --help Show this help
49
43
  skitterspec --version Print version
50
44
 
@@ -53,21 +47,15 @@ Options (init / update):
53
47
  --dir <path> Target project dir (default: positional arg or cwd)
54
48
  --no-claude-md Skip creating/patching CLAUDE.md
55
49
  --yes, -y Accept defaults; skip the interactive setup prompts
56
-
57
- Release-tooling options (init) — drive setup non-interactively:
58
- --changelog / --no-changelog Enable/disable CHANGELOG generation
59
- --releases / --no-releases Enable/disable user-facing release notes
60
- --changelog-file=NAME Changelog filename (default CHANGELOG.md)
61
- --releases-file=NAME Release-notes filename (default RELEASES.md)
62
- --product-name=NAME Product name shown in the release-notes header
63
- --version-hook / --no-version-hook Wire (or skip) the npm "version" hook
64
50
  --isolation / --no-isolation Enable/skip per-spec isolation (a git
65
51
  worktree per spec; writes env.config.json)
52
+ --remove-release-tooling (update) Remove leftover release tooling
53
+ non-interactively (moved to skittership)
66
54
 
67
55
  Examples:
68
56
  npx @skitterbyte/skitterspec init
69
57
  npx @skitterbyte/skitterspec init ./my-app --yes
70
- npx @skitterbyte/skitterspec init --no-releases --changelog-file=HISTORY.md
58
+ npx @skitterbyte/skitterspec init --isolation
71
59
  npx @skitterbyte/skitterspec update --force
72
60
  `
73
61
 
@@ -77,13 +65,8 @@ function parse(argv) {
77
65
  claudeMd: true,
78
66
  dir: null,
79
67
  yes: false,
80
- changelog: undefined,
81
- releases: undefined,
82
- changelogFile: undefined,
83
- releasesFile: undefined,
84
- productName: undefined,
85
- versionHook: undefined,
86
68
  isolation: undefined,
69
+ removeReleaseTooling: false,
87
70
  }
88
71
  const positional = []
89
72
  for (let i = 0; i < argv.length; i++) {
@@ -93,15 +76,7 @@ function parse(argv) {
93
76
  else if (a === '--yes' || a === '-y') opts.yes = true
94
77
  else if (a === '--isolation') opts.isolation = true
95
78
  else if (a === '--no-isolation') opts.isolation = false
96
- else if (a === '--changelog') opts.changelog = true
97
- else if (a === '--no-changelog') opts.changelog = false
98
- else if (a === '--releases') opts.releases = true
99
- else if (a === '--no-releases') opts.releases = false
100
- else if (a === '--version-hook') opts.versionHook = true
101
- else if (a === '--no-version-hook') opts.versionHook = false
102
- else if (a.startsWith('--changelog-file=')) opts.changelogFile = a.slice('--changelog-file='.length)
103
- else if (a.startsWith('--releases-file=')) opts.releasesFile = a.slice('--releases-file='.length)
104
- else if (a.startsWith('--product-name=')) opts.productName = a.slice('--product-name='.length)
79
+ else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
105
80
  else if (a === '--dir') opts.dir = argv[++i]
106
81
  else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
107
82
  else positional.push(a)
@@ -109,22 +84,36 @@ function parse(argv) {
109
84
  return { opts, positional }
110
85
  }
111
86
 
112
- // Resolve the release config: flags win, else the existing/default config.
113
- // `existing` is a loaded skitterspec.config.json (loadConfig merges defaults).
114
- function resolveRelease(existing, opts) {
115
- const pick = (flag, fallback) => (flag === undefined ? fallback : flag)
116
- return {
117
- changelog: {
118
- enabled: pick(opts.changelog, existing.changelog.enabled),
119
- file: opts.changelogFile || existing.changelog.file,
120
- },
121
- releases: {
122
- enabled: pick(opts.releases, existing.releases.enabled),
123
- file: opts.releasesFile || existing.releases.file,
124
- productName: opts.productName || existing.releases.productName,
125
- scopeAreas: existing.releases.scopeAreas,
126
- },
127
- versionHook: pick(opts.versionHook, existing.versionHook),
87
+ // After an `update`, clean up release tooling left by an older skitterspec (it
88
+ // now lives in @skitterbyte/skittership). Deletes only on an explicit interactive
89
+ // "yes" or --remove-release-tooling; a non-TTY/--yes run only prints the pointer,
90
+ // so CI never mutates files. Nothing to do when no release tooling is present.
91
+ async function cleanupReleaseTooling(dir, opts) {
92
+ const detection = detectReleaseTooling(dir)
93
+ if (!detection.present) return
94
+
95
+ const printRemoved = (removed) => {
96
+ process.stdout.write('\nRemoved release tooling (moved to @skitterbyte/skittership):\n')
97
+ for (const it of removed) process.stdout.write(` ${it}\n`)
98
+ process.stdout.write('Your CHANGELOG.md / RELEASES.md content was left untouched.\n')
99
+ }
100
+
101
+ if (opts.removeReleaseTooling) {
102
+ printRemoved(removeReleaseTooling(dir, detection).removed)
103
+ return
104
+ }
105
+
106
+ const interactive = Boolean(process.stdin.isTTY) && !opts.yes
107
+ if (!interactive) {
108
+ process.stdout.write(`\n${releaseToolingNotice()}\n`)
109
+ return
110
+ }
111
+
112
+ const { confirmRemoveReleaseTooling } = require('./prompts.js')
113
+ if (await confirmRemoveReleaseTooling(detection)) {
114
+ printRemoved(removeReleaseTooling(dir, detection).removed)
115
+ } else {
116
+ process.stdout.write(`\n${releaseToolingNotice()}\n`)
128
117
  }
129
118
  }
130
119
 
@@ -157,6 +146,13 @@ function specEnvUp(dir, config, specArg) {
157
146
  return
158
147
  }
159
148
  const spec = resolveSpec(specArg, dir, config)
149
+
150
+ // Trust the shared worktree root so edits into the freshly-provisioned worktree
151
+ // don't prompt. One absolute entry (the root) covers every spec; self-heals on
152
+ // every provision for teammates who only cloned and ran /spec-go.
153
+ const worktreeRootAbs = path.dirname(spec.worktreePath)
154
+ const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
155
+
160
156
  const wantsDocker = spec.stack === 'docker' && config.docker.enabled
161
157
 
162
158
  // Slot allocation is Docker-only: a worktree-only spec never touches the
@@ -188,6 +184,17 @@ function specEnvUp(dir, config, specArg) {
188
184
  } else {
189
185
  out.push(' stack: worktree-only (no docker, no port block)')
190
186
  }
187
+ if (trust.reason === 'malformed') {
188
+ out.push(
189
+ ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
190
+ `\n add ${worktreeRootAbs} to permissions.additionalDirectories yourself`,
191
+ )
192
+ } else {
193
+ out.push(
194
+ ` trusted: ${worktreeRootAbs} ` +
195
+ `(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
196
+ )
197
+ }
191
198
  out.push('')
192
199
  out.push(' run these:')
193
200
  for (const cmd of plan.commands) out.push(` ${cmd}`)
@@ -202,37 +209,49 @@ function specEnvUp(dir, config, specArg) {
202
209
  process.stdout.write(out.join('\n') + '\n')
203
210
  }
204
211
 
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
212
+ // A read-only git reader over `cwd`: returns trimmed stdout, or null on failure.
213
+ function gitReader(cwd) {
214
+ return (argv) => {
215
+ try {
216
+ return execFileSync('git', ['-C', cwd, ...argv], {
217
+ stdio: ['ignore', 'pipe', 'ignore'],
218
+ })
219
+ .toString()
220
+ .trim()
221
+ } catch {
222
+ return null
223
+ }
221
224
  }
225
+ }
226
+
227
+ // Query a worktree's git state (side-effecting — kept in the CLI, not the pure
228
+ // planner). A missing worktree → nothing to lose (safe to tear down). `base` is
229
+ // the resolved integration branch; `merged` is true when HEAD is already an
230
+ // ancestor of it (fully landed), which lets teardown skip the unpushed guard.
231
+ function worktreeGitState(worktreePath, base) {
232
+ if (!fs.existsSync(worktreePath)) return { dirty: false, unpushed: false, merged: true }
233
+ const git = gitReader(worktreePath)
234
+
235
+ const status = git(['status', '--porcelain'])
236
+ const dirty = status !== null && status.length > 0
222
237
 
223
238
  let unpushed = false
224
- try {
239
+ const ahead = git(['rev-list', '--count', '@{u}..HEAD'])
240
+ if (ahead !== null) {
225
241
  // commits on HEAD's upstream branch not yet pushed
226
- unpushed = Number(git(['rev-list', '--count', '@{u}..HEAD'])) > 0
227
- } catch {
242
+ unpushed = Number(ahead) > 0
243
+ } else {
228
244
  // 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
- }
245
+ const local = git(['log', '--oneline', 'HEAD', '--not', '--remotes'])
246
+ unpushed = local !== null && local.length > 0
234
247
  }
235
- return { dirty, unpushed }
248
+
249
+ // merged = HEAD is an ancestor of base (every commit already landed). The
250
+ // worktree shares the object store, so `base` is visible here. `--is-ancestor`
251
+ // exits 0 when true; gitReader maps a non-zero exit to null.
252
+ const merged = base != null && git(['merge-base', '--is-ancestor', 'HEAD', base]) !== null
253
+
254
+ return { dirty, unpushed, merged }
236
255
  }
237
256
 
238
257
  // A deterministic-enough compact timestamp for backup filenames (CLI-only; the
@@ -246,7 +265,10 @@ function compactTimestamp() {
246
265
  }
247
266
 
248
267
  // Teardown: evaluate guards, print the plan, free the slot. Idempotent no-op
249
- // when the spec was never provisioned / already torn down.
268
+ // when the spec was never provisioned / already torn down. Deliberately does NOT
269
+ // touch the trusted worktree root in .claude/settings.local.json — that entry is
270
+ // the shared parent of every spec's worktree and harmless when empty; removing it
271
+ // would just re-prompt on the next /spec-go (see spec: isolation-trusts-worktree-dir).
250
272
  function specEnvDown(dir, config, specArg, flags) {
251
273
  if (!specArg) {
252
274
  process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
@@ -263,7 +285,8 @@ function specEnvDown(dir, config, specArg, flags) {
263
285
  return
264
286
  }
265
287
 
266
- const worktreeState = worktreeGitState(spec.worktreePath)
288
+ const base = resolveBaseBranch(config, gitReader(dir))
289
+ const worktreeState = worktreeGitState(spec.worktreePath, base)
267
290
  const plan = planDown(spec, config, flags, { worktreeState, timestamp: compactTimestamp() })
268
291
 
269
292
  if (plan.blocked) {
@@ -293,6 +316,63 @@ function specEnvDown(dir, config, specArg, flags) {
293
316
  process.stdout.write(out.join('\n') + '\n')
294
317
  }
295
318
 
319
+ // Integrate: land a spec's worktree branch onto the base branch (rebase + ff).
320
+ // Queries git for the facts, prints the plan / block / no-op. The /spec-complete
321
+ // skill executes the printed commands (and aborts a conflicting rebase).
322
+ function specEnvIntegrate(dir, config, specArg) {
323
+ if (!specArg) {
324
+ process.stdout.write('Usage: skitterspec spec-env integrate <spec>\n')
325
+ return
326
+ }
327
+
328
+ // /spec-complete runs this from inside the worktree, but the spec's coordinates
329
+ // (worktreePath via {repo}, the base branch) must resolve against the PRIMARY
330
+ // checkout. Resolve it first (parent of the shared git dir) and anchor
331
+ // everything to it, so integrate works whether invoked from main or a worktree.
332
+ const commonDir = gitReader(dir)(['rev-parse', '--git-common-dir'])
333
+ const mainRepoPath = commonDir ? path.dirname(path.resolve(dir, commonDir)) : dir
334
+
335
+ const spec = resolveSpec(specArg, mainRepoPath, config)
336
+
337
+ if (!fs.existsSync(spec.worktreePath)) {
338
+ process.stdout.write(
339
+ `spec-env integrate: ${spec.folder} has no worktree — nothing to integrate.\n`,
340
+ )
341
+ return
342
+ }
343
+
344
+ const base = resolveBaseBranch(config, gitReader(mainRepoPath))
345
+ const wtGit = gitReader(spec.worktreePath)
346
+ const status = wtGit(['status', '--porcelain'])
347
+ const dirty = status !== null && status.length > 0
348
+ const ahead = wtGit(['rev-list', '--count', `${base}..HEAD`])
349
+ const aheadOfBase = ahead !== null && Number(ahead) > 0
350
+
351
+ const plan = planIntegrate(spec, config, { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath })
352
+
353
+ if (plan.blocked) {
354
+ process.stdout.write(`spec-env integrate: blocked — ${plan.reason}.\n`)
355
+ return
356
+ }
357
+ if (plan.noop) {
358
+ process.stdout.write(
359
+ `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
360
+ )
361
+ return
362
+ }
363
+
364
+ const out = []
365
+ out.push(`spec-env integrate: ${spec.folder}`)
366
+ out.push('')
367
+ out.push(` base: ${plan.base}`)
368
+ out.push(` branch: ${plan.branch}`)
369
+ out.push(` worktree: ${spec.worktreePath}`)
370
+ out.push('')
371
+ out.push(' run these (abort the rebase on conflict):')
372
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
373
+ process.stdout.write(out.join('\n') + '\n')
374
+ }
375
+
296
376
  // Print the resolved identity/coordinates for a single spec.
297
377
  function specEnvResolve(dir, config, specArg) {
298
378
  if (!specArg) {
@@ -340,6 +420,9 @@ function specEnv(rest) {
340
420
  case 'down':
341
421
  specEnvDown(dir, config, positional[0], flags)
342
422
  break
423
+ case 'integrate':
424
+ specEnvIntegrate(dir, config, positional[0])
425
+ break
343
426
  case 'status':
344
427
  specEnvStatus(dir, config)
345
428
  break
@@ -348,226 +431,7 @@ function specEnv(rest) {
348
431
  break
349
432
  default:
350
433
  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',
434
+ 'Usage: skitterspec spec-env <up|down|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
571
435
  )
572
436
  }
573
437
  }
@@ -589,39 +453,31 @@ async function run(argv) {
589
453
  return
590
454
  }
591
455
 
592
- if (cmd === 'spec-sync') {
593
- await specSync(rest)
594
- return
595
- }
596
-
597
456
  const { opts, positional } = parse(rest)
598
457
  const dir = path.resolve(opts.dir || positional[0] || process.cwd())
599
458
 
600
459
  switch (cmd) {
601
460
  case 'init': {
602
- const existing = loadConfig(dir)
603
- let release = resolveRelease(existing, opts)
604
461
  // Isolation defaults OFF; a flag or an interactive "yes" opts in.
605
462
  let isolation = opts.isolation === true
606
463
 
607
464
  const interactive = Boolean(process.stdin.isTTY) && !opts.yes
608
465
  if (interactive) {
609
466
  const { promptSetup } = require('./prompts.js')
610
- const pkgExists = fs.existsSync(path.join(dir, 'package.json'))
611
- const result = await promptSetup({ seed: release, pkgExists, isolationSeed: isolation })
612
- release = result.release
467
+ const result = await promptSetup({ isolationSeed: isolation })
613
468
  isolation = result.isolation
614
469
  }
615
470
 
616
- await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', release, isolation })
471
+ await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', isolation })
617
472
  break
618
473
  }
619
474
  case 'update':
620
475
  await init({ dir, force: true, claudeMd: opts.claudeMd, mode: 'update' })
476
+ await cleanupReleaseTooling(dir, opts)
621
477
  break
622
478
  default:
623
479
  throw new Error(`unknown command: ${cmd} (try --help)`)
624
480
  }
625
481
  }
626
482
 
627
- module.exports = { run, parse, resolveRelease }
483
+ module.exports = { run, parse }