@skitterbyte/skitterspec-linear 12.0.0 → 13.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 (55) hide show
  1. package/MIGRATION.md +208 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +38 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/SETUP.md +21 -3
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +77 -30
  9. package/assets/core/linear.config.md +58 -0
  10. package/assets/review/page.html +1501 -0
  11. package/assets/rules/spec-planning.md +198 -10
  12. package/assets/rules/spec-reports.md +269 -0
  13. package/assets/skills/spec/SKILL.md +33 -5
  14. package/assets/skills/spec-bug/SKILL.md +172 -9
  15. package/assets/skills/spec-cancel/SKILL.md +98 -21
  16. package/assets/skills/spec-claim/SKILL.md +114 -0
  17. package/assets/skills/spec-complete/SKILL.md +94 -25
  18. package/assets/skills/spec-diff/SKILL.md +564 -0
  19. package/assets/skills/spec-hotfix/SKILL.md +172 -11
  20. package/assets/skills/spec-init/SKILL.md +34 -7
  21. package/assets/skills/spec-linear-setup/SKILL.md +55 -1
  22. package/assets/skills/spec-list/SKILL.md +218 -0
  23. package/assets/skills/spec-next/SKILL.md +299 -6
  24. package/assets/skills/spec-push/SKILL.md +32 -8
  25. package/assets/skills/spec-review/SKILL.md +40 -5
  26. package/assets/skills/spec-reviewed/SKILL.md +241 -0
  27. package/assets/skills/spec-start/SKILL.md +386 -106
  28. package/assets/skills/spec-status/SKILL.md +24 -2
  29. package/assets/skills/spec-sync/SKILL.md +40 -4
  30. package/assets/skills/spec-to-main/SKILL.md +28 -6
  31. package/package.json +11 -7
  32. package/src/cli.js +1513 -89
  33. package/src/env/building.js +143 -0
  34. package/src/env/config.js +42 -9
  35. package/src/env/provision.js +54 -15
  36. package/src/env/proxy.js +34 -1
  37. package/src/env/render.js +3 -12
  38. package/src/env/resolve.js +295 -9
  39. package/src/env/review.js +1329 -0
  40. package/src/env/serve.js +549 -0
  41. package/src/env/teardown.js +13 -6
  42. package/src/init.js +96 -1
  43. package/src/vendor/linear/api.js +104 -1
  44. package/src/vendor/linear/cli-sync.js +854 -17
  45. package/src/vendor/linear/config.js +8 -0
  46. package/src/vendor/linear/credentials.js +94 -0
  47. package/src/vendor/linear/doctor.js +35 -0
  48. package/src/vendor/linear/identity.js +105 -0
  49. package/src/vendor/linear/mcp.js +26 -0
  50. package/src/vendor/sync-core/index.js +6 -2
  51. package/src/vendor/sync-core/src/compare.js +49 -3
  52. package/src/vendor/sync-core/src/normalize.js +30 -0
  53. package/src/vendor/sync-core/src/push.js +11 -1
  54. package/src/vendor/sync-core/src/write.js +38 -0
  55. 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
+ }
package/src/env/config.js CHANGED
@@ -27,7 +27,6 @@
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)
33
32
  * spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
@@ -86,7 +85,6 @@ const DEFAULT_CONFIG = Object.freeze({
86
85
  // Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
87
86
  // exposes one connected spec's frontPort processes on the canonical ports.
88
87
  proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
89
- open: Object.freeze({ command: '' }),
90
88
  registry: '.spec-env/registry.json',
91
89
  // Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
92
90
  // when a tracker provider is linked, {identifier}; `identifierField` names the
@@ -104,11 +102,31 @@ const DEFAULT_CONFIG = Object.freeze({
104
102
  baseBranch: '',
105
103
  guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
106
104
  // Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
107
- // `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)
108
108
  // plans the delete in its own confirm-first section for the skill to ask about,
109
109
  // "never" omits it, "always" folds it into the run-blind command list. Only ever
110
110
  // planned for a LANDED branch — see teardown.js.
111
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' }),
112
130
  // Live overlay (`spec-env live`). `migrations` is a list of globs marking
113
131
  // migration files; a branch that changes any of them is treated as stateful and
114
132
  // `live take` refuses it (code-only v1). Default: none (nothing is stateful).
@@ -135,13 +153,13 @@ function defaults() {
135
153
  setup: [],
136
154
  dev: [],
137
155
  proxy: { ...DEFAULT_CONFIG.proxy },
138
- open: { ...DEFAULT_CONFIG.open },
139
156
  registry: DEFAULT_CONFIG.registry,
140
157
  branch: { ...DEFAULT_CONFIG.branch },
141
158
  spec: { companionPaths: [] },
142
159
  baseBranch: DEFAULT_CONFIG.baseBranch,
143
160
  guards: { ...DEFAULT_CONFIG.guards },
144
161
  teardown: { ...DEFAULT_CONFIG.teardown },
162
+ review: { ...DEFAULT_CONFIG.review },
145
163
  live: { migrations: [] },
146
164
  hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
147
165
  }
@@ -269,11 +287,6 @@ function mergeConfig(base, parsed) {
269
287
  assign(base.proxy, parsed.proxy, 'host', 'string')
270
288
  }
271
289
 
272
- if (isObject(parsed.open)) {
273
- // command may be intentionally empty (no auto-open)
274
- assign(base.open, parsed.open, 'command', 'string?')
275
- }
276
-
277
290
  if (isObject(parsed.branch)) {
278
291
  assign(base.branch, parsed.branch, 'pattern', 'string')
279
292
  assign(base.branch, parsed.branch, 'identifierField', 'string')
@@ -307,6 +320,26 @@ function mergeConfig(base, parsed) {
307
320
  }
308
321
  }
309
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
+
310
343
  if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
311
344
  base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
312
345
  }
@@ -5,15 +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
14
  const { classifyDirtyTree } = require('./classify.js')
15
15
  const { portOffset } = require('./registry.js')
16
- const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
+ const { renderEnvFile } = require('./render.js')
17
17
  const { expandTokens } = require('./resolve.js')
18
18
 
19
19
  /**
@@ -114,11 +114,20 @@ function listPaths(paths) {
114
114
  * tell (an unreadable git, or a hotfix, which forks from a tag predating its own
115
115
  * spec), and routes to carrying on, never to refusing.
116
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
+ *
117
125
  * ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
118
- * @returns {{blocked: boolean, reason: string|null, commands: string[]}}
126
+ * @returns {{blocked: boolean, reason: string|null, commands: string[],
127
+ * owned: string[], foreign: string[]}}
119
128
  */
120
129
  function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
121
- const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null }
130
+ const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null, foreign: [] }
122
131
  const c = ctx || {}
123
132
 
124
133
  if (!Array.isArray(c.dirtyPaths)) {
@@ -140,15 +149,30 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
140
149
 
141
150
  const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
142
151
 
143
- if (foreign.length) {
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) {
144
166
  return {
145
167
  blocked: true,
146
168
  reason:
147
169
  `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)' : '') +
170
+ 'commit or stash them first (switching would carry them onto the new branch)' +
150
171
  `: ${listPaths(foreign)}`,
151
172
  commands: [],
173
+ owned: [],
174
+ verb: null,
175
+ foreign,
152
176
  }
153
177
  }
154
178
 
@@ -165,20 +189,34 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
165
189
  ? c.specUntracked
166
190
  : owned.includes(`specs/${spec.bucket}/${spec.folder}`)
167
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(' ')
168
200
  return {
169
201
  blocked: false,
170
202
  reason: null,
171
203
  owned,
172
204
  verb,
205
+ foreign,
173
206
  commands: [
174
- `git add ${owned.map((p) => `"${p}"`).join(' ')}`,
175
- `git commit -m "chore(spec): ${verb} ${spec.folder}"`,
207
+ `git add -- ${paths}`,
208
+ `git commit -m "chore(spec): ${verb} ${spec.folder}" -- ${paths}`,
176
209
  ],
177
210
  }
178
211
  }
179
212
 
180
213
  // Tree is clean. The spec must already be in the base branch's tree, or the
181
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
+
182
220
  if (c.specOnFork === false) {
183
221
  return {
184
222
  blocked: true,
@@ -201,7 +239,7 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
201
239
  * existed in the registry (re-run → attach, don't clobber).
202
240
  * @param {object} config normalised env config.
203
241
  * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
204
- * envContents, openCommand, commands, seedCommands,
242
+ * envContents, commands, seedCommands,
205
243
  * setupCommands, attached }
206
244
  */
207
245
  function planUp(spec, alloc, config, ctx) {
@@ -228,7 +266,6 @@ function planUp(spec, alloc, config, ctx) {
228
266
  portOffset: offset === null ? '' : String(offset),
229
267
  }
230
268
 
231
- const openCommand = expandOpenCommand(config.open.command, tokens)
232
269
 
233
270
  // File seeding runs *in the worktree* after `git worktree add`, before the
234
271
  // setup commands (which may depend on the seeded .env). Each entry becomes an
@@ -276,13 +313,16 @@ function planUp(spec, alloc, config, ctx) {
276
313
  blocked: gate.blocked,
277
314
  reason: gate.reason,
278
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 || [],
279
320
  worktreePath: spec.worktreePath,
280
321
  branch: spec.branch,
281
322
  projectName: spec.projectName,
282
323
  slot: wantsDocker ? slot : null,
283
324
  portOffset: offset,
284
325
  envContents,
285
- openCommand,
286
326
  commands: gate.blocked ? [] : [...gate.commands, ...commands],
287
327
  seedCommands: gate.blocked ? [] : seedCommands,
288
328
  setupCommands: gate.blocked ? [] : setupCommands,
@@ -306,8 +346,7 @@ function planUp(spec, alloc, config, ctx) {
306
346
  * about someone else's unfinished spec. Being on THIS spec's branch is not a
307
347
  * refusal — it is the re-run, and the answer is "already attached".
308
348
  *
309
- * There is no bootstrap and no opener: the primary checkout already has its
310
- * dependencies, and no new session is being opened.
349
+ * There is no bootstrap: the primary checkout already has its dependencies.
311
350
  */
312
351
  function planCheckoutUp(spec, ctx, config) {
313
352
  const base = ctx.base || (config && config.baseBranch) || 'main'
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.
package/src/env/render.js CHANGED
@@ -3,24 +3,15 @@
3
3
  /**
4
4
  * Pure renderers for per-spec isolation artifacts.
5
5
  *
6
- * `renderEnvFile` produces the worktree's `.env` body (the only file the engine
7
- * writes). `expandOpenCommand` expands the optional, editor/terminal-agnostic
8
- * `open.command` template. No side effects — unit-testable in isolation.
6
+ * `renderEnvFile` produces the worktree's `.env` body the only file the engine
7
+ * writes. No side effects unit-testable in isolation.
9
8
  */
10
9
 
11
- const { expandTokens } = require('./resolve.js')
12
-
13
10
  // The worktree `.env`: COMPOSE_PROJECT_NAME namespaces the Docker stack and its
14
11
  // named volumes; PORT_OFFSET shifts the spec's reserved port block.
15
12
  function renderEnvFile({ projectName, portOffset }) {
16
13
  return `COMPOSE_PROJECT_NAME=${projectName}\nPORT_OFFSET=${portOffset}\n`
17
14
  }
18
15
 
19
- // Expand the opener template with the provided tokens. An empty/whitespace-only
20
- // template means "no auto-open" → returns null.
21
- function expandOpenCommand(template, tokens) {
22
- if (typeof template !== 'string' || !template.trim()) return null
23
- return expandTokens(template, tokens)
24
- }
25
16
 
26
- module.exports = { renderEnvFile, expandOpenCommand }
17
+ module.exports = { renderEnvFile }