@skitterbyte/skitterspec-linear 3.4.0 → 5.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 +45 -5
- package/assets/core/SETUP.md +11 -5
- package/assets/core/env.config.json.example +3 -0
- package/assets/core/env.config.md +9 -0
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +38 -1
- package/assets/rules/spec-planning.md +14 -0
- package/assets/skills/spec-complete/SKILL.md +6 -0
- package/assets/skills/spec-connect/SKILL.md +6 -0
- package/assets/skills/spec-go/SKILL.md +3 -1
- package/assets/skills/spec-init/SKILL.md +8 -0
- package/assets/skills/spec-live/SKILL.md +70 -0
- package/assets/skills/spec-pull/SKILL.md +4 -1
- package/assets/skills/spec-push/SKILL.md +21 -0
- package/package.json +1 -1
- package/src/cli.js +377 -29
- package/src/env/config.js +12 -1
- package/src/env/live.js +348 -0
- package/src/env/resolve.js +25 -0
- package/src/init.js +241 -6
- package/src/prompts.js +37 -1
- package/src/vendor/linear/cli-sync.js +32 -1
- package/src/vendor/linear/config.js +22 -0
- package/src/vendor/linear/mcp.js +17 -0
- package/src/vendor/sync-core/src/compare.js +116 -0
- package/src/vendor/sync-core/src/normalize.js +92 -26
- package/src/vendor/sync-core/src/pull.js +47 -16
- package/src/vendor/sync-core/src/push.js +48 -9
- package/src/vendor/sync-core/src/write.js +247 -0
package/src/env/live.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live-overlay receipt — the advisory record of which spec currently holds the
|
|
5
|
+
* primary checkout for live testing.
|
|
6
|
+
*
|
|
7
|
+
* The *authority* on "who's live" is the branch checked out in the primary
|
|
8
|
+
* checkout (see `assertPrimaryOnMain` in resolve.js): on the base branch → free;
|
|
9
|
+
* on a feature branch → that spec is in control. This receipt is only metadata —
|
|
10
|
+
* it powers `live status` and crash recovery (`live abort` reads `baseMainCommit`
|
|
11
|
+
* from here to restore the primary checkout). It lives beside the slot registry
|
|
12
|
+
* at the primary checkout root (`.spec-env/live.json`, gitignored).
|
|
13
|
+
*
|
|
14
|
+
* `receiptPath`/`renderReceipt`/`summarizeReceipt` are pure; `read`/`write`/`clear`
|
|
15
|
+
* are the thin IO seam the CLI drives, mirroring env/registry.js. No `Date.now()`
|
|
16
|
+
* — the caller passes `heldSince`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
const path = require('node:path')
|
|
21
|
+
|
|
22
|
+
const REQUIRED = ['spec', 'branch', 'holder', 'heldSince', 'baseMainCommit']
|
|
23
|
+
|
|
24
|
+
// Absolute path to the receipt — a sibling of the configured registry file, so it
|
|
25
|
+
// follows wherever `.spec-env` is configured (default `.spec-env/live.json`).
|
|
26
|
+
function receiptPath(rootDir, config) {
|
|
27
|
+
return path.resolve(rootDir, path.dirname(config.registry), 'live.json')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Normalize receipt fields into the persisted shape. Pure; throws on a missing
|
|
31
|
+
// field so a half-formed receipt is never written.
|
|
32
|
+
function renderReceipt(fields) {
|
|
33
|
+
for (const key of REQUIRED) {
|
|
34
|
+
if (!fields || !fields[key]) throw new Error(`live receipt: missing ${key}`)
|
|
35
|
+
}
|
|
36
|
+
const receipt = {}
|
|
37
|
+
for (const key of REQUIRED) receipt[key] = String(fields[key])
|
|
38
|
+
return receipt
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Read the receipt. Missing file → null (no one is live). Malformed JSON → Error.
|
|
42
|
+
function readReceipt(rootDir, config) {
|
|
43
|
+
const file = receiptPath(rootDir, config)
|
|
44
|
+
let raw
|
|
45
|
+
try {
|
|
46
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT') return null
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(raw)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Invalid live receipt ${path.dirname(config.registry)}/live.json: ${error.message}`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Persist the receipt, creating its parent dir as needed. Returns the written shape.
|
|
61
|
+
function writeReceipt(rootDir, config, fields) {
|
|
62
|
+
const receipt = renderReceipt(fields)
|
|
63
|
+
const file = receiptPath(rootDir, config)
|
|
64
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
65
|
+
fs.writeFileSync(file, JSON.stringify(receipt, null, 2) + '\n')
|
|
66
|
+
return receipt
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Remove the receipt. Idempotent: clearing an absent receipt is a clean no-op.
|
|
70
|
+
function clearReceipt(rootDir, config) {
|
|
71
|
+
const file = receiptPath(rootDir, config)
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(file)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code !== 'ENOENT') throw error
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A one-line human summary of a receipt (or the "free" state when null). Pure.
|
|
80
|
+
function summarizeReceipt(receipt) {
|
|
81
|
+
if (!receipt) return 'free — no spec is live'
|
|
82
|
+
return (
|
|
83
|
+
`${receipt.spec} (branch ${receipt.branch}) — ` +
|
|
84
|
+
`held by ${receipt.holder} since ${receipt.heldSince}`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- stateful detection (glob matching) -----------------------------------
|
|
89
|
+
|
|
90
|
+
// Translate a restricted glob into an anchored RegExp: `**` matches across path
|
|
91
|
+
// separators (with an optional trailing `/`), `*` matches within a segment, `?`
|
|
92
|
+
// a single non-separator char. Enough for migration-path globs, no dependency.
|
|
93
|
+
function globToRegExp(glob) {
|
|
94
|
+
let re = ''
|
|
95
|
+
for (let i = 0; i < glob.length; i++) {
|
|
96
|
+
const ch = glob[i]
|
|
97
|
+
if (ch === '*') {
|
|
98
|
+
if (glob[i + 1] === '*') {
|
|
99
|
+
re += '.*'
|
|
100
|
+
i++
|
|
101
|
+
if (glob[i + 1] === '/') i++ // consume the `/` in `**/`
|
|
102
|
+
} else {
|
|
103
|
+
re += '[^/]*'
|
|
104
|
+
}
|
|
105
|
+
} else if (ch === '?') {
|
|
106
|
+
re += '[^/]'
|
|
107
|
+
} else if ('.+^${}()|[]\\/'.includes(ch)) {
|
|
108
|
+
re += '\\' + ch
|
|
109
|
+
} else {
|
|
110
|
+
re += ch
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return new RegExp('^' + re + '$')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// True when any file path matches any of the migration globs. Empty patterns or
|
|
117
|
+
// no files → false (nothing configured / nothing changed).
|
|
118
|
+
function migrationsHit(files, patterns) {
|
|
119
|
+
if (!Array.isArray(files) || !files.length) return false
|
|
120
|
+
if (!Array.isArray(patterns) || !patterns.length) return false
|
|
121
|
+
const res = patterns.map(globToRegExp)
|
|
122
|
+
return files.some((f) => res.some((r) => r.test(f)))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- take planner ---------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pure planner for `spec-env live take`. Validates the preconditions for putting
|
|
129
|
+
* a spec live on the running instance by branch-switch, and returns either a
|
|
130
|
+
* structured refusal or the ordered git steps + the receipt to write. All git /
|
|
131
|
+
* port state is probed by the CLI and passed in `ctx`, keeping this deterministic
|
|
132
|
+
* and unit-testable with no live git.
|
|
133
|
+
*
|
|
134
|
+
* ctx:
|
|
135
|
+
* primary { onBase, branch, baseBranch } — the guard result for the primary checkout
|
|
136
|
+
* primaryPath absolute path of the primary checkout (the checkout target)
|
|
137
|
+
* clean boolean — primary checkout working tree is clean
|
|
138
|
+
* worktreeExists boolean — the spec's worktree is on disk
|
|
139
|
+
* base resolved base branch name (rebase target)
|
|
140
|
+
* baseMainCommit primary HEAD before the switch (receipt / crash recovery)
|
|
141
|
+
* serverUp true | false | null — null = no canonical ports declared → no gate
|
|
142
|
+
* canonicalPorts number[] — declared canonical (frontPort) ports, for messages
|
|
143
|
+
* migrationsHit boolean — the branch diff touches configured migration globs
|
|
144
|
+
* depsChanged boolean — the branch diff touches a lockfile/manifest (→ warn)
|
|
145
|
+
* holder,heldSince receipt provenance (injected by the CLI; no Date.now here)
|
|
146
|
+
*
|
|
147
|
+
* @returns {object} { blocked, reason, commands, warnings, receipt, base, branch, worktreePath }
|
|
148
|
+
*/
|
|
149
|
+
function planTake(spec, config, ctx) {
|
|
150
|
+
const c = ctx || {}
|
|
151
|
+
const branch = spec.branch
|
|
152
|
+
const base = c.base
|
|
153
|
+
const result = {
|
|
154
|
+
blocked: false,
|
|
155
|
+
reason: null,
|
|
156
|
+
commands: [],
|
|
157
|
+
warnings: [],
|
|
158
|
+
receipt: null,
|
|
159
|
+
base,
|
|
160
|
+
branch,
|
|
161
|
+
worktreePath: spec.worktreePath,
|
|
162
|
+
}
|
|
163
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
164
|
+
|
|
165
|
+
// 1. The lock: the primary checkout must be on base (free). Off-base → a spec
|
|
166
|
+
// (or you) already holds the live instance.
|
|
167
|
+
if (!c.primary || !c.primary.onBase) {
|
|
168
|
+
const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
|
|
169
|
+
return block(
|
|
170
|
+
`primary checkout is on ${on}, not ${base} — a spec already holds the live ` +
|
|
171
|
+
'instance; release it with `/spec-live main` first',
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
// 2. Never switch a dirty tree — the checkout is reset back to base on release.
|
|
175
|
+
if (!c.clean) {
|
|
176
|
+
return block('primary checkout has uncommitted changes — commit or stash them first')
|
|
177
|
+
}
|
|
178
|
+
// 3. Need a worktree holding the branch to detach and hand over.
|
|
179
|
+
if (!c.worktreeExists) {
|
|
180
|
+
return block(`${spec.folder} has no worktree — run \`/spec-go ${spec.folder}\` first`)
|
|
181
|
+
}
|
|
182
|
+
// 4. v1 is code-only: refuse a stateful spec (Stack: worktree + docker)…
|
|
183
|
+
if (spec.stack === 'docker') {
|
|
184
|
+
return block(
|
|
185
|
+
`${spec.folder} is stateful (Stack: worktree + docker) — live overlay is ` +
|
|
186
|
+
'code-only; use `/spec-connect` for a Docker-backed spec',
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
// 5. …and refuse a branch that changes migrations (would mutate the shared DB).
|
|
190
|
+
if (c.migrationsHit) {
|
|
191
|
+
return block(
|
|
192
|
+
`${spec.folder}'s branch changes migrations — live overlay is code-only; ` +
|
|
193
|
+
'use `/spec-connect`',
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
// 6. Verify-only: a dev server must be listening to hot-reload the switch.
|
|
197
|
+
if (c.serverUp === false) {
|
|
198
|
+
return block(
|
|
199
|
+
`no dev server listening on canonical port(s) ${(c.canonicalPorts || []).join(', ')} — ` +
|
|
200
|
+
'start your dev server (or `skitterspec spec-env dev up`) first',
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const warnings = []
|
|
205
|
+
if (c.depsChanged) {
|
|
206
|
+
warnings.push('dependencies changed on this branch — restart your dev server after the switch')
|
|
207
|
+
}
|
|
208
|
+
if (c.serverUp === null) {
|
|
209
|
+
warnings.push('no canonical dev ports configured — nothing to hot-reload; switching anyway')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
...result,
|
|
214
|
+
commands: [
|
|
215
|
+
`git -C ${spec.worktreePath} rebase ${base}`,
|
|
216
|
+
`git -C ${spec.worktreePath} switch --detach`,
|
|
217
|
+
`git -C ${c.primaryPath} checkout ${branch}`,
|
|
218
|
+
],
|
|
219
|
+
warnings,
|
|
220
|
+
receipt: {
|
|
221
|
+
spec: spec.folder,
|
|
222
|
+
branch,
|
|
223
|
+
holder: c.holder,
|
|
224
|
+
heldSince: c.heldSince,
|
|
225
|
+
baseMainCommit: c.baseMainCommit,
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- release planner ------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Pure planner for `spec-env live release` — hand the running instance back to
|
|
234
|
+
* base and re-isolate the spec's branch into its worktree (the graceful exit of
|
|
235
|
+
* an unfinished session). All git state is probed by the CLI and passed in.
|
|
236
|
+
*
|
|
237
|
+
* In the branch-switch model `take` never moves the base ref, so release is just
|
|
238
|
+
* `checkout base` (frees the branch) then re-attach it to the worktree — no reset.
|
|
239
|
+
*
|
|
240
|
+
* ctx: { primary:{onBase,branch}, primaryPath, base, clean, worktreeExists }
|
|
241
|
+
* @returns {object} { blocked, noop, reason, commands, clears, base, branch, worktreePath }
|
|
242
|
+
*/
|
|
243
|
+
function planRelease(spec, config, ctx) {
|
|
244
|
+
const c = ctx || {}
|
|
245
|
+
const branch = spec.branch
|
|
246
|
+
const base = c.base
|
|
247
|
+
const result = {
|
|
248
|
+
blocked: false,
|
|
249
|
+
noop: false,
|
|
250
|
+
reason: null,
|
|
251
|
+
commands: [],
|
|
252
|
+
clears: false,
|
|
253
|
+
base,
|
|
254
|
+
branch,
|
|
255
|
+
worktreePath: spec.worktreePath,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Nothing live — the primary checkout is already on base.
|
|
259
|
+
if (c.primary && c.primary.onBase) {
|
|
260
|
+
return { ...result, noop: true, reason: `nothing is live — the primary checkout is on ${base}` }
|
|
261
|
+
}
|
|
262
|
+
// A different spec holds the instance.
|
|
263
|
+
if (c.primary && c.primary.branch !== branch) {
|
|
264
|
+
return {
|
|
265
|
+
...result,
|
|
266
|
+
blocked: true,
|
|
267
|
+
reason: `the live spec is ${c.primary.branch}, not ${branch} — release that one`,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Fixes made while live must be committed to the branch first (never discarded).
|
|
271
|
+
if (!c.clean) {
|
|
272
|
+
return {
|
|
273
|
+
...result,
|
|
274
|
+
blocked: true,
|
|
275
|
+
reason: `primary checkout has uncommitted changes — commit your fixes to ${branch} first`,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const commands = [`git -C ${c.primaryPath} checkout ${base}`]
|
|
280
|
+
if (c.worktreeExists) commands.push(`git -C ${spec.worktreePath} switch ${branch}`)
|
|
281
|
+
return { ...result, commands, clears: true }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- abort planner --------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Pure planner for `spec-env live abort` — crash recovery. Works from the receipt
|
|
288
|
+
* (not a resolved spec), so it recovers even when the spec folder can't be found.
|
|
289
|
+
* Conservative: it refuses to discard uncommitted work, and won't force anything
|
|
290
|
+
* without a receipt to tell it what "live" was.
|
|
291
|
+
*
|
|
292
|
+
* Like release, recovery is `checkout base` + re-isolate — no `reset --hard`:
|
|
293
|
+
* branch-switch never moved base, and resetting to the receipt's recorded commit
|
|
294
|
+
* would discard any legitimate advance of base. `baseMainCommit` stays a record.
|
|
295
|
+
*
|
|
296
|
+
* ctx: { receipt, primary:{onBase,branch}, primaryPath, base, clean, worktreeExists, worktreePath }
|
|
297
|
+
* @returns {object} { blocked, noop, reason, commands, clears, base, branch }
|
|
298
|
+
*/
|
|
299
|
+
function planAbort(config, ctx) {
|
|
300
|
+
const c = ctx || {}
|
|
301
|
+
const base = c.base
|
|
302
|
+
const result = { blocked: false, noop: false, reason: null, commands: [], clears: false, base, branch: null }
|
|
303
|
+
|
|
304
|
+
if (!c.receipt) {
|
|
305
|
+
if (c.primary && c.primary.onBase) {
|
|
306
|
+
return { ...result, noop: true, reason: `nothing is live — the primary checkout is on ${base}` }
|
|
307
|
+
}
|
|
308
|
+
const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
|
|
309
|
+
return {
|
|
310
|
+
...result,
|
|
311
|
+
blocked: true,
|
|
312
|
+
reason:
|
|
313
|
+
`no live receipt, but the primary checkout is on ${on} — no recorded base ` +
|
|
314
|
+
`to restore; check out ${base} manually once you're sure it's safe`,
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Receipt present. Never discard uncommitted work — surface it instead.
|
|
319
|
+
if (!c.clean) {
|
|
320
|
+
return {
|
|
321
|
+
...result,
|
|
322
|
+
blocked: true,
|
|
323
|
+
reason:
|
|
324
|
+
'the primary checkout has uncommitted changes that abort would discard — ' +
|
|
325
|
+
'commit or stash them (or resolve manually) first',
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const branch = c.receipt.branch
|
|
330
|
+
const commands = []
|
|
331
|
+
if (!(c.primary && c.primary.onBase)) commands.push(`git -C ${c.primaryPath} checkout ${base}`)
|
|
332
|
+
if (c.worktreeExists) commands.push(`git -C ${c.worktreePath} switch ${branch}`)
|
|
333
|
+
return { ...result, commands, clears: true, branch }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = {
|
|
337
|
+
receiptPath,
|
|
338
|
+
renderReceipt,
|
|
339
|
+
readReceipt,
|
|
340
|
+
writeReceipt,
|
|
341
|
+
clearReceipt,
|
|
342
|
+
summarizeReceipt,
|
|
343
|
+
globToRegExp,
|
|
344
|
+
migrationsHit,
|
|
345
|
+
planTake,
|
|
346
|
+
planRelease,
|
|
347
|
+
planAbort,
|
|
348
|
+
}
|
package/src/env/resolve.js
CHANGED
|
@@ -165,6 +165,29 @@ function resolvePrimaryCheckout(dir, git) {
|
|
|
165
165
|
return common ? path.dirname(path.resolve(dir, common)) : dir
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The current branch of a checkout, or `null` when detached (or not a repo).
|
|
170
|
+
* `git` is a reader bound to the target checkout — for the live-overlay guard the
|
|
171
|
+
* CLI binds it to the primary checkout. (`symbolic-ref --short HEAD` exits
|
|
172
|
+
* non-zero on a detached HEAD, which the reader maps to `null`.)
|
|
173
|
+
*/
|
|
174
|
+
function currentBranch(git) {
|
|
175
|
+
return git(['symbolic-ref', '--short', 'HEAD'])
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The live-overlay guard: is the primary checkout on the integration base branch
|
|
180
|
+
* (free) or on a feature branch (a spec is in control)? Returns
|
|
181
|
+
* `{ onBase, branch, baseBranch }` — a structured result, never a throw, so each
|
|
182
|
+
* caller phrases its own refusal. `git` must be bound to the primary checkout.
|
|
183
|
+
* `onBase` is false on a detached HEAD (`branch === null`), which is not the base.
|
|
184
|
+
*/
|
|
185
|
+
function assertPrimaryOnMain(config, git) {
|
|
186
|
+
const baseBranch = resolveBaseBranch(config, git)
|
|
187
|
+
const branch = currentBranch(git)
|
|
188
|
+
return { onBase: branch === baseBranch, branch, baseBranch }
|
|
189
|
+
}
|
|
190
|
+
|
|
168
191
|
/**
|
|
169
192
|
* Resolve a spec argument to its identity + isolation coordinates.
|
|
170
193
|
* Throws a clear Error when the spec folder can't be found.
|
|
@@ -208,6 +231,8 @@ module.exports = {
|
|
|
208
231
|
resolveSpec,
|
|
209
232
|
resolveBaseBranch,
|
|
210
233
|
resolvePrimaryCheckout,
|
|
234
|
+
currentBranch,
|
|
235
|
+
assertPrimaryOnMain,
|
|
211
236
|
branchFor,
|
|
212
237
|
splitPrefix,
|
|
213
238
|
repoInfo,
|
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
|
}
|