@skitterbyte/skitterspec 8.0.0 → 8.1.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.
@@ -12,6 +12,10 @@
12
12
  "envFile": ".env",
13
13
  "backupCommand": ""
14
14
  },
15
+ "seedFiles": {
16
+ "mode": "symlink",
17
+ "files": [".env"]
18
+ },
15
19
  "setup": [],
16
20
  "dev": [],
17
21
  "proxy": {
@@ -52,6 +52,28 @@ no live `env.config.json` was found.
52
52
  // empty = no backup, volumes dropped directly.
53
53
  },
54
54
 
55
+ // Gitignored files seeded from the primary checkout into a fresh worktree by
56
+ // `spec-env up`, right after `git worktree add` and BEFORE `setup` runs — so a
57
+ // fresh linked worktree (which starts with none of the repo's gitignored files)
58
+ // has the .env / local secret overrides / local config that setup steps and
59
+ // git hooks depend on. Without this a step like `prisma generate` hard-fails in
60
+ // the new worktree because .env (its datasource URL) isn't there.
61
+ // mode "symlink" (default) points the worktree file at the main file, so it
62
+ // stays in sync; "copy" makes an independent copy.
63
+ // files repo-relative paths to seed. A source absent in main is a printed
64
+ // no-op (not an error); a target that already exists is left untouched
65
+ // (idempotent — safe when `spec-env up` re-attaches an existing
66
+ // worktree). The main checkout is resolved robustly at run time via
67
+ // `git rev-parse --git-common-dir` — no hardcoded repo name or path.
68
+ // Shorthand: `"seedFiles": [".env", …]` == `{ "mode": "symlink", "files": […] }`.
69
+ // Seeded files are gitignored, so they never make the worktree "dirty" and
70
+ // never block teardown; they vanish with the worktree at `spec-env down`.
71
+ // [] (or absent) = seed nothing (current behaviour).
72
+ "seedFiles": {
73
+ "mode": "symlink",
74
+ "files": [".env"]
75
+ },
76
+
55
77
  // Bootstrap commands `spec-env up <spec>` runs IN the worktree, right after
56
78
  // `git worktree add` (before Docker/dev), on every provision including
57
79
  // re-attach — so a fresh worktree's dependencies exist and git hooks,
@@ -31,12 +31,16 @@ housekeeping below lands on the spec's branch and never on `main`:
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
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.
34
+ working tree — no installed dependencies, and none of the repo's gitignored
35
+ files (`.env`, local secret/config overrides) so git hooks, typechecks,
36
+ builds and tests fail until they're in place. `spec-env up` prints the
37
+ project's configured **`in the worktree, run:`** commands — run them in the
38
+ worktree, in order, before doing anything else. Those commands are: first any
39
+ **file seeding** (from `env.config.json` `seedFiles`), which symlinks or
40
+ copies the configured gitignored files from the main checkout into the fresh
41
+ worktree so setup can rely on them; then the **`setup`** commands (e.g. an
42
+ install command). With neither configured there's nothing to run; add
43
+ `seedFiles`/`setup` if agents keep stalling on a missing `.env` or dependencies.
40
44
  - **Trust the worktree for this session.** The engine wrote the printed
41
45
  `trusted:` root into `.claude/settings.local.json` (gitignored) so future
42
46
  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",
3
- "version": "8.0.0",
3
+ "version": "8.1.0",
4
4
  "description": "Spec-driven development for Claude Code — a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
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 { resolveSpec, resolveBaseBranch, repoInfo, expandTokens, splitPrefix } = require('./env/resolve.js')
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,10 +212,13 @@ 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}`)
208
- if (plan.setupCommands.length) {
215
+ // Seed files first (setup may depend on them), then the setup commands —
216
+ // both run in the worktree, under one heading.
217
+ const worktreeSteps = [...plan.seedCommands, ...plan.setupCommands]
218
+ if (worktreeSteps.length) {
209
219
  out.push('')
210
220
  out.push(' in the worktree, run:')
211
- for (const cmd of plan.setupCommands) out.push(` ${cmd}`)
221
+ for (const cmd of worktreeSteps) out.push(` ${cmd}`)
212
222
  }
213
223
  if (plan.envContents) {
214
224
  out.push('')
@@ -336,26 +346,23 @@ function specEnvIntegrate(dir, config, specArg) {
336
346
  return
337
347
  }
338
348
 
339
- // /spec-complete runs this from inside the worktree, but the spec's coordinates
340
- // (worktreePath via {repo}, the base branch) must resolve against the PRIMARY
341
- // checkout. Resolve it first (parent of the shared git dir) and anchor
342
- // everything to it, so integrate works whether invoked from main or a worktree.
343
- const commonDir = gitReader(dir)(['rev-parse', '--git-common-dir'])
344
- const mainRepoPath = commonDir ? path.dirname(path.resolve(dir, commonDir)) : dir
345
-
349
+ // `dir` is already anchored on the primary checkout by the dispatch, so it is
350
+ // both where the spec resolves and the target of the fast-forward — /spec-complete
351
+ // can run this from inside the worktree and still land on main.
352
+ //
346
353
  // A spec authored entirely on its branch may not exist in the primary
347
354
  // checkout's specs/** (it was never committed to base) — but its worktree
348
355
  // does, and the worktree path is derivable from config without the folder.
349
356
  // Offer it as a fallback search location so integrate can still find the spec.
350
357
  const { slug } = splitPrefix(path.basename(specArg))
351
- const { repo, repoSlug } = repoInfo(mainRepoPath)
358
+ const { repo, repoSlug } = repoInfo(dir)
352
359
  const wtTokens = { repo, repoSlug, slug }
353
360
  const worktreeGuess = path.resolve(
354
- mainRepoPath,
361
+ dir,
355
362
  expandTokens(config.worktree.root, wtTokens),
356
363
  expandTokens(config.worktree.folderPattern, wtTokens),
357
364
  )
358
- const spec = resolveSpec(specArg, mainRepoPath, config, { searchDirs: [worktreeGuess] })
365
+ const spec = resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
359
366
 
360
367
  if (!fs.existsSync(spec.worktreePath)) {
361
368
  process.stdout.write(
@@ -364,14 +371,19 @@ function specEnvIntegrate(dir, config, specArg) {
364
371
  return
365
372
  }
366
373
 
367
- const base = resolveBaseBranch(config, gitReader(mainRepoPath))
374
+ const base = resolveBaseBranch(config, gitReader(dir))
368
375
  const wtGit = gitReader(spec.worktreePath)
369
376
  const status = wtGit(['status', '--porcelain'])
370
377
  const dirty = status !== null && status.length > 0
371
378
  const ahead = wtGit(['rev-list', '--count', `${base}..HEAD`])
372
379
  const aheadOfBase = ahead !== null && Number(ahead) > 0
373
380
 
374
- const plan = planIntegrate(spec, config, { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath })
381
+ const plan = planIntegrate(spec, config, {
382
+ worktreeState: { dirty },
383
+ base,
384
+ aheadOfBase,
385
+ mainRepoPath: dir,
386
+ })
375
387
 
376
388
  if (plan.blocked) {
377
389
  process.stdout.write(`spec-env integrate: blocked — ${plan.reason}.\n`)
@@ -588,6 +600,9 @@ async function specEnv(rest) {
588
600
  else positional.push(args[i])
589
601
  }
590
602
  dir = path.resolve(dir)
603
+ // Anchor on the primary checkout so every subcommand resolves {repo}, worktree
604
+ // paths, and the registry identically whether run from main or a worktree.
605
+ dir = resolvePrimaryCheckout(dir, gitReader(dir))
591
606
 
592
607
  const { config, present } = loadEnvConfig(dir)
593
608
  if (!present) {
package/src/env/config.js CHANGED
@@ -16,6 +16,9 @@
16
16
  * worktree: { root, folderPattern },
17
17
  * docker: { enabled, composeFile, projectNamePattern, portBase,
18
18
  * portsPerSpec, envFile, backupCommand },
19
+ * seedFiles:{ mode, files } | [ ".env", ... ], // gitignored files copied/
20
+ * // symlinked from the main checkout into a fresh worktree before
21
+ * // setup runs (mode: "symlink" default | "copy"); empty = none
19
22
  * setup: [ "cmd", ... ], // bootstrap commands run in the worktree right
20
23
  * // after `git worktree add` (e.g. install deps); empty = none
21
24
  * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
@@ -45,6 +48,12 @@ const DEFAULT_CONFIG = Object.freeze({
45
48
  envFile: '.env',
46
49
  backupCommand: '',
47
50
  }),
51
+ // Gitignored files seeded from the main checkout into a fresh worktree by
52
+ // `spec-env up`, right after `git worktree add` and before `setup` runs — so a
53
+ // fresh worktree has the .env / local overrides that setup steps depend on.
54
+ // `mode` is "symlink" (default, stays in sync with main) or "copy" (an
55
+ // independent copy). `files` is a list of repo-relative paths. Default: none.
56
+ seedFiles: Object.freeze({ mode: 'symlink', files: Object.freeze([]) }),
48
57
  // Bootstrap commands run in the worktree by `spec-env up`, right after
49
58
  // `git worktree add` (before Docker/dev), on every provision. Array of shell
50
59
  // strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
@@ -76,6 +85,7 @@ function defaults() {
76
85
  return {
77
86
  worktree: { ...DEFAULT_CONFIG.worktree },
78
87
  docker: { ...DEFAULT_CONFIG.docker },
88
+ seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
79
89
  setup: [],
80
90
  dev: [],
81
91
  proxy: { ...DEFAULT_CONFIG.proxy },
@@ -127,6 +137,34 @@ function normalizeDev(parsed) {
127
137
  return out
128
138
  }
129
139
 
140
+ // Keep only trimmed, non-empty strings from an array of file paths (lenient).
141
+ function normalizeFileList(parsed) {
142
+ const out = []
143
+ for (const raw of parsed) {
144
+ if (typeof raw !== 'string') continue
145
+ const file = raw.trim()
146
+ if (file) out.push(file)
147
+ }
148
+ return out
149
+ }
150
+
151
+ /**
152
+ * Normalise a parsed `seedFiles` value into `{ mode, files }`. Accepts two forms:
153
+ * - the array shorthand `[".env", ...]` → { mode: 'symlink', files: [...] }
154
+ * - the object form `{ mode?, files? }` — `mode` is 'copy' or 'symlink'
155
+ * (anything else falls back to 'symlink'); `files` is a list of paths.
156
+ * Malformed entries are dropped (lenient, like `normalizeSetup`) so a stray
157
+ * value can't crash provisioning.
158
+ */
159
+ function normalizeSeedFiles(parsed) {
160
+ if (Array.isArray(parsed)) {
161
+ return { mode: 'symlink', files: normalizeFileList(parsed) }
162
+ }
163
+ const mode = parsed.mode === 'copy' ? 'copy' : 'symlink'
164
+ const files = Array.isArray(parsed.files) ? normalizeFileList(parsed.files) : []
165
+ return { mode, files }
166
+ }
167
+
130
168
  /**
131
169
  * Normalise a parsed `setup` array into bootstrap commands: keep only trimmed,
132
170
  * non-empty strings, drop everything else (lenient, like `normalizeDev`) so a
@@ -164,6 +202,10 @@ function mergeConfig(base, parsed) {
164
202
  assign(base.docker, parsed.docker, 'backupCommand', 'string?')
165
203
  }
166
204
 
205
+ if (Array.isArray(parsed.seedFiles) || isObject(parsed.seedFiles)) {
206
+ base.seedFiles = normalizeSeedFiles(parsed.seedFiles)
207
+ }
208
+
167
209
  if (Array.isArray(parsed.setup)) {
168
210
  base.setup = normalizeSetup(parsed.setup)
169
211
  }
@@ -15,6 +15,31 @@ const { portOffset } = require('./registry.js')
15
15
  const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
16
  const { expandTokens } = require('./resolve.js')
17
17
 
18
+ /**
19
+ * Build one idempotent POSIX-sh command that seeds a gitignored file from the
20
+ * main checkout into the current worktree (the cwd when the skill runs it).
21
+ *
22
+ * The main checkout is resolved at run time from inside the worktree via
23
+ * `git rev-parse --git-common-dir` (absolute `<main>/.git` in a linked worktree)
24
+ * and its dirname — never a hardcoded repo name or a `../..` hop. The command is
25
+ * safe to re-run: a source absent in main is a printed no-op, an already-seeded
26
+ * target (real file or symlink) is left untouched, and only a genuinely missing
27
+ * target is created. `mode` is 'symlink' (points at main, stays in sync) or
28
+ * 'copy' (an independent copy). Output mirrors the setup style: `seeded <f> → …`.
29
+ */
30
+ function seedCommandFor(file, mode) {
31
+ const op =
32
+ mode === 'copy'
33
+ ? `cp "$m/${file}" "${file}"`
34
+ : `ln -s "$m/${file}" "${file}"`
35
+ return (
36
+ 'm="$(dirname "$(git rev-parse --git-common-dir)")"; ' +
37
+ `if [ ! -e "$m/${file}" ]; then echo "seed ${file}: not in main — skipped"; ` +
38
+ `elif [ -e "${file}" ] || [ -L "${file}" ]; then echo "seed ${file}: exists — skipped"; ` +
39
+ `else mkdir -p "$(dirname "${file}")" && ${op} && echo "seeded ${file} → $m/${file}"; fi`
40
+ )
41
+ }
42
+
18
43
  /**
19
44
  * Plan a provisioning run.
20
45
  *
@@ -24,8 +49,8 @@ const { expandTokens } = require('./resolve.js')
24
49
  * existed in the registry (re-run → attach, don't clobber).
25
50
  * @param {object} config normalised env config.
26
51
  * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
27
- * envContents, openCommand, commands, setupCommands,
28
- * attached }
52
+ * envContents, openCommand, commands, seedCommands,
53
+ * setupCommands, attached }
29
54
  */
30
55
  function planUp(spec, alloc, config) {
31
56
  const { slot, attached } = alloc
@@ -53,6 +78,14 @@ function planUp(spec, alloc, config) {
53
78
 
54
79
  const openCommand = expandOpenCommand(config.open.command, tokens)
55
80
 
81
+ // File seeding runs *in the worktree* after `git worktree add`, before the
82
+ // setup commands (which may depend on the seeded .env). Each entry becomes an
83
+ // idempotent shell command resolving the main checkout at run time. Absent
84
+ // config ⇒ no commands ⇒ current behaviour.
85
+ const seed = config.seedFiles || { mode: 'symlink', files: [] }
86
+ const seedMode = seed.mode === 'copy' ? 'copy' : 'symlink'
87
+ const seedCommands = (seed.files || []).map((file) => seedCommandFor(file, seedMode))
88
+
56
89
  // Bootstrap commands run *in the worktree* after `git worktree add` (before
57
90
  // Docker/dev), on every provision including re-attach — deps must exist for
58
91
  // the worktree to be usable. Kept separate from `commands` (run from the
@@ -79,9 +112,10 @@ function planUp(spec, alloc, config) {
79
112
  envContents,
80
113
  openCommand,
81
114
  commands,
115
+ seedCommands,
82
116
  setupCommands,
83
117
  attached,
84
118
  }
85
119
  }
86
120
 
87
- module.exports = { planUp }
121
+ module.exports = { planUp, seedCommandFor }
@@ -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,