@skitterbyte/skitterspec-linear 4.0.0 → 6.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 +39 -0
- package/assets/core/env.config.json.example +3 -0
- package/assets/core/env.config.md +48 -2
- package/assets/rules/spec-planning.md +46 -13
- package/assets/skills/spec-cancel/SKILL.md +5 -0
- package/assets/skills/spec-complete/SKILL.md +61 -11
- package/assets/skills/spec-connect/SKILL.md +6 -0
- package/assets/skills/spec-go/SKILL.md +3 -1
- package/assets/skills/spec-hotfix/SKILL.md +161 -0
- package/assets/skills/spec-init/SKILL.md +8 -0
- package/assets/skills/spec-live/SKILL.md +73 -0
- package/assets/skills/spec-to-main/SKILL.md +99 -0
- package/package.json +1 -1
- package/src/cli.js +647 -32
- package/src/env/config.js +30 -1
- package/src/env/hotfix.js +133 -0
- package/src/env/live.js +357 -0
- package/src/env/provision.js +5 -1
- package/src/env/prune.js +119 -0
- package/src/env/resolve.js +53 -4
- package/src/env/teardown.js +20 -13
- package/src/init.js +241 -6
- package/src/prompts.js +37 -1
package/src/env/resolve.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/env/teardown.js
CHANGED
|
@@ -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
|
|
40
|
-
// with no remote — so /spec-complete's local land-then-teardown
|
|
41
|
-
// --force. Block only when
|
|
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
|
|
89
|
-
// Runs after the worktree remove frees the branch.
|
|
90
|
-
// unmerged branch
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|