@skitterbyte/skitterspec 17.0.0 → 18.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/src/cli.js CHANGED
@@ -22,6 +22,7 @@ const {
22
22
  resolveBaseBranch,
23
23
  resolvePrimaryCheckout,
24
24
  assertPrimaryOnMain,
25
+ currentBranch,
25
26
  repoInfo,
26
27
  expandTokens,
27
28
  splitPrefix,
@@ -94,6 +95,11 @@ Usage:
94
95
  hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
95
96
  status list provisioned specs + port blocks
96
97
  resolve <spec> print resolved slug/type/branch/paths
98
+ skitterspec gating <cmd> Release-gating check (opt-in; needs
99
+ specs/.core/gating.config.json). Subcommands:
100
+ check [spec] report specs with no recorded gating
101
+ decision (--all, --json). Advisory:
102
+ always exits 0, never blocks.
97
103
  skitterspec --help Show this help
98
104
  skitterspec --version Print version
99
105
 
@@ -105,6 +111,8 @@ Options (init / update):
105
111
  --diff (update) Show the upstream changes each customized
106
112
  file declined, as a unified diff
107
113
  --dir <path> Target project dir (default: positional arg or cwd)
114
+ --gating (init) Adopt release gating: each spec records whether
115
+ it ships behind a feature flag
108
116
  --no-claude-md Skip creating/patching CLAUDE.md
109
117
  --yes, -y Accept defaults; skip the interactive setup prompts
110
118
  --isolation / --no-isolation Enable/skip per-spec isolation (a git
@@ -139,6 +147,10 @@ function parse(argv) {
139
147
  else if (a === '--yes' || a === '-y') opts.yes = true
140
148
  else if (a === '--isolation') opts.isolation = true
141
149
  else if (a === '--no-isolation') opts.isolation = false
150
+ else if (a === '--gating') opts.gating = true
151
+ else if (a === '--no-gating') opts.gating = false
152
+ else if (a === '--all') opts.all = true
153
+ else if (a === '--json') opts.json = true
142
154
  else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
143
155
  else if (a === '--resync') opts.resync = true
144
156
  else if (a === '--reset') opts.reset = true
@@ -233,13 +245,172 @@ function specEnvStatus(dir, config) {
233
245
  // the /spec-env skill executes (git worktree add, docker compose up, .env,
234
246
  // opener). This creates no worktree and starts no stack — the caller runs the
235
247
  // printed commands. Keep the output's verb honest about that.
248
+
249
+ // git quotes a path containing unusual bytes and C-escapes it. Unquote what we
250
+ // can; anything we cannot parse confidently is returned as-is, which makes it
251
+ // fail the spec-folder comparison and land in `foreign` — a refusal, which is the
252
+ // safe direction to be wrong in.
253
+ function unquotePath(p) {
254
+ if (!p.startsWith('"') || !p.endsWith('"')) return p
255
+ try {
256
+ return JSON.parse(p)
257
+ } catch {
258
+ return p
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Repo-relative paths of everything uncommitted. Returns null when git could not
264
+ * be read at all — the caller must treat that as "nobody looked", never "clean".
265
+ *
266
+ * Two prefix-free listings rather than `git status --porcelain`, deliberately.
267
+ * Porcelain prefixes every path with a two-character status field, and the shared
268
+ * git reader TRIMS its output — which eats the leading space of the first line
269
+ * only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
270
+ * These emit bare paths, so there is no offset to get wrong. `--others` also
271
+ * lists untracked files INDIVIDUALLY, where porcelain collapses them into their
272
+ * topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
273
+ * an ancestor attributable to no single spec, and so refusing the very tree this
274
+ * gate exists to accept. Both were found by running it, not by reading it.
275
+ */
276
+ function dirtyPaths(git) {
277
+ const lists = [
278
+ git(['diff', '--name-only', 'HEAD']),
279
+ git(['ls-files', '--others', '--exclude-standard']),
280
+ ]
281
+ if (lists.every((l) => l === null)) return null
282
+ const out = []
283
+ for (const list of lists) {
284
+ if (!list) continue
285
+ for (const line of list.split('\n')) {
286
+ const q = line.trim()
287
+ if (q) out.push(unquotePath(q))
288
+ }
289
+ }
290
+ return out
291
+ }
292
+
293
+ // Is this spec new to git? Asked directly, so the commit subject does not depend
294
+ // on the shape of `git status` output.
295
+ function specIsUntracked(dir, git, spec) {
296
+ const rel = path.relative(dir, spec.path).split(path.sep).join('/')
297
+ const tracked = git(['ls-files', '--', rel])
298
+ return tracked === null ? null : tracked.length === 0
299
+ }
300
+
301
+ /**
302
+ * Is this spec present in the commit the worktree will fork from?
303
+ *
304
+ * A worktree forks from HEAD (`git worktree add -b`), so a spec absent there
305
+ * yields a branch missing the very spec it is for — and a clean working tree
306
+ * cannot detect that, because the spec may be perfectly well committed elsewhere.
307
+ * This asks the positive question rather than inferring it from cleanliness.
308
+ *
309
+ * A HOTFIX IS EXEMPT, and not as an edge case: it forks from a released tag
310
+ * (`spec.baseRef`) that by definition predates the spec describing the fix, so
311
+ * the spec is *supposed* to be absent there. Checking it would refuse every
312
+ * hotfix — an accusation aimed squarely at correct behaviour.
313
+ *
314
+ * Returns `{ onFork, foundOn }`; `onFork: null` means "cannot tell" (a hotfix, or
315
+ * a git that would not answer), which the planner routes to carrying on.
316
+ */
317
+ function specOnForkPoint(dir, git, spec) {
318
+ if (spec.baseRef) return { onFork: null, foundOn: null }
319
+ const rel = path.relative(dir, spec.path).split(path.sep).join('/')
320
+ if (git(['cat-file', '-e', `HEAD:${rel}/00-overview.md`]) !== null) {
321
+ return { onFork: true, foundOn: null }
322
+ }
323
+ // Best-effort: name the branch that does have it, so the refusal is actionable.
324
+ let foundOn = null
325
+ const sha = git(['log', '--all', '--format=%H', '-1', '--', rel])
326
+ if (sha) {
327
+ const branches = git(['branch', '--contains', sha, '--format=%(refname:short)'])
328
+ if (branches) foundOn = branches.split('\n').map((b) => b.trim()).filter(Boolean)[0] || null
329
+ }
330
+ return { onFork: false, foundOn }
331
+ }
332
+
333
+
334
+ // Say what is about to be committed, and why it qualified. The commit is planned
335
+ // on the operator's behalf, so it is never allowed to be a surprise: the paths are
336
+ // listed before the commands that stage them.
337
+ function specCommitLines(plan, folder) {
338
+ if (!plan.specCommit) return []
339
+ const out = ['', ` uncommitted, and all of it is ${folder}'s — it will be committed first:`]
340
+ for (const p of plan.specCommit.paths) out.push(` ${p}`)
341
+ return out
342
+ }
343
+
236
344
  // `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
237
345
  // planner, and prints the plan or the refusal.
346
+
347
+ /**
348
+ * `skitterspec gating check [spec] [--all] [--json]`
349
+ *
350
+ * ADVISORY BY CONSTRUCTION. It reports and exits 0 — always, including when it
351
+ * finds something. The point of release gating is that the decision is recorded
352
+ * and reviewable, not that a machine enforces it: a project that has not decided
353
+ * yet is not broken, and a check that stopped someone's work over a missing
354
+ * header would be a worse failure than the omission it names.
355
+ */
356
+ function gatingCheck(dir, argv) {
357
+ const { opts, positional } = parse(argv)
358
+ const { checkGating, activeSpecs } = require('./gating.js')
359
+
360
+ let specs = null
361
+ if (positional.length && !opts.all) {
362
+ const name = path.basename(positional[0])
363
+ const found = activeSpecs(dir).filter((s) => s.folder === name)
364
+ if (!found.length) {
365
+ // Not an accusation: a name that matches no ACTIVE spec is usually a
366
+ // finished one, which gating never covers anyway.
367
+ process.stdout.write(`gating: no active spec named ${name} — nothing to check.\n`)
368
+ return
369
+ }
370
+ specs = found
371
+ }
372
+
373
+ const result = checkGating(dir, specs)
374
+
375
+ if (opts.json) {
376
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n')
377
+ return
378
+ }
379
+ if (!result.configured) {
380
+ process.stdout.write('gating: not configured — nothing to check.\n')
381
+ return
382
+ }
383
+ if (!result.findings.length) {
384
+ process.stdout.write(
385
+ `gating: ${result.checked} spec(s) checked — every one records a decision.\n`,
386
+ )
387
+ return
388
+ }
389
+ const lines = []
390
+ for (const f of result.findings) {
391
+ lines.push(
392
+ f.kind === 'missing'
393
+ ? ` ${f.folder} (${f.bucket}): no Gating: header`
394
+ : ` ${f.folder} (${f.bucket}): Gating: "${f.raw}" says nothing — a bare "none" is not a reason`,
395
+ )
396
+ }
397
+ lines.push('')
398
+ lines.push(' decide, then record it on 00-overview.md beside Stack:')
399
+ lines.push(' > **Gating:** <flag name — or "none: <one-line reason>">')
400
+ if (result.guidance) lines.push(` see ${result.guidance}`)
401
+ process.stdout.write(
402
+ `gating: ${result.findings.length} of ${result.checked} spec(s) record no decision\n` +
403
+ lines.join('\n') +
404
+ '\n',
405
+ )
406
+ }
407
+
238
408
  function specEnvUpCheckout(dir, config, spec) {
239
409
  const git = gitReader(dir)
240
410
  const primary = assertPrimaryOnMain(config, git)
241
411
  const base = resolveBaseBranch(config, git)
242
- const status = git(['status', '--porcelain'])
412
+ const status = git(['status', '--porcelain', '-uall'])
413
+ const onFork = specOnForkPoint(dir, git, spec)
243
414
 
244
415
  const plan = planCheckoutUp(
245
416
  spec,
@@ -251,6 +422,11 @@ function specEnvUpCheckout(dir, config, spec) {
251
422
  // the harmless outcome of being wrong is a refusal the operator can act
252
423
  // on, and the harmful one is carrying their work onto a new branch.
253
424
  clean: status !== null && status.length === 0,
425
+ dirtyPaths: dirtyPaths(git),
426
+ specOnFork: onFork.onFork,
427
+ specFoundOn: onFork.foundOn,
428
+ forkRef: primary.branch || 'HEAD',
429
+ specUntracked: specIsUntracked(dir, git, spec),
254
430
  branchExists: git(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) !== null,
255
431
  checkoutPath: dir,
256
432
  },
@@ -271,6 +447,7 @@ function specEnvUpCheckout(dir, config, spec) {
271
447
  ` branch: ${plan.branch}`,
272
448
  ' stack: checkout-only (no worktree, no docker, no port block)',
273
449
  ]
450
+ out.push(...specCommitLines(plan, spec.folder))
274
451
  if (plan.commands.length) {
275
452
  out.push('')
276
453
  out.push(' to provision, run:')
@@ -326,7 +503,24 @@ function specEnvUp(dir, config, specArg) {
326
503
  attached = fs.existsSync(spec.worktreePath)
327
504
  }
328
505
 
329
- const plan = planUp(spec, { slot, attached }, config)
506
+ // The tree gate: the same facts the checkout planner gets. A worktree forks
507
+ // from base, so an uncommitted spec would produce a branch without it.
508
+ const upGit = gitReader(dir)
509
+ const upStatus = upGit(['status', '--porcelain'])
510
+ const upOnFork = specOnForkPoint(dir, upGit, spec)
511
+ const plan = planUp(spec, { slot, attached }, config, {
512
+ clean: upStatus !== null && upStatus.length === 0,
513
+ dirtyPaths: dirtyPaths(upGit),
514
+ specOnFork: upOnFork.onFork,
515
+ specFoundOn: upOnFork.foundOn,
516
+ forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
517
+ specUntracked: specIsUntracked(dir, upGit, spec),
518
+ })
519
+
520
+ if (plan.blocked) {
521
+ process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
522
+ return
523
+ }
330
524
 
331
525
  const out = []
332
526
  // `up` is a planner: it prints commands for the caller to run and creates no
@@ -364,6 +558,7 @@ function specEnvUp(dir, config, specArg) {
364
558
  `(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
365
559
  )
366
560
  }
561
+ out.push(...specCommitLines(plan, spec.folder))
367
562
  out.push('')
368
563
  out.push(' to provision, run:')
369
564
  for (const cmd of plan.commands) out.push(` ${cmd}`)
@@ -1687,6 +1882,14 @@ async function run(argv) {
1687
1882
 
1688
1883
  const [cmd, ...rest] = argv
1689
1884
 
1885
+ if (cmd === 'gating') {
1886
+ const [sub, ...gArgs] = rest
1887
+ const gDir = process.cwd()
1888
+ if (sub === 'check') gatingCheck(gDir, gArgs)
1889
+ else process.stdout.write('Usage: skitterspec gating check [spec] [--all] [--json]\n')
1890
+ return
1891
+ }
1892
+
1690
1893
  if (cmd === 'spec-env') {
1691
1894
  await specEnv(rest)
1692
1895
  return
@@ -1735,11 +1938,13 @@ async function run(argv) {
1735
1938
  // interactive "yes" opts in. Only prompt for isolation on a fresh repo.
1736
1939
  let isolation = opts.isolation === true
1737
1940
  let workspaceMode = 'worktree'
1941
+ let gating = opts.gating === true
1738
1942
  if (interactive && !isExistingSetup(dir)) {
1739
1943
  const { promptSetup } = require('./prompts.js')
1740
- const answers = await promptSetup({ isolationSeed: isolation })
1944
+ const answers = await promptSetup({ isolationSeed: isolation, gatingSeed: gating })
1741
1945
  isolation = answers.isolation
1742
1946
  workspaceMode = answers.mode
1947
+ gating = answers.gating
1743
1948
  }
1744
1949
  await init({
1745
1950
  dir,
@@ -1748,6 +1953,7 @@ async function run(argv) {
1748
1953
  mode: 'init',
1749
1954
  isolation,
1750
1955
  workspaceMode,
1956
+ gating,
1751
1957
  })
1752
1958
  break
1753
1959
  }
@@ -0,0 +1,91 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Which uncommitted paths belong to ONE spec?
5
+ *
6
+ * `/spec-start` refuses a dirty tree because moving another spec's unfinished
7
+ * work is not ours to do. But the commonest dirty tree there is — the spec you
8
+ * just authored and are now starting — is not another spec's work at all, and
9
+ * refusing it costs a round trip through `/commit` on every single start.
10
+ *
11
+ * The distinction this module draws is deliberately NOT "does the dirt look
12
+ * important?" (a judgement, and the gate rightly refuses to make one) but
13
+ * membership in an exactly-known set: the spec's own folder, plus whatever the
14
+ * project declares in `spec.companionPaths`. Everything else is foreign, and one
15
+ * foreign path disqualifies the whole tree.
16
+ */
17
+
18
+ const path = require('node:path')
19
+ const { BUCKETS, expandTokens, readFrontmatterField } = require('./resolve.js')
20
+
21
+ // git reports repo-relative, forward-slashed paths; an untracked directory comes
22
+ // back with a trailing slash. Normalise both away so comparisons are exact.
23
+ function normalize(p) {
24
+ return String(p || '')
25
+ .trim()
26
+ .replace(/\\/g, '/')
27
+ .replace(/\/+$/, '')
28
+ }
29
+
30
+ /**
31
+ * Expand a `companionPaths` pattern for this spec, or return null when it cannot
32
+ * be expanded.
33
+ *
34
+ * A PATTERN THAT REFERENCES `{identifier}` WHEN NO IDENTIFIER RESOLVES MATCHES
35
+ * NOTHING, deliberately. Two ordinary situations produce a spec with no id — a
36
+ * project that never set `branch.identifierField`, and a spec deliberately kept
37
+ * local, never pushed to a tracker — and in both the file the pattern describes
38
+ * either does not exist or belongs to some OTHER spec. Expanding `{identifier}`
39
+ * to a placeholder, or dropping the token, would widen the owned set to a path
40
+ * this spec has no claim on and sweep another spec's snapshot into its commit.
41
+ * Being wrong this way costs a refusal the operator can fix with `/commit`; the
42
+ * other way costs them a file they never staged.
43
+ */
44
+ function expandCompanion(pattern, spec, config) {
45
+ const tokens = { slug: spec.slug }
46
+ if (/\{identifier\}/.test(pattern)) {
47
+ const field = config && config.branch && config.branch.identifierField
48
+ const identifier = readFrontmatterField(spec.path, field)
49
+ if (!identifier) return null
50
+ tokens.identifier = identifier
51
+ }
52
+ return normalize(expandTokens(pattern, tokens))
53
+ }
54
+
55
+ /**
56
+ * Split `dirtyPaths` into the ones that belong to `spec` and the ones that do
57
+ * not. Repo-relative paths in, repo-relative paths out; nothing is read from git
58
+ * and nothing is written.
59
+ *
60
+ * Owned:
61
+ * - anything inside `specs/<bucket>/<folder>/` for ANY bucket. Every bucket is
62
+ * checked rather than the spec's current one because starting a spec MOVES it
63
+ * (backlog → in-progress), so a tree mid-move is dirty in two buckets at once
64
+ * and both halves are the same spec's.
65
+ * - each expandable `spec.companionPaths` entry.
66
+ *
67
+ * @returns {{owned: string[], foreign: string[]}}
68
+ */
69
+ function classifyDirtyTree(spec, dirtyPaths, config) {
70
+ const owned = []
71
+ const foreign = []
72
+ if (!spec) return { owned, foreign: (dirtyPaths || []).map(normalize).filter(Boolean) }
73
+
74
+ const folders = BUCKETS.map((bucket) => `specs/${bucket}/${spec.folder}`)
75
+ const companions = new Set()
76
+ for (const pattern of (config && config.spec && config.spec.companionPaths) || []) {
77
+ const expanded = expandCompanion(pattern, spec, config)
78
+ if (expanded) companions.add(expanded)
79
+ }
80
+
81
+ for (const raw of dirtyPaths || []) {
82
+ const p = normalize(raw)
83
+ if (!p) continue
84
+ const inFolder = folders.some((f) => p === f || p.startsWith(`${f}/`))
85
+ if (inFolder || companions.has(p)) owned.push(p)
86
+ else foreign.push(p)
87
+ }
88
+ return { owned, foreign }
89
+ }
90
+
91
+ module.exports = { classifyDirtyTree, expandCompanion }
package/src/env/config.js CHANGED
@@ -30,6 +30,9 @@
30
30
  * open: { command }, // optional, editor/terminal-agnostic opener
31
31
  * registry: ".spec-env/registry.json",
32
32
  * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
33
+ * spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
34
+ * // spec alongside its own folder (provider-neutral; {slug} and
35
+ * // {identifier} expand); empty = the spec folder only
33
36
  * baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
34
37
  * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
35
38
  * teardown: { deleteRemoteBranch },
@@ -90,6 +93,13 @@ const DEFAULT_CONFIG = Object.freeze({
90
93
  // 00-overview.md frontmatter field a provider writes the ticket id into (empty
91
94
  // = no identifier, so patterns referencing {identifier} fall back to type/slug).
92
95
  branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
96
+ // Paths that belong to a spec ALONGSIDE its own `specs/<bucket>/<name>/` folder
97
+ // — a tracker provider's per-spec snapshot, for instance. Provider-neutral by
98
+ // design: the base engine must not know that any particular tracker exists, so
99
+ // the project declares the shape and `{slug}` / `{identifier}` expand exactly as
100
+ // they do in `branch.pattern` ({identifier} via `branch.identifierField`).
101
+ // Default: none, so a spec owns only its own folder.
102
+ spec: Object.freeze({ companionPaths: Object.freeze([]) }),
93
103
  // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
94
104
  baseBranch: '',
95
105
  guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
@@ -128,6 +138,7 @@ function defaults() {
128
138
  open: { ...DEFAULT_CONFIG.open },
129
139
  registry: DEFAULT_CONFIG.registry,
130
140
  branch: { ...DEFAULT_CONFIG.branch },
141
+ spec: { companionPaths: [] },
131
142
  baseBranch: DEFAULT_CONFIG.baseBranch,
132
143
  guards: { ...DEFAULT_CONFIG.guards },
133
144
  teardown: { ...DEFAULT_CONFIG.teardown },
@@ -296,6 +307,10 @@ function mergeConfig(base, parsed) {
296
307
  }
297
308
  }
298
309
 
310
+ if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
311
+ base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
312
+ }
313
+
299
314
  if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
300
315
  base.live.migrations = normalizeFileList(parsed.live.migrations)
301
316
  }
@@ -11,6 +11,7 @@
11
11
  * no live git/docker.
12
12
  */
13
13
 
14
+ const { classifyDirtyTree } = require('./classify.js')
14
15
  const { portOffset } = require('./registry.js')
15
16
  const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
17
  const { expandTokens } = require('./resolve.js')
@@ -71,6 +72,126 @@ function seedCommandFor(file, mode) {
71
72
  )
72
73
  }
73
74
 
75
+
76
+ // Cap a path list in a message: enough to recognise, not a wall of text.
77
+ function listPaths(paths) {
78
+ const shown = paths.slice(0, 5).join(', ')
79
+ return paths.length > 5 ? `${shown}, and ${paths.length - 5} more` : shown
80
+ }
81
+
82
+ /**
83
+ * Decide what an uncommitted tree means for a provisioning run: commit it, refuse
84
+ * it, or ignore it.
85
+ *
86
+ * `/spec-start` refuses a dirty tree so another spec's unfinished work is never
87
+ * moved without its author asking. But the commonest dirty tree of all is the
88
+ * spec you just wrote and are now starting, and refusing THAT costs a round trip
89
+ * through `/commit` on every start. So the question asked here is not "does this
90
+ * dirt look important?" — a judgement the gate rightly declines to make — but
91
+ * membership in an exactly-known set (see `classify.js`). One foreign path
92
+ * disqualifies the whole tree.
93
+ *
94
+ * WHAT WOULD FOOL THIS: `ctx.dirtyPaths` being absent. That is not "the tree is
95
+ * clean" — it is "nobody looked", which happens on every legacy caller and
96
+ * whenever git itself could not be read. Either way it is never permission to
97
+ * commit: with no classification there is no owned set, so no commit is planned.
98
+ *
99
+ * Whether it also REFUSES depends on `carriesChanges`, and the asymmetry is the
100
+ * two modes' actual risk, not an inconsistency:
101
+ *
102
+ * - checkout mode (`carriesChanges`) — `git switch -c` silently carries
103
+ * uncommitted work onto the new branch. Unable to see the tree means unable
104
+ * to rule that out, so it refuses, exactly as it did before this gate existed.
105
+ * - worktree mode — `git worktree add` carries nothing, and forks from a commit
106
+ * regardless. Refusing here would fire on a healthy repo whose git we merely
107
+ * could not read, which is a cost paid by someone who did nothing wrong. So
108
+ * it plans no commit and provisions as before.
109
+ *
110
+ * `specOnFork` is the other half, and a POSITIVE signal rather than an absence:
111
+ * the worktree forks from a specific commit, so a spec absent from it produces a
112
+ * branch missing the very spec it is for. A clean tree cannot detect that — the
113
+ * spec may be committed, just somewhere else. `null` means the caller could not
114
+ * tell (an unreadable git, or a hotfix, which forks from a tag predating its own
115
+ * spec), and routes to carrying on, never to refusing.
116
+ *
117
+ * ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
118
+ * @returns {{blocked: boolean, reason: string|null, commands: string[]}}
119
+ */
120
+ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
121
+ const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null }
122
+ const c = ctx || {}
123
+
124
+ if (!Array.isArray(c.dirtyPaths)) {
125
+ // Nobody looked: never commit, and refuse only where switching could carry
126
+ // work we cannot see (see the asymmetry above).
127
+ if (carriesChanges && c.clean === false) {
128
+ return {
129
+ blocked: true,
130
+ reason:
131
+ 'the primary checkout has uncommitted changes — commit or stash them first' +
132
+ (carriesChanges ? ' (switching would carry them onto the new branch)' : ''),
133
+ commands: [],
134
+ owned: [],
135
+ verb: null,
136
+ }
137
+ }
138
+ return ok
139
+ }
140
+
141
+ const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
142
+
143
+ if (foreign.length) {
144
+ return {
145
+ blocked: true,
146
+ reason:
147
+ `the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
148
+ 'commit or stash them first' +
149
+ (carriesChanges ? ' (switching would carry them onto the new branch)' : '') +
150
+ `: ${listPaths(foreign)}`,
151
+ commands: [],
152
+ }
153
+ }
154
+
155
+ if (owned.length) {
156
+ // `add` or `update`? Asked of git (`ctx.specUntracked`, from `git ls-files`)
157
+ // rather than inferred from the shape of the status output. The inference —
158
+ // "a bare directory entry means the folder is wholly untracked" — was wrong in
159
+ // the ordinary case of the FIRST spec in a bucket, where git reports the
160
+ // bucket as the untracked directory instead. The old inference stays as a
161
+ // fallback for callers that pass no fact; unknown yields `update`, which is
162
+ // never a false claim.
163
+ const isNew =
164
+ typeof c.specUntracked === 'boolean'
165
+ ? c.specUntracked
166
+ : owned.includes(`specs/${spec.bucket}/${spec.folder}`)
167
+ const verb = isNew ? 'add' : 'update'
168
+ return {
169
+ blocked: false,
170
+ reason: null,
171
+ owned,
172
+ verb,
173
+ commands: [
174
+ `git add ${owned.map((p) => `"${p}"`).join(' ')}`,
175
+ `git commit -m "chore(spec): ${verb} ${spec.folder}"`,
176
+ ],
177
+ }
178
+ }
179
+
180
+ // Tree is clean. The spec must already be in the base branch's tree, or the
181
+ // worktree forks without it.
182
+ if (c.specOnFork === false) {
183
+ return {
184
+ blocked: true,
185
+ reason:
186
+ `${spec.folder} is not committed in ${c.forkRef || 'the fork point'}` +
187
+ (c.specFoundOn ? ` — it is on ${c.specFoundOn}` : '') +
188
+ ' — the worktree would fork without the spec it is for',
189
+ commands: [],
190
+ }
191
+ }
192
+ return ok
193
+ }
194
+
74
195
  /**
75
196
  * Plan a provisioning run.
76
197
  *
@@ -83,7 +204,7 @@ function seedCommandFor(file, mode) {
83
204
  * envContents, openCommand, commands, seedCommands,
84
205
  * setupCommands, attached }
85
206
  */
86
- function planUp(spec, alloc, config) {
207
+ function planUp(spec, alloc, config, ctx) {
87
208
  const { slot, attached } = alloc
88
209
 
89
210
  // Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
@@ -143,7 +264,18 @@ function planUp(spec, alloc, config) {
143
264
  commands.push(`docker compose --project-name ${spec.projectName} up -d`)
144
265
  }
145
266
 
267
+ // The tree gate. A worktree forks from the base branch's tree, so an
268
+ // uncommitted spec would produce a branch missing the spec it is for — this is
269
+ // where that gets committed, or refused. Worktree mode had NO clean gate before
270
+ // this: `git worktree add` does not carry uncommitted changes anywhere, so the
271
+ // failure surfaced three steps later as a live-overlay refusal naming the wrong
272
+ // stage. An absent `ctx` means no caller looked, and changes nothing.
273
+ const gate = planSpecCommit(spec, ctx, config)
274
+
146
275
  return {
276
+ blocked: gate.blocked,
277
+ reason: gate.reason,
278
+ specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
147
279
  worktreePath: spec.worktreePath,
148
280
  branch: spec.branch,
149
281
  projectName: spec.projectName,
@@ -151,9 +283,9 @@ function planUp(spec, alloc, config) {
151
283
  portOffset: offset,
152
284
  envContents,
153
285
  openCommand,
154
- commands,
155
- seedCommands,
156
- setupCommands,
286
+ commands: gate.blocked ? [] : [...gate.commands, ...commands],
287
+ seedCommands: gate.blocked ? [] : seedCommands,
288
+ setupCommands: gate.blocked ? [] : setupCommands,
157
289
  attached,
158
290
  }
159
291
  }
@@ -196,12 +328,10 @@ function planCheckoutUp(spec, ctx, config) {
196
328
  if (ctx.current && ctx.current === spec.branch) {
197
329
  return { ...result, attached: true }
198
330
  }
199
- if (!ctx.clean) {
200
- return block(
201
- 'the primary checkout has uncommitted changes commit or stash them first ' +
202
- '(switching would carry them onto the new branch)',
203
- )
204
- }
331
+ // Same gate as worktree mode, but `git switch -c` genuinely CARRIES uncommitted
332
+ // work onto the new branch, so the refusal keeps saying so.
333
+ const gate = planSpecCommit(spec, ctx, config, { carriesChanges: true })
334
+ if (gate.blocked) return block(gate.reason)
205
335
  if (!ctx.onBase) {
206
336
  return block(
207
337
  `the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
@@ -209,10 +339,14 @@ function planCheckoutUp(spec, ctx, config) {
209
339
  )
210
340
  }
211
341
 
342
+ if (gate.owned && gate.owned.length) {
343
+ result.specCommit = { paths: gate.owned, verb: gate.verb }
344
+ }
345
+ result.commands.push(...gate.commands)
212
346
  result.commands.push(
213
347
  ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
214
348
  )
215
349
  return result
216
350
  }
217
351
 
218
- module.exports = { planUp, planCheckoutUp, seedCommandFor, worktreeCd }
352
+ module.exports = { planUp, planCheckoutUp, planSpecCommit, seedCommandFor, worktreeCd }
@@ -267,6 +267,7 @@ module.exports = {
267
267
  repoInfo,
268
268
  expandTokens,
269
269
  findSpecFolder,
270
+ readFrontmatterField,
270
271
  readStackField,
271
272
  readBaseVersionField,
272
273
  }