@skitterbyte/skitterspec 17.0.0 → 19.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.
Files changed (40) hide show
  1. package/MIGRATION.md +260 -10
  2. package/README.md +53 -4
  3. package/assets/claude-md-section.md +48 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/env.config.json.example +9 -3
  7. package/assets/core/env.config.md +102 -30
  8. package/assets/core/gating.config.json.example +4 -0
  9. package/assets/core/gating.config.md +81 -0
  10. package/assets/review/page.html +1501 -0
  11. package/assets/rules/spec-planning.md +224 -15
  12. package/assets/rules/spec-reports.md +269 -0
  13. package/assets/skills/spec/SKILL.md +63 -12
  14. package/assets/skills/spec-bug/SKILL.md +134 -26
  15. package/assets/skills/spec-cancel/SKILL.md +85 -6
  16. package/assets/skills/spec-complete/SKILL.md +109 -20
  17. package/assets/skills/spec-diff/SKILL.md +564 -0
  18. package/assets/skills/spec-hotfix/SKILL.md +143 -21
  19. package/assets/skills/spec-init/SKILL.md +49 -9
  20. package/assets/skills/spec-next/SKILL.md +289 -7
  21. package/assets/skills/spec-review/SKILL.md +45 -9
  22. package/assets/skills/spec-reviewed/SKILL.md +241 -0
  23. package/assets/skills/spec-start/SKILL.md +323 -66
  24. package/assets/skills/spec-to-main/SKILL.md +42 -20
  25. package/package.json +11 -7
  26. package/src/cli.js +1710 -80
  27. package/src/env/building.js +143 -0
  28. package/src/env/classify.js +91 -0
  29. package/src/env/config.js +57 -9
  30. package/src/env/provision.js +192 -19
  31. package/src/env/proxy.js +34 -1
  32. package/src/env/render.js +3 -12
  33. package/src/env/resolve.js +296 -9
  34. package/src/env/review.js +1329 -0
  35. package/src/env/serve.js +549 -0
  36. package/src/env/teardown.js +13 -6
  37. package/src/gating.js +155 -0
  38. package/src/init.js +124 -2
  39. package/src/prompts.js +10 -1
  40. package/LICENSE +0 -21
@@ -0,0 +1,143 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The primary-checkout leak guard.
5
+ *
6
+ * A phase can be built in a worktree from a session standing somewhere else —
7
+ * every write an absolute path, every command `cd`-prefixed. That works;
8
+ * `spec-env review` already reads a worktree exactly that way. What it cannot do
9
+ * is *prove* it worked: one relative path and the edit lands in the primary
10
+ * checkout, on the base branch, silently.
11
+ *
12
+ * So record what the primary checkout looked like before the build
13
+ * (`--record-primary`) and compare after it (`--assert-primary-clean`).
14
+ *
15
+ * The baseline is the whole point. `negative-checks.md` rule 1 asks for a
16
+ * positive signal rather than an absence, and "these were the dirty paths at a
17
+ * known moment" is one; a bare "the primary must be clean" check would accuse
18
+ * anyone who left an unrelated edit open in another window. Rule 4 supplies the
19
+ * third state: when the baseline is missing, or is for another spec, the check
20
+ * cannot tell — and cannot-tell reports and exits 0 rather than accusing.
21
+ */
22
+
23
+ const path = require('node:path')
24
+
25
+ // The state dir sits beside the registry file, e.g. `.spec-env` — the same
26
+ // convention `dev.js` uses for logs and pids.
27
+ function stateDir(config) {
28
+ return path.posix.dirname(config.registry) || '.spec-env'
29
+ }
30
+
31
+ /** Where the baseline lives. Gitignored with the rest of `.spec-env/`. */
32
+ function baselinePath(dir, config) {
33
+ return path.join(dir, stateDir(config), 'building.json')
34
+ }
35
+
36
+ /**
37
+ * Merge one or more newline-separated git path listings into a sorted, deduped
38
+ * array. Blank lines are dropped; nothing is parsed out of the line.
39
+ *
40
+ * DELIBERATELY NOT `git status --porcelain`, for two reasons, both found by
41
+ * running this against a real worktree:
42
+ *
43
+ * 1. Porcelain puts the path behind two fixed status columns and a space, and
44
+ * the repo's `gitReader` TRIMS its stdout — so the first line loses its
45
+ * leading space and a fixed `slice(3)` eats the first character of the
46
+ * first path. A guard that renames the file it is accusing you of is worse
47
+ * than no guard.
48
+ * 2. Porcelain reports stat-dirty entries: a file whose mtime moved but whose
49
+ * content is identical to HEAD. One was observed here, reported as ` M`
50
+ * with an empty `git diff`. Accusing someone of leaking a file they never
51
+ * changed is exactly the false accusation `negative-checks.md` exists to
52
+ * prevent.
53
+ *
54
+ * `git diff --name-only HEAD` compares CONTENT, and
55
+ * `git ls-files --others --exclude-standard` lists genuinely new files. Between
56
+ * them they answer "what did this tree gain?" without a column to miscount.
57
+ */
58
+ function mergePaths(...listings) {
59
+ const out = new Set()
60
+ for (const listing of listings) {
61
+ for (const line of String(listing || '').split('\n')) {
62
+ const p = line.replace(/\r$/, '').trim()
63
+ if (p !== '') out.add(p)
64
+ }
65
+ }
66
+ return [...out].sort()
67
+ }
68
+
69
+ /** Are these two paths the same tree? */
70
+ function sameTree(a, b) {
71
+ return path.resolve(a) === path.resolve(b)
72
+ }
73
+
74
+ /** The record written by `--record-primary`. Pure. */
75
+ function buildBaseline({ spec, worktreePath, primary, paths }) {
76
+ return { spec, worktreePath, primary, paths: [...paths].sort() }
77
+ }
78
+
79
+ /**
80
+ * Compare a recorded baseline against the primary checkout's current state.
81
+ *
82
+ * Three verdicts, never two (`negative-checks.md` rule 4):
83
+ * - `clean` — nothing new appeared; say nothing and exit 0.
84
+ * - `leaked` — these paths appeared since; name them and exit non-zero.
85
+ * - `unknown` — the check cannot tell; say why and exit 0.
86
+ *
87
+ * Pure: every input is supplied, nothing is read from disk or git.
88
+ *
89
+ * WHAT WOULD FOOL THIS CHECK, deliberately unhandled:
90
+ * - Work the build COMMITTED in the primary checkout. A commit empties the
91
+ * porcelain, so a leak that was tidied away looks identical to no leak. The
92
+ * guard is aimed at the actual failure mode — writes going astray mid-build,
93
+ * while nothing has been committed yet — and runs before the phase commit
94
+ * for that reason.
95
+ * - A write to a GITIGNORED path in the primary (`.spec-env/` itself, build
96
+ * output, `node_modules`). `git status` cannot see it, by design, and the
97
+ * baseline file lives in exactly such a path so that recording it is not
98
+ * itself a change the next comparison trips over.
99
+ * - A path dirty at record time and then edited FURTHER by the build. It is in
100
+ * the baseline, so it stays silent. That is the deliberate trade: silence
101
+ * there costs a missed edit to a file someone was already working on, and
102
+ * the opposite default accuses every healthy concurrent edit.
103
+ */
104
+ function compare(baseline, currentPaths, { spec, worktreePath, primary }) {
105
+ if (sameTree(primary, worktreePath)) {
106
+ return {
107
+ verdict: 'unknown',
108
+ paths: [],
109
+ reason: 'the worktree IS the primary checkout — there is no second tree to leak into',
110
+ }
111
+ }
112
+ if (!baseline) {
113
+ return { verdict: 'unknown', paths: [], reason: 'no baseline recorded' }
114
+ }
115
+ if (baseline.spec !== spec) {
116
+ return {
117
+ verdict: 'unknown',
118
+ paths: [],
119
+ reason: `the baseline was recorded for ${baseline.spec}, not ${spec}`,
120
+ }
121
+ }
122
+ if (!sameTree(baseline.worktreePath, worktreePath)) {
123
+ return {
124
+ verdict: 'unknown',
125
+ paths: [],
126
+ reason: 'the baseline records a different worktree for this spec',
127
+ }
128
+ }
129
+ const before = new Set(baseline.paths || [])
130
+ const appeared = [...currentPaths].filter((p) => !before.has(p)).sort()
131
+ return appeared.length === 0
132
+ ? { verdict: 'clean', paths: [], reason: null }
133
+ : { verdict: 'leaked', paths: appeared, reason: null }
134
+ }
135
+
136
+ module.exports = {
137
+ stateDir,
138
+ baselinePath,
139
+ mergePaths,
140
+ sameTree,
141
+ buildBaseline,
142
+ compare,
143
+ }
@@ -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
@@ -27,9 +27,11 @@
27
27
  * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
28
28
  * // servers started on the spec's port block (empty = none)
29
29
  * proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
30
- * open: { command }, // optional, editor/terminal-agnostic opener
31
30
  * registry: ".spec-env/registry.json",
32
31
  * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
32
+ * spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
33
+ * // spec alongside its own folder (provider-neutral; {slug} and
34
+ * // {identifier} expand); empty = the spec folder only
33
35
  * baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
34
36
  * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
35
37
  * teardown: { deleteRemoteBranch },
@@ -83,22 +85,48 @@ const DEFAULT_CONFIG = Object.freeze({
83
85
  // Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
84
86
  // exposes one connected spec's frontPort processes on the canonical ports.
85
87
  proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
86
- open: Object.freeze({ command: '' }),
87
88
  registry: '.spec-env/registry.json',
88
89
  // Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
89
90
  // when a tracker provider is linked, {identifier}; `identifierField` names the
90
91
  // 00-overview.md frontmatter field a provider writes the ticket id into (empty
91
92
  // = no identifier, so patterns referencing {identifier} fall back to type/slug).
92
93
  branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
94
+ // Paths that belong to a spec ALONGSIDE its own `specs/<bucket>/<name>/` folder
95
+ // — a tracker provider's per-spec snapshot, for instance. Provider-neutral by
96
+ // design: the base engine must not know that any particular tracker exists, so
97
+ // the project declares the shape and `{slug}` / `{identifier}` expand exactly as
98
+ // they do in `branch.pattern` ({identifier} via `branch.identifierField`).
99
+ // Default: none, so a spec owns only its own folder.
100
+ spec: Object.freeze({ companionPaths: Object.freeze([]) }),
93
101
  // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
94
102
  baseBranch: '',
95
103
  guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
96
104
  // Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
97
- // `spec-env down` does about the branch `/spec-start` pushed: "prompt" (default)
105
+ // `spec-env down` does about a branch the USER published by hand — nothing
106
+ // publishes one at provisioning, so there is often no remote ref at all and
107
+ // the plan then says nothing: "prompt" (default)
98
108
  // plans the delete in its own confirm-first section for the skill to ask about,
99
109
  // "never" omits it, "always" folds it into the run-blind command list. Only ever
100
110
  // planned for a LANDED branch — see teardown.js.
101
111
  teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
112
+
113
+ // `reader` decides how a diff's location is worded, and — via
114
+ // `serveOnRemote` — whether the engine stands the local server up so a remote
115
+ // reader gets a link that opens. It never decides to PUBLISH: publishing
116
+ // leaves a page this tooling cannot remove, so it stays an explicit ask.
117
+ // `detect` sniffs; `local`/`remote` are the operator's own answer and are
118
+ // believed without sniffing, because they know where they are reading and no
119
+ // signal can outrank that.
120
+ // `commitWith` names the skill a COMMITTING verdict hands off to. `/commit`
121
+ // ships with skittership, a different package — so it may not be installed,
122
+ // and skitterspec must never vendor a copy of it.
123
+ //
124
+ // There is no off switch, and that is deliberate: `"none"` existed and was
125
+ // removed, because it produced the one thing a review page must not have —
126
+ // a verdict that records itself and does nothing. A review is the guard in
127
+ // front of an action; recording an approval for SOMEONE ELSE to act on is a
128
+ // different mechanism, not a value of this key.
129
+ review: Object.freeze({ reader: 'detect', servePort: 7777, serveOnRemote: true, commitWith: '/commit' }),
102
130
  // Live overlay (`spec-env live`). `migrations` is a list of globs marking
103
131
  // migration files; a branch that changes any of them is treated as stateful and
104
132
  // `live take` refuses it (code-only v1). Default: none (nothing is stateful).
@@ -125,12 +153,13 @@ function defaults() {
125
153
  setup: [],
126
154
  dev: [],
127
155
  proxy: { ...DEFAULT_CONFIG.proxy },
128
- open: { ...DEFAULT_CONFIG.open },
129
156
  registry: DEFAULT_CONFIG.registry,
130
157
  branch: { ...DEFAULT_CONFIG.branch },
158
+ spec: { companionPaths: [] },
131
159
  baseBranch: DEFAULT_CONFIG.baseBranch,
132
160
  guards: { ...DEFAULT_CONFIG.guards },
133
161
  teardown: { ...DEFAULT_CONFIG.teardown },
162
+ review: { ...DEFAULT_CONFIG.review },
134
163
  live: { migrations: [] },
135
164
  hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
136
165
  }
@@ -258,11 +287,6 @@ function mergeConfig(base, parsed) {
258
287
  assign(base.proxy, parsed.proxy, 'host', 'string')
259
288
  }
260
289
 
261
- if (isObject(parsed.open)) {
262
- // command may be intentionally empty (no auto-open)
263
- assign(base.open, parsed.open, 'command', 'string?')
264
- }
265
-
266
290
  if (isObject(parsed.branch)) {
267
291
  assign(base.branch, parsed.branch, 'pattern', 'string')
268
292
  assign(base.branch, parsed.branch, 'identifierField', 'string')
@@ -296,6 +320,30 @@ function mergeConfig(base, parsed) {
296
320
  }
297
321
  }
298
322
 
323
+ // An unrecognised reader falls through to `detect`, which is the state that
324
+ // claims least: it can answer "unknown", and unknown is wired to today's
325
+ // behaviour. A typo must never become a confident `local`, because a confident
326
+ // `local` is exactly the dead `file://` link this key exists to prevent.
327
+ if (isObject(parsed.review)) {
328
+ const reader = parsed.review.reader
329
+ if (reader === 'local' || reader === 'remote' || reader === 'detect') {
330
+ base.review.reader = reader
331
+ }
332
+ assign(base.review, parsed.review, 'servePort', 'number')
333
+ // Opting OUT is the only thing this key can do — a non-boolean leaves the
334
+ // default in place rather than being read as a refusal, so a typo cannot
335
+ // quietly restore the dead `file://` link on a remote reader.
336
+ assign(base.review, parsed.review, 'serveOnRemote', 'boolean')
337
+ // An empty string leaves `/commit` standing, like every other string key
338
+ // here. There is nothing it could mean instead: the hand-off has no off
339
+ // switch, so a blank value is a typo rather than an instruction.
340
+ assign(base.review, parsed.review, 'commitWith', 'string')
341
+ }
342
+
343
+ if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
344
+ base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
345
+ }
346
+
299
347
  if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
300
348
  base.live.migrations = normalizeFileList(parsed.live.migrations)
301
349
  }
@@ -5,14 +5,15 @@
5
5
  *
6
6
  * Given a resolved spec and its allocated slot, `planUp` returns the exact
7
7
  * side-effecting commands the `/spec-env` skill runs (`git worktree add`,
8
- * `docker compose up`), the rendered `.env` contents, and the expanded opener
9
- * but performs no side effects itself. The caller (the CLI) reads/allocates the
8
+ * `docker compose up`) and the rendered `.env` contents but performs no side
9
+ * effects itself. The caller (the CLI) reads/allocates the
10
10
  * registry and passes the slot; this stays deterministic and unit-testable with
11
11
  * no live git/docker.
12
12
  */
13
13
 
14
+ const { classifyDirtyTree } = require('./classify.js')
14
15
  const { portOffset } = require('./registry.js')
15
- const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
+ const { renderEnvFile } = require('./render.js')
16
17
  const { expandTokens } = require('./resolve.js')
17
18
 
18
19
  /**
@@ -71,6 +72,164 @@ 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
+ * FOREIGN DIRT IS THE SAME ASYMMETRY, for the same reason. Another spec's
118
+ * uncommitted work refuses in checkout mode, where `git switch -c` would carry
119
+ * it onto the new branch, and does not in worktree mode, where nothing carries
120
+ * anywhere. Refusing there fired on the commonest tree this workflow produces —
121
+ * author spec B while spec A is still uncommitted, then start B — and prevented
122
+ * nothing, since the only write to the primary checkout is a pathspec-limited
123
+ * commit of this spec's own paths. It is reported instead, on `foreign`.
124
+ *
125
+ * ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
126
+ * @returns {{blocked: boolean, reason: string|null, commands: string[],
127
+ * owned: string[], foreign: string[]}}
128
+ */
129
+ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
130
+ const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null, foreign: [] }
131
+ const c = ctx || {}
132
+
133
+ if (!Array.isArray(c.dirtyPaths)) {
134
+ // Nobody looked: never commit, and refuse only where switching could carry
135
+ // work we cannot see (see the asymmetry above).
136
+ if (carriesChanges && c.clean === false) {
137
+ return {
138
+ blocked: true,
139
+ reason:
140
+ 'the primary checkout has uncommitted changes — commit or stash them first' +
141
+ (carriesChanges ? ' (switching would carry them onto the new branch)' : ''),
142
+ commands: [],
143
+ owned: [],
144
+ verb: null,
145
+ }
146
+ }
147
+ return ok
148
+ }
149
+
150
+ const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
151
+
152
+ // FOREIGN DIRT REFUSES ONLY WHERE IT CAN REACH THE BRANCH, which is checkout
153
+ // mode and nowhere else. `git switch -c` carries the working tree onto the new
154
+ // branch, silently, so there it is someone else's work being moved without
155
+ // them asking. `git worktree add` carries nothing and forks from a commit, so
156
+ // in worktree mode the same tree is simply none of this run's business.
157
+ //
158
+ // WHAT WOULD MAKE THIS UNSAFE AGAIN: an unbounded commit. The only thing this
159
+ // run does to the primary checkout is the spec commit below, and it is safe to
160
+ // leave a colleague's files sitting beside it only because that commit is
161
+ // pathspec-limited on BOTH halves (`git add --`, `git commit … --`) and so
162
+ // cannot reach a path it does not own — not even one another session has
163
+ // already staged into the shared index. Drop the `--` and this stops being a
164
+ // false guard and starts being a missing one.
165
+ if (foreign.length && carriesChanges) {
166
+ return {
167
+ blocked: true,
168
+ reason:
169
+ `the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
170
+ 'commit or stash them first (switching would carry them onto the new branch)' +
171
+ `: ${listPaths(foreign)}`,
172
+ commands: [],
173
+ owned: [],
174
+ verb: null,
175
+ foreign,
176
+ }
177
+ }
178
+
179
+ if (owned.length) {
180
+ // `add` or `update`? Asked of git (`ctx.specUntracked`, from `git ls-files`)
181
+ // rather than inferred from the shape of the status output. The inference —
182
+ // "a bare directory entry means the folder is wholly untracked" — was wrong in
183
+ // the ordinary case of the FIRST spec in a bucket, where git reports the
184
+ // bucket as the untracked directory instead. The old inference stays as a
185
+ // fallback for callers that pass no fact; unknown yields `update`, which is
186
+ // never a false claim.
187
+ const isNew =
188
+ typeof c.specUntracked === 'boolean'
189
+ ? c.specUntracked
190
+ : owned.includes(`specs/${spec.bucket}/${spec.folder}`)
191
+ const verb = isNew ? 'add' : 'update'
192
+ // BOTH HALVES ARE PATHSPEC-LIMITED, and they answer different failures.
193
+ // `add` is what makes a path git has never seen committable at all — every
194
+ // brand-new spec folder is untracked. The `--` on the COMMIT is what bounds
195
+ // what lands: a checkout has one `.git/index` and every session standing in
196
+ // it shares that index, so a bare `git commit` takes whatever another
197
+ // session has already staged, however exactly this one named its own paths.
198
+ // Dropping the `--` would leave the naming above as decoration.
199
+ const paths = owned.map((p) => `"${p}"`).join(' ')
200
+ return {
201
+ blocked: false,
202
+ reason: null,
203
+ owned,
204
+ verb,
205
+ foreign,
206
+ commands: [
207
+ `git add -- ${paths}`,
208
+ `git commit -m "chore(spec): ${verb} ${spec.folder}" -- ${paths}`,
209
+ ],
210
+ }
211
+ }
212
+
213
+ // Tree is clean. The spec must already be in the base branch's tree, or the
214
+ // worktree forks without it.
215
+ // Nothing of this spec's is uncommitted, but somebody else's may be — say so
216
+ // rather than returning the bare `ok`, or the caller has no way to report what
217
+ // it left alone.
218
+ if (foreign.length && c.specOnFork !== false) return { ...ok, foreign }
219
+
220
+ if (c.specOnFork === false) {
221
+ return {
222
+ blocked: true,
223
+ reason:
224
+ `${spec.folder} is not committed in ${c.forkRef || 'the fork point'}` +
225
+ (c.specFoundOn ? ` — it is on ${c.specFoundOn}` : '') +
226
+ ' — the worktree would fork without the spec it is for',
227
+ commands: [],
228
+ }
229
+ }
230
+ return ok
231
+ }
232
+
74
233
  /**
75
234
  * Plan a provisioning run.
76
235
  *
@@ -80,10 +239,10 @@ function seedCommandFor(file, mode) {
80
239
  * existed in the registry (re-run → attach, don't clobber).
81
240
  * @param {object} config normalised env config.
82
241
  * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
83
- * envContents, openCommand, commands, seedCommands,
242
+ * envContents, commands, seedCommands,
84
243
  * setupCommands, attached }
85
244
  */
86
- function planUp(spec, alloc, config) {
245
+ function planUp(spec, alloc, config, ctx) {
87
246
  const { slot, attached } = alloc
88
247
 
89
248
  // Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
@@ -107,7 +266,6 @@ function planUp(spec, alloc, config) {
107
266
  portOffset: offset === null ? '' : String(offset),
108
267
  }
109
268
 
110
- const openCommand = expandOpenCommand(config.open.command, tokens)
111
269
 
112
270
  // File seeding runs *in the worktree* after `git worktree add`, before the
113
271
  // setup commands (which may depend on the seeded .env). Each entry becomes an
@@ -143,17 +301,31 @@ function planUp(spec, alloc, config) {
143
301
  commands.push(`docker compose --project-name ${spec.projectName} up -d`)
144
302
  }
145
303
 
304
+ // The tree gate. A worktree forks from the base branch's tree, so an
305
+ // uncommitted spec would produce a branch missing the spec it is for — this is
306
+ // where that gets committed, or refused. Worktree mode had NO clean gate before
307
+ // this: `git worktree add` does not carry uncommitted changes anywhere, so the
308
+ // failure surfaced three steps later as a live-overlay refusal naming the wrong
309
+ // stage. An absent `ctx` means no caller looked, and changes nothing.
310
+ const gate = planSpecCommit(spec, ctx, config)
311
+
146
312
  return {
313
+ blocked: gate.blocked,
314
+ reason: gate.reason,
315
+ specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
316
+ // What this run deliberately did not touch. Carried out rather than dropped
317
+ // because "provisioned, and left four of your files alone" is a different
318
+ // report from "provisioned", and the caller cannot reconstruct it.
319
+ untouched: gate.foreign || [],
147
320
  worktreePath: spec.worktreePath,
148
321
  branch: spec.branch,
149
322
  projectName: spec.projectName,
150
323
  slot: wantsDocker ? slot : null,
151
324
  portOffset: offset,
152
325
  envContents,
153
- openCommand,
154
- commands,
155
- seedCommands,
156
- setupCommands,
326
+ commands: gate.blocked ? [] : [...gate.commands, ...commands],
327
+ seedCommands: gate.blocked ? [] : seedCommands,
328
+ setupCommands: gate.blocked ? [] : setupCommands,
157
329
  attached,
158
330
  }
159
331
  }
@@ -174,8 +346,7 @@ function planUp(spec, alloc, config) {
174
346
  * about someone else's unfinished spec. Being on THIS spec's branch is not a
175
347
  * refusal — it is the re-run, and the answer is "already attached".
176
348
  *
177
- * There is no bootstrap and no opener: the primary checkout already has its
178
- * dependencies, and no new session is being opened.
349
+ * There is no bootstrap: the primary checkout already has its dependencies.
179
350
  */
180
351
  function planCheckoutUp(spec, ctx, config) {
181
352
  const base = ctx.base || (config && config.baseBranch) || 'main'
@@ -196,12 +367,10 @@ function planCheckoutUp(spec, ctx, config) {
196
367
  if (ctx.current && ctx.current === spec.branch) {
197
368
  return { ...result, attached: true }
198
369
  }
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
- }
370
+ // Same gate as worktree mode, but `git switch -c` genuinely CARRIES uncommitted
371
+ // work onto the new branch, so the refusal keeps saying so.
372
+ const gate = planSpecCommit(spec, ctx, config, { carriesChanges: true })
373
+ if (gate.blocked) return block(gate.reason)
205
374
  if (!ctx.onBase) {
206
375
  return block(
207
376
  `the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
@@ -209,10 +378,14 @@ function planCheckoutUp(spec, ctx, config) {
209
378
  )
210
379
  }
211
380
 
381
+ if (gate.owned && gate.owned.length) {
382
+ result.specCommit = { paths: gate.owned, verb: gate.verb }
383
+ }
384
+ result.commands.push(...gate.commands)
212
385
  result.commands.push(
213
386
  ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
214
387
  )
215
388
  return result
216
389
  }
217
390
 
218
- module.exports = { planUp, planCheckoutUp, seedCommandFor, worktreeCd }
391
+ module.exports = { planUp, planCheckoutUp, planSpecCommit, seedCommandFor, worktreeCd }
package/src/env/proxy.js CHANGED
@@ -141,7 +141,40 @@ async function waitListening(
141
141
  return false
142
142
  }
143
143
 
144
- module.exports = { renderRoutes, createRouteServer, startProxy, portsInUse, waitListening }
144
+
145
+ /**
146
+ * Ask whether `port` is free on EVERY host it will need, one host at a time.
147
+ *
148
+ * SEQUENTIAL IS THE WHOLE POINT, and it is not a style choice. The probe works
149
+ * by binding, so two probes of the same port running concurrently contend with
150
+ * each other — and on Linux a wildcard bind and a loopback bind of one port are
151
+ * mutually exclusive, so the pair reports a completely free port as busy. The
152
+ * review server then refused to start on every Linux machine, which is to say
153
+ * on CI, while every macOS run stayed green because BSD lets the two coexist.
154
+ *
155
+ * Probing one host at a time asks the question that was always intended — is
156
+ * anything ELSE holding this port — instead of racing the caller against
157
+ * itself. Verified on node:24-alpine: parallel reports busy on a free port,
158
+ * sequential does not; both still refuse a real squatter on either address.
159
+ *
160
+ * `probe` is injectable so the concurrency can be asserted without sockets.
161
+ */
162
+ async function portsInUseOn(port, hosts, probe = portsInUse) {
163
+ const busy = []
164
+ for (const host of [...new Set(hosts)]) {
165
+ busy.push(...(await probe([port], host)))
166
+ }
167
+ return [...new Set(busy)]
168
+ }
169
+
170
+ module.exports = {
171
+ renderRoutes,
172
+ createRouteServer,
173
+ startProxy,
174
+ portsInUse,
175
+ portsInUseOn,
176
+ waitListening,
177
+ }
145
178
 
146
179
  // Entry point: run as a detached process by the CLI. Reads its routes from a
147
180
  // file so a re-`connect` just rewrites the file and restarts this (tiny) process.