@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.
- package/README.md +27 -244
- package/assets/claude-md-section.md +0 -6
- package/assets/core/env.config.json.example +5 -1
- package/assets/core/env.config.md +21 -5
- package/assets/rules/spec-planning.md +14 -10
- package/assets/skills/spec/SKILL.md +11 -38
- package/assets/skills/spec-complete/SKILL.md +31 -4
- package/assets/skills/spec-env/SKILL.md +6 -0
- package/assets/skills/spec-env-down/SKILL.md +16 -8
- package/assets/skills/spec-go/SKILL.md +15 -17
- package/package.json +6 -11
- package/src/cli.js +174 -318
- package/src/deprecate.js +138 -0
- package/src/env/config.js +17 -4
- package/src/env/integrate.js +46 -0
- package/src/env/resolve.js +54 -45
- package/src/env/teardown.js +19 -4
- package/src/env/trust.js +87 -0
- package/src/init.js +78 -170
- package/src/prompts.js +26 -63
- package/LICENSE +0 -21
- package/assets/core/linear.config.json.example +0 -39
- package/assets/core/linear.config.md +0 -121
- package/assets/rules/commit-messages.md +0 -85
- package/assets/scripts/generate-changelog.js +0 -274
- package/assets/scripts/generate-releases.js +0 -360
- package/assets/scripts/lib/config.js +0 -127
- package/assets/scripts/lib/git-commits.js +0 -265
- package/assets/skills/commit/SKILL.md +0 -28
- package/assets/skills/spec-pull/SKILL.md +0 -46
- package/assets/skills/spec-push/SKILL.md +0 -53
- package/assets/skills/spec-status/SKILL.md +0 -46
- package/src/config.js +0 -13
- package/src/sync/apply.js +0 -66
- package/src/sync/base.js +0 -83
- package/src/sync/compare.js +0 -99
- package/src/sync/config.js +0 -198
- package/src/sync/mcp.js +0 -112
- package/src/sync/normalize.js +0 -249
- package/src/sync/pull.js +0 -84
- package/src/sync/push.js +0 -106
- 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 {
|
|
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 {
|
|
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/
|
|
33
|
-
|
|
34
|
-
skitterspec update [dir] Re-copy skills + rule
|
|
35
|
-
specs/
|
|
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 --
|
|
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 === '--
|
|
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
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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(
|
|
227
|
-
}
|
|
242
|
+
unpushed = Number(ahead) > 0
|
|
243
|
+
} else {
|
|
228
244
|
// no upstream configured → any commit on HEAD not on a remote counts
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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',
|
|
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
|
|
483
|
+
module.exports = { run, parse }
|