@skitterbyte/skitterspec 11.0.0 → 13.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.
@@ -36,10 +36,10 @@ function findSpecFolder(specArg, dir, extraDirs = []) {
36
36
  return null
37
37
  }
38
38
 
39
- // Split a `feat-`/`bug-` prefix. Unknown prefix → type defaults to `feat` and
40
- // the whole folder name is the slug.
39
+ // Split a `feat-`/`bug-`/`hotfix-` prefix. Unknown prefix → type defaults to
40
+ // `feat` and the whole folder name is the slug.
41
41
  function splitPrefix(folder) {
42
- const m = /^(feat|bug)-(.+)$/.exec(folder)
42
+ const m = /^(feat|bug|hotfix)-(.+)$/.exec(folder)
43
43
  if (m) return { type: m[1], slug: m[2] }
44
44
  return { type: 'feat', slug: folder }
45
45
  }
@@ -104,6 +104,26 @@ function readStackField(specPath, config) {
104
104
  return config.docker && config.docker.enabled ? 'docker' : 'worktree'
105
105
  }
106
106
 
107
+ /**
108
+ * Read a spec's `> **Base version:** <tag>` blockquote field from 00-overview.md.
109
+ * This is the release tag a hotfix forks its worktree from (e.g. `v33.16.4`),
110
+ * authored by the `/spec-hotfix` skill. Returns the trimmed tag, or null when the
111
+ * field / file is absent — so a non-hotfix spec resolves to `baseRef: null` and
112
+ * provisioning forks from base HEAD as before.
113
+ */
114
+ function readBaseVersionField(specPath) {
115
+ const overview = path.join(specPath, '00-overview.md')
116
+ let raw
117
+ try {
118
+ raw = fs.readFileSync(overview, 'utf-8')
119
+ } catch {
120
+ return null
121
+ }
122
+ const m = /^>\s*\*\*Base version:\*\*\s*(.+)$/m.exec(raw)
123
+ if (!m) return null
124
+ return m[1].trim().replace(/^["'`]|["'`]$/g, '') || null
125
+ }
126
+
107
127
  /**
108
128
  * Derive the git branch for a spec from the provider-neutral `branch.pattern`
109
129
  * (`{type}`, `{slug}`, and optionally `{identifier}`). When the pattern uses
@@ -165,6 +185,29 @@ function resolvePrimaryCheckout(dir, git) {
165
185
  return common ? path.dirname(path.resolve(dir, common)) : dir
166
186
  }
167
187
 
188
+ /**
189
+ * The current branch of a checkout, or `null` when detached (or not a repo).
190
+ * `git` is a reader bound to the target checkout — for the live-overlay guard the
191
+ * CLI binds it to the primary checkout. (`symbolic-ref --short HEAD` exits
192
+ * non-zero on a detached HEAD, which the reader maps to `null`.)
193
+ */
194
+ function currentBranch(git) {
195
+ return git(['symbolic-ref', '--short', 'HEAD'])
196
+ }
197
+
198
+ /**
199
+ * The live-overlay guard: is the primary checkout on the integration base branch
200
+ * (free) or on a feature branch (a spec is in control)? Returns
201
+ * `{ onBase, branch, baseBranch }` — a structured result, never a throw, so each
202
+ * caller phrases its own refusal. `git` must be bound to the primary checkout.
203
+ * `onBase` is false on a detached HEAD (`branch === null`), which is not the base.
204
+ */
205
+ function assertPrimaryOnMain(config, git) {
206
+ const baseBranch = resolveBaseBranch(config, git)
207
+ const branch = currentBranch(git)
208
+ return { onBase: branch === baseBranch, branch, baseBranch }
209
+ }
210
+
168
211
  /**
169
212
  * Resolve a spec argument to its identity + isolation coordinates.
170
213
  * Throws a clear Error when the spec folder can't be found.
@@ -184,7 +227,10 @@ function resolveSpec(specArg, dir, config, opts = {}) {
184
227
  const tokens = { repo, repoSlug, slug }
185
228
 
186
229
  const stack = readStackField(found.path, config)
187
- const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack }
230
+ // A hotfix forks its worktree from a release tag (its `Base version`) instead
231
+ // of base HEAD; every other type resolves to baseRef:null (fork from HEAD).
232
+ const baseRef = type === 'hotfix' ? readBaseVersionField(found.path) : null
233
+ const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack, baseRef }
188
234
  const branch = branchFor(spec, config)
189
235
 
190
236
  const worktreeRoot = expandTokens(config.worktree.root, tokens)
@@ -208,10 +254,13 @@ module.exports = {
208
254
  resolveSpec,
209
255
  resolveBaseBranch,
210
256
  resolvePrimaryCheckout,
257
+ currentBranch,
258
+ assertPrimaryOnMain,
211
259
  branchFor,
212
260
  splitPrefix,
213
261
  repoInfo,
214
262
  expandTokens,
215
263
  findSpecFolder,
216
264
  readStackField,
265
+ readBaseVersionField,
217
266
  }
@@ -21,7 +21,7 @@ const { expandTokens } = require('./resolve.js')
21
21
  * @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
22
22
  * @param {object} config normalised env config.
23
23
  * @param {object} flags { keepVolumes, force }
24
- * @param {object} ctx { worktreeState: { dirty, unpushed, merged }, timestamp }
24
+ * @param {object} ctx { worktreeState: { dirty, unpushed, merged, reachableFromTag }, timestamp }
25
25
  * @returns {object} { blocked, reason, commands, backupCommand, backupPath,
26
26
  * volumesDropped }
27
27
  */
@@ -30,20 +30,22 @@ function planDown(spec, config, flags, ctx) {
30
30
  const force = Boolean(flags && flags.force)
31
31
  const keepVolumes = Boolean(flags && flags.keepVolumes)
32
32
 
33
+ // A hotfix lands by tag + cherry-pick, so its branch is never an ancestor of
34
+ // base — but once its head is captured by a tag (the deploy tag from
35
+ // `hotfix land`), the commits are recoverable and the branch is safe to drop.
36
+ // Treat "reachable from a tag" as landed, alongside merged.
37
+ const landed = Boolean(worktreeState.merged || worktreeState.reachableFromTag)
38
+
33
39
  // --- guards (overridable with --force) ---
34
40
  if (!force) {
35
41
  if (config.guards.refuseTeardownIfDirty && worktreeState.dirty) {
36
42
  return blocked('worktree has uncommitted changes')
37
43
  }
38
44
  // Unpushed commits are only unsafe when they aren't already integrated into
39
- // the base branch. A branch merged into base carries nothing to lose even
40
- // with no remote — so /spec-complete's local land-then-teardown needs no
41
- // --force. Block only when the commits are both unpushed AND unmerged.
42
- if (
43
- config.guards.refuseTeardownIfUnpushed &&
44
- worktreeState.unpushed &&
45
- !worktreeState.merged
46
- ) {
45
+ // the base branch (or captured by a tag). A landed branch carries nothing to
46
+ // lose even with no remote — so /spec-complete's local land-then-teardown
47
+ // needs no --force. Block only when unpushed AND not landed.
48
+ if (config.guards.refuseTeardownIfUnpushed && worktreeState.unpushed && !landed) {
47
49
  return blocked('worktree has unpushed commits not yet merged into the base branch')
48
50
  }
49
51
  }
@@ -85,11 +87,16 @@ function planDown(spec, config, flags, ctx) {
85
87
  : `git worktree remove ${spec.worktreePath}`,
86
88
  )
87
89
 
88
- // --- delete the branch (safe: -d refuses an unmerged branch, never -D) ---
89
- // Runs after the worktree remove frees the branch. On a forced teardown of an
90
- // unmerged branch this fails loudly; the skill relays it rather than -D-ing.
90
+ // --- delete the branch ---
91
+ // Runs after the worktree remove frees the branch. Normally `-d` (safe: refuses
92
+ // an unmerged branch, never -D). A tag-landed hotfix branch is intentionally NOT
93
+ // an ancestor of base, so `-d` would refuse it — but its commits are captured by
94
+ // the deploy tag, so `-D` is safe *only* in that case. Everything else stays `-d`;
95
+ // on a forced teardown of a genuinely unmerged branch it fails loudly and the
96
+ // skill relays it rather than -D-ing.
91
97
  if (spec.branch) {
92
- commands.push(`git branch -d ${spec.branch}`)
98
+ const tagLanded = Boolean(worktreeState.reachableFromTag && !worktreeState.merged)
99
+ commands.push(`git branch ${tagLanded ? '-D' : '-d'} ${spec.branch}`)
93
100
  }
94
101
 
95
102
  return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
package/src/init.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
+ const crypto = require('crypto')
5
6
 
6
7
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
7
8
  const { repoInfo, expandTokens } = require('./env/resolve.js')
@@ -52,7 +53,12 @@ const CORE_FILES = listCoreTemplates()
52
53
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
53
54
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
54
55
 
55
- const report = { created: [], updated: [], skipped: [], removed: [], warnings: [] }
56
+ const report = { created: [], updated: [], skipped: [], removed: [], customized: [], warnings: [] }
57
+
58
+ function resetReport() {
59
+ for (const k of Object.keys(report)) report[k].length = 0
60
+ for (const k of Object.keys(writtenHashes)) delete writtenHashes[k]
61
+ }
56
62
 
57
63
  // Folder index files scaffolded by earlier versions, now retired. `init`/`update`
58
64
  // deletes any left behind so upgrading projects don't keep stale caches.
@@ -69,7 +75,109 @@ function ensureDir(p) {
69
75
  if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true })
70
76
  }
71
77
 
78
+ // --- install manifest (safe re-run baseline) --------------------------------
79
+ //
80
+ // specs/.core/.skitterspec-manifest.json records, per managed file, the sha1 of
81
+ // the content we last wrote. It's the baseline that lets a later resync tell "an
82
+ // old version we own" (safe to update) from "a file the user edited" (keep). It
83
+ // lists only managed FILES (skills, rules, .core templates) — never user content.
84
+
85
+ const MANIFEST_FILE = path.join('specs', '.core', '.skitterspec-manifest.json')
86
+ const MANIFEST_VERSION = 1
87
+
88
+ // Hashes of files actually written in the current run (populated by writeFile),
89
+ // reset each init() alongside `report`.
90
+ const writtenHashes = {}
91
+
92
+ function sha1(content) {
93
+ return crypto.createHash('sha1').update(content).digest('hex')
94
+ }
95
+
96
+ // The full managed set for a target dir: repo-relative path, absolute path, and
97
+ // the bundled content that ships in this distribution's assets.
98
+ function managedTargets(dir) {
99
+ const out = []
100
+ const add = (assetRel, targetAbs) =>
101
+ out.push({
102
+ relPath: rel(dir, targetAbs),
103
+ abs: targetAbs,
104
+ bundled: fs.readFileSync(path.join(ASSETS, assetRel), 'utf8'),
105
+ })
106
+ for (const name of SKILLS) add(path.join('skills', name, 'SKILL.md'), path.join(dir, '.claude', 'skills', name, 'SKILL.md'))
107
+ for (const name of RULES) add(path.join('rules', name), path.join(dir, '.claude', 'rules', name))
108
+ for (const asset of CORE_FILES) add(asset, path.join(dir, 'specs', '.core', path.basename(asset)))
109
+ return out
110
+ }
111
+
112
+ // Read the manifest (tolerant: missing/malformed → an empty baseline).
113
+ function readManifest(dir) {
114
+ try {
115
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8'))
116
+ if (parsed && typeof parsed === 'object' && parsed.files && typeof parsed.files === 'object') {
117
+ return { version: parsed.version || MANIFEST_VERSION, files: parsed.files }
118
+ }
119
+ } catch {
120
+ /* missing or malformed → empty baseline */
121
+ }
122
+ return { version: MANIFEST_VERSION, files: {} }
123
+ }
124
+
125
+ function writeManifest(dir, files) {
126
+ const target = path.join(dir, MANIFEST_FILE)
127
+ ensureDir(path.dirname(target))
128
+ const sorted = {}
129
+ for (const k of Object.keys(files).sort()) sorted[k] = files[k]
130
+ fs.writeFileSync(target, JSON.stringify({ version: MANIFEST_VERSION, files: sorted }, null, 2) + '\n')
131
+ }
132
+
133
+ // Classify a managed file against the manifest baseline.
134
+ // missing — not on disk
135
+ // pristine — on disk and matches the hash we recorded (ours to update)
136
+ // customized — on disk but differs (or unknown) — a user edit; keep it
137
+ function managedState(dir, relPath, manifest) {
138
+ const abs = path.join(dir, relPath)
139
+ if (!fs.existsSync(abs)) return 'missing'
140
+ const known = manifest.files[relPath]
141
+ return known && sha1(fs.readFileSync(abs, 'utf8')) === known ? 'pristine' : 'customized'
142
+ }
143
+
144
+ // Reconcile and persist the manifest after an install/resync run: keep prior
145
+ // entries, apply files written this run, seed any pre-existing managed file that
146
+ // has no entry yet from its bundled hash (migration for repos predating the
147
+ // manifest), and prune entries whose file is gone.
148
+ function flushManifest(dir) {
149
+ // Only managed files belong in the manifest — never live user config (e.g. an
150
+ // env.config.json that installIsolation happens to write through writeFile).
151
+ const managed = managedTargets(dir)
152
+ const managedRel = new Set(managed.map((t) => t.relPath))
153
+ const merged = { ...readManifest(dir).files, ...writtenHashes }
154
+ const next = {}
155
+ for (const [relPath, hash] of Object.entries(merged)) {
156
+ if (managedRel.has(relPath)) next[relPath] = hash
157
+ }
158
+ for (const { relPath, abs, bundled } of managed) {
159
+ if (!next[relPath] && fs.existsSync(abs)) next[relPath] = sha1(bundled) // migration seed
160
+ }
161
+ for (const relPath of Object.keys(next)) {
162
+ if (!fs.existsSync(path.join(dir, relPath))) delete next[relPath] // prune gone
163
+ }
164
+ writeManifest(dir, next)
165
+ }
166
+
72
167
  function writeFile(dir, target, content, { force }) {
168
+ // A dangling symlink (its target no longer exists) is invisible to existsSync,
169
+ // which follows the link — but the link itself is still on disk, so a plain
170
+ // writeFileSync would follow it into a missing directory and throw ENOENT.
171
+ // Drop the broken link and write a real file in its place.
172
+ let link = null
173
+ try {
174
+ link = fs.lstatSync(target)
175
+ } catch {
176
+ /* no such path — nothing to clean up */
177
+ }
178
+ if (link && link.isSymbolicLink() && !fs.existsSync(target)) {
179
+ fs.unlinkSync(target)
180
+ }
73
181
  if (fs.existsSync(target)) {
74
182
  if (!force) {
75
183
  report.skipped.push(rel(dir, target))
@@ -77,15 +185,19 @@ function writeFile(dir, target, content, { force }) {
77
185
  }
78
186
  const existing = fs.readFileSync(target, 'utf8')
79
187
  if (existing === content) {
188
+ // Already the content we'd write — record it as ours (pristine).
189
+ writtenHashes[rel(dir, target)] = sha1(content)
80
190
  report.skipped.push(rel(dir, target))
81
191
  return
82
192
  }
83
193
  fs.writeFileSync(target, content)
194
+ writtenHashes[rel(dir, target)] = sha1(content)
84
195
  report.updated.push(rel(dir, target))
85
196
  return
86
197
  }
87
198
  ensureDir(path.dirname(target))
88
199
  fs.writeFileSync(target, content)
200
+ writtenHashes[rel(dir, target)] = sha1(content)
89
201
  report.created.push(rel(dir, target))
90
202
  }
91
203
 
@@ -253,6 +365,118 @@ function installClaudeMd(dir, { mode }) {
253
365
  report.updated.push('CLAUDE.md (appended spec workflow section)')
254
366
  }
255
367
 
368
+ // --- detection, resync, reset (safe re-run) ---------------------------------
369
+
370
+ // True when the repo looks already set up: any managed file present, any spec
371
+ // lifecycle folder, or the CLAUDE.md spec marker (Decision 1 — detect eagerly).
372
+ function isExistingSetup(dir) {
373
+ if (managedTargets(dir).some((t) => fs.existsSync(t.abs))) return true
374
+ if (SPEC_FOLDERS.some((f) => fs.existsSync(path.join(dir, 'specs', f)))) return true
375
+ const claude = path.join(dir, 'CLAUDE.md')
376
+ return fs.existsSync(claude) && fs.readFileSync(claude, 'utf8').includes(SPEC_MARKER_START)
377
+ }
378
+
379
+ // RESYNC: bring managed files to the latest bundled version WITHOUT clobbering
380
+ // user edits. Per file: missing → create; pristine (matches the manifest) →
381
+ // update; customized (edited) → keep + report, unless `force`.
382
+ function resyncManagedFile(dir, target, manifest, force) {
383
+ const { relPath, abs, bundled } = target
384
+ const state = managedState(dir, relPath, manifest)
385
+ const write = (bucket) => {
386
+ ensureDir(path.dirname(abs))
387
+ fs.writeFileSync(abs, bundled)
388
+ writtenHashes[relPath] = sha1(bundled)
389
+ report[bucket].push(relPath)
390
+ }
391
+ if (state === 'missing') return write('created')
392
+ if (state === 'customized') {
393
+ if (force) return write('updated')
394
+ writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
395
+ return report.customized.push(relPath)
396
+ }
397
+ // pristine — update only if the bundled content actually changed
398
+ if (fs.readFileSync(abs, 'utf8') === bundled) {
399
+ writtenHashes[relPath] = sha1(bundled)
400
+ return report.skipped.push(relPath)
401
+ }
402
+ write('updated')
403
+ }
404
+
405
+ function resync(dir, { force = false, claudeMd = true } = {}) {
406
+ if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
407
+ resetReport()
408
+ const manifest = readManifest(dir)
409
+ for (const t of managedTargets(dir)) resyncManagedFile(dir, t, manifest, force)
410
+ installFolders(dir)
411
+ removeRetiredFiles(dir)
412
+ if (claudeMd) installClaudeMd(dir, { mode: 'update' })
413
+ flushManifest(dir)
414
+ printReport(dir, 'resync')
415
+ }
416
+
417
+ // The never-touch set: START AGAIN may only delete a known managed file, and may
418
+ // never delete spec content or active config (defense-in-depth — the manifest
419
+ // never lists these, but a tampered/foreign entry must still be refused).
420
+ const PROTECTED_SPEC_BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
421
+ const PROTECTED_CONFIG = ['env.config.json', 'linear.config.json']
422
+ const PROTECTED_DIRS = ['linear-base', 'linear-backups']
423
+
424
+ function assertSafeToDelete(relPath, managedSet) {
425
+ const norm = relPath.split(path.sep).join('/')
426
+ for (const b of PROTECTED_SPEC_BUCKETS) {
427
+ if (norm.startsWith(`specs/${b}/`)) throw new Error(`refusing to delete spec content: ${relPath}`)
428
+ }
429
+ if (PROTECTED_CONFIG.includes(path.posix.basename(norm))) {
430
+ throw new Error(`refusing to delete active config: ${relPath}`)
431
+ }
432
+ for (const d of PROTECTED_DIRS) {
433
+ if (norm.includes(`/${d}/`)) throw new Error(`refusing to delete sync state: ${relPath}`)
434
+ }
435
+ if (!managedSet.has(norm)) throw new Error(`refusing to delete a non-managed path: ${relPath}`)
436
+ }
437
+
438
+ // Remove the marked spec-workflow block from CLAUDE.md (leaves the rest intact).
439
+ function stripClaudeMdSection(dir) {
440
+ const target = path.join(dir, 'CLAUDE.md')
441
+ if (!fs.existsSync(target)) return
442
+ const existing = fs.readFileSync(target, 'utf8')
443
+ const next = existing.replace(
444
+ new RegExp(`\\n?${SPEC_MARKER_START}[\\s\\S]*?${SPEC_MARKER_END}\\n?`),
445
+ '\n',
446
+ )
447
+ if (next !== existing) {
448
+ fs.writeFileSync(target, next)
449
+ report.removed.push('CLAUDE.md (spec workflow section)')
450
+ }
451
+ }
452
+
453
+ // START AGAIN: delete exactly the manifest-listed managed files (each guarded),
454
+ // strip the CLAUDE.md marked section, then reinstall fresh. Never touches a
455
+ // non-manifest path. Destructive by design for managed files (that's the point).
456
+ function reset(dir, { claudeMd = true } = {}) {
457
+ if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
458
+ resetReport()
459
+ const manifest = readManifest(dir)
460
+ const managedSet = new Set(managedTargets(dir).map((t) => t.relPath.split(path.sep).join('/')))
461
+ for (const relPath of Object.keys(manifest.files)) {
462
+ assertSafeToDelete(relPath, managedSet)
463
+ const abs = path.join(dir, relPath)
464
+ if (fs.existsSync(abs)) {
465
+ fs.unlinkSync(abs)
466
+ report.removed.push(relPath)
467
+ }
468
+ }
469
+ if (claudeMd) stripClaudeMdSection(dir)
470
+ installSkills(dir, { force: true })
471
+ installRule(dir, { force: true })
472
+ installFolders(dir)
473
+ removeRetiredFiles(dir)
474
+ installCore(dir, { force: true })
475
+ if (claudeMd) installClaudeMd(dir, { mode: 'init' })
476
+ flushManifest(dir)
477
+ printReport(dir, 'reset')
478
+ }
479
+
256
480
  function printReport(dir, mode) {
257
481
  const line = (label, items) => {
258
482
  if (!items.length) return
@@ -263,6 +487,7 @@ function printReport(dir, mode) {
263
487
  line('created', report.created)
264
488
  line('updated', report.updated)
265
489
  line('removed', report.removed)
490
+ line('customized (kept)', report.customized)
266
491
  line('unchanged', report.skipped)
267
492
  if (report.warnings.length) {
268
493
  process.stdout.write('\nwarnings:\n')
@@ -285,11 +510,7 @@ function printReport(dir, mode) {
285
510
 
286
511
  async function init({ dir, force, claudeMd, mode, isolation }) {
287
512
  if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
288
- report.created.length = 0
289
- report.updated.length = 0
290
- report.skipped.length = 0
291
- report.removed.length = 0
292
- report.warnings.length = 0
513
+ resetReport()
293
514
 
294
515
  installSkills(dir, { force })
295
516
  installRule(dir, { force })
@@ -300,6 +521,10 @@ async function init({ dir, force, claudeMd, mode, isolation }) {
300
521
  if (mode !== 'update') installIsolation(dir, { enabled: isolation }, { force })
301
522
  if (claudeMd) installClaudeMd(dir, { mode })
302
523
 
524
+ // Record what we wrote (and migrate a pre-manifest repo) so a later resync can
525
+ // tell our files from the user's.
526
+ flushManifest(dir)
527
+
303
528
  printReport(dir, mode)
304
529
  }
305
530
 
@@ -308,4 +533,14 @@ module.exports = {
308
533
  SKILLS,
309
534
  RULES,
310
535
  SPEC_FOLDERS,
536
+ MANIFEST_FILE,
537
+ sha1,
538
+ readManifest,
539
+ writeManifest,
540
+ managedTargets,
541
+ managedState,
542
+ isExistingSetup,
543
+ resync,
544
+ reset,
545
+ assertSafeToDelete,
311
546
  }
package/src/prompts.js CHANGED
@@ -53,4 +53,40 @@ async function confirmRemoveReleaseTooling() {
53
53
  return Boolean(ans.remove)
54
54
  }
55
55
 
56
- module.exports = { promptSetup, confirmRemoveReleaseTooling }
56
+ /**
57
+ * Interactive choice when `init` finds an already-set-up repo. Returns one of
58
+ * `leave` | `resync` | `reset`. Defaults to (and cancels to) `leave` — the safe
59
+ * no-op — and asks a second confirm before `reset` (destructive to managed files).
60
+ */
61
+ async function promptExistingSetup() {
62
+ const prompts = require('prompts')
63
+ const { action } = await prompts(
64
+ {
65
+ type: 'select',
66
+ name: 'action',
67
+ message: 'This project already has skitterspec set up. What would you like to do?',
68
+ initial: 0,
69
+ choices: [
70
+ { title: 'Leave alone — make no changes', value: 'leave' },
71
+ { title: 'Resync — update managed files to the latest, keep my edits', value: 'resync' },
72
+ { title: 'Start again — reset the scaffolding (your specs & config are kept)', value: 'reset' },
73
+ ],
74
+ },
75
+ { onCancel: () => {} },
76
+ )
77
+ if (action === 'reset') {
78
+ const { confirm } = await prompts(
79
+ {
80
+ type: 'confirm',
81
+ name: 'confirm',
82
+ message: 'Start again overwrites managed skills/rules. Your specs and config are untouched. Continue?',
83
+ initial: false,
84
+ },
85
+ { onCancel: () => false },
86
+ )
87
+ if (!confirm) return 'leave'
88
+ }
89
+ return action || 'leave'
90
+ }
91
+
92
+ module.exports = { promptSetup, confirmRemoveReleaseTooling, promptExistingSetup }