@skitterbyte/skitterspec 21.0.0 → 22.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +127 -6
  3. package/assets/claude-md-section.md +42 -18
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-remote-review.md +22 -0
  6. package/assets/commands/spec-skip.md +24 -0
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +183 -25
  9. package/assets/hooks/main-guard.cjs +145 -0
  10. package/assets/hooks/review-gate.cjs +8 -3
  11. package/assets/review/page.html +1174 -101
  12. package/assets/rules/offered-unblocks.md +100 -0
  13. package/assets/rules/spec-planning.md +195 -14
  14. package/assets/rules/spec-reports.md +181 -29
  15. package/assets/skills/no-spec/SKILL.md +219 -0
  16. package/assets/skills/spec/SKILL.md +248 -21
  17. package/assets/skills/spec-bug/SKILL.md +34 -24
  18. package/assets/skills/spec-cancel/SKILL.md +29 -0
  19. package/assets/skills/spec-complete/SKILL.md +25 -0
  20. package/assets/skills/spec-diff/SKILL.md +164 -37
  21. package/assets/skills/spec-hotfix/SKILL.md +22 -19
  22. package/assets/skills/spec-init/SKILL.md +4 -2
  23. package/assets/skills/spec-next/SKILL.md +166 -58
  24. package/assets/skills/spec-review/SKILL.md +87 -0
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +33 -0
  27. package/package.json +1 -1
  28. package/src/cli.js +1612 -172
  29. package/src/env/classify.js +87 -2
  30. package/src/env/config.js +333 -19
  31. package/src/env/hooks.js +102 -33
  32. package/src/env/live.js +124 -1
  33. package/src/env/mainguard.js +240 -0
  34. package/src/env/provision.js +106 -10
  35. package/src/env/registry.js +70 -6
  36. package/src/env/resolve.js +128 -5
  37. package/src/env/review.js +854 -29
  38. package/src/env/reviewers/coderabbit.js +228 -0
  39. package/src/env/reviewers.js +517 -0
  40. package/src/env/serve.js +474 -50
  41. package/src/env/supervise.js +8 -1
  42. package/src/env/teardown.js +12 -1
  43. package/src/init.js +185 -24
package/src/cli.js CHANGED
@@ -9,16 +9,53 @@ const {
9
9
  removeReleaseTooling,
10
10
  releaseToolingNotice,
11
11
  } = require('./deprecate.js')
12
- const { loadEnvConfig } = require('./env/config.js')
12
+ const { loadEnvConfig, resolveServePort, servePortReason } = require('./env/config.js')
13
+
14
+ // Named once so the advisory below and any future caller agree on the wording.
15
+ const ENV_CONFIG_LABEL = 'env.config.json'
16
+
17
+ // The `spec-env` verbs, in the order the usage line prints them. ONE list,
18
+ // because two drifted: `live` and `stage` were each added to the dispatcher and
19
+ // to this usage line while `--help` was left listing ten of twelve — separately,
20
+ // months apart, which is what makes it a missing constraint rather than two
21
+ // slips. An undocumented verb reads as a removed one.
22
+ //
23
+ // `cli-help-verbs.test.js` holds the three ends together: every verb here
24
+ // appears in HELP, HELP names no verb that is not here, and this list matches
25
+ // the `case` labels the dispatcher actually handles.
26
+ //
27
+ // The `review` SUB-ACTIONS (serve/arm/gate/skip) are deliberately absent — they
28
+ // are arguments to `review`, not verbs, and the usage block spells them out on
29
+ // their own lines below.
30
+ const SPEC_ENV_VERBS = Object.freeze([
31
+ 'up',
32
+ 'down',
33
+ 'prune',
34
+ 'dev',
35
+ 'connect',
36
+ 'integrate',
37
+ 'hotfix',
38
+ 'live',
39
+ 'review',
40
+ 'stage',
41
+ 'status',
42
+ 'resolve',
43
+ 'nospec',
44
+ 'main',
45
+ ])
13
46
  const {
14
47
  readRegistry,
15
48
  writeRegistry,
16
49
  allocateSlot,
17
50
  freeSlot,
18
51
  portOffset,
52
+ recordSpecless,
53
+ forgetSpecless,
54
+ isSpecless,
19
55
  } = require('./env/registry.js')
20
56
  const {
21
57
  resolveSpec,
58
+ resolveSpecless,
22
59
  resolveBaseBranch,
23
60
  liveWorktreePaths,
24
61
  collectSpecFolders,
@@ -39,6 +76,8 @@ const {
39
76
  summarizeReceipt,
40
77
  migrationsHit,
41
78
  planTake,
79
+ liveStateFor,
80
+ liveStateLine,
42
81
  planRelease,
43
82
  planAbort,
44
83
  } = require('./env/live.js')
@@ -46,6 +85,8 @@ const { ensureWorktreeDirTrusted } = require('./env/trust.js')
46
85
  const {
47
86
  rawGitReader,
48
87
  collectReview,
88
+ reviewTierStack,
89
+ reviewTierLine,
49
90
  renderReviewPage,
50
91
  renderReviewBlock,
51
92
  reviewOutPath,
@@ -61,6 +102,7 @@ const {
61
102
  reviewNotesPath,
62
103
  readNotes,
63
104
  writeNotes,
105
+ VERDICTS,
64
106
  validateNotesBlob,
65
107
  judgeVerdict,
66
108
  appendDecision,
@@ -69,6 +111,9 @@ const {
69
111
  writePending,
70
112
  claimPending,
71
113
  passesSince,
114
+ waitForPass,
115
+ WAIT_HEARTBEAT_MS,
116
+ waitingPasses,
72
117
  describePending,
73
118
  pendingAge,
74
119
  reviewPendingPath,
@@ -81,21 +126,45 @@ const {
81
126
  reviewGatePath,
82
127
  readGate,
83
128
  writeGate,
129
+ writeRenderRecord,
84
130
  armGate,
85
131
  disarmGate,
86
132
  gateState,
133
+ markGateOffered,
87
134
  } = require('./env/review.js')
88
- const { planUp, planCheckoutUp } = require('./env/provision.js')
89
- const { classifyDirtyTree } = require('./env/classify.js')
135
+ const {
136
+ CHECKS_VERSION,
137
+ BUNDLED_ADAPTERS,
138
+ runReviewers,
139
+ diffHashOf,
140
+ readChecks,
141
+ writeChecks,
142
+ cacheHit,
143
+ asCached,
144
+ } = require('./env/reviewers.js')
145
+ const { planUp, planCheckoutUp, worktreeCd, resolveStack } = require('./env/provision.js')
146
+ const { classifyDirtyTree, dirtyPaths, specDocsIn } = require('./env/classify.js')
90
147
  const { isGitCommit } = require('./env/commitcmd.js')
91
148
  const { planDown, planDownCheckout } = require('./env/teardown.js')
149
+ const {
150
+ checkMainWrite,
151
+ recordAllow,
152
+ clearAllow,
153
+ mainGuardStatus,
154
+ } = require('./env/mainguard.js')
92
155
  const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
93
156
  const { planIntegrate, planIntegrateCheckout } = require('./env/integrate.js')
94
157
  const { planHotfixLand } = require('./env/hotfix.js')
95
158
  const { planDev } = require('./env/dev.js')
96
159
  const { startProcess, stopProcess, waitHealthy, readPid, isAlive } = require('./env/supervise.js')
97
160
  const { renderRoutes, portsInUse, portsInUseOn, waitListening } = require('./env/proxy.js')
98
- const { mintToken, servableSpecs, engineVersionFor, staleServer } = require('./env/serve.js')
161
+ const {
162
+ mintToken,
163
+ servableSpecs,
164
+ engineVersionFor,
165
+ staleServer,
166
+ pageTiers,
167
+ } = require('./env/serve.js')
99
168
 
100
169
  const pkg = require('../package.json')
101
170
 
@@ -137,6 +206,13 @@ Usage:
137
206
  specs/.core/env.config.json). Subcommands:
138
207
  up <spec> print the plan to provision a worktree +
139
208
  Docker stack (prints commands; creates nothing)
209
+ --docs: a tree to write documents in — no
210
+ setup commands, no Docker
211
+ nospec <name> record a branch with no spec document
212
+ (/no-spec work) and print its plan
213
+ main <check|allow|status>
214
+ the main guard — is a write about to land
215
+ on the base branch? (/allow-main lifts it)
140
216
  down <spec> tear down (guards; --keep-volumes, --force)
141
217
  prune reap orphaned test-DB volumes (--older-than <days>)
142
218
  dev up <spec> start host dev servers on the spec's ports
@@ -144,11 +220,17 @@ Usage:
144
220
  connect <spec> expose a spec on the canonical ports (main = off)
145
221
  integrate <spec> plan rebase + fast-forward onto the base branch
146
222
  hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
147
- status list provisioned specs + port blocks
223
+ live <spec> check a spec out in the primary checkout so
224
+ the running dev server serves it (take |
225
+ release | abort | status; main hands it back)
148
226
  review <spec> write an HTML page of the spec's diff
149
227
  (--branch for the whole spec; --out, --json)
150
228
  (--notes <json> merges a review pass back;
151
- --resolve <json> records what was done)
229
+ --resolve <json> records what was done;
230
+ --run-reviewers runs review.reviewers)
231
+ stage [spec] split the uncommitted tree into this spec's
232
+ documents and everything else
233
+ status list provisioned specs + port blocks
152
234
  resolve <spec> print resolved slug/type/branch/paths
153
235
  skitterspec gating <cmd> Release-gating check (opt-in; needs
154
236
  specs/.core/gating.config.json). Subcommands:
@@ -271,31 +353,95 @@ async function cleanupReleaseTooling(dir, opts) {
271
353
  * `git worktree prune`) stays listed until pruned. That over-reports, which is
272
354
  * the harmless direction for a read-only report.
273
355
  */
356
+ /**
357
+ * The reviews sidecar directory for this repo. Named once, because the scan and
358
+ * everything that renders it must agree on where to look.
359
+ */
360
+ function reviewsDirFor(dir, config) {
361
+ return path.join(dir, stateDirLabel(config), 'reviews')
362
+ }
363
+
364
+ /**
365
+ * Render "what is waiting" for a human. Returns '' when nothing is — a repo
366
+ * with no waiting pass must read exactly as it did before this existed, so the
367
+ * heading is absent rather than printed over an empty list.
368
+ */
369
+ function waitingSection(dir, config, now = new Date().toISOString()) {
370
+ const found = waitingPasses(reviewsDirFor(dir, config))
371
+ if (!found.passes.length && !found.unreadable.length) return ''
372
+ const width = Math.max(0, ...found.passes.map((p) => p.spec.length))
373
+ const rows = found.passes
374
+ .map(
375
+ (p) =>
376
+ ` ${p.spec.padEnd(width)} ${p.code} · ${p.action || p.verdict || 'no verdict'} · ` +
377
+ `${pendingAge(p.at, now)}\n`,
378
+ )
379
+ .join('')
380
+ // Named, never counted as zero: an unreadable store holds someone's pass, and
381
+ // reporting "nothing waiting" over it is the one answer certainly wrong.
382
+ const broken = found.unreadable
383
+ .map((folder) => ` ${folder}: its pending store is not readable JSON — move it aside\n`)
384
+ .join('')
385
+ // The disown line only where there is something to disown. It is information
386
+ // beside a list, not an instruction to act on every pass in it.
387
+ const how = found.passes.length
388
+ ? ' disown one with: skitterspec spec-env review <spec> --drop <code>\n'
389
+ : ''
390
+ return `\nReviews waiting:\n${rows}${broken}${how}`
391
+ }
392
+
393
+ // `review waiting` — the same answer on its own, for a caller who wants only
394
+ // this. It claims nothing and exits 0 whatever it finds: a waiting pass is
395
+ // information, never an accusation.
396
+ function specEnvReviewWaiting(dir, config, flags) {
397
+ const found = waitingPasses(reviewsDirFor(dir, config))
398
+ if (flags.json) {
399
+ // ABSENT, NOT EMPTY. A consumer that predates this must see a
400
+ // byte-identical object when there is nothing waiting.
401
+ const out = {}
402
+ if (found.passes.length) out.waiting = found.passes
403
+ if (found.unreadable.length) out.unreadable = found.unreadable
404
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`)
405
+ return
406
+ }
407
+ const section = waitingSection(dir, config)
408
+ process.stdout.write(section ? `${section.replace(/^\n/, '')}` : 'spec-env review: nothing waiting.\n')
409
+ }
410
+
274
411
  function specEnvStatus(dir, config) {
275
412
  const worktreePaths = liveWorktreePaths(gitReader(dir))
276
- const provisioned = allSpecs(dir, config, worktreePaths)
277
- .map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
413
+ const provisioned = allSpecs(dir, config, worktreePaths, speclessMap(dir, config))
414
+ .map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath), specless: s.specless }))
278
415
  // The primary checkout is itself in `git worktree list`; a spec is
279
416
  // provisioned only when it has its OWN worktree, separate from it.
280
417
  .filter((s) => s.wt !== dir && worktreePaths.has(s.wt))
281
418
  .sort((a, b) => a.folder.localeCompare(b.folder))
282
419
 
420
+ // REACHED EITHER WAY. This used to return here, so a repo with nothing in
421
+ // flight could say nothing about a waiting pass — and a repo with nothing in
422
+ // flight is exactly where one hides longest.
283
423
  if (!provisioned.length) {
284
- process.stdout.write('spec-env: no provisioned specs.\n')
424
+ process.stdout.write(`spec-env: no provisioned specs.\n${waitingSection(dir, config)}`)
285
425
  return
286
426
  }
287
427
 
288
428
  const registry = readRegistry(dir, config)
289
429
  process.stdout.write('Provisioned specs:\n')
290
- for (const { folder, wt } of provisioned) {
430
+ for (const { folder, wt, specless } of provisioned) {
291
431
  const slot = registry.slots[folder]
292
432
  let ports = ''
293
433
  if (slot !== undefined) {
294
434
  const off = portOffset(slot, config)
295
435
  ports = ` slot ${slot} ports ${off}-${off + config.docker.portsPerSpec - 1}`
296
436
  }
297
- process.stdout.write(` ${folder}${ports}\n ${path.relative(dir, wt) || wt}\n`)
298
- }
437
+ // SAID, not left to be noticed. A `/no-spec` branch appears in this list
438
+ // because it has a worktree like everything else, and a reader who goes
439
+ // looking for `specs/**/<name>/` must not conclude the repo is broken when
440
+ // there is nothing there — it is not missing, there never was one.
441
+ const tag = specless ? ' (no spec)' : ''
442
+ process.stdout.write(` ${folder}${tag}${ports}\n ${path.relative(dir, wt) || wt}\n`)
443
+ }
444
+ process.stdout.write(waitingSection(dir, config))
299
445
  }
300
446
 
301
447
  // Plan a provision: allocate the slot, persist the registry, and print the plan
@@ -303,49 +449,6 @@ function specEnvStatus(dir, config) {
303
449
  // This creates no worktree and starts no stack — the caller runs the
304
450
  // printed commands. Keep the output's verb honest about that.
305
451
 
306
- // git quotes a path containing unusual bytes and C-escapes it. Unquote what we
307
- // can; anything we cannot parse confidently is returned as-is, which makes it
308
- // fail the spec-folder comparison and land in `foreign` — a refusal, which is the
309
- // safe direction to be wrong in.
310
- function unquotePath(p) {
311
- if (!p.startsWith('"') || !p.endsWith('"')) return p
312
- try {
313
- return JSON.parse(p)
314
- } catch {
315
- return p
316
- }
317
- }
318
-
319
- /**
320
- * Repo-relative paths of everything uncommitted. Returns null when git could not
321
- * be read at all — the caller must treat that as "nobody looked", never "clean".
322
- *
323
- * Two prefix-free listings rather than `git status --porcelain`, deliberately.
324
- * Porcelain prefixes every path with a two-character status field, and the shared
325
- * git reader TRIMS its output — which eats the leading space of the first line
326
- * only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
327
- * These emit bare paths, so there is no offset to get wrong. `--others` also
328
- * lists untracked files INDIVIDUALLY, where porcelain collapses them into their
329
- * topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
330
- * an ancestor attributable to no single spec, and so refusing the very tree this
331
- * gate exists to accept. Both were found by running it, not by reading it.
332
- */
333
- function dirtyPaths(git) {
334
- const lists = [
335
- git(['diff', '--name-only', 'HEAD']),
336
- git(['ls-files', '--others', '--exclude-standard']),
337
- ]
338
- if (lists.every((l) => l === null)) return null
339
- const out = []
340
- for (const list of lists) {
341
- if (!list) continue
342
- for (const line of list.split('\n')) {
343
- const q = line.trim()
344
- if (q) out.push(unquotePath(q))
345
- }
346
- }
347
- return out
348
- }
349
452
 
350
453
  // Is this spec new to git? Asked directly, so the commit subject does not depend
351
454
  // on the shape of `git status` output.
@@ -541,8 +644,220 @@ function specEnvUpCheckout(dir, config, spec) {
541
644
  process.stdout.write(out.join('\n') + '\n')
542
645
  }
543
646
 
544
- function specEnvUp(dir, config, specArg) {
647
+ /**
648
+ * `spec-env main <check|allow|status>` — the main guard.
649
+ *
650
+ * `check` is what the hook calls, so it follows `review gate --check`'s
651
+ * contract exactly: **exit 1 and print the reason** when the write should be
652
+ * refused, exit 0 and say nothing otherwise. Every cannot-tell is an exit 0.
653
+ *
654
+ * `allow` and `status` are for a person. `allow` is reached only through
655
+ * `/allow-main`, which is user-only — a guard the model can lift is decoration.
656
+ */
657
+ function specEnvMain(dir, config, positional, flags = {}, present = true, invokedFrom = dir) {
658
+ const action = positional[0] || 'status'
659
+ const sessionId = flags.session || process.env.CLAUDE_CODE_SESSION_ID || null
660
+ const git = gitReader(dir)
661
+
662
+ if (action === 'check') {
663
+ // `invokedFrom`, NOT `dir`. Every subcommand is re-anchored on the primary
664
+ // checkout before it runs, so asking `dir` whether it is the primary
665
+ // checkout always answers yes — and the guard would fire on a write inside
666
+ // somebody's worktree, which is the one place it must never fire.
667
+ const from = invokedFrom || dir
668
+ const verdict = checkMainWrite(from, config, gitReader(from), { sessionId, present })
669
+ if (!verdict.refuse) return
670
+ process.stdout.write(`spec-env main: ${verdict.reason}\n`)
671
+ process.exitCode = 1
672
+ return
673
+ }
674
+
675
+ if (action === 'allow') {
676
+ // The primary checkout owns the allow file, so an `/allow-main` typed from a
677
+ // worktree still lands where the guard reads it.
678
+ const root = dir
679
+
680
+ if (flags.off || positional[1] === 'off') {
681
+ const had = clearAllow(root, config)
682
+ process.stdout.write(
683
+ had
684
+ ? 'spec-env main: allow cleared — the base branch is guarded again.\n'
685
+ : 'spec-env main: nothing to clear — the base branch was already guarded.\n',
686
+ )
687
+ return
688
+ }
689
+
690
+ const reason = positional.slice(1).join(' ').trim() || null
691
+ const value = recordAllow(root, config, {
692
+ sessionId,
693
+ reason,
694
+ at: new Date().toISOString(),
695
+ })
696
+ const scope =
697
+ value.scope === 'session'
698
+ ? 'this session only — it lapses when the session ends'
699
+ : 'this repo until cleared — no session id available, so `/allow-main off` is what ends it'
700
+ process.stdout.write(
701
+ `spec-env main: writes to the base branch are allowed (${scope}).\n` +
702
+ (reason ? ` reason: ${reason}\n` : ' reason: none given\n'),
703
+ )
704
+ return
705
+ }
706
+
707
+ if (action === 'status') {
708
+ const st = mainGuardStatus(dir, config, { sessionId, present })
709
+ if (flags.json) {
710
+ process.stdout.write(JSON.stringify(st, null, 2) + '\n')
711
+ return
712
+ }
713
+ if (st.state === 'unconfigured') {
714
+ process.stdout.write('spec-env main: isolation is not configured — the guard is inactive.\n')
715
+ return
716
+ }
717
+ if (st.state === 'off') {
718
+ process.stdout.write(
719
+ 'spec-env main: off for this project (guards.mainIsLandingZone is false).\n',
720
+ )
721
+ return
722
+ }
723
+ if (st.state === 'allowed') {
724
+ process.stdout.write(
725
+ `spec-env main: allowed (${st.scope})\n reason: ${st.reason || 'none given'}\n` +
726
+ ' /allow-main off ends it\n',
727
+ )
728
+ return
729
+ }
730
+ process.stdout.write(
731
+ 'spec-env main: guarded — the base branch is a landing zone.\n' +
732
+ (st.otherSession
733
+ ? ' an allow exists for a different session, so it does not apply here\n'
734
+ : ''),
735
+ )
736
+ return
737
+ }
738
+
739
+ process.stdout.write(
740
+ `spec-env main: unknown action "${action}" — one of check, allow, status.\n`,
741
+ )
742
+ }
743
+
744
+ /**
745
+ * `spec-env nospec <name>` — record a SPECLESS branch and print its plan.
746
+ *
747
+ * `/no-spec` is the lane for work that genuinely has no spec: bumping four
748
+ * projects to a new version, a lockfile refresh, a rename. It exists because a
749
+ * guard that refuses writes on the base branch is a wall unless there is
750
+ * somewhere cheap to go, and because work done on the base branch renders no
751
+ * page and therefore gets no review at all.
752
+ *
753
+ * IT RECORDS FIRST, THEN PLANS, and the order is the correctness condition. The
754
+ * record is what makes the name resolvable (`resolve.js`'s specless fallback),
755
+ * so every later verb — `review`, `integrate`, `down`, `status`, a bare
756
+ * `resolve` — can find a branch with no document behind it. Plan first and a
757
+ * caller that runs the printed commands owns a worktree the engine cannot name.
758
+ *
759
+ * NO `--docs` HERE, deliberately, and it is the opposite call from `/spec`.
760
+ * Documents mode skips the `setup` commands because writing markdown needs no
761
+ * dependencies; `/no-spec` work is *code*, so it needs them. The two lanes want
762
+ * opposite halves of the same provision.
763
+ *
764
+ * It is a planner like `up`: it creates no worktree. The registry write is its
765
+ * only side effect, which is why it is safe to re-run.
766
+ */
767
+ function specEnvNospec(dir, config, name, flags = {}) {
768
+ if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) {
769
+ process.stdout.write(
770
+ 'spec-env nospec: needs a kebab-case name — it becomes the branch and the ' +
771
+ 'worktree folder.\n e.g. skitterspec spec-env nospec bump-deps\n',
772
+ )
773
+ return
774
+ }
775
+
776
+ // A name that is already a spec is refused rather than shadowed. Two things
777
+ // answering to one name is how `down` comes to tear the wrong tree down.
778
+ let clash = null
779
+ try {
780
+ clash = resolveSpec(name, dir, config, {
781
+ searchDirs: [...liveWorktreePaths(gitReader(dir))],
782
+ })
783
+ } catch {
784
+ // Not found is the expected case, and the only one that proceeds.
785
+ }
786
+ if (clash && !clash.specless) {
787
+ process.stdout.write(
788
+ `spec-env nospec: ${name} is already a spec (${clash.bucket}) — ` +
789
+ `use /spec-start ${name} instead.\n`,
790
+ )
791
+ return
792
+ }
793
+
794
+ const before = readRegistry(dir, config)
795
+ const already = isSpecless(before, name)
796
+ const spec = resolveSpecless(name, dir, config, before.specless[name])
797
+ writeRegistry(dir, config, recordSpecless(before, name, { branch: spec.branch }))
798
+
799
+ const worktreeRootAbs = path.dirname(spec.worktreePath)
800
+ const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
801
+ const attached = fs.existsSync(spec.worktreePath)
802
+
803
+ const guard = worktreeCd(spec.worktreePath)
804
+ const setupCommands = (config.setup || []).map((cmd) => `${guard}; ${cmd}`)
805
+
806
+ const out = [
807
+ `spec-env nospec: ${name} ` +
808
+ (attached ? '(plan — worktree exists; will attach)' : '(plan — nothing created yet)'),
809
+ '',
810
+ ` worktree: ${spec.worktreePath}`,
811
+ ` branch: ${spec.branch}`,
812
+ ' stack: worktree-only (no docker, no port block)',
813
+ ` recorded: ${already ? 'already in' : 'added to'} ${config.registry} as a specless branch`,
814
+ ]
815
+ if (trust.reason === 'malformed') {
816
+ out.push(
817
+ ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
818
+ `\n add ${worktreeRootAbs} to permissions.additionalDirectories yourself`,
819
+ )
820
+ } else {
821
+ out.push(
822
+ ` trusted: ${worktreeRootAbs} ` +
823
+ `(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
824
+ )
825
+ }
826
+ out.push('')
827
+ out.push(' to provision, run:')
828
+ out.push(
829
+ attached
830
+ ? ` git worktree add ${spec.worktreePath} ${spec.branch}`
831
+ : ` git worktree add ${spec.worktreePath} -b ${spec.branch}`,
832
+ )
833
+ if (setupCommands.length) {
834
+ out.push('')
835
+ out.push(' then, in the worktree, run:')
836
+ for (const cmd of setupCommands) out.push(` ${cmd}`)
837
+ }
838
+ if (flags.json) {
839
+ process.stdout.write(
840
+ JSON.stringify(
841
+ {
842
+ name,
843
+ branch: spec.branch,
844
+ worktreePath: spec.worktreePath,
845
+ specless: true,
846
+ attached,
847
+ recorded: !already,
848
+ },
849
+ null,
850
+ 2,
851
+ ) + '\n',
852
+ )
853
+ return
854
+ }
855
+ process.stdout.write(out.join('\n') + '\n')
856
+ }
857
+
858
+ function specEnvUp(dir, config, specArg, flags = {}) {
545
859
  const spec = resolveSpecWithWorktree(dir, config, specArg)
860
+ const docs = flags.docs === true
546
861
 
547
862
  // Checkout mode: the branch is built in the primary checkout, so none of the
548
863
  // worktree machinery below applies — no slot, no trust entry and no bootstrap.
@@ -592,21 +907,38 @@ function specEnvUp(dir, config, specArg) {
592
907
  const worktreeRootAbs = path.dirname(spec.worktreePath)
593
908
  const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
594
909
 
595
- const wantsDocker = spec.stack === 'docker' && config.docker.enabled
910
+ // Documents mode brings no stack up, so a Docker spec provisioned this way is
911
+ // a worktree-only run. Asked through `resolveStack`, which `planUp` also
912
+ // calls, so the slot allocated here and the commands planned there cannot
913
+ // disagree about whether there is a stack — they did, for a header-less spec.
914
+ const wantsDocker = resolveStack(spec, config, {
915
+ docs,
916
+ composeFilePresent: composeFilePresent(dir, config),
917
+ }).wantsDocker
596
918
 
597
919
  // Slot allocation is Docker-only: a worktree-only spec never touches the
598
920
  // registry (no slot, no port block). Its re-run signal is the worktree already
599
921
  // existing on disk (attach the branch, don't `-b`); a Docker spec's is its slot.
922
+ //
923
+ // THE WORKTREE ON DISK COUNTS IN BOTH PATHS, and documents mode is what makes
924
+ // that necessary rather than merely tidy. A Docker spec provisioned with
925
+ // `--docs` allocates no slot, so a later `up` without the flag would read the
926
+ // registry, find nothing, and plan `git worktree add -b <branch>` over a
927
+ // worktree and a branch that both already exist — a plan that cannot run. The
928
+ // stronger signal is the honest one either way: a tree on disk is a re-run,
929
+ // whatever the registry remembers.
600
930
  let slot = null
601
931
  let attached
932
+ const worktreeOnDisk = fs.existsSync(spec.worktreePath)
602
933
  if (wantsDocker) {
603
934
  const before = readRegistry(dir, config)
604
- attached = Object.prototype.hasOwnProperty.call(before.slots, spec.folder)
935
+ attached =
936
+ Object.prototype.hasOwnProperty.call(before.slots, spec.folder) || worktreeOnDisk
605
937
  const alloc = allocateSlot(before, spec.folder)
606
938
  slot = alloc.slot
607
939
  writeRegistry(dir, config, alloc.registry) // the engine's only write (Docker path)
608
940
  } else {
609
- attached = fs.existsSync(spec.worktreePath)
941
+ attached = worktreeOnDisk
610
942
  }
611
943
 
612
944
  // The tree gate: the same facts the checkout planner gets, all of them read
@@ -618,7 +950,8 @@ function specEnvUp(dir, config, specArg) {
618
950
  specFoundOn: upOnFork.foundOn,
619
951
  forkRef: spec.baseRef || currentBranch(upGit) || 'HEAD',
620
952
  specUntracked: upSpecUntracked,
621
- })
953
+ composeFilePresent: composeFilePresent(dir, config),
954
+ }, { docs })
622
955
 
623
956
  if (plan.blocked) {
624
957
  process.stdout.write(`spec-env up: blocked — ${plan.reason}.\n`)
@@ -650,6 +983,11 @@ function specEnvUp(dir, config, specArg) {
650
983
  // the operator's only evidence of which mode actually resolved — print it
651
984
  // whenever it was set explicitly, right or wrong.
652
985
  out.push(' mode: worktree (each spec gets its own checkout)')
986
+ // Said positively and only when asked for, so the absent setup step below
987
+ // reads as a choice rather than as a project with nothing configured.
988
+ if (plan.docs) {
989
+ out.push(' docs: documents only — no setup commands, no docker')
990
+ }
653
991
  if (trust.reason === 'malformed') {
654
992
  out.push(
655
993
  ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
@@ -684,6 +1022,21 @@ function specEnvUp(dir, config, specArg) {
684
1022
  process.stdout.write(out.join('\n') + '\n')
685
1023
  }
686
1024
 
1025
+ /**
1026
+ * The recorded specless branches (`/no-spec` work), or an empty map.
1027
+ *
1028
+ * Wrapped because every caller wants the same cannot-tell behaviour: a
1029
+ * malformed or unreadable registry must not stop a resolution that the folder
1030
+ * search can answer on its own (`.claude/rules/negative-checks.md` rule 4).
1031
+ */
1032
+ function speclessMap(dir, config) {
1033
+ try {
1034
+ return readRegistry(dir, config).specless || {}
1035
+ } catch {
1036
+ return {}
1037
+ }
1038
+ }
1039
+
687
1040
  // A read-only git reader over `cwd`: returns trimmed stdout, or null on failure.
688
1041
  function gitReader(cwd) {
689
1042
  return (argv) => {
@@ -862,7 +1215,11 @@ function specEnvDown(dir, config, specArg, flags) {
862
1215
 
863
1216
  const base = resolveBaseBranch(config, gitReader(dir))
864
1217
  const worktreeState = worktreeGitState(spec.worktreePath, base)
865
- const plan = planDown(spec, config, flags, { worktreeState, timestamp: compactTimestamp() })
1218
+ const plan = planDown(spec, config, flags, {
1219
+ worktreeState,
1220
+ timestamp: compactTimestamp(),
1221
+ composeFilePresent: composeFilePresent(dir, config),
1222
+ })
866
1223
 
867
1224
  if (plan.blocked) {
868
1225
  process.stdout.write(
@@ -874,12 +1231,25 @@ function specEnvDown(dir, config, specArg, flags) {
874
1231
 
875
1232
  // Free the slot (the engine's only write on down) — only if one was held; a
876
1233
  // worktree-only teardown never touches the registry.
877
- if (hasSlot) {
878
- writeRegistry(dir, config, freeSlot(registry, spec.folder))
1234
+ //
1235
+ // A SPECLESS BRANCH IS FORGOTTEN HERE TOO, and it has to be: the record is the
1236
+ // only thing that made the name resolvable, so leaving it behind would keep a
1237
+ // torn-down `/no-spec` branch answering to `review`, `integrate` and a bare
1238
+ // `resolve` with a worktree path that is no longer on disk. `forgetSpecless`
1239
+ // is idempotent, so this costs a write and nothing else.
1240
+ const wasSpecless = isSpecless(registry, spec.folder)
1241
+ if (hasSlot || wasSpecless) {
1242
+ let next = registry
1243
+ if (hasSlot) next = freeSlot(next, spec.folder)
1244
+ if (wasSpecless) next = forgetSpecless(next, spec.folder)
1245
+ writeRegistry(dir, config, next)
879
1246
  }
880
1247
 
881
1248
  const out = []
882
- out.push(`spec-env down: ${spec.folder}${hasSlot ? ' (slot freed)' : ''}`)
1249
+ out.push(
1250
+ `spec-env down: ${spec.folder}` +
1251
+ (hasSlot ? ' (slot freed)' : wasSpecless ? ' (specless record forgotten)' : ''),
1252
+ )
883
1253
  out.push('')
884
1254
  out.push(` worktree: ${spec.worktreePath}`)
885
1255
  out.push(` volumes: ${plan.volumesDropped ? 'dropped' : 'kept'}`)
@@ -1001,7 +1371,7 @@ function volumeCreatedAt(names) {
1001
1371
  */
1002
1372
  function provisionedSpecChoice(dir, config, cwd = process.cwd()) {
1003
1373
  const worktreePaths = liveWorktreePaths(gitReader(dir))
1004
- const provisioned = allSpecs(dir, config, worktreePaths)
1374
+ const provisioned = allSpecs(dir, config, worktreePaths, speclessMap(dir, config))
1005
1375
  .map((s) => ({ folder: s.folder, wt: path.resolve(s.worktreePath) }))
1006
1376
  // The primary checkout is itself in `git worktree list`; a spec is
1007
1377
  // provisioned only when it has its OWN worktree, separate from it.
@@ -1081,7 +1451,18 @@ function resolveSpecWithWorktree(dir, config, specArg) {
1081
1451
  // another's. A worktree left behind by a declined teardown can still answer
1082
1452
  // with a stale bucket — that is a leftover to prune, not a lookup to distrust.
1083
1453
  const preferDirs = worktreeGuess === dir ? [] : [worktreeGuess]
1084
- return resolveSpec(specArg, dir, config, { searchDirs, preferDirs })
1454
+ // A SPECLESS BRANCH RESOLVES TOO, on the registry's word and nothing else.
1455
+ // `/no-spec` work has a worktree and a branch and no document under
1456
+ // `specs/**`, so every verb below — down, integrate, review, resolve, status —
1457
+ // would refuse it as "spec not found". Passing the recorded map here is what
1458
+ // makes one name resolvable without making every typo resolvable: `resolveSpec`
1459
+ // consults it only after the folder search has come up empty, and only for a
1460
+ // name the engine itself wrote down.
1461
+ return resolveSpec(specArg, dir, config, {
1462
+ searchDirs,
1463
+ preferDirs,
1464
+ specless: speclessMap(dir, config),
1465
+ })
1085
1466
  }
1086
1467
 
1087
1468
  // Every spec folder name found under specs/* across the given checkout roots.
@@ -1139,7 +1520,7 @@ function specEnvPrune(dir, config, flags) {
1139
1520
  }
1140
1521
 
1141
1522
  const worktrees = liveWorktreePaths(gitReader(dir))
1142
- const specs = allSpecs(dir, config, worktrees)
1523
+ const specs = allSpecs(dir, config, worktrees, speclessMap(dir, config))
1143
1524
  const liveSlugs = liveSlugsForSpecs(specs, worktrees)
1144
1525
 
1145
1526
  const olderThanDays =
@@ -1517,7 +1898,9 @@ function specEnvResolve(dir, config, specArg, flags = {}) {
1517
1898
  if (flags.recordPrimary) return recordPrimary(dir, config, r)
1518
1899
  if (flags.assertPrimaryClean) return assertPrimaryClean(dir, config, r)
1519
1900
  process.stdout.write(
1520
- `spec: ${r.folder} (${r.bucket})\n` +
1901
+ // A specless branch has no bucket because it has no document — say that
1902
+ // rather than printing `(null)`, which reads as a lookup that failed.
1903
+ `spec: ${r.folder} (${r.specless ? 'no spec' : r.bucket})\n` +
1521
1904
  `type/slug: ${r.type} / ${r.slug}\n` +
1522
1905
  `branch: ${r.branch}\n` +
1523
1906
  `worktree: ${r.worktreePath}\n` +
@@ -1617,6 +2000,54 @@ function specEnvStage(dir, config, specArg, flags = {}, invokedFrom = dir) {
1617
2000
  process.stdout.write(out.join('\n') + '\n')
1618
2001
  }
1619
2002
 
2003
+ /**
2004
+ * The tree and the file set behind `review --docs`: this spec's own uncommitted
2005
+ * documents, in the checkout the caller is standing in.
2006
+ *
2007
+ * It is `stage`'s split, reused rather than re-derived — same tree resolution,
2008
+ * same `classifyDirtyTree`. THE `owned` HALF IS WHAT MAKES THIS SAFE TO RENDER:
2009
+ * a checkout is shared by every session standing in it, so the uncommitted tree
2010
+ * routinely holds spec documents this spec has no claim on. Rendering the tree
2011
+ * would put them on this page, and a committing verdict here would then commit
2012
+ * them under this spec's ticket.
2013
+ *
2014
+ * WHAT WOULD FOOL THIS: nothing, for a path outside the spec's folder that the
2015
+ * project declared a companion — those are owned by construction. What it
2016
+ * cannot see is a document belonging to this spec that is already committed:
2017
+ * `dirtyPaths` answers about the uncommitted tree only, so a spec whose files
2018
+ * are all committed renders as nothing to review rather than as its own text.
2019
+ * That is the intended reading — there is no change to review — and it is why
2020
+ * the empty case says so instead of drawing an empty page.
2021
+ *
2022
+ * Three states, never two (`.claude/rules/negative-checks.md` rule 4). A git
2023
+ * that could not be read is `cannot tell`, and must not become an empty file
2024
+ * set: an empty set renders a page saying nothing changed, which is the one
2025
+ * reading that is certainly wrong.
2026
+ *
2027
+ * @returns {{tree: string, owned: string[]}|{error: string}}
2028
+ */
2029
+ function resolveSpecDocs(config, spec, invokedFrom) {
2030
+ const git = gitReader(invokedFrom)
2031
+ const tree = git(['rev-parse', '--show-toplevel']) || invokedFrom
2032
+ // THE CLASSIFICATION IS SHARED with the review server's route for the same
2033
+ // spec (`specDocsIn`), so the served page and the written page cannot
2034
+ // disagree about which files they show. This function adds only the wording.
2035
+ const found = specDocsIn(tree, spec, config, gitReader(tree))
2036
+ if (found.error) {
2037
+ return {
2038
+ error:
2039
+ `git could not be read at ${tree}, so ${spec.folder}'s documents were not classified — ` +
2040
+ 'nothing rendered. This is not "nothing to review".',
2041
+ }
2042
+ }
2043
+ if (found.empty) {
2044
+ return {
2045
+ error: `${spec.folder} has no uncommitted documents in ${tree} — nothing to review.`,
2046
+ }
2047
+ }
2048
+ return found
2049
+ }
2050
+
1620
2051
  /**
1621
2052
  * Write a self-contained HTML review of a spec's diff.
1622
2053
  *
@@ -1635,6 +2066,11 @@ function verdictSaid(v) {
1635
2066
  // thing that will happen to the repo and the reader should see it coming.
1636
2067
  if (v.effective === 'commit') return `committing with ${v.commitWith}`
1637
2068
  if (v.effective === 'commit-continue') return `committing with ${v.commitWith}, then the next phase`
2069
+ // EVERY COMMITTING VERDICT NEEDS A LINE HERE, and the fall-through below is
2070
+ // why: a verdict this function does not know reads as `discuss first`, so a
2071
+ // reader who pressed a green button would be told their review ended in a
2072
+ // conversation. Adding one to `COMMITTING` and not to this list is silent.
2073
+ if (v.effective === 'commit-start') return `committing with ${v.commitWith}, then putting it in flight`
1638
2074
  // Names what it does NOT do, because the reader of a mid-run page has just
1639
2075
  // pressed a green button and must not read it as a commit.
1640
2076
  if (v.effective === 'continue') return 'read — carrying on, nothing committed'
@@ -1662,6 +2098,135 @@ function gateTarget(dir, config, specArg) {
1662
2098
  }
1663
2099
  }
1664
2100
 
2101
+ /**
2102
+ * `review wait` — block until a verdict arrives, so nobody has to improvise it.
2103
+ *
2104
+ * THE COMMAND EXISTS BECAUSE THE WAIT DID NOT. The skills said "watch the
2105
+ * pending store and end your turn", so every run wrote its own watcher in
2106
+ * shell — and one of them wrote `[ "$x" \> "$y" ]`, which is valid bash and a
2107
+ * syntax error in zsh. It could never be true, spun for five minutes, and
2108
+ * looked exactly like patience the whole time.
2109
+ *
2110
+ * So this says it started. A caller can then tell a live wait from a dead one,
2111
+ * which is the one thing none of the improvised watchers could offer.
2112
+ *
2113
+ * It is a WAIT and not a claim: the pass is left in the holding area for
2114
+ * `--claim-since` to take, so the rule that a pass is never claimed without a
2115
+ * person asking (`/spec-diff` §0) is untouched by anything here.
2116
+ */
2117
+ /**
2118
+ * How often the wait says it is alive, in ms. `0` disables it.
2119
+ *
2120
+ * Three states, not two (`.claude/rules/negative-checks.md` rule 4): a value
2121
+ * that is not a number is a cannot-tell, and it takes the DEFAULT rather than
2122
+ * disabling — being wrong that way costs a line of stderr nobody reads, where
2123
+ * the opposite silently removes the thing keeping the wait alive.
2124
+ */
2125
+ function resolveHeartbeatMs(flags) {
2126
+ if (flags.heartbeat === undefined || flags.heartbeat === null) return WAIT_HEARTBEAT_MS
2127
+ const seconds = Number(flags.heartbeat)
2128
+ if (!Number.isFinite(seconds) || seconds < 0) return WAIT_HEARTBEAT_MS
2129
+ return seconds * 1000
2130
+ }
2131
+
2132
+ async function specEnvReviewWait(dir, config, specArg, flags) {
2133
+ let spec
2134
+ try {
2135
+ spec = resolveSpecWithWorktree(dir, config, specArg)
2136
+ } catch (err) {
2137
+ process.stdout.write(`spec-env review wait: ${err.message}\n`)
2138
+ process.exitCode = 1
2139
+ return
2140
+ }
2141
+
2142
+ // A WINDOW IS REQUIRED, and an absent one is not an open one. Without it
2143
+ // there is no way to tell this sitting's pass from a stranger's, and waiting
2144
+ // for "any pass at all" is how one gets swept up.
2145
+ if (!flags.since) {
2146
+ process.stdout.write(
2147
+ 'spec-env review wait: --since <iso> is required — it is the window that decides which pass is yours\n',
2148
+ )
2149
+ process.exitCode = 1
2150
+ return
2151
+ }
2152
+
2153
+ const out = reviewOutPath(dir, spec.folder, flags.out)
2154
+ const timeoutMs =
2155
+ flags.timeout === undefined || flags.timeout === null ? null : Number(flags.timeout) * 1000
2156
+ if (timeoutMs !== null && !Number.isFinite(timeoutMs)) {
2157
+ process.stdout.write(`spec-env review wait: --timeout ${flags.timeout} is not a number of seconds\n`)
2158
+ process.exitCode = 1
2159
+ return
2160
+ }
2161
+
2162
+ // SAID BEFORE THE FIRST POLL, not after it. The whole point is that a caller
2163
+ // knows the wait is running; a line printed on the way out would arrive only
2164
+ // for the waits that already worked.
2165
+ if (!flags.json) {
2166
+ process.stdout.write(
2167
+ `spec-env review wait: ${spec.folder} — waiting for a verdict sent since ${flags.since}` +
2168
+ `${timeoutMs === null ? '' : ` (up to ${flags.timeout}s)`}\n`,
2169
+ )
2170
+ }
2171
+
2172
+ // THE HEARTBEAT GOES TO STDERR, and that is the whole reason it is safe.
2173
+ // Stdout is the result a backgrounded caller parses and, under a monitor,
2174
+ // every stdout line becomes a notification — so a proof-of-life line there
2175
+ // would either corrupt the answer or wake somebody every five minutes.
2176
+ // Stderr reaches the output file and raises neither.
2177
+ //
2178
+ // SUPPRESSED UNDER `--json`, which has one machine-readable payload and must
2179
+ // not grow a second stream beside it.
2180
+ const heartbeatMs = resolveHeartbeatMs(flags)
2181
+ const onHeartbeat =
2182
+ flags.json || heartbeatMs === 0
2183
+ ? null
2184
+ : ({ elapsedMs }) => {
2185
+ process.stderr.write(
2186
+ `spec-env review wait: ${spec.folder} — still waiting (${Math.round(elapsedMs / 60000)}m), ` +
2187
+ `window since ${flags.since}\n`,
2188
+ )
2189
+ }
2190
+
2191
+ const result = await waitForPass(() => readPending(out, spec.folder), flags.since, {
2192
+ timeoutMs,
2193
+ onHeartbeat,
2194
+ heartbeatMs: heartbeatMs || WAIT_HEARTBEAT_MS,
2195
+ })
2196
+
2197
+ if (flags.json) {
2198
+ process.stdout.write(`${JSON.stringify({ spec: spec.folder, since: flags.since, ...result }, null, 2)}\n`)
2199
+ }
2200
+
2201
+ if (result.state === 'arrived') {
2202
+ if (!flags.json) {
2203
+ process.stdout.write(
2204
+ ` arrived: ${result.code}\n` +
2205
+ ` claim it: skitterspec spec-env review ${spec.folder} --claim-since ${flags.since}\n`,
2206
+ )
2207
+ }
2208
+ return
2209
+ }
2210
+
2211
+ process.exitCode = 1
2212
+ if (flags.json) return
2213
+
2214
+ if (result.state === 'unusable') {
2215
+ process.stdout.write(` --since ${flags.since} is not a timestamp — nothing to wait inside\n`)
2216
+ return
2217
+ }
2218
+ if (result.state === 'ambiguous') {
2219
+ // NAMES THE COUNT, NEVER THE CODES — the same silence `--claim-since` keeps.
2220
+ // Two in one window is two sittings or two people, and the operator has the
2221
+ // codes; printing them here would hand a guesser the answer.
2222
+ process.stdout.write(
2223
+ ` ${result.count} passes arrived in that window — claim one by its code rather than guessing\n`,
2224
+ )
2225
+ return
2226
+ }
2227
+ process.stdout.write(' timed out — no verdict arrived in the window\n')
2228
+ }
2229
+
1665
2230
  // `review arm` — a phase ended, and its diff is now owed a verdict.
1666
2231
  function specEnvReviewArm(dir, config, specArg, flags) {
1667
2232
  const target = gateTarget(dir, config, specArg)
@@ -1748,6 +2313,24 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
1748
2313
  }
1749
2314
  }
1750
2315
 
2316
+ // RECORDED BEFORE THE ANSWER IS WRITTEN, so the offer this call hands back is
2317
+ // also the one it spends. Explicit rather than automatic: a plain read of the
2318
+ // gate must stay a read, because `/spec-next` makes one on every run.
2319
+ //
2320
+ // Best-effort, like the render record. A sidecar that could not be written
2321
+ // costs a second offer, which is the harmless direction — where refusing to
2322
+ // answer would block a commit over a bookkeeping failure.
2323
+ if (flags.offered && judged.state === 'armed' && target.spec) {
2324
+ const marked = markGateOffered(judged.gate, { at: new Date().toISOString() })
2325
+ if (marked.marked) {
2326
+ try {
2327
+ writeGate(target.out, marked.gate)
2328
+ } catch {
2329
+ /* the gate is the guard; this is only its memory of having asked */
2330
+ }
2331
+ }
2332
+ }
2333
+
1751
2334
  if (flags.json) {
1752
2335
  process.stdout.write(
1753
2336
  JSON.stringify(
@@ -1758,6 +2341,9 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
1758
2341
  armedAt: judged.gate ? judged.gate.armedAt : null,
1759
2342
  phase: judged.gate ? judged.gate.phase : null,
1760
2343
  required: config.review.required,
2344
+ // Absent stays absent: only an `armed` gate has a way out to declare,
2345
+ // and every cannot-tell state carries none.
2346
+ ...(judged.offer ? { offer: judged.offer } : {}),
1761
2347
  log: judged.gate && Array.isArray(judged.gate.log) ? judged.gate.log : [],
1762
2348
  },
1763
2349
  null,
@@ -1787,6 +2373,134 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
1787
2373
  if (flags.check && judged.state === 'armed') process.exitCode = 1
1788
2374
  }
1789
2375
 
2376
+ /**
2377
+ * `review allow <network|remote> [--off]` — permit or withdraw a review tier.
2378
+ *
2379
+ * THE FIRST THING IN THIS ENGINE TO WRITE `env.config.json`, and that is why it
2380
+ * is narrow: it reads, sets one key under `review`, and writes back with the
2381
+ * indent the file already uses. It never reorders, never adds a key nobody
2382
+ * asked for, and never touches another section.
2383
+ *
2384
+ * WHAT WOULD FOOL THIS: a config written with comments. `loadEnvConfig` parses
2385
+ * it with `JSON.parse`, so such a file already fails to load and there is no
2386
+ * comment-preserving case to protect — but a file indented with anything other
2387
+ * than two spaces IS reformatted, which is cosmetic and worth knowing before it
2388
+ * shows up in someone's diff.
2389
+ *
2390
+ * IT EDITS A COMMITTED FILE. Turning network reviews on for yourself turns them
2391
+ * on for everyone who pulls, so the output says so rather than leaving it to be
2392
+ * discovered by a colleague's render.
2393
+ */
2394
+ function specEnvReviewAllow(dir, config, tier, flags) {
2395
+ const TIERS = { network: 'allowNetwork', remote: 'allowRemote' }
2396
+ const key = TIERS[String(tier || '').trim()]
2397
+ if (!key) {
2398
+ // Refused by name, never coerced to a default: silently permitting the
2399
+ // wrong tier is the one outcome worth more than a round trip.
2400
+ process.stdout.write(
2401
+ `spec-env review allow: ${JSON.stringify(tier || '')} is not a tier — ` +
2402
+ `one of ${Object.keys(TIERS).join(', ')}. Nothing changed.\n`,
2403
+ )
2404
+ process.exitCode = 1
2405
+ return
2406
+ }
2407
+ // THREE WAYS TO SAY WHAT THE TIER SHOULD BE, and only the third is new.
2408
+ // Bare `allow <tier>` turns it ON and `--off` turns it off, exactly as they
2409
+ // always have — every existing caller and test reads those.
2410
+ //
2411
+ // `--set` exists because a SLASH COMMAND can only make one static
2412
+ // substitution: `/spec-remote-review on` has to reach the engine as the word
2413
+ // the person typed, not as a flag the command file worked out. An EMPTY value
2414
+ // is the bare form of that command, and it toggles — which is the common case,
2415
+ // because a reader flipping a tier is looking at the line that says which way
2416
+ // it currently is.
2417
+ let on = !flags.off
2418
+ if (flags.set !== undefined) {
2419
+ const said = String(flags.set).trim().toLowerCase()
2420
+ if (said === '') {
2421
+ // Toggle. Read through the same precedence the report below uses, so
2422
+ // "turn it to the other thing" means the other thing the render showed.
2423
+ const current = Boolean(
2424
+ (() => {
2425
+ try {
2426
+ const now = JSON.parse(fs.readFileSync(path.resolve(dir, 'specs/.core/env.config.json'), 'utf8'))
2427
+ return now.review && now.review[key] !== undefined ? now.review[key] : config.review[key]
2428
+ } catch {
2429
+ return config.review[key]
2430
+ }
2431
+ })(),
2432
+ )
2433
+ on = !current
2434
+ } else if (said === 'on' || said === 'off') {
2435
+ on = said === 'on'
2436
+ } else {
2437
+ // Refused by name, like an unknown tier. A misspelt state coerced to a
2438
+ // default is the one outcome worth more than a round trip — and the
2439
+ // wrong default here opens a port or permits a publish.
2440
+ process.stdout.write(
2441
+ `spec-env review allow: ${JSON.stringify(String(flags.set))} is not a state — ` +
2442
+ 'one of on, off, or nothing at all to toggle. Nothing changed.\n',
2443
+ )
2444
+ process.exitCode = 1
2445
+ return
2446
+ }
2447
+ }
2448
+ const file = path.resolve(dir, 'specs/.core/env.config.json')
2449
+ let parsed
2450
+ try {
2451
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
2452
+ } catch (err) {
2453
+ // Cannot tell what is in there, so write nothing. Overwriting a config we
2454
+ // could not read is unrecoverable, and the alternative costs one message.
2455
+ //
2456
+ // NARROWER THAN IT LOOKS: `loadEnvConfig` already refuses the whole
2457
+ // `spec-env` command on an unparseable config, naming the file and the
2458
+ // parse position — so this branch is reachable only if the file changes
2459
+ // between that load and this write. It is kept for that race rather than
2460
+ // deleted as dead, and the upstream refusal is what the test asserts.
2461
+ process.stdout.write(
2462
+ `spec-env review allow: ${file} could not be read as JSON (${err.message}) — ` +
2463
+ 'nothing changed.\n',
2464
+ )
2465
+ process.exitCode = 1
2466
+ return
2467
+ }
2468
+ const before = Boolean(
2469
+ parsed.review && parsed.review[key] !== undefined ? parsed.review[key] : config.review[key],
2470
+ )
2471
+ parsed.review = { ...(parsed.review || {}), [key]: on }
2472
+ fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
2473
+
2474
+ const lines = [
2475
+ `spec-env review allow: ${tier} reviews are now ${on ? 'ON' : 'OFF'}` +
2476
+ (before === on ? ' (unchanged)' : ''),
2477
+ // THE ABSOLUTE PATH, not one relative to `dir`. `dir` is re-anchored to the
2478
+ // primary checkout, so run from inside a worktree this writes a file in a
2479
+ // DIFFERENT TREE — and a relative path reads as the tree you are standing
2480
+ // in. Found by running it from a worktree and reverting the surprise.
2481
+ ` wrote: ${file}`,
2482
+ ' that is the primary checkout, whichever tree you ran this from, and the file is',
2483
+ ' COMMITTED — so it changes for everyone who pulls, and leaves that tree dirty.',
2484
+ ]
2485
+ if (key === 'allowNetwork') {
2486
+ lines.push(
2487
+ on
2488
+ ? ' the next render binds every interface, so the page opens on your phone.'
2489
+ : ' the next render binds 127.0.0.1 only, reachable from this machine.',
2490
+ )
2491
+ } else {
2492
+ lines.push(
2493
+ on
2494
+ ? ' this PERMITS publishing; it publishes nothing. A published page cannot be\n' +
2495
+ ' deleted by skitterspec, and a verdict there needs /spec-reviewed.\n' +
2496
+ ' turn it back off with: /spec-remote-review'
2497
+ : ' publishing is no longer permitted; any page already published stays up.\n' +
2498
+ ' turn it back on with: /spec-remote-review',
2499
+ )
2500
+ }
2501
+ process.stdout.write(lines.join('\n') + '\n')
2502
+ }
2503
+
1790
2504
  // `review skip` — move on without a verdict, on the record.
1791
2505
  function specEnvReviewSkip(dir, config, reason, flags) {
1792
2506
  const said = String(reason || '').trim()
@@ -1831,7 +2545,7 @@ function specEnvReviewSkip(dir, config, reason, flags) {
1831
2545
  )
1832
2546
  }
1833
2547
 
1834
- async function specEnvReview(dir, config, specArg, flags) {
2548
+ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
1835
2549
  // REFUSED BY NAME, never coerced to the default. A typo'd button set silently
1836
2550
  // rendering the committing page is the same failure the verdict validator
1837
2551
  // refuses for the same reason: a caller asking for the mid-run page and
@@ -1850,21 +2564,178 @@ async function specEnvReview(dir, config, specArg, flags) {
1850
2564
  // of the wrong spec looks exactly like a review of the right one.
1851
2565
  const spec = resolveSpecWithWorktree(dir, config, specArg)
1852
2566
 
2567
+ // THE SIDECAR PATHS, RESOLVED BEFORE THE WORKTREE CHECK — and that ordering
2568
+ // is the whole of this feature. `.spec-env/reviews/` lives in the PRIMARY
2569
+ // CHECKOUT, keyed by the spec's folder name; not one byte of it comes from
2570
+ // the worktree. `--out` moves the page and its sidecars together.
2571
+ const out = reviewOutPath(dir, spec.folder, flags.out)
2572
+
2573
+ // The other half of a confirmation: a pass the operator says is not theirs.
2574
+ // Left in the store it is reported on every render until they stop reading the
2575
+ // line — which is how the real one gets waved away too.
2576
+ //
2577
+ // AHEAD OF THE WORKTREE CHECK, because a pass that needs disowning has almost
2578
+ // always outlived its spec. `review waiting` finds the stranded ones and tells
2579
+ // the reader to `--drop` them; refusing that on a missing worktree made the
2580
+ // hint impossible to follow for every pass it could find, and ten real passes
2581
+ // sat behind it. Disowning reads and writes this one file, so the tree it was
2582
+ // being refused for was never involved.
2583
+ //
2584
+ // IT DOES NOT RETURN when it succeeds. A spec that still HAS a worktree falls
2585
+ // through to the render exactly as before, so the page is rewritten without
2586
+ // the pass on it; the gate below is what turns a successful drop into an
2587
+ // ending, and only when there is no page left to write.
2588
+ //
2589
+ // WHAT WOULD FOOL THIS: a spec whose FOLDER was deleted rather than completed.
2590
+ // `resolveSpecWithWorktree` above needs a document to resolve a name, so such
2591
+ // a pass stays unreachable by name however this gate is ordered — only
2592
+ // `spec-env review waiting`, which scans the store directly, can see it.
2593
+ // Relaxing the worktree check does not cover that case and was never meant to:
2594
+ // the two absences are different, and only one of them is what the ten
2595
+ // stranded passes were behind.
2596
+ let dropped = null
2597
+ if (flags.drop) {
2598
+ const heldRead = readPending(out, spec.folder)
2599
+ if (heldRead.corrupt) {
2600
+ process.stdout.write(
2601
+ `spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
2602
+ 'move it aside rather than losing the passes it holds.\n',
2603
+ )
2604
+ return
2605
+ }
2606
+ // Same match and the SAME SILENCE as a claim. A drop that listed the codes
2607
+ // it could not find would hand a guesser exactly what the claim withholds.
2608
+ const result = claimPending(heldRead.pending, String(flags.drop).trim())
2609
+ if (!result.pass) {
2610
+ process.stdout.write(
2611
+ `spec-env review: no pending pass with that code` +
2612
+ `${result.count ? ` (${result.count} waiting)` : ''}\n`,
2613
+ )
2614
+ return
2615
+ }
2616
+ writePending(out, result.pending)
2617
+ // Nothing is merged, and the notes sidecar is not touched.
2618
+ dropped = { code: result.pass.code, remaining: result.count }
2619
+ }
2620
+
2621
+ // `--docs` reviews the spec's OWN DOCUMENTS from the tree in hand, and so
2622
+ // never consults a worktree — a spec in `backlog/` has none, which is exactly
2623
+ // the case with no page today.
2624
+ let docs = null
2625
+ if (flags.docs) {
2626
+ docs = resolveSpecDocs(config, spec, invokedFrom)
2627
+ if (docs.error) {
2628
+ process.stdout.write(`spec-env review: ${docs.error}\n`)
2629
+ return
2630
+ }
2631
+ }
2632
+
1853
2633
  // The worktree is what we read; without it there is nothing to say. This is an
1854
2634
  // absence that means something — `git worktree list` is the same source that
1855
2635
  // resolved the path — so it is safe to act on.
1856
- if (!fs.existsSync(spec.worktreePath)) {
2636
+ //
2637
+ // It names `--docs` because for a spec that has landed or has not yet started,
2638
+ // provisioning a worktree is not what the reader wanted: they wanted to read
2639
+ // the spec.
2640
+ // A LIVE SPEC IS THE EXCEPTION, and it is not a loosening of this absence.
2641
+ // `live take` moves the branch into the primary checkout, so the work is
2642
+ // somewhere readable even when the worktree has gone — refusing here would
2643
+ // refuse a spec whose diff is right there. Every other missing worktree still
2644
+ // refuses exactly as before.
2645
+ const liveHere = (() => {
2646
+ try {
2647
+ const st = assertPrimaryOnMain(config, gitReader(dir))
2648
+ return st.onBase === false && st.branch === spec.branch
2649
+ } catch {
2650
+ return false
2651
+ }
2652
+ })()
2653
+ if (!docs && !liveHere && !fs.existsSync(spec.worktreePath)) {
2654
+ // A DROP THAT ALREADY HAPPENED IS AN ENDING, not a refusal. The work this
2655
+ // invocation asked for is done and written; there is simply no page left to
2656
+ // rewrite, which is a fact about the spec rather than a failure of the run.
2657
+ if (dropped) {
2658
+ // THE SAME SPELLING the render uses for the same event. A drop reported
2659
+ // one way here and another way there is two events as far as anyone
2660
+ // grepping their scrollback is concerned.
2661
+ process.stdout.write(
2662
+ `spec-env review: ${spec.folder}\n` +
2663
+ ` dropped: ${dropped.code} — merged nothing` +
2664
+ `${dropped.remaining ? ` (${dropped.remaining} still waiting)` : ''}\n` +
2665
+ ' no worktree, so nothing was rendered.\n',
2666
+ )
2667
+ return
2668
+ }
2669
+
2670
+ // THE REFUSAL NAMES THE EXIT THAT WORKS. It used to say "run /spec-start to
2671
+ // provision it", which for a completed spec is advice to resurrect it in
2672
+ // order to throw a pass away — and that was the only advice a reader holding
2673
+ // a stranded pass ever got.
2674
+ //
2675
+ // A STRANDED PASS STAYS UNCLAIMABLE, deliberately. Once the branch has
2676
+ // merged and the worktree is gone a `commit` verdict has nowhere to land, so
2677
+ // honouring one would report work that did not happen. Disowning is the only
2678
+ // honest ending, so it is the only one offered.
2679
+ // WHICH MESSAGE depends on a POSITIVE SIGNAL — a pass actually sitting in
2680
+ // the store. That is not decoration: the two readers are in opposite
2681
+ // situations, and one sentence cannot serve both.
2682
+ //
2683
+ // No pass waiting is overwhelmingly the commonest case — a spec in
2684
+ // `backlog/` that nobody has started — and there `/spec-start` is exactly
2685
+ // the right advice, so that message is left byte-for-byte as it was.
2686
+ //
2687
+ // A pass waiting means the spec has almost certainly finished, and telling
2688
+ // that reader to provision a worktree is telling them to resurrect a
2689
+ // completed spec in order to throw a pass away. A corrupt store reads as
2690
+ // zero, so it takes the untouched message rather than inventing a count.
2691
+ const held = readPending(out, spec.folder)
2692
+ const count = held.corrupt ? 0 : (held.pending.passes || []).length
2693
+ if (!count) {
2694
+ process.stdout.write(
2695
+ `spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
2696
+ 'run /spec-start to provision it, or --docs to review the spec itself.\n',
2697
+ )
2698
+ return
2699
+ }
1857
2700
  process.stdout.write(
1858
- `spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
1859
- 'run /spec-start to provision it.\n',
2701
+ `spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath}.\n` +
2702
+ ` ${count} waiting — disown ${count === 1 ? 'it' : 'one'} with --drop <code>, ` +
2703
+ 'or `spec-env review waiting` to list them.\n' +
2704
+ // WHY CLAIMING IS NOT OFFERED, said rather than left to be discovered.
2705
+ // A stranded pass is unclaimable by design: with no worktree a `commit`
2706
+ // verdict has nowhere to land, so honouring one would report work that
2707
+ // did not happen. Disowning is the only honest ending, so it is the
2708
+ // only one named.
2709
+ ' It cannot be claimed: with no worktree, a verdict has nowhere to land.\n' +
2710
+ " --docs reviews the spec's own documents.\n",
1860
2711
  )
1861
2712
  return
1862
2713
  }
1863
2714
 
1864
- const git = rawGitReader(spec.worktreePath)
1865
- const trimmed = gitReader(spec.worktreePath)
2715
+ // WHERE THE BRANCH ACTUALLY IS. `live take` detaches the worktree and checks
2716
+ // the branch out in the primary checkout, so while a spec is live the tree
2717
+ // holding its work — including anything uncommitted, since a fix made while
2718
+ // live is made there — is the primary checkout. Reading the worktree then
2719
+ // reports a clean tree and shows the reader nothing.
2720
+ //
2721
+ // WHAT WOULD FOOL THIS: it trusts the branch the primary checkout is on, so a
2722
+ // branch checked out there by hand with no receipt still reads as live. That
2723
+ // is the right answer — the work IS there — and it is the same authority
2724
+ // `assertPrimaryOnMain` and `liveStateFor` use.
2725
+ const primaryState = assertPrimaryOnMain(config, gitReader(dir))
2726
+ const live = liveStateFor(
2727
+ spec,
2728
+ liveContext(dir, config, spec, {
2729
+ onBase: primaryState.onBase,
2730
+ primaryBranch: primaryState.branch,
2731
+ }),
2732
+ )
1866
2733
 
1867
- let mode = 'working'
2734
+ const readFrom = docs ? docs.tree : live.state === 'on' ? dir : spec.worktreePath
2735
+ const git = rawGitReader(readFrom)
2736
+ const trimmed = gitReader(readFrom)
2737
+
2738
+ let mode = docs ? 'docs' : 'working'
1868
2739
  let ref = 'HEAD'
1869
2740
  let base = null
1870
2741
 
@@ -1898,9 +2769,6 @@ async function specEnvReview(dir, config, specArg, flags) {
1898
2769
  mode = 'branch'
1899
2770
  }
1900
2771
 
1901
- // The sidecar is keyed to the page's path, so resolve that first — `--out`
1902
- // moves both together.
1903
- const out = reviewOutPath(dir, spec.folder, flags.out)
1904
2772
  const stored = readNotes(out, spec.folder)
1905
2773
  let notes = stored.notes
1906
2774
  let merged = null
@@ -1947,6 +2815,39 @@ async function specEnvReview(dir, config, specArg, flags) {
1947
2815
  claimCode = window.codes[0]
1948
2816
  }
1949
2817
 
2818
+ // ONE VERDICT PER INVOCATION, and this refuses rather than reconciles. The
2819
+ // three ways a verdict arrives can carry three different words, and there is
2820
+ // no honest rule for picking between them: acting on either would commit
2821
+ // somebody's work on the strength of a word they did not mean as the answer.
2822
+ // Refused BEFORE anything is looked up, so a rejected combination cannot also
2823
+ // spend a pending code or read a blob off disk.
2824
+ if (flags.verdict && (flags.claim || flags.claimSince || flags.notes)) {
2825
+ process.stdout.write(
2826
+ 'spec-env review: --verdict carries one verdict and so does ' +
2827
+ `${flags.notes ? '--notes' : '--claim'} — send one, not both\n`,
2828
+ )
2829
+ return
2830
+ }
2831
+
2832
+ // A VERDICT WITH NOTHING ATTACHED — the word a `file://` page hands over when
2833
+ // the pass it would otherwise build carries nothing but the conclusion. It
2834
+ // joins the same merge below rather than forking: `judgeVerdict` still
2835
+ // refuses a commit over open comments, the log still records it, and the gate
2836
+ // still clears. What it cannot carry is marks, and it does not pretend to —
2837
+ // `merged` stays null, because nothing was.
2838
+ if (flags.verdict) {
2839
+ const word = String(flags.verdict).trim()
2840
+ if (!VERDICTS.includes(word)) {
2841
+ // The engine's own vocabulary, named in full. A word refused without the
2842
+ // list is a typo the reader has to go looking for.
2843
+ process.stdout.write(
2844
+ `spec-env review: verdict "${word}" is not one of ${VERDICTS.join(', ')}\n`,
2845
+ )
2846
+ return
2847
+ }
2848
+ sentVerdict = word
2849
+ }
2850
+
1950
2851
  // A CLAIM IS A DELIVERY MECHANISM, not a second kind of review. It lifts a
1951
2852
  // pass out of the holding area and hands it to exactly the same merge a
1952
2853
  // pasted blob goes through, so nothing downstream can tell — or behave
@@ -1995,37 +2896,27 @@ async function specEnvReview(dir, config, specArg, flags) {
1995
2896
  accepted: parsed.accepted.length,
1996
2897
  unaccepted: parsed.unaccepted.length,
1997
2898
  comments: parsed.comments.length,
2899
+ // THE INSTRUCTION THE PASS CARRIED, and the one field here that is not a
2900
+ // count. An action blob concludes nothing — it asks for something to be
2901
+ // done and hands the page straight back — so a claim that reported only
2902
+ // the counts was indistinguishable from a pass carrying no decision at
2903
+ // all, and `/spec-diff` §2b could never fire. That is what a pressed
2904
+ // `live-on` looked like: "0 accepts, 0 withdrawn, 0 comments".
2905
+ //
2906
+ // `null` rather than absent, deliberately. Every other optional key on
2907
+ // this payload is spread away so an existing consumer sees nothing new,
2908
+ // but `claimed` is itself conditional — anything reading it was written
2909
+ // after this shipped, and a present `null` is what separates "carried no
2910
+ // action" from "an engine too old to say".
2911
+ //
2912
+ // It is NOT a verdict and must never reach `sentVerdict`: `ACTIONS` is
2913
+ // disjoint from `VERDICTS` by construction, and the gate reads the
2914
+ // verdict. This is a field beside it, not a widening of it.
2915
+ action: parsed.action || null,
1998
2916
  }
1999
2917
  merged = { accepted: claimed.accepted, unaccepted: claimed.unaccepted, comments: claimed.comments }
2000
2918
  }
2001
2919
 
2002
- // The other half of a confirmation: a pass the operator says is not theirs.
2003
- // Left in the store it is reported on every render until they stop reading the
2004
- // line — which is how the real one gets waved away too.
2005
- let dropped = null
2006
- if (flags.drop) {
2007
- const heldRead = readPending(out, spec.folder)
2008
- if (heldRead.corrupt) {
2009
- process.stdout.write(
2010
- `spec-env review: ${reviewPendingPath(out)} is not readable JSON — ` +
2011
- 'move it aside rather than losing the passes it holds.\n',
2012
- )
2013
- return
2014
- }
2015
- // Same match and the SAME SILENCE as a claim. A drop that listed the codes
2016
- // it could not find would hand a guesser exactly what the claim withholds.
2017
- const result = claimPending(heldRead.pending, String(flags.drop).trim())
2018
- if (!result.pass) {
2019
- process.stdout.write(
2020
- `spec-env review: no pending pass with that code` +
2021
- `${result.count ? ` (${result.count} waiting)` : ''}\n`,
2022
- )
2023
- return
2024
- }
2025
- writePending(out, result.pending)
2026
- // Nothing is merged, and the notes sidecar is not touched.
2027
- dropped = { code: result.pass.code, remaining: result.count }
2028
- }
2029
2920
 
2030
2921
  if (flags.notes) {
2031
2922
  // Refuse rather than write over notes we could not read: an unreadable
@@ -2109,7 +3000,16 @@ async function specEnvReview(dir, config, specArg, flags) {
2109
3000
  // The log is appended only for a verdict that was ACTED ON. A refused
2110
3001
  // approval did not happen, and recording it as history would leave a
2111
3002
  // trail of decisions the repo never took.
2112
- notes = appendDecision(notes, { verdict: judged.effective, at: new Date().toISOString() })
3003
+ // THE CODE GOES IN THE LOG, and it is the only present thing that lets
3004
+ // the page conclude a pass was picked up. Without it `claimed` could only
3005
+ // be read off the code being gone from the holding area — which `--drop`,
3006
+ // a moved store and a mistyped folder all achieve without anyone claiming
3007
+ // anything. Null for a pasted blob, which carried no code to record.
3008
+ notes = appendDecision(notes, {
3009
+ verdict: judged.effective,
3010
+ at: new Date().toISOString(),
3011
+ code: claimed ? claimed.code : null,
3012
+ })
2113
3013
  writeNotes(out, notes)
2114
3014
  }
2115
3015
  verdictReport = {
@@ -2185,7 +3085,23 @@ async function specEnvReview(dir, config, specArg, flags) {
2185
3085
  const gate = gateNow.corrupt ? null : gateNow.gate
2186
3086
 
2187
3087
  const now = new Date().toISOString()
2188
- let data = collectReview({ spec, git, mode, ref, base, now, notes, gate, buttons })
3088
+ // The surfaces block, on a worktree view only: a `--docs` page belongs to a
3089
+ // spec with no branch to put live (decision 6), so it carries neither key and
3090
+ // renders exactly as it did before this existed.
3091
+ const surfaceArgs = docs ? {} : { live, tiers: pageTiers(config) }
3092
+ let data = collectReview({
3093
+ spec,
3094
+ git,
3095
+ mode,
3096
+ ref,
3097
+ base,
3098
+ now,
3099
+ notes,
3100
+ gate,
3101
+ buttons,
3102
+ ...surfaceArgs,
3103
+ ...(docs ? { only: docs.owned, treePath: docs.tree } : {}),
3104
+ })
2189
3105
 
2190
3106
  // A CLEAN WORKING TREE IS NOT "NOTHING TO REVIEW". It is the state a phase
2191
3107
  // ends in: the page is rendered before the commit, the commit happens
@@ -2201,8 +3117,14 @@ async function specEnvReview(dir, config, specArg, flags) {
2201
3117
  // An explicit `--branch` is never re-interpreted, and a non-empty working tree
2202
3118
  // is never swapped out from under the reader. The swap only ever replaces an
2203
3119
  // empty view, so no information is lost by it.
3120
+ //
3121
+ // `--docs` NEVER FALLS BACK. Its file set is this spec's documents and the
3122
+ // branch range is every file on the branch, so the swap would replace "the
3123
+ // spec you asked for" with "everything this branch changed" — and on the base
3124
+ // branch, where a backlog spec is read, that range is the whole of main. The
3125
+ // empty case is already refused above, so there is nothing here to rescue.
2204
3126
  let fellBack = false
2205
- if (!flags.branch && data.totals.files === 0) {
3127
+ if (!docs && !flags.branch && data.totals.files === 0) {
2206
3128
  const fallbackBase = reviewBase()
2207
3129
  const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
2208
3130
  // Cannot tell -> do nothing, exactly as the `--branch` path refuses. No
@@ -2219,6 +3141,7 @@ async function specEnvReview(dir, config, specArg, flags) {
2219
3141
  notes,
2220
3142
  gate,
2221
3143
  buttons,
3144
+ ...surfaceArgs,
2222
3145
  fellBack: true,
2223
3146
  })
2224
3147
  if (wider.totals.files > 0) {
@@ -2238,8 +3161,79 @@ async function specEnvReview(dir, config, specArg, flags) {
2238
3161
  data.review = JSON.parse(raw)
2239
3162
  }
2240
3163
 
3164
+ // THE MACHINE HALF. It composes with the written review rather than replacing
3165
+ // it: a page can carry a person's read and a tool's findings at once, and
3166
+ // which is which is on every check as its `source`.
3167
+ //
3168
+ // `--docs` deliberately never runs them. A spec document is prose, not the
3169
+ // code these tools read, and spending a rate-limited review on one is waste.
3170
+ let reviewerOutcomes = null
3171
+ if (flags.runReviewers && !docs && (config.review.reviewers || []).length) {
3172
+ const scope = mode === 'branch' ? 'branch' : 'working'
3173
+ const diffHash = diffHashOf(data.files)
3174
+ const { cache, corrupt } = readChecks(out, spec.folder)
3175
+ let checks
3176
+ let outcomes
3177
+ if (!corrupt && cacheHit(cache, spec.folder, diffHash)) {
3178
+ // KEYED ON THE DIFF, NOT A CLOCK. Re-rendering unchanged work must not
3179
+ // spend another review against an hourly limit — and any change at all
3180
+ // must re-run, because findings about code that has moved on are worse
3181
+ // than no findings.
3182
+ checks = cache.checks
3183
+ outcomes = asCached(cache.outcomes)
3184
+ } else {
3185
+ const ran = await runReviewers(config, {
3186
+ worktree: spec.worktreePath,
3187
+ base: base || reviewBase(),
3188
+ scope,
3189
+ spec: spec.folder,
3190
+ adapters: BUNDLED_ADAPTERS,
3191
+ })
3192
+ checks = ran.checks
3193
+ outcomes = ran.outcomes
3194
+ // Best-effort. A cache that could not be written costs a re-run next
3195
+ // time, which is the harmless direction — it must never fail the render.
3196
+ try {
3197
+ writeChecks(out, {
3198
+ version: CHECKS_VERSION,
3199
+ spec: spec.folder,
3200
+ diffHash,
3201
+ at: now,
3202
+ checks,
3203
+ outcomes,
3204
+ })
3205
+ } catch {
3206
+ /* a cache is an optimisation, never a record */
3207
+ }
3208
+ }
3209
+ reviewerOutcomes = outcomes
3210
+ // The written review's checks lead — a person's read is what the reader
3211
+ // came for — and the machine's follow, grouped by the reviewer that
3212
+ // produced them because `runReviewers` runs in sequence.
3213
+ data.review = {
3214
+ ...(data.review || {}),
3215
+ checks: [...((data.review && data.review.checks) || []), ...checks],
3216
+ reviewers: outcomes,
3217
+ }
3218
+ }
3219
+
2241
3220
  writeReviewPage(out, renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }))
2242
3221
 
3222
+ // RECORD WHAT THIS RENDER DECLARED, for the served copy to read back. The
3223
+ // daemon re-renders per request and is not the caller, so without this it can
3224
+ // only see what the work IS — enough for `authoring`, `nospec` and
3225
+ // `committing`, and blind to `midrun` and `refresh`, which say whether the run
3226
+ // had finished. `buttonsForView` takes only those two from here.
3227
+ //
3228
+ // Best-effort, like the reviewer cache: a record that could not be written
3229
+ // costs a served page its narrowing, which is the harmless direction, and it
3230
+ // must never fail a render.
3231
+ try {
3232
+ writeRenderRecord(out, { spec: spec.folder, buttons, at: now })
3233
+ } catch {
3234
+ /* the page is the artefact; this is a note for the daemon */
3235
+ }
3236
+
2243
3237
  // The publish-ready copy, ONLY when asked. An ordinary render must not pay for
2244
3238
  // a second copy of the whole diff on disk for a path most renders never take.
2245
3239
  let publishCopy = null
@@ -2269,8 +3263,41 @@ async function specEnvReview(dir, config, specArg, flags) {
2269
3263
  // nothing at all: the ordinary render must read exactly as it did before any
2270
3264
  // of this existed.
2271
3265
  let serverSaid = null
2272
- if (reader.reader === 'remote' && config.review.serveOnRemote) {
2273
- const up = await ensureReviewServer(dir, config, { host: '0.0.0.0' })
3266
+ // EVERY RENDER SERVES, because a `file://` page has no server to POST to and
3267
+ // so its verdict buttons have nowhere to go. The reader is not consulted here
3268
+ // any more — that gate is what handed a local machine a page it could read
3269
+ // and not answer.
3270
+ //
3271
+ // THE BIND STILL COMES FROM THE READER, and that is what makes this free of
3272
+ // new exposure: a remote reader binds every interface exactly as before, and
3273
+ // a local or unknown one binds loopback — `http://127.0.0.1` instead of
3274
+ // `file://`, which opens on the machine holding the page and, unlike
3275
+ // `file://`, can POST. Cannot-tell binds loopback, the harmless direction
3276
+ // (`.claude/rules/negative-checks.md` rule 4).
3277
+ // WHY THERE IS NO SERVED URL, when there is none. The `file://` line used to
3278
+ // mean "nobody asked for serving"; now that every render asks, it can only
3279
+ // mean the ask did not land — so the render says which: the operator turned
3280
+ // it off, or it failed and here is the reason.
3281
+ let noServeBecause = config.review.serve === 'never' ? 'review.serve is "never"' : null
3282
+ if (config.review.serve === 'always') {
3283
+ // THE BIND COMES FROM THE SETTING, not from a guess about the reader. That
3284
+ // guess was the last decision detection had, and it was wrong every time the
3285
+ // reader moved — a phone off the LAN, a local session reading a page whose
3286
+ // buttons cannot POST. `allowNetwork` is the project saying which surfaces
3287
+ // it permits, and the reader picks from what is listed.
3288
+ const host = config.review.allowNetwork ? '0.0.0.0' : '127.0.0.1'
3289
+ const up = await ensureReviewServer(dir, config, { host })
3290
+ // TRANSLATED, because `error` is a code for a caller and this line is read
3291
+ // by a person: `busy` alone does not say which port, and the port is the
3292
+ // whole of what they can act on. An unmapped code is passed through rather
3293
+ // than swallowed — a reason nobody anticipated still beats silence.
3294
+ if (up.error === 'busy') {
3295
+ noServeBecause = `port ${up.port} is already in use`
3296
+ } else if (up.error === 'unreadable') {
3297
+ noServeBecause = `a server is running (pid ${up.pid}) whose settings could not be read`
3298
+ } else if (up.error) {
3299
+ noServeBecause = String(up.error)
3300
+ }
2274
3301
  if (up.replaced === 'engine') {
2275
3302
  serverSaid = up.error
2276
3303
  ? // BOTH facts. A reader told only "could not start" cannot see why it
@@ -2284,7 +3311,7 @@ async function specEnvReview(dir, config, specArg, flags) {
2284
3311
  // The URLs come from the bind the server HAS, not the one asked for just
2285
3312
  // above — adoption can hand back a loopback server whatever was
2286
3313
  // requested. See `reviewServedUrls`.
2287
- const urls = reviewServedUrls(up, lanAddresses(), spec.folder)
3314
+ const urls = reviewServedUrls(up, offerableLanAddresses(), spec.folder)
2288
3315
  if (urls) {
2289
3316
  served = { ...urls, port: up.port, token: up.token, started: up.started }
2290
3317
  }
@@ -2297,15 +3324,38 @@ async function specEnvReview(dir, config, specArg, flags) {
2297
3324
  {
2298
3325
  spec: spec.folder,
2299
3326
  branch: spec.branch,
2300
- worktree: spec.worktreePath,
3327
+ // The tree the diff was read from — `spec.worktreePath` for every
3328
+ // mode but `docs`, where it would name a path that does not exist.
3329
+ worktree: readFrom,
2301
3330
  mode,
2302
3331
  base,
2303
3332
  fellBack,
3333
+ // The paths a committing verdict on this page must commit, so the
3334
+ // skill that routes on it never recomputes the set the reader saw.
3335
+ // Absent for every other mode, which keeps their output identical.
3336
+ ...(docs ? { docs: { paths: docs.owned } } : {}),
2304
3337
  out,
2305
3338
  publishCopy,
2306
3339
  reader: reader.reader,
2307
3340
  readerWhy: reader.why,
2308
3341
  served,
3342
+ // The same stack the text prints, so a skill reads the tiers rather
3343
+ // than parsing prose — and the two can never disagree, because both
3344
+ // come from `reviewTierStack`.
3345
+ tiers: reviewTierStack({
3346
+ served,
3347
+ fileUrl: reviewFileUrl(out),
3348
+ publishedUrl: url,
3349
+ config,
3350
+ }),
3351
+ // The same answer `live status --json` gives, from the same function,
3352
+ // so the page and the command cannot disagree. Always present, unlike
3353
+ // the tiers' optional keys: `unavailable` is a state a consumer needs
3354
+ // to see rather than an absence it has to interpret.
3355
+ live,
3356
+ // Absent when there IS a served URL, so a consumer that only ever
3357
+ // saw a served render sees no new key.
3358
+ ...(served ? {} : noServeBecause ? { notServed: noServeBecause } : {}),
2309
3359
  ...(serverSaid ? { server: serverSaid } : {}),
2310
3360
  fileUrl: reviewFileUrl(out),
2311
3361
  urlFile,
@@ -2316,6 +3366,10 @@ async function specEnvReview(dir, config, specArg, flags) {
2316
3366
  // reporting it would make every existing consumer see a new key.
2317
3367
  ...(data.buttons ? { buttons: data.buttons } : {}),
2318
3368
  reviewed: Boolean(data.review),
3369
+ // Absent unless reviewers actually ran, so a consumer that never
3370
+ // configured one sees no new key. An empty ARRAY would be a claim
3371
+ // that they ran and said nothing.
3372
+ ...(reviewerOutcomes ? { reviewers: reviewerOutcomes } : {}),
2319
3373
  totals: data.totals,
2320
3374
  notes: data.notes,
2321
3375
  merged,
@@ -2375,13 +3429,22 @@ async function specEnvReview(dir, config, specArg, flags) {
2375
3429
  : '') +
2376
3430
  (claimed
2377
3431
  ? ` claimed: ${claimed.code} — ${claimed.accepted} accept${claimed.accepted === 1 ? '' : 's'}, ` +
2378
- `${claimed.unaccepted} withdrawn, ${claimed.comments} comment${claimed.comments === 1 ? '' : 's'}\n`
3432
+ `${claimed.unaccepted} withdrawn, ${claimed.comments} comment${claimed.comments === 1 ? '' : 's'}` +
3433
+ // Last on the line rather than folded into the counts: a reader
3434
+ // scanning for what a press did finds the verb at the end of the one
3435
+ // line the claim prints, and a pass with no action reads exactly as it
3436
+ // did before this existed.
3437
+ (claimed.action ? ` · action: ${claimed.action}` : '') +
3438
+ '\n'
2379
3439
  : '') +
2380
3440
  (dropped ? ` dropped: ${dropped.code} — merged nothing\n` : '') +
2381
3441
  (waiting.length
2382
3442
  ? ` pending: ${waiting.length} waiting\n` +
2383
3443
  waiting
2384
- .map((p) => ` ${p.code} · ${p.verdict || 'no verdict'} · ${pendingAge(p.at, now)}\n`)
3444
+ .map(
3445
+ (p) =>
3446
+ ` ${p.code} · ${p.action || p.verdict || 'no verdict'} · ${pendingAge(p.at, now)}\n`,
3447
+ )
2385
3448
  .join('')
2386
3449
  : '') +
2387
3450
  (outcomeSaid ? ` outcome: ${outcomeSaid}\n` : '') +
@@ -2397,39 +3460,45 @@ async function specEnvReview(dir, config, specArg, flags) {
2397
3460
  ? ''
2398
3461
  : ` reader: ${reader.reader}${reader.why ? ` (${reader.why})` : ''}\n`) +
2399
3462
  ` page: ${out}\n` +
2400
- // Served: the `open:` line is a URL the reader can actually use, and
2401
- // `page:` above still says where the file is. Not served — including every
2402
- // way serving can fail — falls back to exactly the output this printed
2403
- // before, dead link and all: that is the floor, never made worse.
2404
- (served
2405
- ? ` open: ${served.url}\n` +
2406
- // Said only when there is a runner-up. One address is not a choice,
2407
- // and an `also:` line naming nothing reads as a warning.
2408
- (served.alternates.length
2409
- ? served.alternates.map((u) => ` also: ${u}\n`).join('')
2410
- : '') +
2411
- // A loopback server is reachable from this machine and nowhere else.
2412
- // Said here rather than left to be discovered by a phone that cannot
2413
- // open the URL — and it names the command instead of describing it.
2414
- (served.loopback
2415
- ? ' local only: this server is bound to 127.0.0.1 — not reachable from your phone.\n' +
2416
- ` widen: ${served.widen}\n`
2417
- : '') +
2418
- (served.started && !served.loopback
2419
- ? ' serving: every provisioned spec, to anyone with this URL on your network.\n' +
2420
- ' stop: skitterspec spec-env review serve --stop\n'
2421
- : '') +
2422
- // One line, and only when something was actually done on the reader's
2423
- // behalf. An action nobody asked for is reported, not hidden — the
2424
- // same rule teardown follows.
2425
- (serverSaid ? ` ${serverSaid}\n` : '')
2426
- : ` open: ${reviewFileUrl(out)}${
2427
- reader.reader === 'remote' ? ' (will not open where you are reading)' : ''
2428
- }\n` +
2429
- (serverSaid ? ` ${serverSaid}\n` : '') +
2430
- (reader.reader === 'remote'
2431
- ? ' serve: skitterspec spec-env review serve --host 0.0.0.0\n'
2432
- : '')) +
3463
+ // THE STACK, in a fixed order, every tier named. The single `open:` line
3464
+ // it replaces asked the engine to pick which surface the reader could
3465
+ // use, and the engine cannot know — that guess failed three separate ways
3466
+ // in one day. Now every tier is listed, labelled, and either a URL or the
3467
+ // one command that turns it on.
3468
+ reviewTierStack({
3469
+ served,
3470
+ fileUrl: reviewFileUrl(out),
3471
+ publishedUrl: url,
3472
+ config,
3473
+ })
3474
+ .map((t) => reviewTierLine(t) + '\n')
3475
+ .join('') +
3476
+ // The runners-up sit UNDER the network tier, because that is what they are
3477
+ // alternatives to — and virtual adapters are gone from them entirely.
3478
+ (served && !served.loopback && (served.alternates || []).length
3479
+ ? served.alternates.map((u) => ` also: ${u}\n`).join('')
3480
+ : '') +
3481
+ // WHICH TIERS THE WAIT COVERS, once, next to the stack. `local` and
3482
+ // `network` are two doors into one room — the page POSTs to
3483
+ // `location.pathname`, so both reach this server and this pending store.
3484
+ // `remote` writes to the artifact's own store, which nothing here can see.
3485
+ ' the wait covers local + network; a remote verdict needs /spec-reviewed.\n' +
3486
+ // WHETHER IT IS ALSO RUNNING SOMEWHERE, which is the other way to judge a
3487
+ // change. `unavailable` prints nothing — see `liveStateLine`.
3488
+ (liveStateLine(live) ? `${liveStateLine(live)}\n` : '') +
3489
+ // The reason there is no served URL, when there is none. A `file://` link
3490
+ // with nothing said about it reads as the ordinary outcome, and it is not.
3491
+ (!served && noServeBecause ? ` not served: ${noServeBecause}\n` : '') +
3492
+ (served && served.loopback && config.review.allowNetwork
3493
+ ? ` network permitted but the running server is loopback-bound — ${served.widen}\n`
3494
+ : '') +
3495
+ (served && served.started && !served.loopback
3496
+ ? ' serving: every provisioned spec, to anyone with a URL on your network.\n' +
3497
+ ' stop: skitterspec spec-env review serve --stop\n'
3498
+ : '') +
3499
+ // One line, and only when something was actually done on the reader's
3500
+ // behalf. An action nobody asked for is reported, not hidden.
3501
+ (serverSaid ? ` ${serverSaid}\n` : '') +
2433
3502
  // Named on its own line so the skill never has to build the path itself.
2434
3503
  (publishCopy ? ` publish: ${publishCopy}\n` : '') +
2435
3504
  (url ? ` published: ${url}\n` : '') +
@@ -2505,6 +3574,61 @@ function stateDirLabel(config) {
2505
3574
  return path.posix.dirname(config.registry) || '.spec-env'
2506
3575
  }
2507
3576
 
3577
+ /**
3578
+ * The engine script's mtime in ms, or null when it cannot be read.
3579
+ *
3580
+ * WHY MTIME AND NOT A CONTENT HASH: the dist is assembled from several files,
3581
+ * so a hash needs a manifest to stay honest, and this answers the only question
3582
+ * asked — is the code on disk newer than the process serving it? WHY NOT THE
3583
+ * PROCESS START TIME: `ps -o lstart=` is platform-specific and needs parsing,
3584
+ * while this is a number we write ourselves into a file we already write.
3585
+ *
3586
+ * Null is cannot-tell and adopts. See `staleServer`.
3587
+ */
3588
+ function scriptMtimeOf(scriptPath) {
3589
+ try {
3590
+ return fs.statSync(scriptPath).mtimeMs
3591
+ } catch {
3592
+ return null
3593
+ }
3594
+ }
3595
+
3596
+ /**
3597
+ * This repo's serve token, minted once and then read.
3598
+ *
3599
+ * STORED, NOT DERIVED, and the asymmetry with the port is the point. The port
3600
+ * is `PORT_BASE + hash(realpath(repo)) % PORT_SPAN` because a port is not a
3601
+ * secret and a stable one keeps a handed-out link working. The token is the
3602
+ * only guard on a non-loopback bind, and a repo path is guessable by anyone on
3603
+ * the machine — so deriving it the same way would buy stability with the single
3604
+ * property it exists for. It keeps all 48 random bits; only its lifetime moved.
3605
+ *
3606
+ * WHAT WOULD FOOL THIS: a token file someone has emptied or truncated. A short
3607
+ * or non-hex value is treated as absent and replaced, because a malformed token
3608
+ * cannot guard anything — and the alternative, refusing to serve, would take
3609
+ * the page away over a file nobody reads.
3610
+ */
3611
+ function repoToken(dir, config) {
3612
+ const file = path.resolve(dir, `${stateDirLabel(config)}/review-token`)
3613
+ try {
3614
+ const found = String(fs.readFileSync(file, 'utf8')).trim()
3615
+ if (/^[0-9a-f]{12}$/.test(found)) return found
3616
+ } catch {
3617
+ // Absent is the ordinary first-run state, not a problem.
3618
+ }
3619
+ const minted = mintToken()
3620
+ try {
3621
+ fs.mkdirSync(path.dirname(file), { recursive: true })
3622
+ fs.writeFileSync(file, minted + '\n')
3623
+ } catch {
3624
+ // UNWRITEABLE IS NOT FATAL. The server can still serve on this token for
3625
+ // as long as it lives; what is lost is only the survival across a restart,
3626
+ // which is exactly where this started. Taking the page away instead would
3627
+ // be a worse answer to a read-only `.spec-env`.
3628
+ }
3629
+ return minted
3630
+ }
3631
+
2508
3632
  // The supervised proxy process descriptor (paths relative to the checkout root).
2509
3633
  function proxyProcFor(config, routesFileAbs) {
2510
3634
  const sdir = stateDirLabel(config)
@@ -2639,6 +3763,27 @@ function lanAddresses(nets = require('node:os').networkInterfaces()) {
2639
3763
  return rankLanAddresses(nets).map((e) => e.address)
2640
3764
  }
2641
3765
 
3766
+ /**
3767
+ * The addresses worth OFFERING as alternatives — physical and unknown
3768
+ * interfaces, never virtual ones.
3769
+ *
3770
+ * The ranking already puts virtuals last, so the best guess was right; what was
3771
+ * wrong was listing them at all. A render offered `10.211.55.2` and
3772
+ * `10.37.129.2` — both Parallels adapters — as alternatives to a working link,
3773
+ * and no phone can route to either. An alternative that cannot work is worse
3774
+ * than no alternative: it reads as something to try when the first one fails.
3775
+ *
3776
+ * WHAT WOULD FOOL THIS: an interface named outside both patterns is `unknown`
3777
+ * and therefore KEPT, because a machine with unusual naming is more likely to
3778
+ * have a real address than a fake one — being wrong that way offers one dud,
3779
+ * where the opposite hides the only address that works.
3780
+ */
3781
+ function offerableLanAddresses(nets = require('node:os').networkInterfaces()) {
3782
+ return rankLanAddresses(nets)
3783
+ .filter((e) => !VIRTUAL_IFACE.test(e.iface))
3784
+ .map((e) => e.address)
3785
+ }
3786
+
2642
3787
  /**
2643
3788
  * Bring the review server up, or adopt the one already running.
2644
3789
  *
@@ -2679,11 +3824,16 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
2679
3824
  if (settings && settings.port) {
2680
3825
  if (serverScriptOk(settings)) {
2681
3826
  const now = engineVersionFor(proc.script)
2682
- const verdict = staleServer(settings.engine, now)
3827
+ const verdict = staleServer(settings.engine, now, settings.scriptMtime, scriptMtimeOf(proc.script))
2683
3828
  if (verdict !== 'stale') {
2684
3829
  const lb = settings.host === '127.0.0.1' || settings.host === 'localhost'
2685
3830
  return {
2686
3831
  port: settings.port,
3832
+ // How the RUNNING server's port was chosen, as it recorded it. A
3833
+ // server from before this was written has no answer; `null` says so
3834
+ // rather than recomputing one, because a recomputed answer could
3835
+ // disagree with the port actually being served.
3836
+ portSource: settings.portSource || null,
2687
3837
  token: settings.token || null,
2688
3838
  loopback: lb,
2689
3839
  pid: running,
@@ -2721,13 +3871,29 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
2721
3871
  }
2722
3872
  }
2723
3873
 
2724
- const usePort = Number(port || config.review.servePort)
3874
+ // `--port` wins, then an explicit `review.servePort` number, then the
3875
+ // derivation. `source` is recorded in the settings file below so `--status`
3876
+ // can say WHY this port, rather than leaving the operator to read config.js.
3877
+ const resolved = resolveServePort(config, dir, port)
3878
+ const usePort = resolved.port
2725
3879
  const loopback = host === '127.0.0.1' || host === 'localhost'
2726
3880
  // The token is the ONLY guard on a non-loopback bind, so it is minted with the
2727
- // bind rather than offered as an option to forget — except when replacing a
2728
- // server on the same bind, where carrying the old one keeps a link that is
2729
- // already open on someone's phone alive.
2730
- const token = loopback ? null : reuseToken || mintToken()
3881
+ // bind rather than offered as an option to forget. It is read from the repo's
3882
+ // own store, so it OUTLIVES THIS PROCESS: the port beside it is a pure
3883
+ // function of the repo path and stable across a restart by design, and a URL
3884
+ // whose two halves disagree about that is a URL that dies for no reason the
3885
+ // reader can see. Six were handed out for one repo in a single session.
3886
+ //
3887
+ // `reuseToken` still wins where a server is being replaced on the same bind —
3888
+ // now redundant rather than wrong, and left alone as its own guarantee.
3889
+ //
3890
+ // EVERY BIND CARRIES IT, loopback included, and on loopback it guards nothing:
3891
+ // a server reachable only from this machine needs no credential. It is there
3892
+ // so the URL has ONE SHAPE. The bind comes from reader detection, and that
3893
+ // detection flipped `unknown` → `remote` inside a single session — so a
3894
+ // token-only-on-network rule meant the same repo's address gained and lost a
3895
+ // path segment underneath whoever was holding it.
3896
+ const token = reuseToken || repoToken(dir, config)
2731
3897
 
2732
3898
  if (running) await stopProcess(proc, { rootDir: dir })
2733
3899
 
@@ -2752,7 +3918,8 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
2752
3918
  // server refused to start on every Linux machine while macOS stayed green.
2753
3919
  // `portsInUseOn` owns the ordering; see its comment for the verification.
2754
3920
  const busy = await portsInUseOn(usePort, [host, '127.0.0.1'])
2755
- if (busy.length) return { error: 'busy', port: usePort, replaced, engineWas }
3921
+ if (busy.length)
3922
+ return { error: 'busy', port: usePort, portSource: resolved.source, replaced, engineWas }
2756
3923
 
2757
3924
  fs.mkdirSync(path.dirname(abs(settingsFile)), { recursive: true })
2758
3925
  fs.writeFileSync(
@@ -2764,7 +3931,18 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
2764
3931
  // — the process is alive, the file exists, and every page it renders is
2765
3932
  // drawn by code that was replaced underneath it.
2766
3933
  JSON.stringify(
2767
- { dir, port: usePort, host, token, script: proc.script, engine: engineVersionFor(proc.script) },
3934
+ {
3935
+ dir,
3936
+ port: usePort,
3937
+ portSource: resolved.source,
3938
+ host,
3939
+ token,
3940
+ script: proc.script,
3941
+ engine: engineVersionFor(proc.script),
3942
+ // Recorded so the NEXT adoption can tell a rebuild from a restart. A
3943
+ // version alone cannot: the dist is rebuilt at the same version all day.
3944
+ scriptMtime: scriptMtimeOf(proc.script),
3945
+ },
2768
3946
  null,
2769
3947
  2,
2770
3948
  ) + '\n',
@@ -2815,6 +3993,25 @@ async function specEnvReviewServe(dir, config, flags) {
2815
3993
  const pid = readPid(abs(proc.pidFile))
2816
3994
  const running = pid && isAlive(pid) ? pid : null
2817
3995
 
3996
+ // ROTATION IS THE ONLY WAY TO CHANGE A TOKEN, and it says what it costs.
3997
+ // Every other path reads the stored one; a silent mint is the whole bug this
3998
+ // replaced, so the one place that mints deliberately announces it.
3999
+ if (flags.rotateToken) {
4000
+ const file = abs(`${sdir}/review-token`)
4001
+ const minted = mintToken()
4002
+ fs.mkdirSync(path.dirname(file), { recursive: true })
4003
+ fs.writeFileSync(file, minted + '\n')
4004
+ process.stdout.write(
4005
+ 'spec-env review serve: token rotated.\n' +
4006
+ ' EVERY LINK ALREADY HANDED OUT IS NOW DEAD — re-render and send the new one.\n' +
4007
+ (running
4008
+ ? ` the running server (pid ${running}) still answers on the old token; ` +
4009
+ '--stop it, then render again.\n'
4010
+ : ''),
4011
+ )
4012
+ return
4013
+ }
4014
+
2818
4015
  if (flags.status) {
2819
4016
  if (!running) {
2820
4017
  process.stdout.write('spec-env review serve: not running.\n')
@@ -2828,8 +4025,16 @@ async function specEnvReviewServe(dir, config, flags) {
2828
4025
  // ordinary answer and needs no comment; `unknown` is a server from before
2829
4026
  // this was recorded, which is healthy and must not be accused of anything.
2830
4027
  const verdict = staleServer(settings.engine, engineVersionFor(proc.script))
4028
+ // The port AND how it was chosen, so "why is this on 7742?" is answered by
4029
+ // the tool. A server started before `portSource` was recorded has no answer
4030
+ // — the line then carries the port alone rather than a guess, because an
4031
+ // absence is not evidence of any particular source.
4032
+ const reason = servePortReason(settings.portSource)
2831
4033
  process.stdout.write(
2832
4034
  `spec-env review serve: running (pid ${running})\n` +
4035
+ (settings.port
4036
+ ? ` port: ${settings.port}${reason ? ` (${reason})` : ''}\n`
4037
+ : '') +
2833
4038
  (settings.port ? ` local: ${serveUrl('127.0.0.1', settings)}\n` : '') +
2834
4039
  (settings.engine ? ` engine: ${settings.engine}\n` : '') +
2835
4040
  (verdict === 'stale'
@@ -2863,9 +4068,19 @@ async function specEnvReviewServe(dir, config, flags) {
2863
4068
  })
2864
4069
 
2865
4070
  if (started.error === 'busy') {
4071
+ // NAME `servePort`, not only `--port`. `--port` moves this run aside and
4072
+ // leaves every link already handed out pointing at the busy port — which is
4073
+ // the bug this refusal used to send people straight into. Pinning
4074
+ // `review.servePort` is the durable fix: it is what the next link is built
4075
+ // from, so the move happens once and the links follow it.
2866
4076
  process.stdout.write(
2867
4077
  `spec-env review serve: port ${started.port} is already in use — ` +
2868
- 'pass --port, or --stop if this is an older server.\n',
4078
+ (started.portSource === 'derived'
4079
+ ? 'two repos derived the same port. '
4080
+ : '') +
4081
+ 'pin a free one in specs/.core/env.config.json ("review": { "servePort": <n> }) ' +
4082
+ 'so the links follow, or --stop if this is an older server.\n' +
4083
+ ' --port <n> moves this run only, and leaves existing links on the busy port.\n',
2869
4084
  )
2870
4085
  return
2871
4086
  }
@@ -2912,8 +4127,13 @@ async function specEnvReviewServe(dir, config, flags) {
2912
4127
  */
2913
4128
  function reviewServedUrls(up, addrs, folder) {
2914
4129
  const page = (host) => `${serveUrl(host, up)}${encodeURIComponent(folder)}`
4130
+ // ALWAYS AVAILABLE WHEN SERVED. A server bound to 0.0.0.0 answers on loopback
4131
+ // too, so `local` is a real tier in both binds — it is not an alternative to
4132
+ // the network URL, it is the same page from the machine holding it.
4133
+ const loopbackUrl = page('127.0.0.1')
2915
4134
  if (up.loopback) {
2916
4135
  return {
4136
+ loopbackUrl,
2917
4137
  url: page('127.0.0.1'),
2918
4138
  // No runners-up: every other address on this machine is one the server
2919
4139
  // is not listening on.
@@ -2926,8 +4146,14 @@ function reviewServedUrls(up, addrs, folder) {
2926
4146
  // and can be wrong, so the alternates are offered rather than thrown away. No
2927
4147
  // address at all means nothing to offer, and the `file://` fallback is the
2928
4148
  // honest answer.
2929
- if (!addrs.length) return null
2930
- return { url: page(addrs[0]), alternates: addrs.slice(1).map(page), loopback: false, widen: null }
4149
+ if (!addrs.length) return { loopbackUrl, url: null, alternates: [], loopback: false, widen: null }
4150
+ return {
4151
+ loopbackUrl,
4152
+ url: page(addrs[0]),
4153
+ alternates: addrs.slice(1).map(page),
4154
+ loopback: false,
4155
+ widen: null,
4156
+ }
2931
4157
  }
2932
4158
 
2933
4159
  /**
@@ -3062,6 +4288,26 @@ async function specEnvConnect(dir, config, specArg) {
3062
4288
 
3063
4289
  // Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
3064
4290
  // message when the feature isn't enabled (no specs/.core/env.config.json).
4291
+ // Is the project's configured compose file actually on disk?
4292
+ //
4293
+ // Read from the PRIMARY CHECKOUT rather than the worktree, and that is forced
4294
+ // rather than chosen: a fresh provision plans before `git worktree add` runs, so
4295
+ // the worktree does not exist yet to be looked in. A compose file is committed
4296
+ // at the repo root in every layout this supports, so the two answers agree
4297
+ // wherever both could be read.
4298
+ //
4299
+ // Any failure to look is `undefined` — cannot-tell, which callers route to the
4300
+ // branch that does not escalate — never `false`.
4301
+ function composeFilePresent(dir, config) {
4302
+ const file = config && config.docker && config.docker.composeFile
4303
+ if (!file) return undefined
4304
+ try {
4305
+ return fs.existsSync(path.join(dir, file))
4306
+ } catch {
4307
+ return undefined
4308
+ }
4309
+ }
4310
+
3065
4311
  // Run a mutating git command; return { ok, err } (stderr captured, not swallowed).
3066
4312
  function runGit(cwd, args) {
3067
4313
  try {
@@ -3081,7 +4327,7 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
3081
4327
  // feature. The branch that's checked out IS the lock (assertPrimaryOnMain); the
3082
4328
  // receipt is advisory metadata. `status` is read-only; `take` performs the switch
3083
4329
  // (release/abort land in a later phase).
3084
- async function specEnvLive(dir, config, positional) {
4330
+ async function specEnvLive(dir, config, positional, flags) {
3085
4331
  // Both verbs exist to route around the work living somewhere other than the
3086
4332
  // checkout you are in — a proxy to a second stack, or a temporary branch swap.
3087
4333
  // Checkout mode closes that gap permanently, so there is nothing to route.
@@ -3100,10 +4346,10 @@ async function specEnvLive(dir, config, positional) {
3100
4346
  // report cannot describe. It prints ABOVE the report, not instead of it:
3101
4347
  // you asked a question and should still get the answer.
3102
4348
  if (note) process.stdout.write(note)
3103
- specEnvLiveStatus(dir, config, specArg)
4349
+ specEnvLiveStatus(dir, config, specArg, flags)
3104
4350
  break
3105
4351
  case 'take':
3106
- await specEnvLiveTake(dir, config, specArg)
4352
+ await specEnvLiveTake(dir, config, specArg, flags)
3107
4353
  break
3108
4354
  case 'release':
3109
4355
  await specEnvLiveRelease(dir, config, specArg)
@@ -3192,7 +4438,7 @@ function bareLive(dir, config) {
3192
4438
 
3193
4439
  // Take the running instance: rebase the spec's branch onto base, free it from its
3194
4440
  // worktree, and check it out in the primary checkout so the dev server reloads it.
3195
- async function specEnvLiveTake(dir, config, specArg) {
4441
+ async function specEnvLiveTake(dir, config, specArg, flags = {}) {
3196
4442
  const spec = resolveSpecWithWorktree(dir, config, specArg)
3197
4443
 
3198
4444
  // Probe the primary checkout's git state (IO stays here; the planner is pure).
@@ -3240,7 +4486,34 @@ async function specEnvLiveTake(dir, config, specArg) {
3240
4486
  })
3241
4487
 
3242
4488
  if (plan.blocked) {
3243
- process.stdout.write(`spec-env live take: blocked — ${plan.reason}.\n`)
4489
+ // A MACHINE-READABLE REFUSAL, so a caller reads the offer rather than
4490
+ // parsing the sentence. `take` had no `--json` at all, and a skill
4491
+ // scraping `blocked — …` prose for a command to run is exactly the kind of
4492
+ // second implementation that drifts out of step with the first.
4493
+ if (flags.json) {
4494
+ process.stdout.write(
4495
+ JSON.stringify(
4496
+ {
4497
+ spec: spec.folder,
4498
+ blocked: true,
4499
+ reason: plan.reason,
4500
+ // Absent stays absent — most refusals have no way out to declare.
4501
+ ...(plan.offer ? { offer: plan.offer } : {}),
4502
+ },
4503
+ null,
4504
+ 2,
4505
+ ) + '\n',
4506
+ )
4507
+ return
4508
+ }
4509
+ // THE OFFER IS NAMED IN THE TEXT TOO, not only in `--json`. A reader in a
4510
+ // plain terminal is the one person a picker cannot reach, and they are
4511
+ // entitled to the same way out — so the refusal prints what it would have
4512
+ // offered rather than making a harness the only route to it.
4513
+ process.stdout.write(
4514
+ `spec-env live take: blocked — ${plan.reason}.\n` +
4515
+ (plan.offer ? ` ${plan.offer.label}: ${plan.offer.command}\n` : ''),
4516
+ )
3244
4517
  return
3245
4518
  }
3246
4519
 
@@ -3404,8 +4677,9 @@ async function specEnvLiveAbort(dir, config) {
3404
4677
  )
3405
4678
  }
3406
4679
 
3407
- function specEnvLiveStatus(dir, config, specArg) {
4680
+ function specEnvLiveStatus(dir, config, specArg, flags) {
3408
4681
  const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
4682
+ const json = Boolean(flags && flags.json)
3409
4683
 
3410
4684
  // Per-spec query (`live status <spec>`): a clear yes/no verdict /spec-start and
3411
4685
  // skill branches on to decide whether to skip worktree provisioning and work in
@@ -3413,6 +4687,26 @@ function specEnvLiveStatus(dir, config, specArg) {
3413
4687
  if (specArg) {
3414
4688
  const spec = resolveSpecWithWorktree(dir, config, specArg)
3415
4689
  const live = !onBase && branch === spec.branch
4690
+ // `--json` answers with the SAME function the review render uses, so a page
4691
+ // and this command can never disagree about whether a spec is live. The
4692
+ // text form above is left exactly as it was: `/spec-start` reads its
4693
+ // `live: yes|no` line, and a flag must not move a seam.
4694
+ if (json) {
4695
+ process.stdout.write(
4696
+ JSON.stringify(
4697
+ {
4698
+ spec: spec.folder,
4699
+ branch: spec.branch,
4700
+ primary: branch || null,
4701
+ base: baseBranch,
4702
+ live: liveStateFor(spec, liveContext(dir, config, spec, { onBase, primaryBranch: branch })),
4703
+ },
4704
+ null,
4705
+ 2,
4706
+ ) + '\n',
4707
+ )
4708
+ return
4709
+ }
3416
4710
  process.stdout.write(
3417
4711
  `spec-env live status: ${spec.folder}\n` +
3418
4712
  ` spec: ${spec.folder} (branch ${spec.branch})\n` +
@@ -3443,6 +4737,26 @@ function specEnvLiveStatus(dir, config, specArg) {
3443
4737
  ? `${receipt.spec} (branch ${branch || '(detached)'})`
3444
4738
  : `unknown (branch ${branch || '(detached)'} — no receipt; switched by hand?)`
3445
4739
 
4740
+ if (json) {
4741
+ process.stdout.write(
4742
+ JSON.stringify(
4743
+ {
4744
+ primary: branch || null,
4745
+ base: baseBranch,
4746
+ onBase,
4747
+ // The three-state answer the text's `in-flight:` line carries, kept
4748
+ // as three states here too: a branch switched by hand leaves no
4749
+ // receipt, so the spec is `null` while the checkout is plainly busy.
4750
+ inFlight: onBase ? null : receipt && receipt.spec ? String(receipt.spec) : null,
4751
+ receipt: receipt || null,
4752
+ },
4753
+ null,
4754
+ 2,
4755
+ ) + '\n',
4756
+ )
4757
+ return
4758
+ }
4759
+
3446
4760
  process.stdout.write(
3447
4761
  'spec-env live:\n' +
3448
4762
  ` primary: ${branch || '(detached)'} (${state})\n` +
@@ -3451,6 +4765,39 @@ function specEnvLiveStatus(dir, config, specArg) {
3451
4765
  )
3452
4766
  }
3453
4767
 
4768
+ /**
4769
+ * The `ctx` `liveStateFor` wants, probed from this repo.
4770
+ *
4771
+ * ONE BUILDER, so every caller passes the same shaped answer — the review
4772
+ * render and `live status --json` both come through here, which is what makes
4773
+ * "the page and the command agree" a property of the code rather than a habit.
4774
+ *
4775
+ * `onBase`/`primaryBranch` are the caller's, because it has usually already
4776
+ * asked `assertPrimaryOnMain` and asking twice can straddle a branch switch.
4777
+ *
4778
+ * An unreadable receipt is caught and dropped: it costs the holder's NAME and
4779
+ * never the state, which the branch answers (`liveStateFor`).
4780
+ */
4781
+ function liveContext(dir, config, spec, { onBase, primaryBranch }) {
4782
+ let receipt = null
4783
+ try {
4784
+ receipt = readReceipt(dir, config)
4785
+ } catch {
4786
+ receipt = null
4787
+ }
4788
+ const front = (config.dev || []).map((d) => d.frontPort).filter((p) => typeof p === 'number')[0]
4789
+ return {
4790
+ isolated: true,
4791
+ onBase,
4792
+ primaryBranch,
4793
+ receipt,
4794
+ worktreeExists: Boolean(spec && spec.worktreePath && fs.existsSync(spec.worktreePath)),
4795
+ // Null where the project configured no canonical port — there is no URL to
4796
+ // name, and inventing one would send the reader to a closed port.
4797
+ url: front ? `http://${(config.proxy && config.proxy.host) || 'localhost'}:${front}` : null,
4798
+ }
4799
+ }
4800
+
3454
4801
  async function specEnv(rest) {
3455
4802
  const [sub, ...args] = rest
3456
4803
  let dir = process.cwd()
@@ -3463,10 +4810,13 @@ async function specEnv(rest) {
3463
4810
  branch: false,
3464
4811
  out: null,
3465
4812
  review: null,
4813
+ runReviewers: false,
3466
4814
  notes: null,
3467
4815
  resolve: null,
3468
4816
  outcome: null,
3469
4817
  claim: null,
4818
+ offered: false,
4819
+ heartbeat: null,
3470
4820
  drop: null,
3471
4821
  buttons: null,
3472
4822
  json: false,
@@ -3479,6 +4829,8 @@ async function specEnv(rest) {
3479
4829
  else if (args[i] === '--older-than') flags.olderThanDays = Number(args[++i])
3480
4830
  else if (args[i] === '--branch') flags.branch = true
3481
4831
  else if (args[i] === '--stop') flags.stop = true
4832
+ else if (args[i] === '--rotate-token') flags.rotateToken = true
4833
+ else if (args[i] === '--off') flags.off = true
3482
4834
  else if (args[i] === '--status') flags.status = true
3483
4835
  else if (args[i] === '--port') flags.port = args[++i]
3484
4836
  else if (args[i] === '--host') flags.host = args[++i]
@@ -3486,30 +4838,44 @@ async function specEnv(rest) {
3486
4838
  else if (args[i] === '--buttons') flags.buttons = args[++i]
3487
4839
  else if (args[i] === '--out') flags.out = args[++i]
3488
4840
  else if (args[i] === '--review') flags.review = args[++i]
4841
+ else if (args[i] === '--run-reviewers') flags.runReviewers = true
3489
4842
  else if (args[i] === '--notes') flags.notes = args[++i]
4843
+ else if (args[i] === '--verdict') flags.verdict = args[++i]
4844
+ // `--set` keeps an EMPTY STRING rather than coercing it away: empty is the
4845
+ // bare `/spec-remote-review`, and it means toggle. `??` so a missing value
4846
+ // at the end of argv is still the empty form rather than `undefined`, which
4847
+ // would read as the flag never having been passed.
4848
+ else if (args[i] === '--set') flags.set = args[++i] ?? ''
3490
4849
  else if (args[i] === '--resolve') flags.resolve = args[++i]
3491
4850
  else if (args[i] === '--outcome') flags.outcome = args[++i]
3492
4851
  else if (args[i] === '--claim') flags.claim = args[++i]
3493
4852
  else if (args[i] === '--claim-since') flags.claimSince = args[++i]
4853
+ else if (args[i] === '--heartbeat') flags.heartbeat = args[++i]
4854
+ else if (args[i] === '--since') flags.since = args[++i]
4855
+ else if (args[i] === '--timeout') flags.timeout = args[++i]
3494
4856
  else if (args[i] === '--drop') flags.drop = args[++i]
3495
4857
  else if (args[i] === '--json') flags.json = true
3496
4858
  else if (args[i] === '--check') flags.check = true
3497
4859
  else if (args[i] === '--for-command') flags.forCommand = args[++i]
4860
+ else if (args[i] === '--offered') flags.offered = true
3498
4861
  else if (args[i] === '--phase') flags.phase = args[++i]
3499
4862
  else if (args[i] === '--record-primary') flags.recordPrimary = true
4863
+ else if (args[i] === '--docs') flags.docs = true
4864
+ else if (args[i] === '--session') flags.session = args[++i]
3500
4865
  else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
3501
4866
  else positional.push(args[i])
3502
4867
  }
3503
4868
  dir = path.resolve(dir)
3504
- // Where the caller actually is, kept before the re-anchor below. Only `stage`
3505
- // wants it: every other subcommand asks about the repo, while that one asks
3506
- // about the tree in front of you, and the two differ inside a worktree.
4869
+ // Where the caller actually is, kept before the re-anchor below. `stage` and
4870
+ // `review --docs` want it: every other subcommand asks about the repo, while
4871
+ // those two ask about the tree in front of you, and the two differ inside a
4872
+ // worktree.
3507
4873
  const invokedFrom = dir
3508
4874
  // Anchor on the primary checkout so every subcommand resolves {repo}, worktree
3509
4875
  // paths, and the registry identically whether run from main or a worktree.
3510
4876
  dir = resolvePrimaryCheckout(dir, gitReader(dir))
3511
4877
 
3512
- const { config, present } = loadEnvConfig(dir)
4878
+ const { config, present, unknown, badReviewers } = loadEnvConfig(dir)
3513
4879
  if (!present) {
3514
4880
  process.stdout.write(
3515
4881
  'spec-env: isolation not enabled (no specs/.core/env.config.json).\n' +
@@ -3518,9 +4884,61 @@ async function specEnv(rest) {
3518
4884
  return
3519
4885
  }
3520
4886
 
4887
+ // A key the loader does not read is dropped — it always was, and still is.
4888
+ // What changed is that it is no longer dropped in SILENCE: a mis-typed
4889
+ // `review.required` leaves the gate on and a mis-typed
4890
+ // `teardown.deleteRemoteBranch` reverts to prompt, and the only signal either
4891
+ // gave was that nothing happened.
4892
+ //
4893
+ // Every spec-env subcommand passes through here, which is why it sits at this
4894
+ // one point rather than in a reporting verb someone might never run. It is
4895
+ // ADVISORY: it writes lines and changes no exit status, because a forward-compat
4896
+ // key and a typo are indistinguishable from here and only one of them is a
4897
+ // mistake (.claude/rules/negative-checks.md rule 4).
4898
+ //
4899
+ // STDERR, deliberately: `--json` subcommands write their payload to stdout, and
4900
+ // an advisory line on stdout would make it unparseable.
4901
+ for (const key of unknown || []) {
4902
+ process.stderr.write(`spec-env: ${ENV_CONFIG_LABEL} — unknown key "${key}" is ignored.\n`)
4903
+ }
4904
+
4905
+ // The same advisory, for the one place the check above structurally cannot
4906
+ // reach: `review.reviewers` is an array, and `collectUnknownKeys` deliberately
4907
+ // does not walk into one. Dropping an entry silently is worse than dropping a
4908
+ // key silently — the page then shows no line for that reviewer at all, which
4909
+ // reads as "not configured" rather than "configured wrong".
4910
+ for (const bad of badReviewers || []) {
4911
+ process.stderr.write(
4912
+ `spec-env: ${ENV_CONFIG_LABEL} — review.reviewers[${bad.index}] ${bad.reason}; it is ignored.\n`,
4913
+ )
4914
+ }
4915
+
4916
+ // A `use` NAMING AN ADAPTER THIS BUILD DOES NOT HAVE, said here rather than
4917
+ // waiting for the run to report it. The config module deliberately does not
4918
+ // know the registry — shape is its business and the adapter list is the
4919
+ // runner's — so the check lives at the one place that can see both.
4920
+ //
4921
+ // It is advisory like the two above: an older engine reading a config written
4922
+ // for a newer one is a forward-compat gap, not a mistake, and the run reports
4923
+ // it again as that reviewer's own outcome either way.
4924
+ for (const r of config.review.reviewers || []) {
4925
+ if (r.use && !Object.prototype.hasOwnProperty.call(BUNDLED_ADAPTERS, r.use)) {
4926
+ process.stderr.write(
4927
+ `spec-env: ${ENV_CONFIG_LABEL} — review.reviewers "${r.use}" is not a bundled adapter ` +
4928
+ `(have: ${Object.keys(BUNDLED_ADAPTERS).join(', ') || 'none'}); it will not run.\n`,
4929
+ )
4930
+ }
4931
+ }
4932
+
3521
4933
  switch (sub) {
3522
4934
  case 'up':
3523
- specEnvUp(dir, config, positional[0])
4935
+ specEnvUp(dir, config, positional[0], flags)
4936
+ break
4937
+ case 'nospec':
4938
+ specEnvNospec(dir, config, positional[0], flags)
4939
+ break
4940
+ case 'main':
4941
+ specEnvMain(dir, config, positional, flags, present, invokedFrom)
3524
4942
  break
3525
4943
  case 'down':
3526
4944
  specEnvDown(dir, config, positional[0], flags)
@@ -3564,10 +4982,22 @@ async function specEnv(rest) {
3564
4982
  specEnvReviewArm(dir, config, positional[1], flags)
3565
4983
  break
3566
4984
  }
4985
+ if (positional[0] === 'waiting') {
4986
+ specEnvReviewWaiting(dir, config, flags)
4987
+ break
4988
+ }
4989
+ if (positional[0] === 'wait') {
4990
+ await specEnvReviewWait(dir, config, positional[1], flags)
4991
+ break
4992
+ }
3567
4993
  if (positional[0] === 'gate') {
3568
4994
  specEnvReviewGate(dir, config, positional[1], flags, invokedFrom)
3569
4995
  break
3570
4996
  }
4997
+ if (positional[0] === 'allow') {
4998
+ specEnvReviewAllow(dir, config, positional[1], flags)
4999
+ break
5000
+ }
3571
5001
  if (positional[0] === 'skip') {
3572
5002
  // The one positional is the REASON, not a spec: the two are
3573
5003
  // indistinguishable as free text, and the spec is the one thing this
@@ -3575,20 +5005,27 @@ async function specEnv(rest) {
3575
5005
  specEnvReviewSkip(dir, config, positional[1], flags)
3576
5006
  break
3577
5007
  }
3578
- await specEnvReview(dir, config, positional[0], flags)
5008
+ await specEnvReview(dir, config, positional[0], flags, invokedFrom)
3579
5009
  break
3580
5010
  case 'live':
3581
- await specEnvLive(dir, config, positional)
5011
+ await specEnvLive(dir, config, positional, flags)
3582
5012
  break
3583
5013
  default:
3584
5014
  process.stdout.write(
3585
- '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>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n' +
5015
+ `Usage: skitterspec spec-env <${SPEC_ENV_VERBS.join('|')}> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--run-reviewers] [--notes <json>] [--verdict <word>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n` +
3586
5016
  ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
5017
+ ' review serve --rotate-token mint a new URL token; every handed-out link dies\n' +
5018
+ ' review allow <network|remote> [--off] permit a review tier (writes env.config.json)\n' +
3587
5019
  ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
3588
5020
  ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
3589
5021
  ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
3590
5022
  ' review skip "<reason>" move on without one, on the record\n' +
5023
+ ' review gate [spec] --offered record that the bypass was offered (spends it)\n' +
5024
+ ' review wait [spec] --heartbeat <secs> proof-of-life cadence on stderr; 0 disables\n' +
5025
+ ' review wait [spec] --since <iso> block until a verdict arrives ([--timeout <s>])\n' +
5026
+ ' review waiting [--json] every pass waiting, across every spec\n' +
3591
5027
  ' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +
5028
+ ' review [spec] --verdict <word> send just a verdict, with nothing marked\n' +
3592
5029
  ' review [spec] --buttons midrun the page offers Continue, not a commit\n' +
3593
5030
  ' [spec] is optional everywhere: omit it and the worktree you are standing\n' +
3594
5031
  ' in is used, else the sole provisioned spec (several -> it lists them).\n' +
@@ -3704,11 +5141,14 @@ async function run(argv) {
3704
5141
  }
3705
5142
 
3706
5143
  module.exports = {
5144
+ SPEC_ENV_VERBS,
3707
5145
  run,
3708
5146
  parse,
3709
5147
  HELP,
3710
5148
  unknownCommand,
3711
5149
  rankLanAddresses,
5150
+ offerableLanAddresses,
5151
+ reviewTierStack,
3712
5152
  serveProcFor,
3713
5153
  serverScriptOk,
3714
5154
  daemonScript,