@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
package/src/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const { execFileSync } = require('child_process')
6
- const { init, resync, reset, isExistingSetup } = require('./init.js')
6
+ const { init, resync, reset, checkSync, isExistingSetup } = require('./init.js')
7
7
  const {
8
8
  detectReleaseTooling,
9
9
  removeReleaseTooling,
@@ -20,12 +20,18 @@ const {
20
20
  const {
21
21
  resolveSpec,
22
22
  resolveBaseBranch,
23
+ liveWorktreePaths,
24
+ collectSpecFolders,
25
+ allSpecs,
23
26
  resolvePrimaryCheckout,
24
27
  assertPrimaryOnMain,
28
+ currentBranch,
25
29
  repoInfo,
26
30
  expandTokens,
27
31
  splitPrefix,
32
+ BUCKETS,
28
33
  } = require('./env/resolve.js')
34
+ const building = require('./env/building.js')
29
35
  const {
30
36
  readReceipt,
31
37
  writeReceipt,
@@ -37,14 +43,48 @@ const {
37
43
  planAbort,
38
44
  } = require('./env/live.js')
39
45
  const { ensureWorktreeDirTrusted } = require('./env/trust.js')
46
+ const {
47
+ rawGitReader,
48
+ collectReview,
49
+ renderReviewPage,
50
+ renderReviewBlock,
51
+ reviewOutPath,
52
+ reviewFileUrl,
53
+ reviewUrlPath,
54
+ reviewPublishPath,
55
+ readReviewUrl,
56
+ publishedPageNotice,
57
+ reviewServerNotice,
58
+ renderReviewFragment,
59
+ resolveReader,
60
+ writeReviewPage,
61
+ reviewNotesPath,
62
+ readNotes,
63
+ writeNotes,
64
+ validateNotesBlob,
65
+ judgeVerdict,
66
+ appendDecision,
67
+ annotateLastDecision,
68
+ readPending,
69
+ writePending,
70
+ claimPending,
71
+ describePending,
72
+ pendingAge,
73
+ reviewPendingPath,
74
+ validateResolutions,
75
+ mergeNotes,
76
+ applyResolutions,
77
+ } = require('./env/review.js')
40
78
  const { planUp, planCheckoutUp } = require('./env/provision.js')
79
+ const { classifyDirtyTree } = require('./env/classify.js')
41
80
  const { planDown, planDownCheckout } = require('./env/teardown.js')
42
81
  const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
43
82
  const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
44
83
  const { planHotfixLand } = require('./env/hotfix.js')
45
84
  const { planDev } = require('./env/dev.js')
46
- const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
47
- const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
85
+ const { startProcess, stopProcess, waitHealthy, readPid, isAlive } = require('./env/supervise.js')
86
+ const { renderRoutes, portsInUse, portsInUseOn, waitListening } = require('./env/proxy.js')
87
+ const { mintToken, servableSpecs, engineVersionFor, staleServer } = require('./env/serve.js')
48
88
 
49
89
  const pkg = require('../package.json')
50
90
 
@@ -80,7 +120,8 @@ Usage:
80
120
  — non-interactively it just adds anything missing.
81
121
  skitterspec update [dir] Resync managed files to the latest, keeping your
82
122
  edits (--force to overwrite). Leaves specs/ + live
83
- .core config alone.
123
+ .core config alone. --check reports what it would
124
+ change and writes nothing.
84
125
  skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
85
126
  specs/.core/env.config.json). Subcommands:
86
127
  up <spec> print the plan to provision a worktree +
@@ -93,7 +134,16 @@ Usage:
93
134
  integrate <spec> plan rebase + fast-forward onto the base branch
94
135
  hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
95
136
  status list provisioned specs + port blocks
137
+ review <spec> write an HTML page of the spec's diff
138
+ (--branch for the whole spec; --out, --json)
139
+ (--notes <json> merges a review pass back;
140
+ --resolve <json> records what was done)
96
141
  resolve <spec> print resolved slug/type/branch/paths
142
+ skitterspec gating <cmd> Release-gating check (opt-in; needs
143
+ specs/.core/gating.config.json). Subcommands:
144
+ check [spec] report specs with no recorded gating
145
+ decision (--all, --json). Advisory:
146
+ always exits 0, never blocks.
97
147
  skitterspec --help Show this help
98
148
  skitterspec --version Print version
99
149
 
@@ -105,6 +155,8 @@ Options (init / update):
105
155
  --diff (update) Show the upstream changes each customized
106
156
  file declined, as a unified diff
107
157
  --dir <path> Target project dir (default: positional arg or cwd)
158
+ --gating (init) Adopt release gating: each spec records whether
159
+ it ships behind a feature flag
108
160
  --no-claude-md Skip creating/patching CLAUDE.md
109
161
  --yes, -y Accept defaults; skip the interactive setup prompts
110
162
  --isolation / --no-isolation Enable/skip per-spec isolation (a git
@@ -130,6 +182,7 @@ function parse(argv) {
130
182
  resync: false,
131
183
  reset: false,
132
184
  diff: false,
185
+ check: false,
133
186
  }
134
187
  const positional = []
135
188
  for (let i = 0; i < argv.length; i++) {
@@ -139,10 +192,15 @@ function parse(argv) {
139
192
  else if (a === '--yes' || a === '-y') opts.yes = true
140
193
  else if (a === '--isolation') opts.isolation = true
141
194
  else if (a === '--no-isolation') opts.isolation = false
195
+ else if (a === '--gating') opts.gating = true
196
+ else if (a === '--no-gating') opts.gating = false
197
+ else if (a === '--all') opts.all = true
198
+ else if (a === '--json') opts.json = true
142
199
  else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
143
200
  else if (a === '--resync') opts.resync = true
144
201
  else if (a === '--reset') opts.reset = true
145
202
  else if (a === '--diff') opts.diff = true
203
+ else if (a === '--check') opts.check = true
146
204
  else if (a === '--dir') opts.dir = argv[++i]
147
205
  else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
148
206
  else positional.push(a)
@@ -203,7 +261,7 @@ async function cleanupReleaseTooling(dir, opts) {
203
261
  * the harmless direction for a read-only report.
204
262
  */
205
263
  function specEnvStatus(dir, config) {
206
- const worktreePaths = liveWorktreePaths(dir)
264
+ const worktreePaths = liveWorktreePaths(gitReader(dir))
207
265
  const provisioned = allSpecs(dir, config, worktreePaths)
208
266
  .map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
209
267
  // The primary checkout is itself in `git worktree list`; a spec is
@@ -230,16 +288,203 @@ function specEnvStatus(dir, config) {
230
288
  }
231
289
 
232
290
  // Plan a provision: allocate the slot, persist the registry, and print the plan
233
- // the /spec-env skill executes (git worktree add, docker compose up, .env,
234
- // opener). This creates no worktree and starts no stack — the caller runs the
291
+ // the /spec-env skill executes (git worktree add, docker compose up, .env).
292
+ // This creates no worktree and starts no stack — the caller runs the
235
293
  // printed commands. Keep the output's verb honest about that.
294
+
295
+ // git quotes a path containing unusual bytes and C-escapes it. Unquote what we
296
+ // can; anything we cannot parse confidently is returned as-is, which makes it
297
+ // fail the spec-folder comparison and land in `foreign` — a refusal, which is the
298
+ // safe direction to be wrong in.
299
+ function unquotePath(p) {
300
+ if (!p.startsWith('"') || !p.endsWith('"')) return p
301
+ try {
302
+ return JSON.parse(p)
303
+ } catch {
304
+ return p
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Repo-relative paths of everything uncommitted. Returns null when git could not
310
+ * be read at all — the caller must treat that as "nobody looked", never "clean".
311
+ *
312
+ * Two prefix-free listings rather than `git status --porcelain`, deliberately.
313
+ * Porcelain prefixes every path with a two-character status field, and the shared
314
+ * git reader TRIMS its output — which eats the leading space of the first line
315
+ * only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
316
+ * These emit bare paths, so there is no offset to get wrong. `--others` also
317
+ * lists untracked files INDIVIDUALLY, where porcelain collapses them into their
318
+ * topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
319
+ * an ancestor attributable to no single spec, and so refusing the very tree this
320
+ * gate exists to accept. Both were found by running it, not by reading it.
321
+ */
322
+ function dirtyPaths(git) {
323
+ const lists = [
324
+ git(['diff', '--name-only', 'HEAD']),
325
+ git(['ls-files', '--others', '--exclude-standard']),
326
+ ]
327
+ if (lists.every((l) => l === null)) return null
328
+ const out = []
329
+ for (const list of lists) {
330
+ if (!list) continue
331
+ for (const line of list.split('\n')) {
332
+ const q = line.trim()
333
+ if (q) out.push(unquotePath(q))
334
+ }
335
+ }
336
+ return out
337
+ }
338
+
339
+ // Is this spec new to git? Asked directly, so the commit subject does not depend
340
+ // on the shape of `git status` output.
341
+ function specIsUntracked(dir, git, spec) {
342
+ const rel = path.relative(dir, spec.path).split(path.sep).join('/')
343
+ const tracked = git(['ls-files', '--', rel])
344
+ return tracked === null ? null : tracked.length === 0
345
+ }
346
+
347
+ /**
348
+ * Is this spec present in the commit the worktree will fork from?
349
+ *
350
+ * A worktree forks from HEAD (`git worktree add -b`), so a spec absent there
351
+ * yields a branch missing the very spec it is for — and a clean working tree
352
+ * cannot detect that, because the spec may be perfectly well committed elsewhere.
353
+ * This asks the positive question rather than inferring it from cleanliness.
354
+ *
355
+ * A HOTFIX IS EXEMPT, and not as an edge case: it forks from a released tag
356
+ * (`spec.baseRef`) that by definition predates the spec describing the fix, so
357
+ * the spec is *supposed* to be absent there. Checking it would refuse every
358
+ * hotfix — an accusation aimed squarely at correct behaviour.
359
+ *
360
+ * Returns `{ onFork, foundOn }`; `onFork: null` means "cannot tell" (a hotfix, or
361
+ * a git that would not answer), which the planner routes to carrying on.
362
+ */
363
+ function specOnForkPoint(dir, git, spec) {
364
+ if (spec.baseRef) return { onFork: null, foundOn: null }
365
+ // Ask about EVERY bucket, by folder name — never about `spec.path`.
366
+ //
367
+ // A spec's folder is its identity; which bucket holds it is a property of the
368
+ // ref you are asking about, and the two legitimately disagree: `/spec-start`
369
+ // moves a spec to `in-progress` on its own branch while the base still shows
370
+ // `backlog`. Worse, resolution PREFERS the worktree, so `spec.path` routinely
371
+ // points outside this repo entirely (`../<repo>-wt/<slug>/specs/...`) — a path
372
+ // no `cat-file` or `log` can ever match, which turned every in-flight spec
373
+ // into "not committed" and silently emptied the `it is on <branch>` hint too.
374
+ const rels = BUCKETS.map((bucket) => `specs/${bucket}/${spec.folder}`)
375
+ for (const rel of rels) {
376
+ if (git(['cat-file', '-e', `HEAD:${rel}/00-overview.md`]) !== null) {
377
+ return { onFork: true, foundOn: null }
378
+ }
379
+ }
380
+ // Best-effort: name the branch that does have it, so the refusal is actionable.
381
+ let foundOn = null
382
+ const sha = git(['log', '--all', '--format=%H', '-1', '--', ...rels])
383
+ if (sha) {
384
+ const branches = git(['branch', '--contains', sha, '--format=%(refname:short)'])
385
+ if (branches) foundOn = branches.split('\n').map((b) => b.trim()).filter(Boolean)[0] || null
386
+ }
387
+ return { onFork: false, foundOn }
388
+ }
389
+
390
+
391
+ // Say what is about to be committed, and why it qualified. The commit is planned
392
+ // on the operator's behalf, so it is never allowed to be a surprise: the paths are
393
+ // listed before the commands that stage them.
394
+ function specCommitLines(plan, folder) {
395
+ if (!plan.specCommit) return []
396
+ // "all of it" is a claim about the whole tree, and it stops being true the
397
+ // moment somebody else's work is sitting there too.
398
+ const head = (plan.untouched || []).length
399
+ ? `uncommitted, and this much of it is ${folder}'s — it will be committed first:`
400
+ : `uncommitted, and all of it is ${folder}'s — it will be committed first:`
401
+ const out = ['', ` ${head}`]
402
+ for (const p of plan.specCommit.paths) out.push(` ${p}`)
403
+ return out
404
+ }
405
+
406
+ // What the run is deliberately leaving alone. Printed rather than swallowed,
407
+ // because provisioning beside somebody else's uncommitted work is a fact the
408
+ // operator should be told — and never printed as a warning, because it is not
409
+ // one: a worktree carries nothing, and the spec commit above names its own paths.
410
+ function untouchedLines(plan) {
411
+ const untouched = plan.untouched || []
412
+ if (!untouched.length) return []
413
+ const out = ['', ` not this spec's — left untouched (${untouched.length}):`]
414
+ for (const p of untouched) out.push(` ${p}`)
415
+ return out
416
+ }
417
+
236
418
  // `spec-env up` in checkout mode. Gathers the git facts, hands them to the pure
237
419
  // planner, and prints the plan or the refusal.
420
+
421
+ /**
422
+ * `skitterspec gating check [spec] [--all] [--json]`
423
+ *
424
+ * ADVISORY BY CONSTRUCTION. It reports and exits 0 — always, including when it
425
+ * finds something. The point of release gating is that the decision is recorded
426
+ * and reviewable, not that a machine enforces it: a project that has not decided
427
+ * yet is not broken, and a check that stopped someone's work over a missing
428
+ * header would be a worse failure than the omission it names.
429
+ */
430
+ function gatingCheck(dir, argv) {
431
+ const { opts, positional } = parse(argv)
432
+ const { checkGating, activeSpecs } = require('./gating.js')
433
+
434
+ let specs = null
435
+ if (positional.length && !opts.all) {
436
+ const name = path.basename(positional[0])
437
+ const found = activeSpecs(dir).filter((s) => s.folder === name)
438
+ if (!found.length) {
439
+ // Not an accusation: a name that matches no ACTIVE spec is usually a
440
+ // finished one, which gating never covers anyway.
441
+ process.stdout.write(`gating: no active spec named ${name} — nothing to check.\n`)
442
+ return
443
+ }
444
+ specs = found
445
+ }
446
+
447
+ const result = checkGating(dir, specs)
448
+
449
+ if (opts.json) {
450
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n')
451
+ return
452
+ }
453
+ if (!result.configured) {
454
+ process.stdout.write('gating: not configured — nothing to check.\n')
455
+ return
456
+ }
457
+ if (!result.findings.length) {
458
+ process.stdout.write(
459
+ `gating: ${result.checked} spec(s) checked — every one records a decision.\n`,
460
+ )
461
+ return
462
+ }
463
+ const lines = []
464
+ for (const f of result.findings) {
465
+ lines.push(
466
+ f.kind === 'missing'
467
+ ? ` ${f.folder} (${f.bucket}): no Gating: header`
468
+ : ` ${f.folder} (${f.bucket}): Gating: "${f.raw}" says nothing — a bare "none" is not a reason`,
469
+ )
470
+ }
471
+ lines.push('')
472
+ lines.push(' decide, then record it on 00-overview.md beside Stack:')
473
+ lines.push(' > **Gating:** <flag name — or "none: <one-line reason>">')
474
+ if (result.guidance) lines.push(` see ${result.guidance}`)
475
+ process.stdout.write(
476
+ `gating: ${result.findings.length} of ${result.checked} spec(s) record no decision\n` +
477
+ lines.join('\n') +
478
+ '\n',
479
+ )
480
+ }
481
+
238
482
  function specEnvUpCheckout(dir, config, spec) {
239
483
  const git = gitReader(dir)
240
484
  const primary = assertPrimaryOnMain(config, git)
241
485
  const base = resolveBaseBranch(config, git)
242
- const status = git(['status', '--porcelain'])
486
+ const status = git(['status', '--porcelain', '-uall'])
487
+ const onFork = specOnForkPoint(dir, git, spec)
243
488
 
244
489
  const plan = planCheckoutUp(
245
490
  spec,
@@ -251,6 +496,11 @@ function specEnvUpCheckout(dir, config, spec) {
251
496
  // the harmless outcome of being wrong is a refusal the operator can act
252
497
  // on, and the harmful one is carrying their work onto a new branch.
253
498
  clean: status !== null && status.length === 0,
499
+ dirtyPaths: dirtyPaths(git),
500
+ specOnFork: onFork.onFork,
501
+ specFoundOn: onFork.foundOn,
502
+ forkRef: primary.branch || 'HEAD',
503
+ specUntracked: specIsUntracked(dir, git, spec),
254
504
  branchExists: git(['rev-parse', '--verify', `refs/heads/${spec.branch}`]) !== null,
255
505
  checkoutPath: dir,
256
506
  },
@@ -271,6 +521,7 @@ function specEnvUpCheckout(dir, config, spec) {
271
521
  ` branch: ${plan.branch}`,
272
522
  ' stack: checkout-only (no worktree, no docker, no port block)',
273
523
  ]
524
+ out.push(...specCommitLines(plan, spec.folder))
274
525
  if (plan.commands.length) {
275
526
  out.push('')
276
527
  out.push(' to provision, run:')
@@ -283,8 +534,8 @@ function specEnvUp(dir, config, specArg) {
283
534
  const spec = resolveSpecWithWorktree(dir, config, specArg)
284
535
 
285
536
  // Checkout mode: the branch is built in the primary checkout, so none of the
286
- // worktree machinery below applies — no slot, no trust entry, no bootstrap and
287
- // no opener. Handled first precisely so none of that runs by accident.
537
+ // worktree machinery below applies — no slot, no trust entry and no bootstrap.
538
+ // Handled first precisely so none of that runs by accident.
288
539
  if (config.mode === 'checkout') {
289
540
  specEnvUpCheckout(dir, config, spec)
290
541
  return
@@ -303,6 +554,27 @@ function specEnvUp(dir, config, specArg) {
303
554
  return
304
555
  }
305
556
 
557
+ // READ THE TREE BEFORE WRITING ANYTHING INTO IT.
558
+ //
559
+ // The report below divides the uncommitted tree into this spec's paths and
560
+ // everyone else's, and "everyone else's" means *the operator's* — work that
561
+ // was here before this command ran. Anything this command writes must
562
+ // therefore be invisible to that read, or `up` reports its own output as
563
+ // somebody's unfinished business.
564
+ //
565
+ // It did exactly that: the trust write below creates
566
+ // .claude/settings.local.json, and a clean checkout then reported
567
+ // "not this spec's — left untouched (1)" naming that very file. It went
568
+ // unnoticed for as long as it did because git reads ~/.config/git/ignore as
569
+ // its global excludes with no core.excludesFile setting needed, and the
570
+ // author's happened to list that path — so git never mentioned the file on
571
+ // the one machine the suite ever ran on. The first CI runner disagreed.
572
+ const upGit = gitReader(dir)
573
+ const upStatus = upGit(['status', '--porcelain'])
574
+ const upDirtyPaths = dirtyPaths(upGit)
575
+ const upOnFork = specOnForkPoint(dir, upGit, spec)
576
+ const upSpecUntracked = specIsUntracked(dir, upGit, spec)
577
+
306
578
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
307
579
  // don't prompt. One absolute entry (the root) covers every spec; self-heals on
308
580
  // every provision for teammates who only cloned and ran /spec-start.
@@ -326,7 +598,21 @@ function specEnvUp(dir, config, specArg) {
326
598
  attached = fs.existsSync(spec.worktreePath)
327
599
  }
328
600
 
329
- const plan = planUp(spec, { slot, attached }, config)
601
+ // The tree gate: the same facts the checkout planner gets, all of them read
602
+ // above — before this command wrote anything of its own into the tree.
603
+ const plan = planUp(spec, { slot, attached }, config, {
604
+ clean: upStatus !== null && upStatus.length === 0,
605
+ dirtyPaths: upDirtyPaths,
606
+ specOnFork: upOnFork.onFork,
607
+ specFoundOn: upOnFork.foundOn,
608
+ forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
609
+ specUntracked: upSpecUntracked,
610
+ })
611
+
612
+ if (plan.blocked) {
613
+ process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
614
+ return
615
+ }
330
616
 
331
617
  const out = []
332
618
  // `up` is a planner: it prints commands for the caller to run and creates no
@@ -364,10 +650,11 @@ function specEnvUp(dir, config, specArg) {
364
650
  `(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
365
651
  )
366
652
  }
653
+ out.push(...specCommitLines(plan, spec.folder))
654
+ out.push(...untouchedLines(plan))
367
655
  out.push('')
368
656
  out.push(' to provision, run:')
369
657
  for (const cmd of plan.commands) out.push(` ${cmd}`)
370
- if (plan.openCommand) out.push(` ${plan.openCommand}`)
371
658
  // Seed files first (setup may depend on them), then the setup commands —
372
659
  // both run in the worktree, under one heading.
373
660
  const worktreeSteps = [...plan.seedCommands, ...plan.setupCommands]
@@ -488,6 +775,31 @@ function compactTimestamp() {
488
775
  // touch the trusted worktree root in .claude/settings.local.json — that entry is
489
776
  // the shared parent of every spec's worktree and harmless when empty; removing it
490
777
  // would just re-prompt on the next /spec-start (see spec: isolation-trusts-worktree-dir).
778
+ /**
779
+ * Gather the facts `reviewServerNotice` needs, and ask it what to say.
780
+ *
781
+ * `othersServed` counts the specs the server would still have work for once
782
+ * this one is gone. Evaluated BEFORE the teardown commands run — the worktree
783
+ * is still on disk at this point — so the spec being torn down is excluded by
784
+ * name rather than by waiting for it to disappear.
785
+ */
786
+ function reviewServerNoticeFor(dir, config, folder) {
787
+ const sdir = stateDirLabel(config)
788
+ const proc = serveProcFor(config, path.resolve(dir, `${sdir}/review-serve.json`), { root: dir })
789
+ const pid = readPid(path.resolve(dir, proc.pidFile))
790
+ let othersServed = 0
791
+ try {
792
+ othersServed = servableSpecs(dir, config, gitReader(dir)).filter(
793
+ (sp) => sp.folder !== folder,
794
+ ).length
795
+ } catch {
796
+ // Cannot tell how many are left — so say nothing rather than tell someone
797
+ // to stop a server other specs may still need (negative-checks rule 4).
798
+ return []
799
+ }
800
+ return reviewServerNotice({ running: Boolean(pid && isAlive(pid)), othersServed })
801
+ }
802
+
491
803
  function specEnvDown(dir, config, specArg, flags) {
492
804
  const spec = resolveSpecWithWorktree(dir, config, specArg)
493
805
 
@@ -522,6 +834,10 @@ function specEnvDown(dir, config, specArg, flags) {
522
834
  ' mode: checkout (no worktree, no slot, no volumes)',
523
835
  ` branch: ${dplan.branch}`, '', ' run these:']
524
836
  for (const cmd of dplan.commands) dout.push(` ${cmd}`)
837
+ // Checkout mode has no worktree, but a published page outlives it just the
838
+ // same — the two modes must not disagree about what is left behind.
839
+ dout.push(...publishedPageNotice(readReviewUrl(reviewOutPath(dir, spec.folder))))
840
+ dout.push(...reviewServerNoticeFor(dir, config, spec.folder))
525
841
  process.stdout.write(dout.join('\n') + '\n')
526
842
  return
527
843
  }
@@ -570,6 +886,10 @@ function specEnvDown(dir, config, specArg, flags) {
570
886
  out.push(' remote branch — confirm with the user first:')
571
887
  for (const cmd of plan.remoteCommands) out.push(` ${cmd}`)
572
888
  }
889
+ // The one survivor. Read through `readReviewUrl` rather than by building the
890
+ // path, and reported after the commands because it is not one of them.
891
+ out.push(...publishedPageNotice(readReviewUrl(reviewOutPath(dir, spec.folder))))
892
+ out.push(...reviewServerNoticeFor(dir, config, spec.folder))
573
893
  process.stdout.write(out.join('\n') + '\n')
574
894
  }
575
895
 
@@ -621,17 +941,6 @@ function volumeCreatedAt(names) {
621
941
  }
622
942
 
623
943
  // Absolute paths of every checkout git knows about (primary + all worktrees).
624
- function liveWorktreePaths(dir) {
625
- const out = gitReader(dir)(['worktree', 'list', '--porcelain'])
626
- const paths = new Set()
627
- if (out == null) return paths
628
- for (const line of out.split('\n')) {
629
- if (line.startsWith('worktree ')) {
630
- paths.add(path.resolve(line.slice('worktree '.length).trim()))
631
- }
632
- }
633
- return paths
634
- }
635
944
 
636
945
  /**
637
946
  * The spec to act on when the caller named none.
@@ -667,8 +976,20 @@ function liveWorktreePaths(dir) {
667
976
  * git keeps listing it as prunable. That over-reports rather than under-reports,
668
977
  * so the failure is an ambiguity error, never a wrong spec.
669
978
  */
670
- function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
671
- const worktreePaths = liveWorktreePaths(dir)
979
+ /**
980
+ * The same resolution as `soleProvisionedSpec`, returned as DATA rather than
981
+ * thrown: `{ folder }` when exactly one answer, `{ candidates }` when several,
982
+ * `{}` when none has a worktree.
983
+ *
984
+ * It exists because `liveGrammar` has to tell "several worktrees" from "no
985
+ * worktrees" and act differently on each, and the only other way to do that is
986
+ * to pattern-match the wording of an Error — which makes a message nobody
987
+ * thought was load-bearing into API. One implementation, two presentations:
988
+ * `soleProvisionedSpec` below is a thin wrapper that turns the two empty-handed
989
+ * cases into the exact errors every other subcommand already relies on.
990
+ */
991
+ function provisionedSpecChoice(dir, config, cwd = process.cwd()) {
992
+ const worktreePaths = liveWorktreePaths(gitReader(dir))
672
993
  const provisioned = allSpecs(dir, config, worktreePaths)
673
994
  .map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
674
995
  // The primary checkout is itself in `git worktree list`; a spec is
@@ -687,20 +1008,26 @@ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
687
1008
  const inside = provisioned
688
1009
  .filter((s) => here === s.wt || here.startsWith(s.wt + path.sep))
689
1010
  .sort((a, b) => b.wt.length - a.wt.length)[0]
690
- if (inside) return inside.folder
1011
+ if (inside) return { folder: inside.folder }
691
1012
 
692
1013
  // 2. Otherwise only an unambiguous set answers.
693
- if (provisioned.length === 1) return provisioned[0].folder
694
- if (provisioned.length === 0) {
1014
+ if (provisioned.length === 1) return { folder: provisioned[0].folder }
1015
+ return { candidates: provisioned.map((s) => s.folder) }
1016
+ }
1017
+
1018
+ function soleProvisionedSpec(dir, config, cwd = process.cwd()) {
1019
+ const { folder, candidates } = provisionedSpecChoice(dir, config, cwd)
1020
+ if (folder) return folder
1021
+ if (!candidates.length) {
695
1022
  throw new Error(
696
1023
  'no spec given, and no spec has a worktree — name one explicitly, or run ' +
697
1024
  '/spec-start to provision it.',
698
1025
  )
699
1026
  }
700
1027
  throw new Error(
701
- `no spec given, and ${provisioned.length} specs have worktrees — name the one ` +
1028
+ `no spec given, and ${candidates.length} specs have worktrees — name the one ` +
702
1029
  `you mean, or run this from inside one:\n` +
703
- provisioned.map((s, i) => ` ${i + 1}. ${s.folder}`).join('\n'),
1030
+ candidates.map((f, i) => ` ${i + 1}. ${f}`).join('\n'),
704
1031
  )
705
1032
  }
706
1033
 
@@ -727,47 +1054,30 @@ function resolveSpecWithWorktree(dir, config, specArg) {
727
1054
  expandTokens(config.worktree.root, wtTokens),
728
1055
  expandTokens(config.worktree.folderPattern, wtTokens),
729
1056
  )
730
- const searchDirs = [...new Set([worktreeGuess, ...liveWorktreePaths(dir)])].filter(
1057
+ const searchDirs = [...new Set([worktreeGuess, ...liveWorktreePaths(gitReader(dir))])].filter(
731
1058
  (p) => p !== dir,
732
1059
  )
733
- return resolveSpec(specArg, dir, config, { searchDirs })
1060
+ // This spec's OWN worktree is preferred over the primary checkout, not merely a
1061
+ // fallback to it. `/spec-start` moves a spec to `in-progress` on the spec's own
1062
+ // branch, so the primary checkout goes on reporting `backlog` for the whole
1063
+ // life of the spec — and the same stale file supplies `Stack:` and
1064
+ // `Base version:`, so escalating a spec to docker by editing its header in the
1065
+ // worktree was invisible to `spec-env up`.
1066
+ //
1067
+ // WHAT WOULD FOOL THIS: only this spec's own worktree is promoted, never the
1068
+ // other entries in `searchDirs`. Those are other specs' checkouts, and letting
1069
+ // one of them answer for this spec would swap one wrong branch's view for
1070
+ // another's. A worktree left behind by a declined teardown can still answer
1071
+ // with a stale bucket — that is a leftover to prune, not a lookup to distrust.
1072
+ const preferDirs = worktreeGuess === dir ? [] : [worktreeGuess]
1073
+ return resolveSpec(specArg, dir, config, { searchDirs, preferDirs })
734
1074
  }
735
1075
 
736
1076
  // Every spec folder name found under specs/* across the given checkout roots.
737
- // An in-progress spec lives on its *worktree branch*, not the primary checkout,
738
- // so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
739
- function collectSpecFolders(roots) {
740
- const folders = new Set()
741
- for (const root of roots) {
742
- for (const bucket of ['backlog', 'in-progress', 'complete', 'cancelled']) {
743
- let entries
744
- try {
745
- entries = fs.readdirSync(path.join(root, 'specs', bucket), { withFileTypes: true })
746
- } catch {
747
- continue
748
- }
749
- for (const entry of entries) if (entry.isDirectory()) folders.add(entry.name)
750
- }
751
- }
752
- return folders
753
- }
754
1077
 
755
1078
  // Resolve every spec folder (found in the primary checkout OR any worktree) to
756
1079
  // { folder, slug, worktreePath }. `searchDirs` lets resolveSpec locate a spec
757
1080
  // that was authored on its branch and never committed to the primary checkout.
758
- function allSpecs(dir, config, worktreePaths) {
759
- const searchDirs = [...worktreePaths]
760
- const specs = []
761
- for (const folder of collectSpecFolders([dir, ...searchDirs])) {
762
- try {
763
- const spec = resolveSpec(folder, dir, config, { searchDirs })
764
- specs.push({ folder: spec.folder, slug: spec.slug, worktreePath: spec.worktreePath })
765
- } catch {
766
- // Unresolvable folder (not a real spec) — skip.
767
- }
768
- }
769
- return specs
770
- }
771
1081
 
772
1082
  // Prune: reconcile namespace volumes against specs that still have a worktree and
773
1083
  // print the `docker volume rm` commands for the orphans. Liveness keys off the
@@ -775,7 +1085,37 @@ function allSpecs(dir, config, worktreePaths) {
775
1085
  // this correctly reaps those and frees their stale slots. Destructive removal is
776
1086
  // executed by the caller (skill) after confirmation — the CLI only plans + writes
777
1087
  // the registry, mirroring `spec-env down`.
1088
+ /**
1089
+ * Delete a review-server pidfile whose process is gone.
1090
+ *
1091
+ * A LIVE pid is left strictly alone: `isAlive` is the positive signal, and the
1092
+ * file merely existing proves nothing — a crashed server leaves one behind,
1093
+ * which is the whole case this reaps. Being wrong in the other direction would
1094
+ * mean deleting the record of a server that is still listening, so the unknown
1095
+ * case (unreadable file, failed unlink) does nothing at all.
1096
+ */
1097
+ function reapStaleServePid(dir, config) {
1098
+ const proc = serveProcFor(config, path.resolve(dir, `${stateDirLabel(config)}/review-serve.json`), { root: dir })
1099
+ const file = path.resolve(dir, proc.pidFile)
1100
+ const pid = readPid(file)
1101
+ if (!pid || isAlive(pid)) return null
1102
+ try {
1103
+ fs.rmSync(file, { force: true })
1104
+ } catch {
1105
+ return null
1106
+ }
1107
+ return pid
1108
+ }
1109
+
778
1110
  function specEnvPrune(dir, config, flags) {
1111
+ // Before the docker section, deliberately: a stale pidfile is not docker's
1112
+ // business, and every branch below can return early.
1113
+ const reapedPid = reapStaleServePid(dir, config)
1114
+ if (reapedPid) {
1115
+ process.stdout.write(
1116
+ `spec-env prune: reaped a stale review-server pidfile (pid ${reapedPid} is gone).\n`,
1117
+ )
1118
+ }
779
1119
  const { repoSlug } = repoInfo(dir)
780
1120
 
781
1121
  const vols = listRepoVolumes(repoSlug)
@@ -787,7 +1127,7 @@ function specEnvPrune(dir, config, flags) {
787
1127
  return
788
1128
  }
789
1129
 
790
- const worktrees = liveWorktreePaths(dir)
1130
+ const worktrees = liveWorktreePaths(gitReader(dir))
791
1131
  const specs = allSpecs(dir, config, worktrees)
792
1132
  const liveSlugs = liveSlugsForSpecs(specs, worktrees)
793
1133
 
@@ -1078,9 +1418,93 @@ function specEnvHotfix(dir, config, positional, flags) {
1078
1418
  process.stdout.write(out.join('\n') + '\n')
1079
1419
  }
1080
1420
 
1421
+ // What the primary checkout has GAINED right now, by content. `dir` is already
1422
+ // anchored on the primary checkout by the dispatcher, so this reads the tree the
1423
+ // build must not be writing into. See env/building.js for why this is two
1424
+ // content-based queries rather than one `status --porcelain`.
1425
+ function primaryPaths(dir) {
1426
+ const git = gitReader(dir)
1427
+ return building.mergePaths(
1428
+ git(['diff', '--name-only', 'HEAD']),
1429
+ git(['ls-files', '--others', '--exclude-standard']),
1430
+ )
1431
+ }
1432
+
1433
+ // `--record-primary`: stamp the baseline a later --assert-primary-clean reads.
1434
+ function recordPrimary(dir, config, r) {
1435
+ const file = building.baselinePath(dir, config)
1436
+ const baseline = building.buildBaseline({
1437
+ spec: r.folder,
1438
+ worktreePath: r.worktreePath,
1439
+ primary: dir,
1440
+ paths: primaryPaths(dir),
1441
+ })
1442
+ fs.mkdirSync(path.dirname(file), { recursive: true })
1443
+ fs.writeFileSync(file, JSON.stringify(baseline, null, 2) + '\n')
1444
+ const n = baseline.paths.length
1445
+ process.stdout.write(
1446
+ `spec-env resolve: baseline recorded for ${r.folder}\n` +
1447
+ ` primary: ${dir}\n` +
1448
+ ` worktree: ${r.worktreePath}\n` +
1449
+ ` dirty now: ${n === 0 ? 'nothing' : `${n} path(s) — these will not be reported later`}\n`,
1450
+ )
1451
+ }
1452
+
1453
+ // `--assert-primary-clean`: did the build write into the primary checkout?
1454
+ //
1455
+ // THREE OUTCOMES, NOT TWO. It accuses only on `leaked`; `unknown` reports what
1456
+ // blinded it and exits 0, because a baseline that is missing or belongs to
1457
+ // another spec is an absence, and an absence is not evidence
1458
+ // (`.claude/rules/negative-checks.md` rules 1 and 4). The blind spots this
1459
+ // cannot see are named on `compare()` in env/building.js.
1460
+ function assertPrimaryClean(dir, config, r) {
1461
+ const file = building.baselinePath(dir, config)
1462
+ let baseline = null
1463
+ try {
1464
+ baseline = JSON.parse(fs.readFileSync(file, 'utf8'))
1465
+ } catch {
1466
+ baseline = null
1467
+ }
1468
+ const result = building.compare(baseline, primaryPaths(dir), {
1469
+ spec: r.folder,
1470
+ worktreePath: r.worktreePath,
1471
+ primary: dir,
1472
+ })
1473
+
1474
+ if (result.verdict === 'leaked') {
1475
+ // States the OBSERVATION, not the attribution. All this knows is that the
1476
+ // primary checkout gained these paths since the baseline — it cannot know
1477
+ // who wrote them, and in practice another session writing a backlog spec
1478
+ // into the primary looks identical to a leaked build write. Both readings
1479
+ // get a next step, so being wrong about which one costs a re-record rather
1480
+ // than someone deleting work that was never a leak.
1481
+ throw new Error(
1482
+ `${result.paths.length} path(s) appeared in the PRIMARY checkout since the baseline:\n` +
1483
+ result.paths.map((p) => ` ${p}`).join('\n') +
1484
+ `\n primary: ${dir}` +
1485
+ `\n worktree: ${r.worktreePath}` +
1486
+ '\n if this build wrote them, move them into the worktree before committing.' +
1487
+ '\n if something else did, re-run --record-primary and carry on.',
1488
+ )
1489
+ }
1490
+ if (result.verdict === 'unknown') {
1491
+ process.stdout.write(
1492
+ `spec-env resolve: cannot tell — ${result.reason}.\n` +
1493
+ ' no leak is being claimed; run --record-primary before the build to enable this check.\n',
1494
+ )
1495
+ return
1496
+ }
1497
+ process.stdout.write(
1498
+ `spec-env resolve: primary checkout clean for ${r.folder}\n` +
1499
+ ` nothing was written into ${dir}\n`,
1500
+ )
1501
+ }
1502
+
1081
1503
  // Print the resolved identity/coordinates for a single spec.
1082
- function specEnvResolve(dir, config, specArg) {
1504
+ function specEnvResolve(dir, config, specArg, flags = {}) {
1083
1505
  const r = resolveSpecWithWorktree(dir, config, specArg)
1506
+ if (flags.recordPrimary) return recordPrimary(dir, config, r)
1507
+ if (flags.assertPrimaryClean) return assertPrimaryClean(dir, config, r)
1084
1508
  process.stdout.write(
1085
1509
  `spec: ${r.folder} (${r.bucket})\n` +
1086
1510
  `type/slug: ${r.type} / ${r.slug}\n` +
@@ -1090,6 +1514,637 @@ function specEnvResolve(dir, config, specArg) {
1090
1514
  )
1091
1515
  }
1092
1516
 
1517
+ /**
1518
+ * `skitterspec spec-env stage [<spec>] [--json]`
1519
+ *
1520
+ * Which uncommitted paths belong to this spec, and which belong to someone else?
1521
+ *
1522
+ * `classifyDirtyTree` has answered that since the `/spec-start` gate was written,
1523
+ * but only `spec-env up` could reach it — so every skill that commits a spec
1524
+ * hand-wrote `git add specs/` instead, staging a DIRECTORY. With more than one
1525
+ * session writing into `specs/` at once that sweeps a colleague's in-progress
1526
+ * spec into this spec's commit, under this spec's ticket trailer. This verb is
1527
+ * how a skill asks instead of guessing.
1528
+ *
1529
+ * IT ACCUSES NOBODY. `foreign` is not a complaint and not a refusal — it is the
1530
+ * list of paths to leave alone. Nothing here exits non-zero and nothing here
1531
+ * writes.
1532
+ *
1533
+ * `owned` IS THE SPEC'S DOCUMENTS, NEVER ITS CODE. A phase's own implementation
1534
+ * lands in `foreign` — correctly, and this is the point: the commits this verb
1535
+ * exists to bound are the lifecycle ones (`chore(spec): complete <name>`), which
1536
+ * carry a status flip and a folder move and nothing else. A caller that staged
1537
+ * `owned` expecting a phase's work would commit the spec file alone and think it
1538
+ * had committed the feature.
1539
+ *
1540
+ * WHICH TREE IT READS: the one the caller is standing in, resolved from the
1541
+ * invocation cwd rather than from `dir` (which every subcommand re-anchors on
1542
+ * the primary checkout so worktree paths and the registry resolve identically).
1543
+ * That distinction is the whole point here: `/spec-complete` and `/spec-cancel`
1544
+ * run INSIDE the spec's worktree and must be told about that tree, while
1545
+ * `/spec-start` runs in the primary checkout and must be told about that one.
1546
+ * Re-anchoring would silently answer about the wrong checkout, so the tree read
1547
+ * is printed rather than assumed.
1548
+ */
1549
+ function specEnvStage(dir, config, specArg, flags = {}, invokedFrom = dir) {
1550
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1551
+
1552
+ // The git root CONTAINING the caller, not the primary checkout. `git ls-files`
1553
+ // is scoped to its cwd, so reading from a subdirectory would list only that
1554
+ // subdirectory's untracked files and report the rest of the spec as absent.
1555
+ const git = gitReader(invokedFrom)
1556
+ const tree = git(['rev-parse', '--show-toplevel']) || invokedFrom
1557
+ const paths = dirtyPaths(gitReader(tree))
1558
+
1559
+ // Three states, not two (`.claude/rules/negative-checks.md` rule 4). A null
1560
+ // here is "nobody could look", never "clean" — so it must not become an empty
1561
+ // owned set, which a caller would stage happily and commit as nothing.
1562
+ if (paths === null) {
1563
+ if (flags.json) {
1564
+ process.stdout.write(
1565
+ JSON.stringify({
1566
+ spec: spec.folder,
1567
+ tree,
1568
+ owned: null,
1569
+ foreign: null,
1570
+ error: 'git could not be read',
1571
+ }) + '\n',
1572
+ )
1573
+ return
1574
+ }
1575
+ process.stdout.write(
1576
+ `spec-env stage: ${spec.folder} — git could not be read at ${tree}, so nothing was classified.\n` +
1577
+ ' This is not "clean": stage nothing on the strength of it.\n',
1578
+ )
1579
+ return
1580
+ }
1581
+
1582
+ const { owned, foreign } = classifyDirtyTree(spec, paths, config)
1583
+
1584
+ if (flags.json) {
1585
+ process.stdout.write(JSON.stringify({ spec: spec.folder, tree, owned, foreign }) + '\n')
1586
+ return
1587
+ }
1588
+
1589
+ const out = [
1590
+ `spec-env stage: ${spec.folder} — ${owned.length} owned, ${foreign.length} foreign`,
1591
+ ` tree: ${tree}`,
1592
+ ]
1593
+ // An empty list is omitted rather than printed under its heading: a heading
1594
+ // with nothing beneath it reads as a finding.
1595
+ if (owned.length) {
1596
+ out.push('', ` owned (${spec.folder}'s — safe to commit):`)
1597
+ for (const p of owned) out.push(` ${p}`)
1598
+ }
1599
+ if (foreign.length) {
1600
+ out.push('', ' foreign (not this spec\'s — leave them alone):')
1601
+ for (const p of foreign) out.push(` ${p}`)
1602
+ }
1603
+ if (!owned.length && !foreign.length) {
1604
+ out.push('', ' nothing uncommitted.')
1605
+ }
1606
+ process.stdout.write(out.join('\n') + '\n')
1607
+ }
1608
+
1609
+ /**
1610
+ * Write a self-contained HTML review of a spec's diff.
1611
+ *
1612
+ * Read entirely through `git -C <worktreePath>` — the caller's shell never moves,
1613
+ * which is the whole point: the work being reviewed lives in a worktree, and the
1614
+ * terminal is somewhere else (often a phone). Two shapes: the uncommitted working
1615
+ * tree (the default — "what did this phase just do") and `--branch` (everything
1616
+ * since the base branch — "what does this whole spec do").
1617
+ */
1618
+ // How a judged verdict reads on the `notes:` line. The refused case leads with
1619
+ // the word `refused` rather than burying it after the reason, because the one
1620
+ // thing the reader must take away is that the approval did not happen.
1621
+ function verdictSaid(v) {
1622
+ if (!v.honoured) return `commit refused — ${v.reason}`
1623
+ // A committing verdict names what it hands off to, because that is the next
1624
+ // thing that will happen to the repo and the reader should see it coming.
1625
+ if (v.effective === 'commit') return `committing with ${v.commitWith}`
1626
+ if (v.effective === 'commit-continue') return `committing with ${v.commitWith}, then the next phase`
1627
+ if (v.effective === 'changes') return 'changes requested'
1628
+ return 'discuss first'
1629
+ }
1630
+
1631
+ async function specEnvReview(dir, config, specArg, flags) {
1632
+ // An unknown name throws here rather than falling back to the branch: a review
1633
+ // of the wrong spec looks exactly like a review of the right one.
1634
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1635
+
1636
+ // The worktree is what we read; without it there is nothing to say. This is an
1637
+ // absence that means something — `git worktree list` is the same source that
1638
+ // resolved the path — so it is safe to act on.
1639
+ if (!fs.existsSync(spec.worktreePath)) {
1640
+ process.stdout.write(
1641
+ `spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
1642
+ 'run /spec-start to provision it.\n',
1643
+ )
1644
+ return
1645
+ }
1646
+
1647
+ const git = rawGitReader(spec.worktreePath)
1648
+ const trimmed = gitReader(spec.worktreePath)
1649
+
1650
+ let mode = 'working'
1651
+ let ref = 'HEAD'
1652
+ let base = null
1653
+
1654
+ // WHICH BASE THE BRANCH VIEW MEASURES FROM. A hotfix forks its worktree from a
1655
+ // release tag rather than the base branch, so the range that answers "what
1656
+ // does this spec change" starts at that tag — `spec.baseRef`, read from the
1657
+ // `> **Base version:**` header, and null for every other spec type.
1658
+ //
1659
+ // Measuring a hotfix from the base branch is wrong in two ways at once: the
1660
+ // header says `since main`, which is not where the work started, and when the
1661
+ // tag is not an ancestor of the base branch (a release line that never merged
1662
+ // back) the range widens to include commits the hotfix never touched.
1663
+ //
1664
+ // Lazy, so the common working-tree path pays nothing for it.
1665
+ const reviewBase = () => spec.baseRef || resolveBaseBranch(config, trimmed)
1666
+
1667
+ if (flags.branch) {
1668
+ base = reviewBase()
1669
+ const mergeBase = trimmed(['merge-base', base, 'HEAD'])
1670
+ // Cannot tell → do nothing. A missing merge-base means the branch and the
1671
+ // base share no history (a fresh repo, an unfetched base); diffing against
1672
+ // the base tip anyway would report every file in the project as changed.
1673
+ if (!mergeBase) {
1674
+ process.stdout.write(
1675
+ `spec-env review: no merge-base between ${base} and ${spec.branch} — ` +
1676
+ 'cannot compute the branch range (fetch the base branch?).\n',
1677
+ )
1678
+ return
1679
+ }
1680
+ ref = mergeBase
1681
+ mode = 'branch'
1682
+ }
1683
+
1684
+ // The sidecar is keyed to the page's path, so resolve that first — `--out`
1685
+ // moves both together.
1686
+ const out = reviewOutPath(dir, spec.folder, flags.out)
1687
+ const stored = readNotes(out, spec.folder)
1688
+ let notes = stored.notes
1689
+ let merged = null
1690
+ let sentVerdict = null
1691
+ let claimed = null
1692
+
1693
+ // A CLAIM IS A DELIVERY MECHANISM, not a second kind of review. It lifts a
1694
+ // pass out of the holding area and hands it to exactly the same merge a
1695
+ // pasted blob goes through, so nothing downstream can tell — or behave
1696
+ // differently — by how the pass arrived.
1697
+ if (flags.claim) {
1698
+ const heldRead = readPending(out, spec.folder)
1699
+ if (heldRead.corrupt) {
1700
+ // Same rule as the notes sidecar: a file we cannot parse is not "nothing
1701
+ // pending", and claiming against it must refuse rather than find nothing.
1702
+ process.stdout.write(
1703
+ `spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
1704
+ 'move it aside rather than losing the passes it holds.\n',
1705
+ )
1706
+ return
1707
+ }
1708
+ const result = claimPending(heldRead.pending, String(flags.claim).trim())
1709
+ if (!result.pass) {
1710
+ // NO FALLBACK, EVER. Not "the only one", not "the most recent" — either
1711
+ // would let a pass nobody read out reach the review, which is the whole
1712
+ // thing the code exists to prevent. And it names nothing: listing the
1713
+ // pending codes would hand a guesser the answer.
1714
+ process.stdout.write(
1715
+ `spec-env review: no pending pass with that code` +
1716
+ `${result.count ? ` (${result.count} waiting)` : ''}\n`,
1717
+ )
1718
+ return
1719
+ }
1720
+ // Validated on the way in as well as on the way out. The blob has been
1721
+ // through a socket and sat on disk, so it is untrusted input twice over.
1722
+ let parsed
1723
+ try {
1724
+ parsed = validateNotesBlob(result.pass.blob, spec.folder)
1725
+ } catch (err) {
1726
+ process.stdout.write(`spec-env review: ${err.message}\n`)
1727
+ return
1728
+ }
1729
+ notes = mergeNotes(notes, parsed, new Date().toISOString())
1730
+ writeNotes(out, notes)
1731
+ // Spent by the claim, so the same code cannot be claimed twice.
1732
+ writePending(out, result.pending)
1733
+ sentVerdict = parsed.verdict
1734
+ claimed = {
1735
+ code: result.pass.code,
1736
+ at: result.pass.at || null,
1737
+ remaining: result.count,
1738
+ accepted: parsed.accepted.length,
1739
+ unaccepted: parsed.unaccepted.length,
1740
+ comments: parsed.comments.length,
1741
+ }
1742
+ merged = { accepted: claimed.accepted, unaccepted: claimed.unaccepted, comments: claimed.comments }
1743
+ }
1744
+
1745
+ // The other half of a confirmation: a pass the operator says is not theirs.
1746
+ // Left in the store it is reported on every render until they stop reading the
1747
+ // line — which is how the real one gets waved away too.
1748
+ let dropped = null
1749
+ if (flags.drop) {
1750
+ const heldRead = readPending(out, spec.folder)
1751
+ if (heldRead.corrupt) {
1752
+ process.stdout.write(
1753
+ `spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
1754
+ 'move it aside rather than losing the passes it holds.\n',
1755
+ )
1756
+ return
1757
+ }
1758
+ // Same match and the SAME SILENCE as a claim. A drop that listed the codes
1759
+ // it could not find would hand a guesser exactly what the claim withholds.
1760
+ const result = claimPending(heldRead.pending, String(flags.drop).trim())
1761
+ if (!result.pass) {
1762
+ process.stdout.write(
1763
+ `spec-env review: no pending pass with that code` +
1764
+ `${result.count ? ` (${result.count} waiting)` : ''}\n`,
1765
+ )
1766
+ return
1767
+ }
1768
+ writePending(out, result.pending)
1769
+ // Nothing is merged, and the notes sidecar is not touched.
1770
+ dropped = { code: result.pass.code, remaining: result.count }
1771
+ }
1772
+
1773
+ if (flags.notes) {
1774
+ // Refuse rather than write over notes we could not read: an unreadable
1775
+ // sidecar is a whole review pass, and overwriting it is unrecoverable.
1776
+ if (stored.corrupt) {
1777
+ process.stdout.write(
1778
+ `spec-env review: ${reviewNotesPath(out)} is not readable JSON — ` +
1779
+ 'move it aside and re-paste, rather than losing what it holds.\n',
1780
+ )
1781
+ return
1782
+ }
1783
+ let blob
1784
+ try {
1785
+ blob = JSON.parse(fs.readFileSync(path.resolve(flags.notes), 'utf8'))
1786
+ } catch (err) {
1787
+ process.stdout.write(`spec-env review: notes blob: not valid JSON (${err.message})\n`)
1788
+ return
1789
+ }
1790
+ let parsed
1791
+ try {
1792
+ parsed = validateNotesBlob(blob, spec.folder)
1793
+ } catch (err) {
1794
+ // Nothing has been written at this point, and nothing will be.
1795
+ process.stdout.write(`spec-env review: ${err.message}\n`)
1796
+ return
1797
+ }
1798
+ // MERGED BEFORE THE VERDICT IS JUDGED, and written either way. The notes
1799
+ // are good work even when the verdict that came with them is one we cannot
1800
+ // honour; dropping them would punish the mistake twice, and the reader
1801
+ // would have to re-read the diff to write them again.
1802
+ notes = mergeNotes(notes, parsed, new Date().toISOString())
1803
+ writeNotes(out, notes)
1804
+ sentVerdict = parsed.verdict
1805
+ merged = {
1806
+ accepted: parsed.accepted.length,
1807
+ unaccepted: parsed.unaccepted.length,
1808
+ comments: parsed.comments.length,
1809
+ }
1810
+ }
1811
+
1812
+ let resolvedNow = null
1813
+ if (flags.resolve) {
1814
+ if (stored.corrupt) {
1815
+ process.stdout.write(
1816
+ `spec-env review: ${reviewNotesPath(out)} is not readable JSON — ` +
1817
+ 'move it aside and re-paste, rather than losing what it holds.\n',
1818
+ )
1819
+ return
1820
+ }
1821
+ // Nothing recorded means every id would be unknown. Say that once, rather
1822
+ // than listing every id back as a mistake, and write no sidecar for it.
1823
+ if (!stored.present && !flags.notes) {
1824
+ process.stdout.write(
1825
+ `spec-env review: no notes recorded for ${spec.folder} — nothing to resolve.\n`,
1826
+ )
1827
+ return
1828
+ }
1829
+ let list
1830
+ try {
1831
+ list = validateResolutions(JSON.parse(fs.readFileSync(path.resolve(flags.resolve), 'utf8')))
1832
+ } catch (err) {
1833
+ process.stdout.write(`spec-env review: ${err.message}\n`)
1834
+ return
1835
+ }
1836
+ const result = applyResolutions(notes, list, new Date().toISOString())
1837
+ notes = result.notes
1838
+ writeNotes(out, notes)
1839
+ resolvedNow = { applied: result.applied, unknown: result.unknown }
1840
+ }
1841
+
1842
+ // AFTER the merge and AFTER any resolutions, so the count the approval is
1843
+ // judged against is the one that is true now. A note raised and answered in
1844
+ // the same invocation is not an open note.
1845
+ //
1846
+ // Said nothing about when no verdict was sent: a blob without one behaves as
1847
+ // it always did, and gains no key in either output.
1848
+ let verdictReport = null
1849
+ if (sentVerdict) {
1850
+ const judged = judgeVerdict(sentVerdict, notes)
1851
+ if (judged.honoured) {
1852
+ // The log is appended only for a verdict that was ACTED ON. A refused
1853
+ // approval did not happen, and recording it as history would leave a
1854
+ // trail of decisions the repo never took.
1855
+ notes = appendDecision(notes, { verdict: judged.effective, at: new Date().toISOString() })
1856
+ writeNotes(out, notes)
1857
+ }
1858
+ verdictReport = {
1859
+ sent: judged.sent,
1860
+ effective: judged.effective,
1861
+ honoured: judged.honoured,
1862
+ reason: judged.reason,
1863
+ // `openCount` rather than the bare word: a dotted property of that name
1864
+ // is what the removed opener used, and `assets-spec-start-one-path`
1865
+ // guards the engine against it coming back under any spelling.
1866
+ openCount: judged.openCount,
1867
+ openFiles: judged.openFiles,
1868
+ // Named here so the skill that routes on the verdict does not have to
1869
+ // read the config itself — one answer, from the engine that owns it.
1870
+ commitWith: config.review.commitWith,
1871
+ }
1872
+ }
1873
+
1874
+ // What the last decision PRODUCED — written after the thing it asked for has
1875
+ // happened, which is why it is a separate invocation rather than part of the
1876
+ // verdict above. Nothing reads it back; it is history for the page to show.
1877
+ let outcomeSaid = null
1878
+ if (flags.outcome) {
1879
+ const result = annotateLastDecision(notes, flags.outcome)
1880
+ if (result.annotated) {
1881
+ notes = result.notes
1882
+ writeNotes(out, notes)
1883
+ outcomeSaid = flags.outcome
1884
+ } else {
1885
+ // Says so rather than inventing a decision to hang it on. An outcome with
1886
+ // no decision behind it is a record of something nobody chose.
1887
+ process.stdout.write('spec-env review: no decision to record an outcome against — ignored\n')
1888
+ }
1889
+ }
1890
+
1891
+ // Information, never a prompt. Nothing counts these to decide anything and
1892
+ // nothing refuses over them — the same rule the marks have lived under since
1893
+ // `feat-review-round-trip`.
1894
+ //
1895
+ // DESCRIBED, not counted. The code is here so a skill can name it without
1896
+ // opening the store: an agent that still has to read `.pending.json` to find
1897
+ // a code will read it, and the rule against claiming unasked becomes a
1898
+ // request rather than a discipline.
1899
+ const waiting = describePending(readPending(out, spec.folder).pending)
1900
+
1901
+ const now = new Date().toISOString()
1902
+ let data = collectReview({ spec, git, mode, ref, base, now, notes })
1903
+
1904
+ // A CLEAN WORKING TREE IS NOT "NOTHING TO REVIEW". It is the state a phase
1905
+ // ends in: the page is rendered before the commit, the commit happens
1906
+ // immediately after, and from then on the working view is empty for the rest
1907
+ // of the spec's life. Falling back to the branch range is what keeps the page
1908
+ // answering after that commit.
1909
+ //
1910
+ // What could fool this: a *fresh* branch is clean too, and its branch range is
1911
+ // empty as well. That costs nothing, because the fallback is kept only when it
1912
+ // actually found something — so a spec with no work at all prints exactly what
1913
+ // it printed before any of this existed.
1914
+ //
1915
+ // An explicit `--branch` is never re-interpreted, and a non-empty working tree
1916
+ // is never swapped out from under the reader. The swap only ever replaces an
1917
+ // empty view, so no information is lost by it.
1918
+ let fellBack = false
1919
+ if (!flags.branch && data.totals.files === 0) {
1920
+ const fallbackBase = reviewBase()
1921
+ const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
1922
+ // Cannot tell -> do nothing, exactly as the `--branch` path refuses. No
1923
+ // merge-base means base and HEAD share no history, and diffing against the
1924
+ // base tip would report every file in the project as changed.
1925
+ if (mergeBase) {
1926
+ const wider = collectReview({
1927
+ spec,
1928
+ git,
1929
+ mode: 'branch',
1930
+ ref: mergeBase,
1931
+ base: fallbackBase,
1932
+ now,
1933
+ notes,
1934
+ fellBack: true,
1935
+ })
1936
+ if (wider.totals.files > 0) {
1937
+ data = wider
1938
+ mode = 'branch'
1939
+ base = fallbackBase
1940
+ ref = mergeBase
1941
+ fellBack = true
1942
+ }
1943
+ }
1944
+ }
1945
+
1946
+ // The written review is the model's half, and it arrives as JSON so no prose
1947
+ // ever has to round-trip through markup. Absent → the page renders without it.
1948
+ if (flags.review) {
1949
+ const raw = fs.readFileSync(path.resolve(flags.review), 'utf8')
1950
+ data.review = JSON.parse(raw)
1951
+ }
1952
+
1953
+ writeReviewPage(out, renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }))
1954
+
1955
+ // The publish-ready copy, ONLY when asked. An ordinary render must not pay for
1956
+ // a second copy of the whole diff on disk for a path most renders never take.
1957
+ let publishCopy = null
1958
+ if (flags.publishCopy) {
1959
+ publishCopy = reviewPublishPath(out)
1960
+ writeReviewPage(
1961
+ publishCopy,
1962
+ renderReviewFragment(data, { reviewHtml: renderReviewBlock(data.review) }),
1963
+ )
1964
+ }
1965
+
1966
+ // Read, never written, and never interpreted: the engine cannot publish, and
1967
+ // names this file only so the skill that can never has to build a path.
1968
+ const urlFile = reviewUrlPath(out)
1969
+ const url = readReviewUrl(out)
1970
+
1971
+ // Resolved before the --json early return, so both outputs agree.
1972
+ const reader = resolveReader(config, process.env)
1973
+
1974
+ // A remote reader cannot open a path on this machine — that is the whole of
1975
+ // what detection established. Serving is how the engine answers it: a local
1976
+ // process, ended by one flag, leaving nothing behind. PUBLISHING is still
1977
+ // never automatic here; it leaves a page this tooling cannot remove, so it
1978
+ // stays an explicit ask no detection can stand in for.
1979
+ let served = null
1980
+ // What to say about the server, if anything. `current` and `unknown` say
1981
+ // nothing at all: the ordinary render must read exactly as it did before any
1982
+ // of this existed.
1983
+ let serverSaid = null
1984
+ if (reader.reader === 'remote' && config.review.serveOnRemote) {
1985
+ const up = await ensureReviewServer(dir, config, { host: '0.0.0.0' })
1986
+ if (up.replaced === 'engine') {
1987
+ serverSaid = up.error
1988
+ ? // BOTH facts. A reader told only "could not start" cannot see why it
1989
+ // was trying, and "it is running an old engine and I could not replace
1990
+ // it" is the pair that explains the page they are about to open.
1991
+ `the server was running engine ${up.engineWas} and could not be replaced (${up.error}) — ` +
1992
+ `its pages are drawn by that engine`
1993
+ : `the server was running engine ${up.engineWas}; restarted on ${up.engineIs}`
1994
+ }
1995
+ if (!up.error) {
1996
+ // The URLs come from the bind the server HAS, not the one asked for just
1997
+ // above — adoption can hand back a loopback server whatever was
1998
+ // requested. See `reviewServedUrls`.
1999
+ const urls = reviewServedUrls(up, lanAddresses(), spec.folder)
2000
+ if (urls) {
2001
+ served = { ...urls, port: up.port, token: up.token, started: up.started }
2002
+ }
2003
+ }
2004
+ }
2005
+
2006
+ if (flags.json) {
2007
+ process.stdout.write(
2008
+ JSON.stringify(
2009
+ {
2010
+ spec: spec.folder,
2011
+ branch: spec.branch,
2012
+ worktree: spec.worktreePath,
2013
+ mode,
2014
+ base,
2015
+ fellBack,
2016
+ out,
2017
+ publishCopy,
2018
+ reader: reader.reader,
2019
+ readerWhy: reader.why,
2020
+ served,
2021
+ ...(serverSaid ? { server: serverSaid } : {}),
2022
+ fileUrl: reviewFileUrl(out),
2023
+ urlFile,
2024
+ url,
2025
+ notesFile: reviewNotesPath(out),
2026
+ reviewed: Boolean(data.review),
2027
+ totals: data.totals,
2028
+ notes: data.notes,
2029
+ merged,
2030
+ resolved: resolvedNow,
2031
+ ...(verdictReport ? { verdict: verdictReport } : {}),
2032
+ ...(outcomeSaid ? { outcome: outcomeSaid } : {}),
2033
+ ...(claimed ? { claimed } : {}),
2034
+ ...(dropped ? { dropped } : {}),
2035
+ ...(waiting.length ? { pending: waiting } : {}),
2036
+ files: data.files.map((f) => ({
2037
+ path: f.path,
2038
+ status: f.status,
2039
+ additions: f.additions,
2040
+ deletions: f.deletions,
2041
+ whole: f.whole,
2042
+ noise: f.noise,
2043
+ hash: f.hash,
2044
+ accepted: f.accepted,
2045
+ acceptedAt: f.acceptedAt,
2046
+ comments: f.comments,
2047
+ })),
2048
+ },
2049
+ null,
2050
+ 2,
2051
+ ) + '\n',
2052
+ )
2053
+ return
2054
+ }
2055
+
2056
+ const t = data.totals
2057
+ const n = data.notes.totals
2058
+ const hasNotes = n.accepted + n.lapsed + n.unresolved + n.resolved > 0
2059
+ process.stdout.write(
2060
+ `spec-env review: ${spec.folder} (${
2061
+ fellBack ? `working tree clean — since ${base}` : mode === 'branch' ? `since ${base}` : 'uncommitted'
2062
+ })\n` +
2063
+ ` ${t.files} file${t.files === 1 ? '' : 's'}, +${t.additions} -${t.deletions}\n` +
2064
+ (merged
2065
+ ? ` merged: ${merged.accepted} accept${merged.accepted === 1 ? '' : 's'}, ` +
2066
+ `${merged.unaccepted} withdrawn, ${merged.comments} comment${merged.comments === 1 ? '' : 's'}\n`
2067
+ : '') +
2068
+ (resolvedNow
2069
+ ? ` resolved: ${resolvedNow.applied} comment${resolvedNow.applied === 1 ? '' : 's'}` +
2070
+ (resolvedNow.unknown.length
2071
+ ? ` (skipped ${resolvedNow.unknown.length} unknown id: ${resolvedNow.unknown.join(', ')})`
2072
+ : '') +
2073
+ '\n'
2074
+ : '') +
2075
+ // The verdict is said ONCE, on the line that already carries the review
2076
+ // state. An approve of a clean read has no counts to print, so the line
2077
+ // appears for a verdict too — but never for a blob that carried none.
2078
+ (hasNotes || verdictReport
2079
+ ? ` notes: ${n.accepted} accepted · ${n.lapsed} lapsed · ` +
2080
+ `${n.unresolved} open · ${n.resolved} resolved` +
2081
+ (verdictReport ? ` · ${verdictSaid(verdictReport)}` : '') +
2082
+ '\n'
2083
+ : '') +
2084
+ (claimed
2085
+ ? ` claimed: ${claimed.code} — ${claimed.accepted} accept${claimed.accepted === 1 ? '' : 's'}, ` +
2086
+ `${claimed.unaccepted} withdrawn, ${claimed.comments} comment${claimed.comments === 1 ? '' : 's'}\n`
2087
+ : '') +
2088
+ (dropped ? ` dropped: ${dropped.code} — merged nothing\n` : '') +
2089
+ (waiting.length
2090
+ ? ` pending: ${waiting.length} waiting\n` +
2091
+ waiting
2092
+ .map((p) => ` ${p.code} · ${p.verdict || 'no verdict'} · ${pendingAge(p.at, now)}\n`)
2093
+ .join('')
2094
+ : '') +
2095
+ (outcomeSaid ? ` outcome: ${outcomeSaid}\n` : '') +
2096
+ // Said only when it is true, so a review with no sidecar reads exactly as
2097
+ // it did before any of this existed.
2098
+ (stored.corrupt && !flags.notes
2099
+ ? ` notes: ${reviewNotesPath(out)} is not readable JSON — ignored, not overwritten\n`
2100
+ : '') +
2101
+ // Said only when there is something to say. `unknown` is the ordinary
2102
+ // state on a local machine, and announcing it would be noise about a
2103
+ // healthy session.
2104
+ (reader.reader === 'unknown'
2105
+ ? ''
2106
+ : ` reader: ${reader.reader}${reader.why ? ` (${reader.why})` : ''}\n`) +
2107
+ ` page: ${out}\n` +
2108
+ // Served: the `open:` line is a URL the reader can actually use, and
2109
+ // `page:` above still says where the file is. Not served — including every
2110
+ // way serving can fail — falls back to exactly the output this printed
2111
+ // before, dead link and all: that is the floor, never made worse.
2112
+ (served
2113
+ ? ` open: ${served.url}\n` +
2114
+ // Said only when there is a runner-up. One address is not a choice,
2115
+ // and an `also:` line naming nothing reads as a warning.
2116
+ (served.alternates.length
2117
+ ? served.alternates.map((u) => ` also: ${u}\n`).join('')
2118
+ : '') +
2119
+ // A loopback server is reachable from this machine and nowhere else.
2120
+ // Said here rather than left to be discovered by a phone that cannot
2121
+ // open the URL — and it names the command instead of describing it.
2122
+ (served.loopback
2123
+ ? ' local only: this server is bound to 127.0.0.1 — not reachable from your phone.\n' +
2124
+ ` widen: ${served.widen}\n`
2125
+ : '') +
2126
+ (served.started && !served.loopback
2127
+ ? ' serving: every provisioned spec, to anyone with this URL on your network.\n' +
2128
+ ' stop: skitterspec spec-env review serve --stop\n'
2129
+ : '') +
2130
+ // One line, and only when something was actually done on the reader's
2131
+ // behalf. An action nobody asked for is reported, not hidden — the
2132
+ // same rule teardown follows.
2133
+ (serverSaid ? ` ${serverSaid}\n` : '')
2134
+ : ` open: ${reviewFileUrl(out)}${
2135
+ reader.reader === 'remote' ? ' (will not open where you are reading)' : ''
2136
+ }\n` +
2137
+ (serverSaid ? ` ${serverSaid}\n` : '') +
2138
+ (reader.reader === 'remote'
2139
+ ? ' serve: skitterspec spec-env review serve --host 0.0.0.0\n'
2140
+ : '')) +
2141
+ // Named on its own line so the skill never has to build the path itself.
2142
+ (publishCopy ? ` publish: ${publishCopy}\n` : '') +
2143
+ (url ? ` published: ${url}\n` : '') +
2144
+ (t.files === 0 ? ' nothing to review — no changes found.\n' : ''),
2145
+ )
2146
+ }
2147
+
1093
2148
  // Start/stop a spec's host dev servers on its reserved port block. Host dev
1094
2149
  // servers (e.g. `pnpm dev`) need a block even on a worktree-only spec, so `up`
1095
2150
  // allocates a slot if the spec has none (idempotent). The planner is pure
@@ -1170,6 +2225,441 @@ function proxyProcFor(config, routesFileAbs) {
1170
2225
  }
1171
2226
  }
1172
2227
 
2228
+ // Where a checkout keeps its copy of the daemon, relative to its root: this
2229
+ // monorepo developing itself, and a project that installed the package. Naming
2230
+ // the distribution here is a path, not provider machinery — `init.js` and
2231
+ // `PROVIDER_COMMANDS` above already know package names by name.
2232
+ const DAEMON_LOCATIONS = [
2233
+ path.join('node_modules', '@skitterbyte', 'skitterspec', 'src', 'env', 'serve.js'),
2234
+ path.join('packages', 'common', 'src', 'env', 'serve.js'),
2235
+ ]
2236
+
2237
+ /**
2238
+ * The `serve.js` the daemon should actually execute.
2239
+ *
2240
+ * NOT `__dirname` — that is the module directory of whichever copy of the CLI
2241
+ * is running, and running one from a worktree pinned the daemon to that
2242
+ * worktree's tree (its `review.js` then resolves the page template into the
2243
+ * worktree's `assets/`). Teardown removed the worktree, the daemon carried on
2244
+ * answering on its port, and every render failed with ENOENT for every spec.
2245
+ *
2246
+ * `root` is the primary checkout, which every spec-env command has already
2247
+ * resolved. A copy found there outlives every worktree, which is the whole
2248
+ * point. Three states, and the third is the common one — a global install or
2249
+ * `npx` has no copy under the checkout at all — so it falls back to the running
2250
+ * module rather than refusing to serve. Being wrong there costs exactly what
2251
+ * happens today; refusing would cost a feature.
2252
+ */
2253
+ function daemonScript(root) {
2254
+ if (root) {
2255
+ for (const rel of DAEMON_LOCATIONS) {
2256
+ const candidate = path.join(root, rel)
2257
+ if (fs.existsSync(candidate)) return candidate
2258
+ }
2259
+ }
2260
+ return path.join(__dirname, 'env', 'serve.js')
2261
+ }
2262
+
2263
+ /**
2264
+ * Is a running server's recorded script still on disk?
2265
+ *
2266
+ * A POSITIVE SIGNAL, and the one adoption was missing: a live pid and a
2267
+ * readable settings file were treated as proof the server works, and neither
2268
+ * can see that the code the process is executing has been deleted.
2269
+ *
2270
+ * WHAT WOULD FOOL THIS: a settings file written before `script` was recorded
2271
+ * has no key to check. That absence is not evidence — it describes every
2272
+ * healthy server started by an older build — so it adopts as before. The
2273
+ * destructive reading would kill a working server over a key it never had
2274
+ * (`.claude/rules/negative-checks.md` rule 4).
2275
+ */
2276
+ function serverScriptOk(settings) {
2277
+ const script = settings && settings.script
2278
+ if (!script) return true
2279
+ return fs.existsSync(script)
2280
+ }
2281
+
2282
+ // The supervised review-server process descriptor. Same shape as the proxy's:
2283
+ // a tiny detached node process reading its settings from a file, so a restart is
2284
+ // a rewrite of that file rather than an argv change.
2285
+ function serveProcFor(config, settingsFileAbs, { root = null } = {}) {
2286
+ const sdir = stateDirLabel(config)
2287
+ return {
2288
+ name: 'review-serve',
2289
+ command: `node ${daemonScript(root)} ${settingsFileAbs}`,
2290
+ script: daemonScript(root),
2291
+ env: {},
2292
+ logFile: `${sdir}/logs/review-serve.log`,
2293
+ pidFile: `${sdir}/pids/review-serve.pid`,
2294
+ }
2295
+ }
2296
+
2297
+ // Interface names that mean "a network the reader's phone is not on". On the
2298
+ // machine this was written for, `bridge100`/`bridge101` are Parallels and `en0`
2299
+ // is the wifi the phone shares — so the real address is neither first nor last
2300
+ // in `networkInterfaces()` order, and order alone is a coin toss.
2301
+ //
2302
+ // WHAT WOULD FOOL THIS: it reads interface NAMES, so a VPN on a renamed adapter,
2303
+ // an unusual driver, or a platform that names things differently all rank wrong.
2304
+ // That is exactly why the runners-up are printed rather than discarded — a bad
2305
+ // guess costs a glance, not a dead end.
2306
+ const VIRTUAL_IFACE = /^(bridge|vmnet|vnic|vboxnet|docker|utun|tap|tun|veth|ppp|awdl|llw)/i
2307
+ const PHYSICAL_IFACE = /^(en|eth|wl)\d/i
2308
+
2309
+ // Within a tier, the range a phone most plausibly shares. Only ever a
2310
+ // tie-break: a corporate LAN is legitimately 10/8, so this must never outrank
2311
+ // the interface name.
2312
+ function rangeRank(address) {
2313
+ if (/^192\.168\./.test(address)) return 0
2314
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(address)) return 1
2315
+ if (/^10\./.test(address)) return 2
2316
+ return 3
2317
+ }
2318
+
2319
+ /**
2320
+ * Order this machine's non-loopback IPv4 addresses, best candidate first.
2321
+ *
2322
+ * PURE — takes the interface map as an argument rather than reading
2323
+ * `os.networkInterfaces()`, so a test states the machine it describes instead of
2324
+ * depending on the one it runs on. Same discipline as `detectReader` and its
2325
+ * environment, and for the same reason.
2326
+ */
2327
+ function rankLanAddresses(nets) {
2328
+ const out = []
2329
+ for (const name of Object.keys(nets || {})) {
2330
+ for (const net of nets[name] || []) {
2331
+ if (net.family !== 'IPv4' || net.internal) continue
2332
+ const tier = PHYSICAL_IFACE.test(name) ? 0 : VIRTUAL_IFACE.test(name) ? 2 : 1
2333
+ out.push({ address: net.address, iface: name, tier })
2334
+ }
2335
+ }
2336
+ // Stable sort, so an unrankable set keeps discovery order rather than
2337
+ // shuffling between runs.
2338
+ return out
2339
+ .map((e, i) => ({ ...e, i }))
2340
+ .sort((a, b) => a.tier - b.tier || rangeRank(a.address) - rangeRank(b.address) || a.i - b.i)
2341
+ .map(({ address, iface }) => ({ address, iface }))
2342
+ }
2343
+
2344
+ // Every non-loopback IPv4 address of this machine, best candidate first, for
2345
+ // printing a URL a phone on the same network can actually open.
2346
+ function lanAddresses(nets = require('node:os').networkInterfaces()) {
2347
+ return rankLanAddresses(nets).map((e) => e.address)
2348
+ }
2349
+
2350
+ /**
2351
+ * Bring the review server up, or adopt the one already running.
2352
+ *
2353
+ * Shared by `serve` (which prints its own report) and by `review` on a remote
2354
+ * reader (which needs a URL, not a report). Only the STARTING is shared — how
2355
+ * each one talks about the result is its own business — so the two can never
2356
+ * drift on how a server comes up.
2357
+ *
2358
+ * Reuse is deliberate and load-bearing: restarting mints a fresh token, which
2359
+ * would silently kill a URL the operator already has open on their phone. Only
2360
+ * an explicit `serve` invocation (`restart`) is allowed to do that.
2361
+ *
2362
+ * Returns `{ port, token, loopback, pid, started }`, or `{ error }` — never
2363
+ * throws, because every caller's fallback is to carry on without a server.
2364
+ */
2365
+ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, restart = false } = {}) {
2366
+ const sdir = stateDirLabel(config)
2367
+ const abs = (rel) => path.resolve(dir, rel)
2368
+ const settingsFile = `${sdir}/review-serve.json`
2369
+ const proc = serveProcFor(config, abs(settingsFile), { root: dir })
2370
+
2371
+ // A POSITIVE SIGNAL, not an absence: a pidfile on disk proves nothing (a
2372
+ // crashed process leaves one behind), so `isAlive` is what decides.
2373
+ const pid = readPid(abs(proc.pidFile))
2374
+ const running = pid && isAlive(pid) ? pid : null
2375
+
2376
+ let replaced = false
2377
+ // What the replaced server was running, and the URL it was answering on.
2378
+ // Both survive into the result so the caller can say what happened, and the
2379
+ // token survives into the NEW server so the operator's link keeps working.
2380
+ let engineWas = null
2381
+ let reuseToken = null
2382
+ if (running && !restart) {
2383
+ let settings = null
2384
+ try {
2385
+ settings = JSON.parse(fs.readFileSync(abs(settingsFile), 'utf-8'))
2386
+ } catch {}
2387
+ if (settings && settings.port) {
2388
+ if (serverScriptOk(settings)) {
2389
+ const now = engineVersionFor(proc.script)
2390
+ const verdict = staleServer(settings.engine, now)
2391
+ if (verdict !== 'stale') {
2392
+ const lb = settings.host === '127.0.0.1' || settings.host === 'localhost'
2393
+ return {
2394
+ port: settings.port,
2395
+ token: settings.token || null,
2396
+ loopback: lb,
2397
+ pid: running,
2398
+ started: false,
2399
+ // `current` needs no comment and `unknown` claims nothing — a server
2400
+ // from before the version was recorded is healthy, not suspect.
2401
+ engine: verdict,
2402
+ engineWas: settings.engine || null,
2403
+ engineIs: now,
2404
+ }
2405
+ }
2406
+ // STALE: alive, addressable, its script still on disk — and drawing
2407
+ // every page with an engine that has been replaced underneath it. This
2408
+ // is invisible to every other check here, because each render IS
2409
+ // current: the counts move, the timestamp moves, the diff is right.
2410
+ // Only the renderer is old. Replace it rather than serve yesterday's
2411
+ // output under today's timestamp.
2412
+ replaced = 'engine'
2413
+ engineWas = settings.engine || null
2414
+ // KEEP THE URL. The operator is usually holding the old link on a phone,
2415
+ // and a token minted here would kill it silently — the very thing the
2416
+ // adoption path exists to avoid. Reused only when the bind is unchanged;
2417
+ // a loopback restart has no token to carry and needs none.
2418
+ reuseToken = settings.token || null
2419
+ } else {
2420
+ // Alive, addressable, and executing code that has been deleted — the
2421
+ // worktree it was started from is gone. It answers on the port and fails
2422
+ // on every page, so adopting it is worse than replacing it.
2423
+ replaced = 'script'
2424
+ }
2425
+ } else {
2426
+ // Running, but its settings are unreadable — we cannot address it, and
2427
+ // killing a server we cannot describe is worse than declining to use it.
2428
+ return { error: 'unreadable', pid: running }
2429
+ }
2430
+ }
2431
+
2432
+ const usePort = Number(port || config.review.servePort)
2433
+ const loopback = host === '127.0.0.1' || host === 'localhost'
2434
+ // The token is the ONLY guard on a non-loopback bind, so it is minted with the
2435
+ // bind rather than offered as an option to forget — except when replacing a
2436
+ // server on the same bind, where carrying the old one keeps a link that is
2437
+ // already open on someone's phone alive.
2438
+ const token = loopback ? null : reuseToken || mintToken()
2439
+
2440
+ if (running) await stopProcess(proc, { rootDir: dir })
2441
+
2442
+ // ASK ABOUT BOTH ADDRESSES, because a loopback bind and a wildcard bind
2443
+ // COEXIST under BSD semantics and neither probe sees the other:
2444
+ //
2445
+ // - probing 127.0.0.1 for a 0.0.0.0 bind succeeds while a wildcard squatter
2446
+ // holds the port — the substitution that let a leaked daemon sit on 7777
2447
+ // while every render claimed to have started a server
2448
+ // - probing 0.0.0.0 alone succeeds while something holds 127.0.0.1, which
2449
+ // would leave a server answering on the LAN and not on `localhost` — the
2450
+ // `local:` URL this CLI prints would be dead
2451
+ //
2452
+ // A port either half-taken is not usable, so both are asked and either
2453
+ // refuses. Deduped, so a loopback bind asks once.
2454
+ //
2455
+ // ASKED ONE AT A TIME, and that is load-bearing rather than tidy. The probe
2456
+ // binds, so two probes of one port contend with each other — and the BSD
2457
+ // coexistence this comment relies on is exactly what Linux does NOT do, where
2458
+ // a wildcard and a loopback bind of one port are mutually exclusive. Asked
2459
+ // concurrently the pair therefore reported a free port as busy, and the review
2460
+ // server refused to start on every Linux machine while macOS stayed green.
2461
+ // `portsInUseOn` owns the ordering; see its comment for the verification.
2462
+ const busy = await portsInUseOn(usePort, [host, '127.0.0.1'])
2463
+ if (busy.length) return { error: 'busy', port: usePort, replaced, engineWas }
2464
+
2465
+ fs.mkdirSync(path.dirname(abs(settingsFile)), { recursive: true })
2466
+ fs.writeFileSync(
2467
+ abs(settingsFile),
2468
+ // `script` is recorded so adoption has something to check. Without it the
2469
+ // only evidence a server is healthy is that its process exists. `engine` is
2470
+ // the second half of the same idea: the script can still be on disk and be a
2471
+ // DIFFERENT VERSION of itself, which is invisible to every other check here
2472
+ // — the process is alive, the file exists, and every page it renders is
2473
+ // drawn by code that was replaced underneath it.
2474
+ JSON.stringify(
2475
+ { dir, port: usePort, host, token, script: proc.script, engine: engineVersionFor(proc.script) },
2476
+ null,
2477
+ 2,
2478
+ ) + '\n',
2479
+ )
2480
+ const res = startProcess(proc, { cwd: dir, rootDir: dir })
2481
+ const up = await waitListening([usePort], { host: loopback ? host : '127.0.0.1' })
2482
+ // A PORT ANSWERING IS NOT PROOF THAT OUR PROCESS IS ANSWERING IT. `waitListening`
2483
+ // connects, and anything already bound satisfies a connect — so on its own it
2484
+ // reports success for a daemon that died on EADDRINUSE seconds earlier. The
2485
+ // process we spawned still being alive is the specific evidence; the port
2486
+ // answering is merely consistent with it.
2487
+ if (!up || !isAlive(res.pid)) {
2488
+ // The stale context rides out on the failure too. A caller that only learns
2489
+ // "could not start" cannot say WHY it was trying, and "your server is running
2490
+ // an old engine and I could not replace it" is two facts the reader needs.
2491
+ return { error: up ? 'died' : 'silent', port: usePort, pid: res.pid, replaced, engineWas }
2492
+ }
2493
+
2494
+ return {
2495
+ port: usePort,
2496
+ token,
2497
+ loopback,
2498
+ pid: res.pid,
2499
+ started: true,
2500
+ replaced,
2501
+ engineWas,
2502
+ engineIs: engineVersionFor(proc.script),
2503
+ }
2504
+ }
2505
+
2506
+ /**
2507
+ * `spec-env review serve` — stand up the local diff server.
2508
+ *
2509
+ * Three actions on one verb: start (the default), `--stop`, `--status`. The
2510
+ * pidfile is the single source of truth for all three, so `--status` cannot
2511
+ * claim a server that died and `--stop` cannot kill something it did not start.
2512
+ */
2513
+ async function specEnvReviewServe(dir, config, flags) {
2514
+ const sdir = stateDirLabel(config)
2515
+ const abs = (rel) => path.resolve(dir, rel)
2516
+ const settingsFile = `${sdir}/review-serve.json`
2517
+ const proc = serveProcFor(config, abs(settingsFile), { root: dir })
2518
+
2519
+ // A POSITIVE SIGNAL, not an absence: a pidfile on disk proves nothing (a
2520
+ // crashed process leaves one behind), so `isAlive` is what decides. Three
2521
+ // states — running, not running, and a stale file, which reads as not running
2522
+ // and is overwritten rather than reported as an error.
2523
+ const pid = readPid(abs(proc.pidFile))
2524
+ const running = pid && isAlive(pid) ? pid : null
2525
+
2526
+ if (flags.status) {
2527
+ if (!running) {
2528
+ process.stdout.write('spec-env review serve: not running.\n')
2529
+ return
2530
+ }
2531
+ let settings = {}
2532
+ try {
2533
+ settings = JSON.parse(fs.readFileSync(abs(settingsFile), 'utf-8'))
2534
+ } catch {}
2535
+ // Three states, and only one of them is worth a line. `current` is the
2536
+ // ordinary answer and needs no comment; `unknown` is a server from before
2537
+ // this was recorded, which is healthy and must not be accused of anything.
2538
+ const verdict = staleServer(settings.engine, engineVersionFor(proc.script))
2539
+ process.stdout.write(
2540
+ `spec-env review serve: running (pid ${running})\n` +
2541
+ (settings.port ? ` local: ${serveUrl('127.0.0.1', settings)}\n` : '') +
2542
+ (settings.engine ? ` engine: ${settings.engine}\n` : '') +
2543
+ (verdict === 'stale'
2544
+ ? ` the engine moved under it — this one is ${engineVersionFor(proc.script)}\n`
2545
+ : ''),
2546
+ )
2547
+ return
2548
+ }
2549
+
2550
+ if (flags.stop) {
2551
+ if (!running) {
2552
+ process.stdout.write('spec-env review serve: not running — nothing to stop.\n')
2553
+ return
2554
+ }
2555
+ await stopProcess(proc, { rootDir: dir })
2556
+ process.stdout.write(`spec-env review serve: stopped (pid ${running}).\n`)
2557
+ return
2558
+ }
2559
+
2560
+ // Read what the running server is bound to BEFORE replacing it, so a restart
2561
+ // without `--host` keeps that bind instead of silently narrowing to loopback.
2562
+ let currentSettings = null
2563
+ try {
2564
+ currentSettings = running ? JSON.parse(fs.readFileSync(abs(settingsFile), 'utf-8')) : null
2565
+ } catch {}
2566
+
2567
+ const started = await ensureReviewServer(dir, config, {
2568
+ host: restartHost(flags.host, currentSettings),
2569
+ port: flags.port,
2570
+ restart: true,
2571
+ })
2572
+
2573
+ if (started.error === 'busy') {
2574
+ process.stdout.write(
2575
+ `spec-env review serve: port ${started.port} is already in use — ` +
2576
+ 'pass --port, or --stop if this is an older server.\n',
2577
+ )
2578
+ return
2579
+ }
2580
+ if (started.error === 'silent') {
2581
+ process.stdout.write(
2582
+ `spec-env review serve: started (pid ${started.pid}) but port ${started.port} never came up — ` +
2583
+ `see ${proc.logFile}\n`,
2584
+ )
2585
+ return
2586
+ }
2587
+
2588
+ const { port, token, loopback } = started
2589
+ const res = { pid: started.pid }
2590
+
2591
+ const specs = servableSpecs(dir, config, gitReader(dir))
2592
+ process.stdout.write(
2593
+ `spec-env review serve: serving ${specs.length} spec${specs.length === 1 ? '' : 's'} ` +
2594
+ `(pid ${res.pid})\n` +
2595
+ ` local: ${serveUrl('127.0.0.1', { port, token })}\n` +
2596
+ (loopback
2597
+ ? ''
2598
+ : lanAddresses()
2599
+ .map((a) => ` lan: ${serveUrl(a, { port, token })}\n`)
2600
+ .join('') +
2601
+ ' anyone with the lan URL can read every spec\'s diff while this runs.\n') +
2602
+ ' stop: skitterspec spec-env review serve --stop\n',
2603
+ )
2604
+ }
2605
+
2606
+ /**
2607
+ * The URLs to print for a served page — derived from the bind the server
2608
+ * ACTUALLY has, never from the one the caller asked for.
2609
+ *
2610
+ * `specEnvReview` requests `0.0.0.0` on a remote reader, but
2611
+ * `ensureReviewServer` adopts a server that is already running rather than
2612
+ * restarting it — deliberately, since a restart mints a fresh token and kills
2613
+ * the URL already open on someone's phone. So the server in hand may be
2614
+ * loopback-bound whatever was asked for, and printing `lanAddresses()` anyway
2615
+ * produced a URL that could not be opened, with nothing saying why.
2616
+ *
2617
+ * Loopback does not go widened silently. Restarting to satisfy the printout
2618
+ * would break the open URL to fix a description, so it prints the address that
2619
+ * works and names the command that widens it.
2620
+ */
2621
+ function reviewServedUrls(up, addrs, folder) {
2622
+ const page = (host) => `${serveUrl(host, up)}${encodeURIComponent(folder)}`
2623
+ if (up.loopback) {
2624
+ return {
2625
+ url: page('127.0.0.1'),
2626
+ // No runners-up: every other address on this machine is one the server
2627
+ // is not listening on.
2628
+ alternates: [],
2629
+ loopback: true,
2630
+ widen: 'skitterspec spec-env review serve --host 0.0.0.0',
2631
+ }
2632
+ }
2633
+ // Best candidate first, with the rest kept: the ranking reads interface names
2634
+ // and can be wrong, so the alternates are offered rather than thrown away. No
2635
+ // address at all means nothing to offer, and the `file://` fallback is the
2636
+ // honest answer.
2637
+ if (!addrs.length) return null
2638
+ return { url: page(addrs[0]), alternates: addrs.slice(1).map(page), loopback: false, widen: null }
2639
+ }
2640
+
2641
+ /**
2642
+ * The host a `--restart` should bind to.
2643
+ *
2644
+ * An explicit `--host` wins. Otherwise KEEP WHAT THE RUNNING SERVER HAD: a
2645
+ * restart defaulting back to `127.0.0.1` narrowed the bind silently, which is
2646
+ * how a `--host 0.0.0.0` server became unreachable without anyone touching a
2647
+ * flag.
2648
+ *
2649
+ * WHAT WOULD FOOL THIS: nothing running, or a settings file with no `host`.
2650
+ * Both read as loopback — the narrow branch — because widening a bind by
2651
+ * inference is the one direction that must never happen by accident
2652
+ * (`.claude/rules/negative-checks.md` rule 4).
2653
+ */
2654
+ function restartHost(flagHost, settings) {
2655
+ if (flagHost) return flagHost
2656
+ return (settings && settings.host) || '127.0.0.1'
2657
+ }
2658
+
2659
+ function serveUrl(hostname, { port, token }) {
2660
+ return `http://${hostname}:${port}/${token ? token + '/' : ''}`
2661
+ }
2662
+
1173
2663
  // Connect the canonical origin to ONE spec (exclusive model): (re)start the
1174
2664
  // bundled proxy pointing at that spec's warm dev servers. `connect main` stops
1175
2665
  // the proxy so the primary checkout owns the canonical ports again.
@@ -1189,9 +2679,20 @@ async function specEnvConnect(dir, config, specArg) {
1189
2679
  const routesFile = `${sdir}/proxy.json`
1190
2680
  const connectedFile = `${sdir}/connected`
1191
2681
  const proxyProc = proxyProcFor(config, abs(routesFile))
1192
- const target = specArg || 'main'
1193
2682
 
1194
- if (target === 'main') {
2683
+ // DISCONNECT IS NAMED, NOT ASSUMED. A missing spec used to mean `main` — so
2684
+ // the bare form handed the ports BACK, the one verb in the family whose
2685
+ // zero-arg behaviour was the opposite of acting on your spec. It now resolves
2686
+ // like every other verb: the worktree you are standing in, else the sole
2687
+ // provisioned spec, else a refusal that names the candidates.
2688
+ //
2689
+ // This reverses `feat-script-only-commands` Decision 8, deliberately and as
2690
+ // the whole point of the change rather than as a side effect of one — see
2691
+ // `feat-bare-argument-parity`. The literal `main` is honoured even where the
2692
+ // base branch is called something else, matching `live main`, so the muscle
2693
+ // memory works in either repo.
2694
+ const base = resolveBaseBranch(config, gitReader(dir))
2695
+ if (specArg === 'main' || specArg === base) {
1195
2696
  const res = await stopProcess(proxyProc, { rootDir: dir })
1196
2697
  for (const f of [connectedFile, routesFile]) {
1197
2698
  try {
@@ -1208,7 +2709,11 @@ async function specEnvConnect(dir, config, specArg) {
1208
2709
  return
1209
2710
  }
1210
2711
 
1211
- const spec = resolveSpecWithWorktree(dir, config, target)
2712
+ // Ambiguity REFUSES here rather than degrading. `live` can fall back to its
2713
+ // status report; `connect` has no read-only answer to fall back to, and a
2714
+ // fallback to `main` would reinstate the very inversion above — disconnecting
2715
+ // you at the moment you are least sure what is connected.
2716
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1212
2717
  const registry = readRegistry(dir, config)
1213
2718
  if (!Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)) {
1214
2719
  process.stdout.write(
@@ -1296,9 +2801,13 @@ async function specEnvLive(dir, config, positional) {
1296
2801
  )
1297
2802
  return
1298
2803
  }
1299
- const { action, specArg } = liveGrammar(dir, config, positional)
2804
+ const { action, specArg, note } = liveGrammar(dir, config, positional)
1300
2805
  switch (action) {
1301
2806
  case 'status':
2807
+ // Only the bare form sets a note, and only for the one ambiguity the
2808
+ // report cannot describe. It prints ABOVE the report, not instead of it:
2809
+ // you asked a question and should still get the answer.
2810
+ if (note) process.stdout.write(note)
1302
2811
  specEnvLiveStatus(dir, config, specArg)
1303
2812
  break
1304
2813
  case 'take':
@@ -1335,7 +2844,7 @@ const LIVE_VERBS = new Set(['status', 'take', 'release', 'abort'])
1335
2844
  // matching `connect main`, so the muscle memory works in either repo.
1336
2845
  function liveGrammar(dir, config, positional) {
1337
2846
  const [first, second] = positional
1338
- if (!first) return { action: 'status', specArg: undefined }
2847
+ if (!first) return bareLive(dir, config)
1339
2848
  if (LIVE_VERBS.has(first)) return { action: first, specArg: second }
1340
2849
  if (first === 'main' || first === resolveBaseBranch(config, gitReader(dir))) {
1341
2850
  return { action: 'release', specArg: undefined }
@@ -1343,6 +2852,52 @@ function liveGrammar(dir, config, positional) {
1343
2852
  return { action: 'take', specArg: first }
1344
2853
  }
1345
2854
 
2855
+ /**
2856
+ * `/spec-live` with nothing after it: take the spec you are on, when there is
2857
+ * exactly one answer and the workbench is free — otherwise print the status
2858
+ * report.
2859
+ *
2860
+ * TWO POSITIVE SIGNALS, both required, and neither is an absence: a spec must
2861
+ * RESOLVE (not "no error"), and the primary checkout must be demonstrably on
2862
+ * base with no receipt (not "no evidence it is busy"). Every other state —
2863
+ * several worktrees, none, a spec already live, a hand-switched branch — is
2864
+ * *cannot tell*, and cannot-tell prints the report. That is the whole safety
2865
+ * argument for letting a bare command switch a branch at all: the one case it
2866
+ * acts on is the case with a single possible meaning.
2867
+ *
2868
+ * It decides WHICH VERB, never whether the verb is allowed. `specEnvLiveTake`
2869
+ * keeps every refusal it already had — dirty tree, hotfix, stateful spec,
2870
+ * migrations, a held instance — through `planTake`. Re-checking any of them here
2871
+ * would be a second copy free to drift from the first.
2872
+ */
2873
+ function bareLive(dir, config) {
2874
+ const choice = provisionedSpecChoice(dir, config)
2875
+ if (!choice.folder) {
2876
+ // Several worktrees is the only cannot-tell the status report does not
2877
+ // explain — it reports on the repo, not on what you might have meant. With
2878
+ // none provisioned the report's own `in-flight:` line already says it.
2879
+ const note = choice.candidates.length
2880
+ ? `spec-env live: ${choice.candidates.length} specs have worktrees — name the one you mean:\n` +
2881
+ choice.candidates.map((f, i) => ` ${i + 1}. ${f}`).join('\n') +
2882
+ '\n'
2883
+ : undefined
2884
+ return { action: 'status', specArg: undefined, note }
2885
+ }
2886
+
2887
+ // The workbench must be FREE, not merely un-refused. When it is not, the
2888
+ // report names the branch, the in-flight spec and the receipt — so it already
2889
+ // says why nothing was taken, and a note here would only repeat it.
2890
+ const primary = assertPrimaryOnMain(config, gitReader(dir))
2891
+ const receipt = readReceipt(dir, config)
2892
+ if (!primary.onBase || (receipt && receipt.spec)) {
2893
+ return { action: 'status', specArg: undefined }
2894
+ }
2895
+
2896
+ // Resolved here rather than passed as `undefined`, so the verb acts on the
2897
+ // spec this function actually decided about.
2898
+ return { action: 'take', specArg: choice.folder }
2899
+ }
2900
+
1346
2901
  // Take the running instance: rebase the spec's branch onto base, free it from its
1347
2902
  // worktree, and check it out in the primary checkout so the dev server reloads it.
1348
2903
  async function specEnvLiveTake(dir, config, specArg) {
@@ -1608,16 +3163,50 @@ async function specEnv(rest) {
1608
3163
  const [sub, ...args] = rest
1609
3164
  let dir = process.cwd()
1610
3165
  const positional = []
1611
- const flags = { keepVolumes: false, force: false, also: [], olderThanDays: null }
3166
+ const flags = {
3167
+ keepVolumes: false,
3168
+ force: false,
3169
+ also: [],
3170
+ olderThanDays: null,
3171
+ branch: false,
3172
+ out: null,
3173
+ review: null,
3174
+ notes: null,
3175
+ resolve: null,
3176
+ outcome: null,
3177
+ claim: null,
3178
+ drop: null,
3179
+ json: false,
3180
+ }
1612
3181
  for (let i = 0; i < args.length; i++) {
1613
3182
  if (args[i] === '--dir') dir = path.resolve(args[++i])
1614
3183
  else if (args[i] === '--keep-volumes') flags.keepVolumes = true
1615
3184
  else if (args[i] === '--force') flags.force = true
1616
3185
  else if (args[i] === '--also') flags.also.push(args[++i])
1617
3186
  else if (args[i] === '--older-than') flags.olderThanDays = Number(args[++i])
3187
+ else if (args[i] === '--branch') flags.branch = true
3188
+ else if (args[i] === '--stop') flags.stop = true
3189
+ else if (args[i] === '--status') flags.status = true
3190
+ else if (args[i] === '--port') flags.port = args[++i]
3191
+ else if (args[i] === '--host') flags.host = args[++i]
3192
+ else if (args[i] === '--publish-copy') flags.publishCopy = true
3193
+ else if (args[i] === '--out') flags.out = args[++i]
3194
+ else if (args[i] === '--review') flags.review = args[++i]
3195
+ else if (args[i] === '--notes') flags.notes = args[++i]
3196
+ else if (args[i] === '--resolve') flags.resolve = args[++i]
3197
+ else if (args[i] === '--outcome') flags.outcome = args[++i]
3198
+ else if (args[i] === '--claim') flags.claim = args[++i]
3199
+ else if (args[i] === '--drop') flags.drop = args[++i]
3200
+ else if (args[i] === '--json') flags.json = true
3201
+ else if (args[i] === '--record-primary') flags.recordPrimary = true
3202
+ else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
1618
3203
  else positional.push(args[i])
1619
3204
  }
1620
3205
  dir = path.resolve(dir)
3206
+ // Where the caller actually is, kept before the re-anchor below. Only `stage`
3207
+ // wants it: every other subcommand asks about the repo, while that one asks
3208
+ // about the tree in front of you, and the two differ inside a worktree.
3209
+ const invokedFrom = dir
1621
3210
  // Anchor on the primary checkout so every subcommand resolves {repo}, worktree
1622
3211
  // paths, and the registry identically whether run from main or a worktree.
1623
3212
  dir = resolvePrimaryCheckout(dir, gitReader(dir))
@@ -1657,20 +3246,34 @@ async function specEnv(rest) {
1657
3246
  specEnvStatus(dir, config)
1658
3247
  break
1659
3248
  case 'resolve':
1660
- specEnvResolve(dir, config, positional[0])
3249
+ specEnvResolve(dir, config, positional[0], flags)
3250
+ break
3251
+ case 'stage':
3252
+ specEnvStage(dir, config, positional[0], flags, invokedFrom)
3253
+ break
3254
+ case 'review':
3255
+ // `serve` is the one review sub-action rather than a verb of its own: it
3256
+ // answers the same question ("show me this diff") from the same engine,
3257
+ // and a sibling verb would have to re-derive every bit of that.
3258
+ if (positional[0] === 'serve') {
3259
+ await specEnvReviewServe(dir, config, flags)
3260
+ break
3261
+ }
3262
+ await specEnvReview(dir, config, positional[0], flags)
1661
3263
  break
1662
3264
  case 'live':
1663
3265
  await specEnvLive(dir, config, positional)
1664
3266
  break
1665
3267
  default:
1666
3268
  process.stdout.write(
1667
- 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>]\n' +
1668
- ' [spec] is optional for up/down/dev/integrate/hotfix/resolve and live take:\n' +
1669
- ' omit it and the sole provisioned spec is used (several -> it lists them).\n' +
1670
- ' NOTE connect and live status keep their own meaning for a missing spec:\n' +
1671
- ' connect disconnects (= main), live status reports on the whole repo.\n' +
1672
- ' connect and live also take a bare spec name: `live <spec>` takes the\n' +
1673
- ' instance, `live main` (or your base branch) hands it back.\n',
3269
+ 'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--notes <json>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--json] [--record-primary] [--assert-primary-clean]\n' +
3270
+ ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
3271
+ ' [spec] is optional everywhere: omit it and the worktree you are standing\n' +
3272
+ ' in is used, else the sole provisioned spec (several -> it lists them).\n' +
3273
+ ' A bare `live` takes that spec when the workbench is free, and prints the\n' +
3274
+ ' status report when it cannot tell. `live status` still reports on the\n' +
3275
+ ' whole repo. `live main` / `connect main` (or your base branch) hand the\n' +
3276
+ ' instance and the ports back.\n',
1674
3277
  )
1675
3278
  }
1676
3279
  }
@@ -1687,6 +3290,14 @@ async function run(argv) {
1687
3290
 
1688
3291
  const [cmd, ...rest] = argv
1689
3292
 
3293
+ if (cmd === 'gating') {
3294
+ const [sub, ...gArgs] = rest
3295
+ const gDir = process.cwd()
3296
+ if (sub === 'check') gatingCheck(gDir, gArgs)
3297
+ else process.stdout.write('Usage: skitterspec gating check [spec] [--all] [--json]\n')
3298
+ return
3299
+ }
3300
+
1690
3301
  if (cmd === 'spec-env') {
1691
3302
  await specEnv(rest)
1692
3303
  return
@@ -1735,11 +3346,13 @@ async function run(argv) {
1735
3346
  // interactive "yes" opts in. Only prompt for isolation on a fresh repo.
1736
3347
  let isolation = opts.isolation === true
1737
3348
  let workspaceMode = 'worktree'
3349
+ let gating = opts.gating === true
1738
3350
  if (interactive && !isExistingSetup(dir)) {
1739
3351
  const { promptSetup } = require('./prompts.js')
1740
- const answers = await promptSetup({ isolationSeed: isolation })
3352
+ const answers = await promptSetup({ isolationSeed: isolation, gatingSeed: gating })
1741
3353
  isolation = answers.isolation
1742
3354
  workspaceMode = answers.mode
3355
+ gating = answers.gating
1743
3356
  }
1744
3357
  await init({
1745
3358
  dir,
@@ -1748,12 +3361,18 @@ async function run(argv) {
1748
3361
  mode: 'init',
1749
3362
  isolation,
1750
3363
  workspaceMode,
3364
+ gating,
1751
3365
  })
1752
3366
  break
1753
3367
  }
1754
3368
  case 'update':
1755
3369
  // `update` is a resync — refresh managed files, keep customized ones
1756
3370
  // (--force to overwrite). Leaves specs/ and live .core config alone.
3371
+ // `--check` reports what it WOULD change and writes nothing.
3372
+ if (opts.check) {
3373
+ checkSync(dir, { claudeMd: opts.claudeMd })
3374
+ break
3375
+ }
1757
3376
  resync(dir, { claudeMd: opts.claudeMd, force: opts.force, diff: opts.diff })
1758
3377
  await cleanupReleaseTooling(dir, opts)
1759
3378
  break
@@ -1762,4 +3381,15 @@ async function run(argv) {
1762
3381
  }
1763
3382
  }
1764
3383
 
1765
- module.exports = { run, parse, HELP, unknownCommand }
3384
+ module.exports = {
3385
+ run,
3386
+ parse,
3387
+ HELP,
3388
+ unknownCommand,
3389
+ rankLanAddresses,
3390
+ serveProcFor,
3391
+ serverScriptOk,
3392
+ daemonScript,
3393
+ reviewServedUrls,
3394
+ restartHost,
3395
+ }