@skitterbyte/skitterspec-linear 15.0.0 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +34 -1
  3. package/assets/claude-md-section.md +29 -18
  4. package/assets/commands/spec-remote-review.md +22 -0
  5. package/assets/core/SETUP.md +10 -6
  6. package/assets/core/env.config.json.example +4 -2
  7. package/assets/core/env.config.md +100 -23
  8. package/assets/core/linear.config.json.example +2 -1
  9. package/assets/core/linear.config.md +49 -22
  10. package/assets/review/page.html +1044 -101
  11. package/assets/rules/spec-planning.md +35 -3
  12. package/assets/rules/spec-reports.md +131 -20
  13. package/assets/skills/spec/SKILL.md +161 -4
  14. package/assets/skills/spec-bug/SKILL.md +68 -30
  15. package/assets/skills/spec-cancel/SKILL.md +2 -2
  16. package/assets/skills/spec-claim/SKILL.md +12 -4
  17. package/assets/skills/spec-complete/SKILL.md +2 -2
  18. package/assets/skills/spec-diff/SKILL.md +131 -36
  19. package/assets/skills/spec-hotfix/SKILL.md +61 -25
  20. package/assets/skills/spec-linear-setup/SKILL.md +19 -11
  21. package/assets/skills/spec-next/SKILL.md +121 -58
  22. package/assets/skills/spec-push/SKILL.md +45 -0
  23. package/assets/skills/spec-review/SKILL.md +89 -2
  24. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  25. package/assets/skills/spec-start/SKILL.md +26 -3
  26. package/assets/skills/spec-status/SKILL.md +20 -6
  27. package/assets/skills/spec-sync/SKILL.md +1 -0
  28. package/package.json +2 -2
  29. package/src/cli.js +913 -116
  30. package/src/env/classify.js +87 -2
  31. package/src/env/config.js +214 -17
  32. package/src/env/live.js +94 -0
  33. package/src/env/resolve.js +36 -2
  34. package/src/env/review.js +542 -21
  35. package/src/env/serve.js +298 -19
  36. package/src/env/supervise.js +8 -1
  37. package/src/init.js +60 -9
  38. package/src/vendor/linear/api.js +111 -2
  39. package/src/vendor/linear/cli-sync.js +661 -11
  40. package/src/vendor/linear/config.js +41 -13
  41. package/src/vendor/linear/doctor.js +6 -5
  42. package/src/vendor/sync-core/index.js +11 -3
  43. package/src/vendor/sync-core/src/compare.js +65 -0
  44. package/src/vendor/sync-core/src/normalize.js +26 -0
package/src/cli.js CHANGED
@@ -9,7 +9,38 @@ 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
+ ])
13
44
  const {
14
45
  readRegistry,
15
46
  writeRegistry,
@@ -39,6 +70,8 @@ const {
39
70
  summarizeReceipt,
40
71
  migrationsHit,
41
72
  planTake,
73
+ liveStateFor,
74
+ liveStateLine,
42
75
  planRelease,
43
76
  planAbort,
44
77
  } = require('./env/live.js')
@@ -46,6 +79,8 @@ const { ensureWorktreeDirTrusted } = require('./env/trust.js')
46
79
  const {
47
80
  rawGitReader,
48
81
  collectReview,
82
+ reviewTierStack,
83
+ reviewTierLine,
49
84
  renderReviewPage,
50
85
  renderReviewBlock,
51
86
  reviewOutPath,
@@ -61,6 +96,7 @@ const {
61
96
  reviewNotesPath,
62
97
  readNotes,
63
98
  writeNotes,
99
+ VERDICTS,
64
100
  validateNotesBlob,
65
101
  judgeVerdict,
66
102
  appendDecision,
@@ -69,6 +105,8 @@ const {
69
105
  writePending,
70
106
  claimPending,
71
107
  passesSince,
108
+ waitForPass,
109
+ waitingPasses,
72
110
  describePending,
73
111
  pendingAge,
74
112
  reviewPendingPath,
@@ -86,7 +124,7 @@ const {
86
124
  gateState,
87
125
  } = require('./env/review.js')
88
126
  const { planUp, planCheckoutUp } = require('./env/provision.js')
89
- const { classifyDirtyTree } = require('./env/classify.js')
127
+ const { classifyDirtyTree, dirtyPaths, specDocsIn } = require('./env/classify.js')
90
128
  const { isGitCommit } = require('./env/commitcmd.js')
91
129
  const { planDown, planDownCheckout } = require('./env/teardown.js')
92
130
  const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
@@ -95,7 +133,13 @@ const { planHotfixLand } = require('./env/hotfix.js')
95
133
  const { planDev } = require('./env/dev.js')
96
134
  const { startProcess, stopProcess, waitHealthy, readPid, isAlive } = require('./env/supervise.js')
97
135
  const { renderRoutes, portsInUse, portsInUseOn, waitListening } = require('./env/proxy.js')
98
- const { mintToken, servableSpecs, engineVersionFor, staleServer } = require('./env/serve.js')
136
+ const {
137
+ mintToken,
138
+ servableSpecs,
139
+ engineVersionFor,
140
+ staleServer,
141
+ pageTiers,
142
+ } = require('./env/serve.js')
99
143
 
100
144
  const pkg = require('../package.json')
101
145
 
@@ -144,11 +188,16 @@ Usage:
144
188
  connect <spec> expose a spec on the canonical ports (main = off)
145
189
  integrate <spec> plan rebase + fast-forward onto the base branch
146
190
  hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
147
- status list provisioned specs + port blocks
191
+ live <spec> check a spec out in the primary checkout so
192
+ the running dev server serves it (take |
193
+ release | abort | status; main hands it back)
148
194
  review <spec> write an HTML page of the spec's diff
149
195
  (--branch for the whole spec; --out, --json)
150
196
  (--notes <json> merges a review pass back;
151
197
  --resolve <json> records what was done)
198
+ stage [spec] split the uncommitted tree into this spec's
199
+ documents and everything else
200
+ status list provisioned specs + port blocks
152
201
  resolve <spec> print resolved slug/type/branch/paths
153
202
  skitterspec gating <cmd> Release-gating check (opt-in; needs
154
203
  specs/.core/gating.config.json). Subcommands:
@@ -271,6 +320,61 @@ async function cleanupReleaseTooling(dir, opts) {
271
320
  * `git worktree prune`) stays listed until pruned. That over-reports, which is
272
321
  * the harmless direction for a read-only report.
273
322
  */
323
+ /**
324
+ * The reviews sidecar directory for this repo. Named once, because the scan and
325
+ * everything that renders it must agree on where to look.
326
+ */
327
+ function reviewsDirFor(dir, config) {
328
+ return path.join(dir, stateDirLabel(config), 'reviews')
329
+ }
330
+
331
+ /**
332
+ * Render "what is waiting" for a human. Returns '' when nothing is — a repo
333
+ * with no waiting pass must read exactly as it did before this existed, so the
334
+ * heading is absent rather than printed over an empty list.
335
+ */
336
+ function waitingSection(dir, config, now = new Date().toISOString()) {
337
+ const found = waitingPasses(reviewsDirFor(dir, config))
338
+ if (!found.passes.length && !found.unreadable.length) return ''
339
+ const width = Math.max(0, ...found.passes.map((p) => p.spec.length))
340
+ const rows = found.passes
341
+ .map(
342
+ (p) =>
343
+ ` ${p.spec.padEnd(width)} ${p.code} · ${p.action || p.verdict || 'no verdict'} · ` +
344
+ `${pendingAge(p.at, now)}\n`,
345
+ )
346
+ .join('')
347
+ // Named, never counted as zero: an unreadable store holds someone's pass, and
348
+ // reporting "nothing waiting" over it is the one answer certainly wrong.
349
+ const broken = found.unreadable
350
+ .map((folder) => ` ${folder}: its pending store is not readable JSON — move it aside\n`)
351
+ .join('')
352
+ // The disown line only where there is something to disown. It is information
353
+ // beside a list, not an instruction to act on every pass in it.
354
+ const how = found.passes.length
355
+ ? ' disown one with: skitterspec spec-env review <spec> --drop <code>\n'
356
+ : ''
357
+ return `\nReviews waiting:\n${rows}${broken}${how}`
358
+ }
359
+
360
+ // `review waiting` — the same answer on its own, for a caller who wants only
361
+ // this. It claims nothing and exits 0 whatever it finds: a waiting pass is
362
+ // information, never an accusation.
363
+ function specEnvReviewWaiting(dir, config, flags) {
364
+ const found = waitingPasses(reviewsDirFor(dir, config))
365
+ if (flags.json) {
366
+ // ABSENT, NOT EMPTY. A consumer that predates this must see a
367
+ // byte-identical object when there is nothing waiting.
368
+ const out = {}
369
+ if (found.passes.length) out.waiting = found.passes
370
+ if (found.unreadable.length) out.unreadable = found.unreadable
371
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`)
372
+ return
373
+ }
374
+ const section = waitingSection(dir, config)
375
+ process.stdout.write(section ? `${section.replace(/^\n/, '')}` : 'spec-env review: nothing waiting.\n')
376
+ }
377
+
274
378
  function specEnvStatus(dir, config) {
275
379
  const worktreePaths = liveWorktreePaths(gitReader(dir))
276
380
  const provisioned = allSpecs(dir, config, worktreePaths)
@@ -280,8 +384,11 @@ function specEnvStatus(dir, config) {
280
384
  .filter((s) => s.wt !== dir && worktreePaths.has(s.wt))
281
385
  .sort((a, b) => a.folder.localeCompare(b.folder))
282
386
 
387
+ // REACHED EITHER WAY. This used to return here, so a repo with nothing in
388
+ // flight could say nothing about a waiting pass — and a repo with nothing in
389
+ // flight is exactly where one hides longest.
283
390
  if (!provisioned.length) {
284
- process.stdout.write('spec-env: no provisioned specs.\n')
391
+ process.stdout.write(`spec-env: no provisioned specs.\n${waitingSection(dir, config)}`)
285
392
  return
286
393
  }
287
394
 
@@ -296,6 +403,7 @@ function specEnvStatus(dir, config) {
296
403
  }
297
404
  process.stdout.write(` ${folder}${ports}\n ${path.relative(dir, wt) || wt}\n`)
298
405
  }
406
+ process.stdout.write(waitingSection(dir, config))
299
407
  }
300
408
 
301
409
  // Plan a provision: allocate the slot, persist the registry, and print the plan
@@ -303,49 +411,6 @@ function specEnvStatus(dir, config) {
303
411
  // This creates no worktree and starts no stack — the caller runs the
304
412
  // printed commands. Keep the output's verb honest about that.
305
413
 
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
414
 
350
415
  // Is this spec new to git? Asked directly, so the commit subject does not depend
351
416
  // on the shape of `git status` output.
@@ -1617,6 +1682,54 @@ function specEnvStage(dir, config, specArg, flags = {}, invokedFrom = dir) {
1617
1682
  process.stdout.write(out.join('\n') + '\n')
1618
1683
  }
1619
1684
 
1685
+ /**
1686
+ * The tree and the file set behind `review --docs`: this spec's own uncommitted
1687
+ * documents, in the checkout the caller is standing in.
1688
+ *
1689
+ * It is `stage`'s split, reused rather than re-derived — same tree resolution,
1690
+ * same `classifyDirtyTree`. THE `owned` HALF IS WHAT MAKES THIS SAFE TO RENDER:
1691
+ * a checkout is shared by every session standing in it, so the uncommitted tree
1692
+ * routinely holds spec documents this spec has no claim on. Rendering the tree
1693
+ * would put them on this page, and a committing verdict here would then commit
1694
+ * them under this spec's ticket.
1695
+ *
1696
+ * WHAT WOULD FOOL THIS: nothing, for a path outside the spec's folder that the
1697
+ * project declared a companion — those are owned by construction. What it
1698
+ * cannot see is a document belonging to this spec that is already committed:
1699
+ * `dirtyPaths` answers about the uncommitted tree only, so a spec whose files
1700
+ * are all committed renders as nothing to review rather than as its own text.
1701
+ * That is the intended reading — there is no change to review — and it is why
1702
+ * the empty case says so instead of drawing an empty page.
1703
+ *
1704
+ * Three states, never two (`.claude/rules/negative-checks.md` rule 4). A git
1705
+ * that could not be read is `cannot tell`, and must not become an empty file
1706
+ * set: an empty set renders a page saying nothing changed, which is the one
1707
+ * reading that is certainly wrong.
1708
+ *
1709
+ * @returns {{tree: string, owned: string[]}|{error: string}}
1710
+ */
1711
+ function resolveSpecDocs(config, spec, invokedFrom) {
1712
+ const git = gitReader(invokedFrom)
1713
+ const tree = git(['rev-parse', '--show-toplevel']) || invokedFrom
1714
+ // THE CLASSIFICATION IS SHARED with the review server's route for the same
1715
+ // spec (`specDocsIn`), so the served page and the written page cannot
1716
+ // disagree about which files they show. This function adds only the wording.
1717
+ const found = specDocsIn(tree, spec, config, gitReader(tree))
1718
+ if (found.error) {
1719
+ return {
1720
+ error:
1721
+ `git could not be read at ${tree}, so ${spec.folder}'s documents were not classified — ` +
1722
+ 'nothing rendered. This is not "nothing to review".',
1723
+ }
1724
+ }
1725
+ if (found.empty) {
1726
+ return {
1727
+ error: `${spec.folder} has no uncommitted documents in ${tree} — nothing to review.`,
1728
+ }
1729
+ }
1730
+ return found
1731
+ }
1732
+
1620
1733
  /**
1621
1734
  * Write a self-contained HTML review of a spec's diff.
1622
1735
  *
@@ -1635,6 +1748,11 @@ function verdictSaid(v) {
1635
1748
  // thing that will happen to the repo and the reader should see it coming.
1636
1749
  if (v.effective === 'commit') return `committing with ${v.commitWith}`
1637
1750
  if (v.effective === 'commit-continue') return `committing with ${v.commitWith}, then the next phase`
1751
+ // EVERY COMMITTING VERDICT NEEDS A LINE HERE, and the fall-through below is
1752
+ // why: a verdict this function does not know reads as `discuss first`, so a
1753
+ // reader who pressed a green button would be told their review ended in a
1754
+ // conversation. Adding one to `COMMITTING` and not to this list is silent.
1755
+ if (v.effective === 'commit-start') return `committing with ${v.commitWith}, then putting it in flight`
1638
1756
  // Names what it does NOT do, because the reader of a mid-run page has just
1639
1757
  // pressed a green button and must not read it as a commit.
1640
1758
  if (v.effective === 'continue') return 'read — carrying on, nothing committed'
@@ -1662,6 +1780,97 @@ function gateTarget(dir, config, specArg) {
1662
1780
  }
1663
1781
  }
1664
1782
 
1783
+ /**
1784
+ * `review wait` — block until a verdict arrives, so nobody has to improvise it.
1785
+ *
1786
+ * THE COMMAND EXISTS BECAUSE THE WAIT DID NOT. The skills said "watch the
1787
+ * pending store and end your turn", so every run wrote its own watcher in
1788
+ * shell — and one of them wrote `[ "$x" \> "$y" ]`, which is valid bash and a
1789
+ * syntax error in zsh. It could never be true, spun for five minutes, and
1790
+ * looked exactly like patience the whole time.
1791
+ *
1792
+ * So this says it started. A caller can then tell a live wait from a dead one,
1793
+ * which is the one thing none of the improvised watchers could offer.
1794
+ *
1795
+ * It is a WAIT and not a claim: the pass is left in the holding area for
1796
+ * `--claim-since` to take, so the rule that a pass is never claimed without a
1797
+ * person asking (`/spec-diff` §0) is untouched by anything here.
1798
+ */
1799
+ async function specEnvReviewWait(dir, config, specArg, flags) {
1800
+ let spec
1801
+ try {
1802
+ spec = resolveSpecWithWorktree(dir, config, specArg)
1803
+ } catch (err) {
1804
+ process.stdout.write(`spec-env review wait: ${err.message}\n`)
1805
+ process.exitCode = 1
1806
+ return
1807
+ }
1808
+
1809
+ // A WINDOW IS REQUIRED, and an absent one is not an open one. Without it
1810
+ // there is no way to tell this sitting's pass from a stranger's, and waiting
1811
+ // for "any pass at all" is how one gets swept up.
1812
+ if (!flags.since) {
1813
+ process.stdout.write(
1814
+ 'spec-env review wait: --since <iso> is required — it is the window that decides which pass is yours\n',
1815
+ )
1816
+ process.exitCode = 1
1817
+ return
1818
+ }
1819
+
1820
+ const out = reviewOutPath(dir, spec.folder, flags.out)
1821
+ const timeoutMs =
1822
+ flags.timeout === undefined || flags.timeout === null ? null : Number(flags.timeout) * 1000
1823
+ if (timeoutMs !== null && !Number.isFinite(timeoutMs)) {
1824
+ process.stdout.write(`spec-env review wait: --timeout ${flags.timeout} is not a number of seconds\n`)
1825
+ process.exitCode = 1
1826
+ return
1827
+ }
1828
+
1829
+ // SAID BEFORE THE FIRST POLL, not after it. The whole point is that a caller
1830
+ // knows the wait is running; a line printed on the way out would arrive only
1831
+ // for the waits that already worked.
1832
+ if (!flags.json) {
1833
+ process.stdout.write(
1834
+ `spec-env review wait: ${spec.folder} — waiting for a verdict sent since ${flags.since}` +
1835
+ `${timeoutMs === null ? '' : ` (up to ${flags.timeout}s)`}\n`,
1836
+ )
1837
+ }
1838
+
1839
+ const result = await waitForPass(() => readPending(out, spec.folder), flags.since, { timeoutMs })
1840
+
1841
+ if (flags.json) {
1842
+ process.stdout.write(`${JSON.stringify({ spec: spec.folder, since: flags.since, ...result }, null, 2)}\n`)
1843
+ }
1844
+
1845
+ if (result.state === 'arrived') {
1846
+ if (!flags.json) {
1847
+ process.stdout.write(
1848
+ ` arrived: ${result.code}\n` +
1849
+ ` claim it: skitterspec spec-env review ${spec.folder} --claim-since ${flags.since}\n`,
1850
+ )
1851
+ }
1852
+ return
1853
+ }
1854
+
1855
+ process.exitCode = 1
1856
+ if (flags.json) return
1857
+
1858
+ if (result.state === 'unusable') {
1859
+ process.stdout.write(` --since ${flags.since} is not a timestamp — nothing to wait inside\n`)
1860
+ return
1861
+ }
1862
+ if (result.state === 'ambiguous') {
1863
+ // NAMES THE COUNT, NEVER THE CODES — the same silence `--claim-since` keeps.
1864
+ // Two in one window is two sittings or two people, and the operator has the
1865
+ // codes; printing them here would hand a guesser the answer.
1866
+ process.stdout.write(
1867
+ ` ${result.count} passes arrived in that window — claim one by its code rather than guessing\n`,
1868
+ )
1869
+ return
1870
+ }
1871
+ process.stdout.write(' timed out — no verdict arrived in the window\n')
1872
+ }
1873
+
1665
1874
  // `review arm` — a phase ended, and its diff is now owed a verdict.
1666
1875
  function specEnvReviewArm(dir, config, specArg, flags) {
1667
1876
  const target = gateTarget(dir, config, specArg)
@@ -1787,6 +1996,134 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
1787
1996
  if (flags.check && judged.state === 'armed') process.exitCode = 1
1788
1997
  }
1789
1998
 
1999
+ /**
2000
+ * `review allow <network|remote> [--off]` — permit or withdraw a review tier.
2001
+ *
2002
+ * THE FIRST THING IN THIS ENGINE TO WRITE `env.config.json`, and that is why it
2003
+ * is narrow: it reads, sets one key under `review`, and writes back with the
2004
+ * indent the file already uses. It never reorders, never adds a key nobody
2005
+ * asked for, and never touches another section.
2006
+ *
2007
+ * WHAT WOULD FOOL THIS: a config written with comments. `loadEnvConfig` parses
2008
+ * it with `JSON.parse`, so such a file already fails to load and there is no
2009
+ * comment-preserving case to protect — but a file indented with anything other
2010
+ * than two spaces IS reformatted, which is cosmetic and worth knowing before it
2011
+ * shows up in someone's diff.
2012
+ *
2013
+ * IT EDITS A COMMITTED FILE. Turning network reviews on for yourself turns them
2014
+ * on for everyone who pulls, so the output says so rather than leaving it to be
2015
+ * discovered by a colleague's render.
2016
+ */
2017
+ function specEnvReviewAllow(dir, config, tier, flags) {
2018
+ const TIERS = { network: 'allowNetwork', remote: 'allowRemote' }
2019
+ const key = TIERS[String(tier || '').trim()]
2020
+ if (!key) {
2021
+ // Refused by name, never coerced to a default: silently permitting the
2022
+ // wrong tier is the one outcome worth more than a round trip.
2023
+ process.stdout.write(
2024
+ `spec-env review allow: ${JSON.stringify(tier || '')} is not a tier — ` +
2025
+ `one of ${Object.keys(TIERS).join(', ')}. Nothing changed.\n`,
2026
+ )
2027
+ process.exitCode = 1
2028
+ return
2029
+ }
2030
+ // THREE WAYS TO SAY WHAT THE TIER SHOULD BE, and only the third is new.
2031
+ // Bare `allow <tier>` turns it ON and `--off` turns it off, exactly as they
2032
+ // always have — every existing caller and test reads those.
2033
+ //
2034
+ // `--set` exists because a SLASH COMMAND can only make one static
2035
+ // substitution: `/spec-remote-review on` has to reach the engine as the word
2036
+ // the person typed, not as a flag the command file worked out. An EMPTY value
2037
+ // is the bare form of that command, and it toggles — which is the common case,
2038
+ // because a reader flipping a tier is looking at the line that says which way
2039
+ // it currently is.
2040
+ let on = !flags.off
2041
+ if (flags.set !== undefined) {
2042
+ const said = String(flags.set).trim().toLowerCase()
2043
+ if (said === '') {
2044
+ // Toggle. Read through the same precedence the report below uses, so
2045
+ // "turn it to the other thing" means the other thing the render showed.
2046
+ const current = Boolean(
2047
+ (() => {
2048
+ try {
2049
+ const now = JSON.parse(fs.readFileSync(path.resolve(dir, 'specs/.core/env.config.json'), 'utf8'))
2050
+ return now.review && now.review[key] !== undefined ? now.review[key] : config.review[key]
2051
+ } catch {
2052
+ return config.review[key]
2053
+ }
2054
+ })(),
2055
+ )
2056
+ on = !current
2057
+ } else if (said === 'on' || said === 'off') {
2058
+ on = said === 'on'
2059
+ } else {
2060
+ // Refused by name, like an unknown tier. A misspelt state coerced to a
2061
+ // default is the one outcome worth more than a round trip — and the
2062
+ // wrong default here opens a port or permits a publish.
2063
+ process.stdout.write(
2064
+ `spec-env review allow: ${JSON.stringify(String(flags.set))} is not a state — ` +
2065
+ 'one of on, off, or nothing at all to toggle. Nothing changed.\n',
2066
+ )
2067
+ process.exitCode = 1
2068
+ return
2069
+ }
2070
+ }
2071
+ const file = path.resolve(dir, 'specs/.core/env.config.json')
2072
+ let parsed
2073
+ try {
2074
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
2075
+ } catch (err) {
2076
+ // Cannot tell what is in there, so write nothing. Overwriting a config we
2077
+ // could not read is unrecoverable, and the alternative costs one message.
2078
+ //
2079
+ // NARROWER THAN IT LOOKS: `loadEnvConfig` already refuses the whole
2080
+ // `spec-env` command on an unparseable config, naming the file and the
2081
+ // parse position — so this branch is reachable only if the file changes
2082
+ // between that load and this write. It is kept for that race rather than
2083
+ // deleted as dead, and the upstream refusal is what the test asserts.
2084
+ process.stdout.write(
2085
+ `spec-env review allow: ${file} could not be read as JSON (${err.message}) — ` +
2086
+ 'nothing changed.\n',
2087
+ )
2088
+ process.exitCode = 1
2089
+ return
2090
+ }
2091
+ const before = Boolean(
2092
+ parsed.review && parsed.review[key] !== undefined ? parsed.review[key] : config.review[key],
2093
+ )
2094
+ parsed.review = { ...(parsed.review || {}), [key]: on }
2095
+ fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
2096
+
2097
+ const lines = [
2098
+ `spec-env review allow: ${tier} reviews are now ${on ? 'ON' : 'OFF'}` +
2099
+ (before === on ? ' (unchanged)' : ''),
2100
+ // THE ABSOLUTE PATH, not one relative to `dir`. `dir` is re-anchored to the
2101
+ // primary checkout, so run from inside a worktree this writes a file in a
2102
+ // DIFFERENT TREE — and a relative path reads as the tree you are standing
2103
+ // in. Found by running it from a worktree and reverting the surprise.
2104
+ ` wrote: ${file}`,
2105
+ ' that is the primary checkout, whichever tree you ran this from, and the file is',
2106
+ ' COMMITTED — so it changes for everyone who pulls, and leaves that tree dirty.',
2107
+ ]
2108
+ if (key === 'allowNetwork') {
2109
+ lines.push(
2110
+ on
2111
+ ? ' the next render binds every interface, so the page opens on your phone.'
2112
+ : ' the next render binds 127.0.0.1 only, reachable from this machine.',
2113
+ )
2114
+ } else {
2115
+ lines.push(
2116
+ on
2117
+ ? ' this PERMITS publishing; it publishes nothing. A published page cannot be\n' +
2118
+ ' deleted by skitterspec, and a verdict there needs /spec-reviewed.\n' +
2119
+ ' turn it back off with: /spec-remote-review'
2120
+ : ' publishing is no longer permitted; any page already published stays up.\n' +
2121
+ ' turn it back on with: /spec-remote-review',
2122
+ )
2123
+ }
2124
+ process.stdout.write(lines.join('\n') + '\n')
2125
+ }
2126
+
1790
2127
  // `review skip` — move on without a verdict, on the record.
1791
2128
  function specEnvReviewSkip(dir, config, reason, flags) {
1792
2129
  const said = String(reason || '').trim()
@@ -1831,7 +2168,7 @@ function specEnvReviewSkip(dir, config, reason, flags) {
1831
2168
  )
1832
2169
  }
1833
2170
 
1834
- async function specEnvReview(dir, config, specArg, flags) {
2171
+ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
1835
2172
  // REFUSED BY NAME, never coerced to the default. A typo'd button set silently
1836
2173
  // rendering the committing page is the same failure the verdict validator
1837
2174
  // refuses for the same reason: a caller asking for the mid-run page and
@@ -1850,21 +2187,70 @@ async function specEnvReview(dir, config, specArg, flags) {
1850
2187
  // of the wrong spec looks exactly like a review of the right one.
1851
2188
  const spec = resolveSpecWithWorktree(dir, config, specArg)
1852
2189
 
2190
+ // `--docs` reviews the spec's OWN DOCUMENTS from the tree in hand, and so
2191
+ // never consults a worktree — a spec in `backlog/` has none, which is exactly
2192
+ // the case with no page today.
2193
+ let docs = null
2194
+ if (flags.docs) {
2195
+ docs = resolveSpecDocs(config, spec, invokedFrom)
2196
+ if (docs.error) {
2197
+ process.stdout.write(`spec-env review: ${docs.error}\n`)
2198
+ return
2199
+ }
2200
+ }
2201
+
1853
2202
  // The worktree is what we read; without it there is nothing to say. This is an
1854
2203
  // absence that means something — `git worktree list` is the same source that
1855
2204
  // resolved the path — so it is safe to act on.
1856
- if (!fs.existsSync(spec.worktreePath)) {
2205
+ //
2206
+ // It names `--docs` because for a spec that has landed or has not yet started,
2207
+ // provisioning a worktree is not what the reader wanted: they wanted to read
2208
+ // the spec.
2209
+ // A LIVE SPEC IS THE EXCEPTION, and it is not a loosening of this absence.
2210
+ // `live take` moves the branch into the primary checkout, so the work is
2211
+ // somewhere readable even when the worktree has gone — refusing here would
2212
+ // refuse a spec whose diff is right there. Every other missing worktree still
2213
+ // refuses exactly as before.
2214
+ const liveHere = (() => {
2215
+ try {
2216
+ const st = assertPrimaryOnMain(config, gitReader(dir))
2217
+ return st.onBase === false && st.branch === spec.branch
2218
+ } catch {
2219
+ return false
2220
+ }
2221
+ })()
2222
+ if (!docs && !liveHere && !fs.existsSync(spec.worktreePath)) {
1857
2223
  process.stdout.write(
1858
2224
  `spec-env review: ${spec.folder} has no worktree at ${spec.worktreePath} — ` +
1859
- 'run /spec-start to provision it.\n',
2225
+ 'run /spec-start to provision it, or --docs to review the spec itself.\n',
1860
2226
  )
1861
2227
  return
1862
2228
  }
1863
2229
 
1864
- const git = rawGitReader(spec.worktreePath)
1865
- const trimmed = gitReader(spec.worktreePath)
2230
+ // WHERE THE BRANCH ACTUALLY IS. `live take` detaches the worktree and checks
2231
+ // the branch out in the primary checkout, so while a spec is live the tree
2232
+ // holding its work — including anything uncommitted, since a fix made while
2233
+ // live is made there — is the primary checkout. Reading the worktree then
2234
+ // reports a clean tree and shows the reader nothing.
2235
+ //
2236
+ // WHAT WOULD FOOL THIS: it trusts the branch the primary checkout is on, so a
2237
+ // branch checked out there by hand with no receipt still reads as live. That
2238
+ // is the right answer — the work IS there — and it is the same authority
2239
+ // `assertPrimaryOnMain` and `liveStateFor` use.
2240
+ const primaryState = assertPrimaryOnMain(config, gitReader(dir))
2241
+ const live = liveStateFor(
2242
+ spec,
2243
+ liveContext(dir, config, spec, {
2244
+ onBase: primaryState.onBase,
2245
+ primaryBranch: primaryState.branch,
2246
+ }),
2247
+ )
2248
+
2249
+ const readFrom = docs ? docs.tree : live.state === 'on' ? dir : spec.worktreePath
2250
+ const git = rawGitReader(readFrom)
2251
+ const trimmed = gitReader(readFrom)
1866
2252
 
1867
- let mode = 'working'
2253
+ let mode = docs ? 'docs' : 'working'
1868
2254
  let ref = 'HEAD'
1869
2255
  let base = null
1870
2256
 
@@ -1947,6 +2333,39 @@ async function specEnvReview(dir, config, specArg, flags) {
1947
2333
  claimCode = window.codes[0]
1948
2334
  }
1949
2335
 
2336
+ // ONE VERDICT PER INVOCATION, and this refuses rather than reconciles. The
2337
+ // three ways a verdict arrives can carry three different words, and there is
2338
+ // no honest rule for picking between them: acting on either would commit
2339
+ // somebody's work on the strength of a word they did not mean as the answer.
2340
+ // Refused BEFORE anything is looked up, so a rejected combination cannot also
2341
+ // spend a pending code or read a blob off disk.
2342
+ if (flags.verdict && (flags.claim || flags.claimSince || flags.notes)) {
2343
+ process.stdout.write(
2344
+ 'spec-env review: --verdict carries one verdict and so does ' +
2345
+ `${flags.notes ? '--notes' : '--claim'} — send one, not both\n`,
2346
+ )
2347
+ return
2348
+ }
2349
+
2350
+ // A VERDICT WITH NOTHING ATTACHED — the word a `file://` page hands over when
2351
+ // the pass it would otherwise build carries nothing but the conclusion. It
2352
+ // joins the same merge below rather than forking: `judgeVerdict` still
2353
+ // refuses a commit over open comments, the log still records it, and the gate
2354
+ // still clears. What it cannot carry is marks, and it does not pretend to —
2355
+ // `merged` stays null, because nothing was.
2356
+ if (flags.verdict) {
2357
+ const word = String(flags.verdict).trim()
2358
+ if (!VERDICTS.includes(word)) {
2359
+ // The engine's own vocabulary, named in full. A word refused without the
2360
+ // list is a typo the reader has to go looking for.
2361
+ process.stdout.write(
2362
+ `spec-env review: verdict "${word}" is not one of ${VERDICTS.join(', ')}\n`,
2363
+ )
2364
+ return
2365
+ }
2366
+ sentVerdict = word
2367
+ }
2368
+
1950
2369
  // A CLAIM IS A DELIVERY MECHANISM, not a second kind of review. It lifts a
1951
2370
  // pass out of the holding area and hands it to exactly the same merge a
1952
2371
  // pasted blob goes through, so nothing downstream can tell — or behave
@@ -2109,7 +2528,16 @@ async function specEnvReview(dir, config, specArg, flags) {
2109
2528
  // The log is appended only for a verdict that was ACTED ON. A refused
2110
2529
  // approval did not happen, and recording it as history would leave a
2111
2530
  // trail of decisions the repo never took.
2112
- notes = appendDecision(notes, { verdict: judged.effective, at: new Date().toISOString() })
2531
+ // THE CODE GOES IN THE LOG, and it is the only present thing that lets
2532
+ // the page conclude a pass was picked up. Without it `claimed` could only
2533
+ // be read off the code being gone from the holding area — which `--drop`,
2534
+ // a moved store and a mistyped folder all achieve without anyone claiming
2535
+ // anything. Null for a pasted blob, which carried no code to record.
2536
+ notes = appendDecision(notes, {
2537
+ verdict: judged.effective,
2538
+ at: new Date().toISOString(),
2539
+ code: claimed ? claimed.code : null,
2540
+ })
2113
2541
  writeNotes(out, notes)
2114
2542
  }
2115
2543
  verdictReport = {
@@ -2185,7 +2613,23 @@ async function specEnvReview(dir, config, specArg, flags) {
2185
2613
  const gate = gateNow.corrupt ? null : gateNow.gate
2186
2614
 
2187
2615
  const now = new Date().toISOString()
2188
- let data = collectReview({ spec, git, mode, ref, base, now, notes, gate, buttons })
2616
+ // The surfaces block, on a worktree view only: a `--docs` page belongs to a
2617
+ // spec with no branch to put live (decision 6), so it carries neither key and
2618
+ // renders exactly as it did before this existed.
2619
+ const surfaceArgs = docs ? {} : { live, tiers: pageTiers(config) }
2620
+ let data = collectReview({
2621
+ spec,
2622
+ git,
2623
+ mode,
2624
+ ref,
2625
+ base,
2626
+ now,
2627
+ notes,
2628
+ gate,
2629
+ buttons,
2630
+ ...surfaceArgs,
2631
+ ...(docs ? { only: docs.owned, treePath: docs.tree } : {}),
2632
+ })
2189
2633
 
2190
2634
  // A CLEAN WORKING TREE IS NOT "NOTHING TO REVIEW". It is the state a phase
2191
2635
  // ends in: the page is rendered before the commit, the commit happens
@@ -2201,8 +2645,14 @@ async function specEnvReview(dir, config, specArg, flags) {
2201
2645
  // An explicit `--branch` is never re-interpreted, and a non-empty working tree
2202
2646
  // is never swapped out from under the reader. The swap only ever replaces an
2203
2647
  // empty view, so no information is lost by it.
2648
+ //
2649
+ // `--docs` NEVER FALLS BACK. Its file set is this spec's documents and the
2650
+ // branch range is every file on the branch, so the swap would replace "the
2651
+ // spec you asked for" with "everything this branch changed" — and on the base
2652
+ // branch, where a backlog spec is read, that range is the whole of main. The
2653
+ // empty case is already refused above, so there is nothing here to rescue.
2204
2654
  let fellBack = false
2205
- if (!flags.branch && data.totals.files === 0) {
2655
+ if (!docs && !flags.branch && data.totals.files === 0) {
2206
2656
  const fallbackBase = reviewBase()
2207
2657
  const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
2208
2658
  // Cannot tell -> do nothing, exactly as the `--branch` path refuses. No
@@ -2219,6 +2669,7 @@ async function specEnvReview(dir, config, specArg, flags) {
2219
2669
  notes,
2220
2670
  gate,
2221
2671
  buttons,
2672
+ ...surfaceArgs,
2222
2673
  fellBack: true,
2223
2674
  })
2224
2675
  if (wider.totals.files > 0) {
@@ -2269,8 +2720,41 @@ async function specEnvReview(dir, config, specArg, flags) {
2269
2720
  // nothing at all: the ordinary render must read exactly as it did before any
2270
2721
  // of this existed.
2271
2722
  let serverSaid = null
2272
- if (reader.reader === 'remote' && config.review.serveOnRemote) {
2273
- const up = await ensureReviewServer(dir, config, { host: '0.0.0.0' })
2723
+ // EVERY RENDER SERVES, because a `file://` page has no server to POST to and
2724
+ // so its verdict buttons have nowhere to go. The reader is not consulted here
2725
+ // any more — that gate is what handed a local machine a page it could read
2726
+ // and not answer.
2727
+ //
2728
+ // THE BIND STILL COMES FROM THE READER, and that is what makes this free of
2729
+ // new exposure: a remote reader binds every interface exactly as before, and
2730
+ // a local or unknown one binds loopback — `http://127.0.0.1` instead of
2731
+ // `file://`, which opens on the machine holding the page and, unlike
2732
+ // `file://`, can POST. Cannot-tell binds loopback, the harmless direction
2733
+ // (`.claude/rules/negative-checks.md` rule 4).
2734
+ // WHY THERE IS NO SERVED URL, when there is none. The `file://` line used to
2735
+ // mean "nobody asked for serving"; now that every render asks, it can only
2736
+ // mean the ask did not land — so the render says which: the operator turned
2737
+ // it off, or it failed and here is the reason.
2738
+ let noServeBecause = config.review.serve === 'never' ? 'review.serve is "never"' : null
2739
+ if (config.review.serve === 'always') {
2740
+ // THE BIND COMES FROM THE SETTING, not from a guess about the reader. That
2741
+ // guess was the last decision detection had, and it was wrong every time the
2742
+ // reader moved — a phone off the LAN, a local session reading a page whose
2743
+ // buttons cannot POST. `allowNetwork` is the project saying which surfaces
2744
+ // it permits, and the reader picks from what is listed.
2745
+ const host = config.review.allowNetwork ? '0.0.0.0' : '127.0.0.1'
2746
+ const up = await ensureReviewServer(dir, config, { host })
2747
+ // TRANSLATED, because `error` is a code for a caller and this line is read
2748
+ // by a person: `busy` alone does not say which port, and the port is the
2749
+ // whole of what they can act on. An unmapped code is passed through rather
2750
+ // than swallowed — a reason nobody anticipated still beats silence.
2751
+ if (up.error === 'busy') {
2752
+ noServeBecause = `port ${up.port} is already in use`
2753
+ } else if (up.error === 'unreadable') {
2754
+ noServeBecause = `a server is running (pid ${up.pid}) whose settings could not be read`
2755
+ } else if (up.error) {
2756
+ noServeBecause = String(up.error)
2757
+ }
2274
2758
  if (up.replaced === 'engine') {
2275
2759
  serverSaid = up.error
2276
2760
  ? // BOTH facts. A reader told only "could not start" cannot see why it
@@ -2284,7 +2768,7 @@ async function specEnvReview(dir, config, specArg, flags) {
2284
2768
  // The URLs come from the bind the server HAS, not the one asked for just
2285
2769
  // above — adoption can hand back a loopback server whatever was
2286
2770
  // requested. See `reviewServedUrls`.
2287
- const urls = reviewServedUrls(up, lanAddresses(), spec.folder)
2771
+ const urls = reviewServedUrls(up, offerableLanAddresses(), spec.folder)
2288
2772
  if (urls) {
2289
2773
  served = { ...urls, port: up.port, token: up.token, started: up.started }
2290
2774
  }
@@ -2297,15 +2781,38 @@ async function specEnvReview(dir, config, specArg, flags) {
2297
2781
  {
2298
2782
  spec: spec.folder,
2299
2783
  branch: spec.branch,
2300
- worktree: spec.worktreePath,
2784
+ // The tree the diff was read from — `spec.worktreePath` for every
2785
+ // mode but `docs`, where it would name a path that does not exist.
2786
+ worktree: readFrom,
2301
2787
  mode,
2302
2788
  base,
2303
2789
  fellBack,
2790
+ // The paths a committing verdict on this page must commit, so the
2791
+ // skill that routes on it never recomputes the set the reader saw.
2792
+ // Absent for every other mode, which keeps their output identical.
2793
+ ...(docs ? { docs: { paths: docs.owned } } : {}),
2304
2794
  out,
2305
2795
  publishCopy,
2306
2796
  reader: reader.reader,
2307
2797
  readerWhy: reader.why,
2308
2798
  served,
2799
+ // The same stack the text prints, so a skill reads the tiers rather
2800
+ // than parsing prose — and the two can never disagree, because both
2801
+ // come from `reviewTierStack`.
2802
+ tiers: reviewTierStack({
2803
+ served,
2804
+ fileUrl: reviewFileUrl(out),
2805
+ publishedUrl: url,
2806
+ config,
2807
+ }),
2808
+ // The same answer `live status --json` gives, from the same function,
2809
+ // so the page and the command cannot disagree. Always present, unlike
2810
+ // the tiers' optional keys: `unavailable` is a state a consumer needs
2811
+ // to see rather than an absence it has to interpret.
2812
+ live,
2813
+ // Absent when there IS a served URL, so a consumer that only ever
2814
+ // saw a served render sees no new key.
2815
+ ...(served ? {} : noServeBecause ? { notServed: noServeBecause } : {}),
2309
2816
  ...(serverSaid ? { server: serverSaid } : {}),
2310
2817
  fileUrl: reviewFileUrl(out),
2311
2818
  urlFile,
@@ -2381,7 +2888,10 @@ async function specEnvReview(dir, config, specArg, flags) {
2381
2888
  (waiting.length
2382
2889
  ? ` pending: ${waiting.length} waiting\n` +
2383
2890
  waiting
2384
- .map((p) => ` ${p.code} · ${p.verdict || 'no verdict'} · ${pendingAge(p.at, now)}\n`)
2891
+ .map(
2892
+ (p) =>
2893
+ ` ${p.code} · ${p.action || p.verdict || 'no verdict'} · ${pendingAge(p.at, now)}\n`,
2894
+ )
2385
2895
  .join('')
2386
2896
  : '') +
2387
2897
  (outcomeSaid ? ` outcome: ${outcomeSaid}\n` : '') +
@@ -2397,39 +2907,45 @@ async function specEnvReview(dir, config, specArg, flags) {
2397
2907
  ? ''
2398
2908
  : ` reader: ${reader.reader}${reader.why ? ` (${reader.why})` : ''}\n`) +
2399
2909
  ` 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
- : '')) +
2910
+ // THE STACK, in a fixed order, every tier named. The single `open:` line
2911
+ // it replaces asked the engine to pick which surface the reader could
2912
+ // use, and the engine cannot know that guess failed three separate ways
2913
+ // in one day. Now every tier is listed, labelled, and either a URL or the
2914
+ // one command that turns it on.
2915
+ reviewTierStack({
2916
+ served,
2917
+ fileUrl: reviewFileUrl(out),
2918
+ publishedUrl: url,
2919
+ config,
2920
+ })
2921
+ .map((t) => reviewTierLine(t) + '\n')
2922
+ .join('') +
2923
+ // The runners-up sit UNDER the network tier, because that is what they are
2924
+ // alternatives to — and virtual adapters are gone from them entirely.
2925
+ (served && !served.loopback && (served.alternates || []).length
2926
+ ? served.alternates.map((u) => ` also: ${u}\n`).join('')
2927
+ : '') +
2928
+ // WHICH TIERS THE WAIT COVERS, once, next to the stack. `local` and
2929
+ // `network` are two doors into one room the page POSTs to
2930
+ // `location.pathname`, so both reach this server and this pending store.
2931
+ // `remote` writes to the artifact's own store, which nothing here can see.
2932
+ ' the wait covers local + network; a remote verdict needs /spec-reviewed.\n' +
2933
+ // WHETHER IT IS ALSO RUNNING SOMEWHERE, which is the other way to judge a
2934
+ // change. `unavailable` prints nothing — see `liveStateLine`.
2935
+ (liveStateLine(live) ? `${liveStateLine(live)}\n` : '') +
2936
+ // The reason there is no served URL, when there is none. A `file://` link
2937
+ // with nothing said about it reads as the ordinary outcome, and it is not.
2938
+ (!served && noServeBecause ? ` not served: ${noServeBecause}\n` : '') +
2939
+ (served && served.loopback && config.review.allowNetwork
2940
+ ? ` network permitted but the running server is loopback-bound — ${served.widen}\n`
2941
+ : '') +
2942
+ (served && served.started && !served.loopback
2943
+ ? ' serving: every provisioned spec, to anyone with a URL on your network.\n' +
2944
+ ' stop: skitterspec spec-env review serve --stop\n'
2945
+ : '') +
2946
+ // One line, and only when something was actually done on the reader's
2947
+ // behalf. An action nobody asked for is reported, not hidden.
2948
+ (serverSaid ? ` ${serverSaid}\n` : '') +
2433
2949
  // Named on its own line so the skill never has to build the path itself.
2434
2950
  (publishCopy ? ` publish: ${publishCopy}\n` : '') +
2435
2951
  (url ? ` published: ${url}\n` : '') +
@@ -2505,6 +3021,61 @@ function stateDirLabel(config) {
2505
3021
  return path.posix.dirname(config.registry) || '.spec-env'
2506
3022
  }
2507
3023
 
3024
+ /**
3025
+ * The engine script's mtime in ms, or null when it cannot be read.
3026
+ *
3027
+ * WHY MTIME AND NOT A CONTENT HASH: the dist is assembled from several files,
3028
+ * so a hash needs a manifest to stay honest, and this answers the only question
3029
+ * asked — is the code on disk newer than the process serving it? WHY NOT THE
3030
+ * PROCESS START TIME: `ps -o lstart=` is platform-specific and needs parsing,
3031
+ * while this is a number we write ourselves into a file we already write.
3032
+ *
3033
+ * Null is cannot-tell and adopts. See `staleServer`.
3034
+ */
3035
+ function scriptMtimeOf(scriptPath) {
3036
+ try {
3037
+ return fs.statSync(scriptPath).mtimeMs
3038
+ } catch {
3039
+ return null
3040
+ }
3041
+ }
3042
+
3043
+ /**
3044
+ * This repo's serve token, minted once and then read.
3045
+ *
3046
+ * STORED, NOT DERIVED, and the asymmetry with the port is the point. The port
3047
+ * is `PORT_BASE + hash(realpath(repo)) % PORT_SPAN` because a port is not a
3048
+ * secret and a stable one keeps a handed-out link working. The token is the
3049
+ * only guard on a non-loopback bind, and a repo path is guessable by anyone on
3050
+ * the machine — so deriving it the same way would buy stability with the single
3051
+ * property it exists for. It keeps all 48 random bits; only its lifetime moved.
3052
+ *
3053
+ * WHAT WOULD FOOL THIS: a token file someone has emptied or truncated. A short
3054
+ * or non-hex value is treated as absent and replaced, because a malformed token
3055
+ * cannot guard anything — and the alternative, refusing to serve, would take
3056
+ * the page away over a file nobody reads.
3057
+ */
3058
+ function repoToken(dir, config) {
3059
+ const file = path.resolve(dir, `${stateDirLabel(config)}/review-token`)
3060
+ try {
3061
+ const found = String(fs.readFileSync(file, 'utf8')).trim()
3062
+ if (/^[0-9a-f]{12}$/.test(found)) return found
3063
+ } catch {
3064
+ // Absent is the ordinary first-run state, not a problem.
3065
+ }
3066
+ const minted = mintToken()
3067
+ try {
3068
+ fs.mkdirSync(path.dirname(file), { recursive: true })
3069
+ fs.writeFileSync(file, minted + '\n')
3070
+ } catch {
3071
+ // UNWRITEABLE IS NOT FATAL. The server can still serve on this token for
3072
+ // as long as it lives; what is lost is only the survival across a restart,
3073
+ // which is exactly where this started. Taking the page away instead would
3074
+ // be a worse answer to a read-only `.spec-env`.
3075
+ }
3076
+ return minted
3077
+ }
3078
+
2508
3079
  // The supervised proxy process descriptor (paths relative to the checkout root).
2509
3080
  function proxyProcFor(config, routesFileAbs) {
2510
3081
  const sdir = stateDirLabel(config)
@@ -2639,6 +3210,27 @@ function lanAddresses(nets = require('node:os').networkInterfaces()) {
2639
3210
  return rankLanAddresses(nets).map((e) => e.address)
2640
3211
  }
2641
3212
 
3213
+ /**
3214
+ * The addresses worth OFFERING as alternatives — physical and unknown
3215
+ * interfaces, never virtual ones.
3216
+ *
3217
+ * The ranking already puts virtuals last, so the best guess was right; what was
3218
+ * wrong was listing them at all. A render offered `10.211.55.2` and
3219
+ * `10.37.129.2` — both Parallels adapters — as alternatives to a working link,
3220
+ * and no phone can route to either. An alternative that cannot work is worse
3221
+ * than no alternative: it reads as something to try when the first one fails.
3222
+ *
3223
+ * WHAT WOULD FOOL THIS: an interface named outside both patterns is `unknown`
3224
+ * and therefore KEPT, because a machine with unusual naming is more likely to
3225
+ * have a real address than a fake one — being wrong that way offers one dud,
3226
+ * where the opposite hides the only address that works.
3227
+ */
3228
+ function offerableLanAddresses(nets = require('node:os').networkInterfaces()) {
3229
+ return rankLanAddresses(nets)
3230
+ .filter((e) => !VIRTUAL_IFACE.test(e.iface))
3231
+ .map((e) => e.address)
3232
+ }
3233
+
2642
3234
  /**
2643
3235
  * Bring the review server up, or adopt the one already running.
2644
3236
  *
@@ -2679,11 +3271,16 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
2679
3271
  if (settings && settings.port) {
2680
3272
  if (serverScriptOk(settings)) {
2681
3273
  const now = engineVersionFor(proc.script)
2682
- const verdict = staleServer(settings.engine, now)
3274
+ const verdict = staleServer(settings.engine, now, settings.scriptMtime, scriptMtimeOf(proc.script))
2683
3275
  if (verdict !== 'stale') {
2684
3276
  const lb = settings.host === '127.0.0.1' || settings.host === 'localhost'
2685
3277
  return {
2686
3278
  port: settings.port,
3279
+ // How the RUNNING server's port was chosen, as it recorded it. A
3280
+ // server from before this was written has no answer; `null` says so
3281
+ // rather than recomputing one, because a recomputed answer could
3282
+ // disagree with the port actually being served.
3283
+ portSource: settings.portSource || null,
2687
3284
  token: settings.token || null,
2688
3285
  loopback: lb,
2689
3286
  pid: running,
@@ -2721,13 +3318,29 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
2721
3318
  }
2722
3319
  }
2723
3320
 
2724
- const usePort = Number(port || config.review.servePort)
3321
+ // `--port` wins, then an explicit `review.servePort` number, then the
3322
+ // derivation. `source` is recorded in the settings file below so `--status`
3323
+ // can say WHY this port, rather than leaving the operator to read config.js.
3324
+ const resolved = resolveServePort(config, dir, port)
3325
+ const usePort = resolved.port
2725
3326
  const loopback = host === '127.0.0.1' || host === 'localhost'
2726
3327
  // 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()
3328
+ // bind rather than offered as an option to forget. It is read from the repo's
3329
+ // own store, so it OUTLIVES THIS PROCESS: the port beside it is a pure
3330
+ // function of the repo path and stable across a restart by design, and a URL
3331
+ // whose two halves disagree about that is a URL that dies for no reason the
3332
+ // reader can see. Six were handed out for one repo in a single session.
3333
+ //
3334
+ // `reuseToken` still wins where a server is being replaced on the same bind —
3335
+ // now redundant rather than wrong, and left alone as its own guarantee.
3336
+ //
3337
+ // EVERY BIND CARRIES IT, loopback included, and on loopback it guards nothing:
3338
+ // a server reachable only from this machine needs no credential. It is there
3339
+ // so the URL has ONE SHAPE. The bind comes from reader detection, and that
3340
+ // detection flipped `unknown` → `remote` inside a single session — so a
3341
+ // token-only-on-network rule meant the same repo's address gained and lost a
3342
+ // path segment underneath whoever was holding it.
3343
+ const token = reuseToken || repoToken(dir, config)
2731
3344
 
2732
3345
  if (running) await stopProcess(proc, { rootDir: dir })
2733
3346
 
@@ -2752,7 +3365,8 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
2752
3365
  // server refused to start on every Linux machine while macOS stayed green.
2753
3366
  // `portsInUseOn` owns the ordering; see its comment for the verification.
2754
3367
  const busy = await portsInUseOn(usePort, [host, '127.0.0.1'])
2755
- if (busy.length) return { error: 'busy', port: usePort, replaced, engineWas }
3368
+ if (busy.length)
3369
+ return { error: 'busy', port: usePort, portSource: resolved.source, replaced, engineWas }
2756
3370
 
2757
3371
  fs.mkdirSync(path.dirname(abs(settingsFile)), { recursive: true })
2758
3372
  fs.writeFileSync(
@@ -2764,7 +3378,18 @@ async function ensureReviewServer(dir, config, { host = '127.0.0.1', port, resta
2764
3378
  // — the process is alive, the file exists, and every page it renders is
2765
3379
  // drawn by code that was replaced underneath it.
2766
3380
  JSON.stringify(
2767
- { dir, port: usePort, host, token, script: proc.script, engine: engineVersionFor(proc.script) },
3381
+ {
3382
+ dir,
3383
+ port: usePort,
3384
+ portSource: resolved.source,
3385
+ host,
3386
+ token,
3387
+ script: proc.script,
3388
+ engine: engineVersionFor(proc.script),
3389
+ // Recorded so the NEXT adoption can tell a rebuild from a restart. A
3390
+ // version alone cannot: the dist is rebuilt at the same version all day.
3391
+ scriptMtime: scriptMtimeOf(proc.script),
3392
+ },
2768
3393
  null,
2769
3394
  2,
2770
3395
  ) + '\n',
@@ -2815,6 +3440,25 @@ async function specEnvReviewServe(dir, config, flags) {
2815
3440
  const pid = readPid(abs(proc.pidFile))
2816
3441
  const running = pid && isAlive(pid) ? pid : null
2817
3442
 
3443
+ // ROTATION IS THE ONLY WAY TO CHANGE A TOKEN, and it says what it costs.
3444
+ // Every other path reads the stored one; a silent mint is the whole bug this
3445
+ // replaced, so the one place that mints deliberately announces it.
3446
+ if (flags.rotateToken) {
3447
+ const file = abs(`${sdir}/review-token`)
3448
+ const minted = mintToken()
3449
+ fs.mkdirSync(path.dirname(file), { recursive: true })
3450
+ fs.writeFileSync(file, minted + '\n')
3451
+ process.stdout.write(
3452
+ 'spec-env review serve: token rotated.\n' +
3453
+ ' EVERY LINK ALREADY HANDED OUT IS NOW DEAD — re-render and send the new one.\n' +
3454
+ (running
3455
+ ? ` the running server (pid ${running}) still answers on the old token; ` +
3456
+ '--stop it, then render again.\n'
3457
+ : ''),
3458
+ )
3459
+ return
3460
+ }
3461
+
2818
3462
  if (flags.status) {
2819
3463
  if (!running) {
2820
3464
  process.stdout.write('spec-env review serve: not running.\n')
@@ -2828,8 +3472,16 @@ async function specEnvReviewServe(dir, config, flags) {
2828
3472
  // ordinary answer and needs no comment; `unknown` is a server from before
2829
3473
  // this was recorded, which is healthy and must not be accused of anything.
2830
3474
  const verdict = staleServer(settings.engine, engineVersionFor(proc.script))
3475
+ // The port AND how it was chosen, so "why is this on 7742?" is answered by
3476
+ // the tool. A server started before `portSource` was recorded has no answer
3477
+ // — the line then carries the port alone rather than a guess, because an
3478
+ // absence is not evidence of any particular source.
3479
+ const reason = servePortReason(settings.portSource)
2831
3480
  process.stdout.write(
2832
3481
  `spec-env review serve: running (pid ${running})\n` +
3482
+ (settings.port
3483
+ ? ` port: ${settings.port}${reason ? ` (${reason})` : ''}\n`
3484
+ : '') +
2833
3485
  (settings.port ? ` local: ${serveUrl('127.0.0.1', settings)}\n` : '') +
2834
3486
  (settings.engine ? ` engine: ${settings.engine}\n` : '') +
2835
3487
  (verdict === 'stale'
@@ -2863,9 +3515,19 @@ async function specEnvReviewServe(dir, config, flags) {
2863
3515
  })
2864
3516
 
2865
3517
  if (started.error === 'busy') {
3518
+ // NAME `servePort`, not only `--port`. `--port` moves this run aside and
3519
+ // leaves every link already handed out pointing at the busy port — which is
3520
+ // the bug this refusal used to send people straight into. Pinning
3521
+ // `review.servePort` is the durable fix: it is what the next link is built
3522
+ // from, so the move happens once and the links follow it.
2866
3523
  process.stdout.write(
2867
3524
  `spec-env review serve: port ${started.port} is already in use — ` +
2868
- 'pass --port, or --stop if this is an older server.\n',
3525
+ (started.portSource === 'derived'
3526
+ ? 'two repos derived the same port. '
3527
+ : '') +
3528
+ 'pin a free one in specs/.core/env.config.json ("review": { "servePort": <n> }) ' +
3529
+ 'so the links follow, or --stop if this is an older server.\n' +
3530
+ ' --port <n> moves this run only, and leaves existing links on the busy port.\n',
2869
3531
  )
2870
3532
  return
2871
3533
  }
@@ -2912,8 +3574,13 @@ async function specEnvReviewServe(dir, config, flags) {
2912
3574
  */
2913
3575
  function reviewServedUrls(up, addrs, folder) {
2914
3576
  const page = (host) => `${serveUrl(host, up)}${encodeURIComponent(folder)}`
3577
+ // ALWAYS AVAILABLE WHEN SERVED. A server bound to 0.0.0.0 answers on loopback
3578
+ // too, so `local` is a real tier in both binds — it is not an alternative to
3579
+ // the network URL, it is the same page from the machine holding it.
3580
+ const loopbackUrl = page('127.0.0.1')
2915
3581
  if (up.loopback) {
2916
3582
  return {
3583
+ loopbackUrl,
2917
3584
  url: page('127.0.0.1'),
2918
3585
  // No runners-up: every other address on this machine is one the server
2919
3586
  // is not listening on.
@@ -2926,8 +3593,14 @@ function reviewServedUrls(up, addrs, folder) {
2926
3593
  // and can be wrong, so the alternates are offered rather than thrown away. No
2927
3594
  // address at all means nothing to offer, and the `file://` fallback is the
2928
3595
  // honest answer.
2929
- if (!addrs.length) return null
2930
- return { url: page(addrs[0]), alternates: addrs.slice(1).map(page), loopback: false, widen: null }
3596
+ if (!addrs.length) return { loopbackUrl, url: null, alternates: [], loopback: false, widen: null }
3597
+ return {
3598
+ loopbackUrl,
3599
+ url: page(addrs[0]),
3600
+ alternates: addrs.slice(1).map(page),
3601
+ loopback: false,
3602
+ widen: null,
3603
+ }
2931
3604
  }
2932
3605
 
2933
3606
  /**
@@ -3081,7 +3754,7 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
3081
3754
  // feature. The branch that's checked out IS the lock (assertPrimaryOnMain); the
3082
3755
  // receipt is advisory metadata. `status` is read-only; `take` performs the switch
3083
3756
  // (release/abort land in a later phase).
3084
- async function specEnvLive(dir, config, positional) {
3757
+ async function specEnvLive(dir, config, positional, flags) {
3085
3758
  // Both verbs exist to route around the work living somewhere other than the
3086
3759
  // checkout you are in — a proxy to a second stack, or a temporary branch swap.
3087
3760
  // Checkout mode closes that gap permanently, so there is nothing to route.
@@ -3100,7 +3773,7 @@ async function specEnvLive(dir, config, positional) {
3100
3773
  // report cannot describe. It prints ABOVE the report, not instead of it:
3101
3774
  // you asked a question and should still get the answer.
3102
3775
  if (note) process.stdout.write(note)
3103
- specEnvLiveStatus(dir, config, specArg)
3776
+ specEnvLiveStatus(dir, config, specArg, flags)
3104
3777
  break
3105
3778
  case 'take':
3106
3779
  await specEnvLiveTake(dir, config, specArg)
@@ -3404,8 +4077,9 @@ async function specEnvLiveAbort(dir, config) {
3404
4077
  )
3405
4078
  }
3406
4079
 
3407
- function specEnvLiveStatus(dir, config, specArg) {
4080
+ function specEnvLiveStatus(dir, config, specArg, flags) {
3408
4081
  const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
4082
+ const json = Boolean(flags && flags.json)
3409
4083
 
3410
4084
  // Per-spec query (`live status <spec>`): a clear yes/no verdict /spec-start and
3411
4085
  // skill branches on to decide whether to skip worktree provisioning and work in
@@ -3413,6 +4087,26 @@ function specEnvLiveStatus(dir, config, specArg) {
3413
4087
  if (specArg) {
3414
4088
  const spec = resolveSpecWithWorktree(dir, config, specArg)
3415
4089
  const live = !onBase && branch === spec.branch
4090
+ // `--json` answers with the SAME function the review render uses, so a page
4091
+ // and this command can never disagree about whether a spec is live. The
4092
+ // text form above is left exactly as it was: `/spec-start` reads its
4093
+ // `live: yes|no` line, and a flag must not move a seam.
4094
+ if (json) {
4095
+ process.stdout.write(
4096
+ JSON.stringify(
4097
+ {
4098
+ spec: spec.folder,
4099
+ branch: spec.branch,
4100
+ primary: branch || null,
4101
+ base: baseBranch,
4102
+ live: liveStateFor(spec, liveContext(dir, config, spec, { onBase, primaryBranch: branch })),
4103
+ },
4104
+ null,
4105
+ 2,
4106
+ ) + '\n',
4107
+ )
4108
+ return
4109
+ }
3416
4110
  process.stdout.write(
3417
4111
  `spec-env live status: ${spec.folder}\n` +
3418
4112
  ` spec: ${spec.folder} (branch ${spec.branch})\n` +
@@ -3443,6 +4137,26 @@ function specEnvLiveStatus(dir, config, specArg) {
3443
4137
  ? `${receipt.spec} (branch ${branch || '(detached)'})`
3444
4138
  : `unknown (branch ${branch || '(detached)'} — no receipt; switched by hand?)`
3445
4139
 
4140
+ if (json) {
4141
+ process.stdout.write(
4142
+ JSON.stringify(
4143
+ {
4144
+ primary: branch || null,
4145
+ base: baseBranch,
4146
+ onBase,
4147
+ // The three-state answer the text's `in-flight:` line carries, kept
4148
+ // as three states here too: a branch switched by hand leaves no
4149
+ // receipt, so the spec is `null` while the checkout is plainly busy.
4150
+ inFlight: onBase ? null : receipt && receipt.spec ? String(receipt.spec) : null,
4151
+ receipt: receipt || null,
4152
+ },
4153
+ null,
4154
+ 2,
4155
+ ) + '\n',
4156
+ )
4157
+ return
4158
+ }
4159
+
3446
4160
  process.stdout.write(
3447
4161
  'spec-env live:\n' +
3448
4162
  ` primary: ${branch || '(detached)'} (${state})\n` +
@@ -3451,6 +4165,39 @@ function specEnvLiveStatus(dir, config, specArg) {
3451
4165
  )
3452
4166
  }
3453
4167
 
4168
+ /**
4169
+ * The `ctx` `liveStateFor` wants, probed from this repo.
4170
+ *
4171
+ * ONE BUILDER, so every caller passes the same shaped answer — the review
4172
+ * render and `live status --json` both come through here, which is what makes
4173
+ * "the page and the command agree" a property of the code rather than a habit.
4174
+ *
4175
+ * `onBase`/`primaryBranch` are the caller's, because it has usually already
4176
+ * asked `assertPrimaryOnMain` and asking twice can straddle a branch switch.
4177
+ *
4178
+ * An unreadable receipt is caught and dropped: it costs the holder's NAME and
4179
+ * never the state, which the branch answers (`liveStateFor`).
4180
+ */
4181
+ function liveContext(dir, config, spec, { onBase, primaryBranch }) {
4182
+ let receipt = null
4183
+ try {
4184
+ receipt = readReceipt(dir, config)
4185
+ } catch {
4186
+ receipt = null
4187
+ }
4188
+ const front = (config.dev || []).map((d) => d.frontPort).filter((p) => typeof p === 'number')[0]
4189
+ return {
4190
+ isolated: true,
4191
+ onBase,
4192
+ primaryBranch,
4193
+ receipt,
4194
+ worktreeExists: Boolean(spec && spec.worktreePath && fs.existsSync(spec.worktreePath)),
4195
+ // Null where the project configured no canonical port — there is no URL to
4196
+ // name, and inventing one would send the reader to a closed port.
4197
+ url: front ? `http://${(config.proxy && config.proxy.host) || 'localhost'}:${front}` : null,
4198
+ }
4199
+ }
4200
+
3454
4201
  async function specEnv(rest) {
3455
4202
  const [sub, ...args] = rest
3456
4203
  let dir = process.cwd()
@@ -3479,6 +4226,8 @@ async function specEnv(rest) {
3479
4226
  else if (args[i] === '--older-than') flags.olderThanDays = Number(args[++i])
3480
4227
  else if (args[i] === '--branch') flags.branch = true
3481
4228
  else if (args[i] === '--stop') flags.stop = true
4229
+ else if (args[i] === '--rotate-token') flags.rotateToken = true
4230
+ else if (args[i] === '--off') flags.off = true
3482
4231
  else if (args[i] === '--status') flags.status = true
3483
4232
  else if (args[i] === '--port') flags.port = args[++i]
3484
4233
  else if (args[i] === '--host') flags.host = args[++i]
@@ -3487,29 +4236,39 @@ async function specEnv(rest) {
3487
4236
  else if (args[i] === '--out') flags.out = args[++i]
3488
4237
  else if (args[i] === '--review') flags.review = args[++i]
3489
4238
  else if (args[i] === '--notes') flags.notes = args[++i]
4239
+ else if (args[i] === '--verdict') flags.verdict = args[++i]
4240
+ // `--set` keeps an EMPTY STRING rather than coercing it away: empty is the
4241
+ // bare `/spec-remote-review`, and it means toggle. `??` so a missing value
4242
+ // at the end of argv is still the empty form rather than `undefined`, which
4243
+ // would read as the flag never having been passed.
4244
+ else if (args[i] === '--set') flags.set = args[++i] ?? ''
3490
4245
  else if (args[i] === '--resolve') flags.resolve = args[++i]
3491
4246
  else if (args[i] === '--outcome') flags.outcome = args[++i]
3492
4247
  else if (args[i] === '--claim') flags.claim = args[++i]
3493
4248
  else if (args[i] === '--claim-since') flags.claimSince = args[++i]
4249
+ else if (args[i] === '--since') flags.since = args[++i]
4250
+ else if (args[i] === '--timeout') flags.timeout = args[++i]
3494
4251
  else if (args[i] === '--drop') flags.drop = args[++i]
3495
4252
  else if (args[i] === '--json') flags.json = true
3496
4253
  else if (args[i] === '--check') flags.check = true
3497
4254
  else if (args[i] === '--for-command') flags.forCommand = args[++i]
3498
4255
  else if (args[i] === '--phase') flags.phase = args[++i]
3499
4256
  else if (args[i] === '--record-primary') flags.recordPrimary = true
4257
+ else if (args[i] === '--docs') flags.docs = true
3500
4258
  else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
3501
4259
  else positional.push(args[i])
3502
4260
  }
3503
4261
  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.
4262
+ // Where the caller actually is, kept before the re-anchor below. `stage` and
4263
+ // `review --docs` want it: every other subcommand asks about the repo, while
4264
+ // those two ask about the tree in front of you, and the two differ inside a
4265
+ // worktree.
3507
4266
  const invokedFrom = dir
3508
4267
  // Anchor on the primary checkout so every subcommand resolves {repo}, worktree
3509
4268
  // paths, and the registry identically whether run from main or a worktree.
3510
4269
  dir = resolvePrimaryCheckout(dir, gitReader(dir))
3511
4270
 
3512
- const { config, present } = loadEnvConfig(dir)
4271
+ const { config, present, unknown } = loadEnvConfig(dir)
3513
4272
  if (!present) {
3514
4273
  process.stdout.write(
3515
4274
  'spec-env: isolation not enabled (no specs/.core/env.config.json).\n' +
@@ -3518,6 +4277,24 @@ async function specEnv(rest) {
3518
4277
  return
3519
4278
  }
3520
4279
 
4280
+ // A key the loader does not read is dropped — it always was, and still is.
4281
+ // What changed is that it is no longer dropped in SILENCE: a mis-typed
4282
+ // `review.required` leaves the gate on and a mis-typed
4283
+ // `teardown.deleteRemoteBranch` reverts to prompt, and the only signal either
4284
+ // gave was that nothing happened.
4285
+ //
4286
+ // Every spec-env subcommand passes through here, which is why it sits at this
4287
+ // one point rather than in a reporting verb someone might never run. It is
4288
+ // ADVISORY: it writes lines and changes no exit status, because a forward-compat
4289
+ // key and a typo are indistinguishable from here and only one of them is a
4290
+ // mistake (.claude/rules/negative-checks.md rule 4).
4291
+ //
4292
+ // STDERR, deliberately: `--json` subcommands write their payload to stdout, and
4293
+ // an advisory line on stdout would make it unparseable.
4294
+ for (const key of unknown || []) {
4295
+ process.stderr.write(`spec-env: ${ENV_CONFIG_LABEL} — unknown key "${key}" is ignored.\n`)
4296
+ }
4297
+
3521
4298
  switch (sub) {
3522
4299
  case 'up':
3523
4300
  specEnvUp(dir, config, positional[0])
@@ -3564,10 +4341,22 @@ async function specEnv(rest) {
3564
4341
  specEnvReviewArm(dir, config, positional[1], flags)
3565
4342
  break
3566
4343
  }
4344
+ if (positional[0] === 'waiting') {
4345
+ specEnvReviewWaiting(dir, config, flags)
4346
+ break
4347
+ }
4348
+ if (positional[0] === 'wait') {
4349
+ await specEnvReviewWait(dir, config, positional[1], flags)
4350
+ break
4351
+ }
3567
4352
  if (positional[0] === 'gate') {
3568
4353
  specEnvReviewGate(dir, config, positional[1], flags, invokedFrom)
3569
4354
  break
3570
4355
  }
4356
+ if (positional[0] === 'allow') {
4357
+ specEnvReviewAllow(dir, config, positional[1], flags)
4358
+ break
4359
+ }
3571
4360
  if (positional[0] === 'skip') {
3572
4361
  // The one positional is the REASON, not a spec: the two are
3573
4362
  // indistinguishable as free text, and the spec is the one thing this
@@ -3575,20 +4364,25 @@ async function specEnv(rest) {
3575
4364
  specEnvReviewSkip(dir, config, positional[1], flags)
3576
4365
  break
3577
4366
  }
3578
- await specEnvReview(dir, config, positional[0], flags)
4367
+ await specEnvReview(dir, config, positional[0], flags, invokedFrom)
3579
4368
  break
3580
4369
  case 'live':
3581
- await specEnvLive(dir, config, positional)
4370
+ await specEnvLive(dir, config, positional, flags)
3582
4371
  break
3583
4372
  default:
3584
4373
  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' +
4374
+ `Usage: skitterspec spec-env <${SPEC_ENV_VERBS.join('|')}> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--notes <json>] [--verdict <word>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n` +
3586
4375
  ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
4376
+ ' review serve --rotate-token mint a new URL token; every handed-out link dies\n' +
4377
+ ' review allow <network|remote> [--off] permit a review tier (writes env.config.json)\n' +
3587
4378
  ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
3588
4379
  ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
3589
4380
  ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
3590
4381
  ' review skip "<reason>" move on without one, on the record\n' +
4382
+ ' review wait [spec] --since <iso> block until a verdict arrives ([--timeout <s>])\n' +
4383
+ ' review waiting [--json] every pass waiting, across every spec\n' +
3591
4384
  ' review [spec] --claim-since <iso> claim the one pass that arrived since <iso>\n' +
4385
+ ' review [spec] --verdict <word> send just a verdict, with nothing marked\n' +
3592
4386
  ' review [spec] --buttons midrun the page offers Continue, not a commit\n' +
3593
4387
  ' [spec] is optional everywhere: omit it and the worktree you are standing\n' +
3594
4388
  ' in is used, else the sole provisioned spec (several -> it lists them).\n' +
@@ -3704,11 +4498,14 @@ async function run(argv) {
3704
4498
  }
3705
4499
 
3706
4500
  module.exports = {
4501
+ SPEC_ENV_VERBS,
3707
4502
  run,
3708
4503
  parse,
3709
4504
  HELP,
3710
4505
  unknownCommand,
3711
4506
  rankLanAddresses,
4507
+ offerableLanAddresses,
4508
+ reviewTierStack,
3712
4509
  serveProcFor,
3713
4510
  serverScriptOk,
3714
4511
  daemonScript,