@skitterbyte/skitterspec-linear 2.0.0 → 3.0.1
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/assets/core/env.config.json.example +1 -0
- package/assets/core/env.config.md +10 -0
- package/assets/skills/spec-go/SKILL.md +7 -0
- package/package.json +1 -1
- package/src/cli.js +30 -13
- package/src/env/config.js +26 -0
- package/src/env/provision.js +14 -3
- package/src/env/resolve.js +15 -0
|
@@ -52,6 +52,16 @@ no live `env.config.json` was found.
|
|
|
52
52
|
// empty = no backup, volumes dropped directly.
|
|
53
53
|
},
|
|
54
54
|
|
|
55
|
+
// Bootstrap commands `spec-env up <spec>` runs IN the worktree, right after
|
|
56
|
+
// `git worktree add` (before Docker/dev), on every provision including
|
|
57
|
+
// re-attach — so a fresh worktree's dependencies exist and git hooks,
|
|
58
|
+
// typechecks, builds and tests work immediately instead of failing on a
|
|
59
|
+
// missing node_modules. An array, run in order; [] = none. Each string is a
|
|
60
|
+
// shell command; {slug}/{branch}/{worktreePath}/{projectName}/{portOffset}
|
|
61
|
+
// expand (the cwd is already the worktree, so {worktreePath} is usually
|
|
62
|
+
// redundant). Example: ["pnpm install --frozen-lockfile"].
|
|
63
|
+
"setup": [],
|
|
64
|
+
|
|
55
65
|
// Host dev servers `spec-env dev up <spec>` starts on the spec's port block
|
|
56
66
|
// (for apps that run via `pnpm dev` on the host, not inside the Docker stack).
|
|
57
67
|
// An array so UI + API (or more) are supervised independently; [] = none.
|
|
@@ -30,6 +30,13 @@ housekeeping below lands on the spec's branch and never on `main`:
|
|
|
30
30
|
worktree on a branch forked from `main`, and — only when the spec's
|
|
31
31
|
`> **Stack:**` header is `worktree + docker` — also brings up its Docker stack.
|
|
32
32
|
Print the worktree path and the opener command it emits.
|
|
33
|
+
- **Bootstrap the worktree's dependencies.** A fresh worktree has an empty
|
|
34
|
+
working tree — no installed dependencies — so git hooks, typechecks, builds and
|
|
35
|
+
tests fail until they're installed. `spec-env up` prints the project's
|
|
36
|
+
configured **`in the worktree, run:`** commands (from `env.config.json` →
|
|
37
|
+
`setup`, e.g. an install command) — run them in the worktree before doing
|
|
38
|
+
anything else. With no `setup` configured there's nothing to run; set one up if
|
|
39
|
+
agents keep stalling on missing dependencies.
|
|
33
40
|
- **Trust the worktree for this session.** The engine wrote the printed
|
|
34
41
|
`trusted:` root into `.claude/settings.local.json` (gitignored) so future
|
|
35
42
|
sessions trust it automatically — but that file likely won't hot-reload now,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with Linear hybrid-sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus git-like /spec-status · /spec-pull · /spec-push and the spec-sync CLI. Install this OR the base, not both.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/cli.js
CHANGED
|
@@ -17,7 +17,14 @@ const {
|
|
|
17
17
|
freeSlot,
|
|
18
18
|
portOffset,
|
|
19
19
|
} = require('./env/registry.js')
|
|
20
|
-
const {
|
|
20
|
+
const {
|
|
21
|
+
resolveSpec,
|
|
22
|
+
resolveBaseBranch,
|
|
23
|
+
resolvePrimaryCheckout,
|
|
24
|
+
repoInfo,
|
|
25
|
+
expandTokens,
|
|
26
|
+
splitPrefix,
|
|
27
|
+
} = require('./env/resolve.js')
|
|
21
28
|
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
22
29
|
const { planUp } = require('./env/provision.js')
|
|
23
30
|
const { planDown } = require('./env/teardown.js')
|
|
@@ -205,6 +212,11 @@ function specEnvUp(dir, config, specArg) {
|
|
|
205
212
|
out.push(' run these:')
|
|
206
213
|
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
207
214
|
if (plan.openCommand) out.push(` ${plan.openCommand}`)
|
|
215
|
+
if (plan.setupCommands.length) {
|
|
216
|
+
out.push('')
|
|
217
|
+
out.push(' in the worktree, run:')
|
|
218
|
+
for (const cmd of plan.setupCommands) out.push(` ${cmd}`)
|
|
219
|
+
}
|
|
208
220
|
if (plan.envContents) {
|
|
209
221
|
out.push('')
|
|
210
222
|
out.push(` write ${config.docker.envFile} in the worktree:`)
|
|
@@ -331,26 +343,23 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
331
343
|
return
|
|
332
344
|
}
|
|
333
345
|
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
const commonDir = gitReader(dir)(['rev-parse', '--git-common-dir'])
|
|
339
|
-
const mainRepoPath = commonDir ? path.dirname(path.resolve(dir, commonDir)) : dir
|
|
340
|
-
|
|
346
|
+
// `dir` is already anchored on the primary checkout by the dispatch, so it is
|
|
347
|
+
// both where the spec resolves and the target of the fast-forward — /spec-complete
|
|
348
|
+
// can run this from inside the worktree and still land on main.
|
|
349
|
+
//
|
|
341
350
|
// A spec authored entirely on its branch may not exist in the primary
|
|
342
351
|
// checkout's specs/** (it was never committed to base) — but its worktree
|
|
343
352
|
// does, and the worktree path is derivable from config without the folder.
|
|
344
353
|
// Offer it as a fallback search location so integrate can still find the spec.
|
|
345
354
|
const { slug } = splitPrefix(path.basename(specArg))
|
|
346
|
-
const { repo, repoSlug } = repoInfo(
|
|
355
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
347
356
|
const wtTokens = { repo, repoSlug, slug }
|
|
348
357
|
const worktreeGuess = path.resolve(
|
|
349
|
-
|
|
358
|
+
dir,
|
|
350
359
|
expandTokens(config.worktree.root, wtTokens),
|
|
351
360
|
expandTokens(config.worktree.folderPattern, wtTokens),
|
|
352
361
|
)
|
|
353
|
-
const spec = resolveSpec(specArg,
|
|
362
|
+
const spec = resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
|
|
354
363
|
|
|
355
364
|
if (!fs.existsSync(spec.worktreePath)) {
|
|
356
365
|
process.stdout.write(
|
|
@@ -359,14 +368,19 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
359
368
|
return
|
|
360
369
|
}
|
|
361
370
|
|
|
362
|
-
const base = resolveBaseBranch(config, gitReader(
|
|
371
|
+
const base = resolveBaseBranch(config, gitReader(dir))
|
|
363
372
|
const wtGit = gitReader(spec.worktreePath)
|
|
364
373
|
const status = wtGit(['status', '--porcelain'])
|
|
365
374
|
const dirty = status !== null && status.length > 0
|
|
366
375
|
const ahead = wtGit(['rev-list', '--count', `${base}..HEAD`])
|
|
367
376
|
const aheadOfBase = ahead !== null && Number(ahead) > 0
|
|
368
377
|
|
|
369
|
-
const plan = planIntegrate(spec, config, {
|
|
378
|
+
const plan = planIntegrate(spec, config, {
|
|
379
|
+
worktreeState: { dirty },
|
|
380
|
+
base,
|
|
381
|
+
aheadOfBase,
|
|
382
|
+
mainRepoPath: dir,
|
|
383
|
+
})
|
|
370
384
|
|
|
371
385
|
if (plan.blocked) {
|
|
372
386
|
process.stdout.write(`spec-env integrate: blocked — ${plan.reason}.\n`)
|
|
@@ -583,6 +597,9 @@ async function specEnv(rest) {
|
|
|
583
597
|
else positional.push(args[i])
|
|
584
598
|
}
|
|
585
599
|
dir = path.resolve(dir)
|
|
600
|
+
// Anchor on the primary checkout so every subcommand resolves {repo}, worktree
|
|
601
|
+
// paths, and the registry identically whether run from main or a worktree.
|
|
602
|
+
dir = resolvePrimaryCheckout(dir, gitReader(dir))
|
|
586
603
|
|
|
587
604
|
const { config, present } = loadEnvConfig(dir)
|
|
588
605
|
if (!present) {
|
package/src/env/config.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* worktree: { root, folderPattern },
|
|
17
17
|
* docker: { enabled, composeFile, projectNamePattern, portBase,
|
|
18
18
|
* portsPerSpec, envFile, backupCommand },
|
|
19
|
+
* setup: [ "cmd", ... ], // bootstrap commands run in the worktree right
|
|
20
|
+
* // after `git worktree add` (e.g. install deps); empty = none
|
|
19
21
|
* dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
|
|
20
22
|
* // servers started on the spec's port block (empty = none)
|
|
21
23
|
* proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
|
|
@@ -43,6 +45,10 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
43
45
|
envFile: '.env',
|
|
44
46
|
backupCommand: '',
|
|
45
47
|
}),
|
|
48
|
+
// Bootstrap commands run in the worktree by `spec-env up`, right after
|
|
49
|
+
// `git worktree add` (before Docker/dev), on every provision. Array of shell
|
|
50
|
+
// strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
|
|
51
|
+
setup: Object.freeze([]),
|
|
46
52
|
// Host dev servers started on the spec's port block by `spec-env dev up`.
|
|
47
53
|
// Each: { name, command, portVar, health?, frontPort? }. Default: none.
|
|
48
54
|
dev: Object.freeze([]),
|
|
@@ -70,6 +76,7 @@ function defaults() {
|
|
|
70
76
|
return {
|
|
71
77
|
worktree: { ...DEFAULT_CONFIG.worktree },
|
|
72
78
|
docker: { ...DEFAULT_CONFIG.docker },
|
|
79
|
+
setup: [],
|
|
73
80
|
dev: [],
|
|
74
81
|
proxy: { ...DEFAULT_CONFIG.proxy },
|
|
75
82
|
open: { ...DEFAULT_CONFIG.open },
|
|
@@ -120,6 +127,21 @@ function normalizeDev(parsed) {
|
|
|
120
127
|
return out
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Normalise a parsed `setup` array into bootstrap commands: keep only trimmed,
|
|
132
|
+
* non-empty strings, drop everything else (lenient, like `normalizeDev`) so a
|
|
133
|
+
* stray entry can't crash provisioning.
|
|
134
|
+
*/
|
|
135
|
+
function normalizeSetup(parsed) {
|
|
136
|
+
const out = []
|
|
137
|
+
for (const raw of parsed) {
|
|
138
|
+
if (typeof raw !== 'string') continue
|
|
139
|
+
const cmd = raw.trim()
|
|
140
|
+
if (cmd) out.push(cmd)
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
123
145
|
/**
|
|
124
146
|
* Merge a parsed config over the defaults. Only known keys are copied (unknown
|
|
125
147
|
* keys ignored for forward-compat). Nested objects are merged field-by-field.
|
|
@@ -142,6 +164,10 @@ function mergeConfig(base, parsed) {
|
|
|
142
164
|
assign(base.docker, parsed.docker, 'backupCommand', 'string?')
|
|
143
165
|
}
|
|
144
166
|
|
|
167
|
+
if (Array.isArray(parsed.setup)) {
|
|
168
|
+
base.setup = normalizeSetup(parsed.setup)
|
|
169
|
+
}
|
|
170
|
+
|
|
145
171
|
if (Array.isArray(parsed.dev)) {
|
|
146
172
|
base.dev = normalizeDev(parsed.dev)
|
|
147
173
|
}
|
package/src/env/provision.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const { portOffset } = require('./registry.js')
|
|
15
15
|
const { renderEnvFile, expandOpenCommand } = require('./render.js')
|
|
16
|
+
const { expandTokens } = require('./resolve.js')
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Plan a provisioning run.
|
|
@@ -23,7 +24,8 @@ const { renderEnvFile, expandOpenCommand } = require('./render.js')
|
|
|
23
24
|
* existed in the registry (re-run → attach, don't clobber).
|
|
24
25
|
* @param {object} config normalised env config.
|
|
25
26
|
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
26
|
-
* envContents, openCommand, commands,
|
|
27
|
+
* envContents, openCommand, commands, setupCommands,
|
|
28
|
+
* attached }
|
|
27
29
|
*/
|
|
28
30
|
function planUp(spec, alloc, config) {
|
|
29
31
|
const { slot, attached } = alloc
|
|
@@ -41,13 +43,21 @@ function planUp(spec, alloc, config) {
|
|
|
41
43
|
? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
|
|
42
44
|
: null
|
|
43
45
|
|
|
44
|
-
const
|
|
46
|
+
const tokens = {
|
|
45
47
|
worktreePath: spec.worktreePath,
|
|
46
48
|
slug: spec.slug,
|
|
47
49
|
branch: spec.branch,
|
|
48
50
|
projectName: spec.projectName,
|
|
49
51
|
portOffset: offset === null ? '' : String(offset),
|
|
50
|
-
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const openCommand = expandOpenCommand(config.open.command, tokens)
|
|
55
|
+
|
|
56
|
+
// Bootstrap commands run *in the worktree* after `git worktree add` (before
|
|
57
|
+
// Docker/dev), on every provision including re-attach — deps must exist for
|
|
58
|
+
// the worktree to be usable. Kept separate from `commands` (run from the
|
|
59
|
+
// primary checkout root); the CLI prints them under an "in the worktree" head.
|
|
60
|
+
const setupCommands = (config.setup || []).map((cmd) => expandTokens(cmd, tokens))
|
|
51
61
|
|
|
52
62
|
const commands = []
|
|
53
63
|
// Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
|
|
@@ -69,6 +79,7 @@ function planUp(spec, alloc, config) {
|
|
|
69
79
|
envContents,
|
|
70
80
|
openCommand,
|
|
71
81
|
commands,
|
|
82
|
+
setupCommands,
|
|
72
83
|
attached,
|
|
73
84
|
}
|
|
74
85
|
}
|
package/src/env/resolve.js
CHANGED
|
@@ -151,6 +151,20 @@ function resolveBaseBranch(config, git) {
|
|
|
151
151
|
return 'main'
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Resolve `dir` to the primary checkout root — the parent of the shared git dir.
|
|
156
|
+
* From the primary checkout `git rev-parse --git-common-dir` is `.git` (relative),
|
|
157
|
+
* so the parent is `dir`; from a linked worktree it's the absolute `<main>/.git`,
|
|
158
|
+
* so the parent is `<main>`. Anchoring every `spec-env` command here means they
|
|
159
|
+
* resolve `{repo}` / worktree paths / the registry identically whether run from
|
|
160
|
+
* `main` or a worktree. `git(args)` returns trimmed stdout or `null` (not a repo)
|
|
161
|
+
* — injected for testability; a `null` degrades to `dir` (today's behaviour).
|
|
162
|
+
*/
|
|
163
|
+
function resolvePrimaryCheckout(dir, git) {
|
|
164
|
+
const common = git(['rev-parse', '--git-common-dir'])
|
|
165
|
+
return common ? path.dirname(path.resolve(dir, common)) : dir
|
|
166
|
+
}
|
|
167
|
+
|
|
154
168
|
/**
|
|
155
169
|
* Resolve a spec argument to its identity + isolation coordinates.
|
|
156
170
|
* Throws a clear Error when the spec folder can't be found.
|
|
@@ -193,6 +207,7 @@ function resolveSpec(specArg, dir, config, opts = {}) {
|
|
|
193
207
|
module.exports = {
|
|
194
208
|
resolveSpec,
|
|
195
209
|
resolveBaseBranch,
|
|
210
|
+
resolvePrimaryCheckout,
|
|
196
211
|
branchFor,
|
|
197
212
|
splitPrefix,
|
|
198
213
|
repoInfo,
|