@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,1044 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * `spec-env review serve` — every spec's diff on one local HTTP server,
5
+ * **rendered per request**.
6
+ *
7
+ * The page-on-disk path (`spec-env review <spec>`) writes a photograph of one
8
+ * moment: true when it was taken, and overwritten by the next render. A served
9
+ * page cannot be out of date, because there is no artefact between the git
10
+ * objects and the response. That is the whole reason this exists, and it is why
11
+ * no HTML is read from or written to `.spec-env/reviews/` — the file path and
12
+ * the served path are two answers to the same question, and keeping the
13
+ * artefacts independent is what stops one quietly becoming the other's cache.
14
+ *
15
+ * THE SIDECARS ARE A DIFFERENT MATTER, and this once over-applied the rule
16
+ * above to them. Notes, the gate and the pending store are the REVIEW'S state,
17
+ * not the page's: they belong to whoever is reading, and a reader on a phone is
18
+ * reading this page. So they are read here (and, for a pass arriving, written)
19
+ * — which is what makes a refresh keep your accepts and the history line say
20
+ * what happened. What is never read here is a rendered page.
21
+ *
22
+ * Dependency-free, in the shape of `proxy.js`: pure functions for routing and
23
+ * the index, an injectable render callback, and a `require.main` entry point so
24
+ * the CLI can supervise it as a detached process exactly as it supervises the
25
+ * proxy.
26
+ */
27
+
28
+ const http = require('node:http')
29
+ const fs = require('node:fs')
30
+ const path = require('node:path')
31
+ const crypto = require('node:crypto')
32
+
33
+ const {
34
+ liveWorktreePaths,
35
+ allSpecs,
36
+ resolveSpec,
37
+ resolveBaseBranch,
38
+ } = require('./resolve.js')
39
+ const { readRegistry } = require('./registry.js')
40
+ const { loadEnvConfig } = require('./config.js')
41
+ const { specDocsIn, classifyDirtyTree, dirtyPaths } = require('./classify.js')
42
+ const { liveStateFor } = require('./live.js')
43
+ const {
44
+ rawGitReader,
45
+ collectReview,
46
+ renderReviewPage,
47
+ renderReviewBlock,
48
+ reviewOutPath,
49
+ withSpecRemote,
50
+ validateNotesBlob,
51
+ readPending,
52
+ passState: readPassState,
53
+ writePending,
54
+ addPending,
55
+ readNotes,
56
+ readGate,
57
+ readRenderRecord,
58
+ DEFAULT_BUTTON_SET,
59
+ } = require('./review.js')
60
+
61
+ /**
62
+ * A random path prefix, minted only when binding beyond loopback.
63
+ *
64
+ * An unguessable path is the whole guard: bound to 0.0.0.0 the server is
65
+ * reachable by anything on the network, and a diff of unreleased work is not
66
+ * something to hand to whoever else is on the coffee-shop wifi. 48 bits from
67
+ * `crypto` — not `Math.random`, which is seeded predictably enough to enumerate.
68
+ */
69
+ function mintToken() {
70
+ return crypto.randomBytes(6).toString('hex')
71
+ }
72
+
73
+ /**
74
+ * Parse a request URL into what to serve. PURE — no git, no fs, no server.
75
+ *
76
+ * With a token, every path must carry it as the first segment. A wrong or
77
+ * missing token is `notfound`, never a redirect: a redirect would confirm the
78
+ * server is here to anybody probing ports, which is the one thing the token is
79
+ * bought to prevent.
80
+ */
81
+ function routeFor(url, { token = null } = {}) {
82
+ const [rawPath, rawQuery] = String(url || '/').split('?')
83
+ const query = new URLSearchParams(rawQuery || '')
84
+ let segments = rawPath.split('/').filter(Boolean).map(decodeURIComponent)
85
+
86
+ if (token) {
87
+ if (segments[0] !== token) return { kind: 'notfound' }
88
+ segments = segments.slice(1)
89
+ }
90
+
91
+ if (segments.length === 0) return { kind: 'index' }
92
+ if (segments.length > 1) return { kind: 'notfound' }
93
+
94
+ // `?pass=<code>` asks what became of ONE pass, and it is checked BEFORE
95
+ // `?branch` because it is not a view of the diff at all — it is the page
96
+ // finding out whether the verdict it sent was picked up. Deliberately only
97
+ // ever one code wide: a route that listed what is waiting would hand a
98
+ // prober exactly what the six digits are bought to withhold, and the POST
99
+ // route was kept from being that oracle for the same reason.
100
+ const pass = query.get('pass')
101
+ if (pass !== null) return { kind: 'pass', spec: segments[0], code: pass }
102
+
103
+ // `?branch` and `?branch=1` both mean the whole-spec view; `?branch=0` does
104
+ // not, so a link can turn it off as well as on.
105
+ const raw = query.get('branch')
106
+ const branch = raw !== null && raw !== '0' && raw !== 'false'
107
+ return { kind: 'spec', spec: segments[0], branch }
108
+ }
109
+
110
+ function escapeHtml(s) {
111
+ return String(s).replace(
112
+ /[&<>"]/g,
113
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c],
114
+ )
115
+ }
116
+
117
+ /**
118
+ * The index page. PURE — takes already-resolved entries and returns HTML.
119
+ *
120
+ * Deliberately plain: the diff page is the designed surface, and this is a
121
+ * doorway. It carries the same theme handling as the page template (a light
122
+ * palette on bare `:root`, redefined under `prefers-color-scheme`) so the two
123
+ * do not disagree when opened side by side.
124
+ */
125
+ function renderIndex(entries, { token = null } = {}) {
126
+ const prefix = token ? `/${token}` : ''
127
+ const rows = entries
128
+ .map((e) => {
129
+ const t = e.totals || { files: 0, additions: 0, deletions: 0 }
130
+ return (
131
+ `<li><a href="${prefix}/${encodeURIComponent(e.folder)}">${escapeHtml(e.folder)}</a>` +
132
+ `<span class="b">${escapeHtml(e.branch || '')}</span>` +
133
+ `<span class="n">${t.files} file${t.files === 1 ? '' : 's'}</span>` +
134
+ `<span class="a">+${t.additions}</span><span class="d">-${t.deletions}</span>` +
135
+ `<a class="w" href="${prefix}/${encodeURIComponent(e.folder)}?branch=1">whole spec</a></li>`
136
+ )
137
+ })
138
+ .join('\n')
139
+
140
+ // An empty list is an ordinary state — no spec has a worktree yet — and says
141
+ // so rather than rendering a bare page that reads as a failure.
142
+ const body = entries.length
143
+ ? `<ul>\n${rows}\n</ul>`
144
+ : '<p class="empty">No spec has a worktree. Run <code>/spec-start &lt;name&gt;</code> to provision one.</p>'
145
+
146
+ return `<!doctype html>
147
+ <html lang="en">
148
+ <head>
149
+ <meta charset="utf-8">
150
+ <meta name="viewport" content="width=device-width, initial-scale=1">
151
+ <title>spec diffs</title>
152
+ <style>
153
+ :root { --bg:#fff; --fg:#1a1a1a; --dim:#6b6b6b; --line:#e3e3e3; --add:#0a7b34; --del:#b3261e; --link:#0b5fbd }
154
+ @media (prefers-color-scheme: dark) {
155
+ :root:not([data-theme="light"]) { --bg:#16181c; --fg:#e6e6e6; --dim:#9a9a9a; --line:#2c2f36; --add:#4ec97a; --del:#ff7b72; --link:#7cb7ff }
156
+ }
157
+ * { box-sizing:border-box }
158
+ body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
159
+ font:15px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif }
160
+ main { max-width:52rem; margin:0 auto }
161
+ h1 { font-size:1.1rem; margin:0 0 1.25rem; font-weight:600 }
162
+ ul { list-style:none; margin:0; padding:0 }
163
+ li { display:flex; flex-wrap:wrap; gap:.75rem; align-items:baseline;
164
+ padding:.7rem 0; border-top:1px solid var(--line) }
165
+ a { color:var(--link); text-decoration:none; font-weight:600 }
166
+ a:hover { text-decoration:underline }
167
+ .b { color:var(--dim); font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.85em }
168
+ .n { color:var(--dim); margin-left:auto }
169
+ .a { color:var(--add) } .d { color:var(--del) }
170
+ .w { font-weight:400; font-size:.85em }
171
+ .empty { color:var(--dim) }
172
+ code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace }
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <main>
177
+ <h1>spec diffs</h1>
178
+ ${body}
179
+ </main>
180
+ </body>
181
+ </html>
182
+ `
183
+ }
184
+
185
+ /**
186
+ * WHICH VIEW A SPEC GETS, decided in one place so every route agrees.
187
+ *
188
+ * `worktree` — it has one, so the page is its branch's diff, exactly as before.
189
+ * `docs` — it has none, so the page is its own uncommitted documents read from
190
+ * the checkout this server is anchored to. That is the authoring page
191
+ * `spec-env review --docs` writes, and this is what lets it be SERVED: a
192
+ * `file://` page has no server to POST to, so without this the authoring page
193
+ * exists and its verdict buttons have nowhere to go.
194
+ * `null` — neither: no worktree and nothing uncommitted of its own, which is
195
+ * the ordinary state of most of `specs/` and a 404 rather than an error.
196
+ *
197
+ * WHAT WOULD FOOL A WORKTREE-ONLY VERSION of this — which is what shipped, and
198
+ * what 404'd a page rendered minutes earlier: an authoring page belongs to a
199
+ * spec with no worktree BY DEFINITION, so gating the route on a worktree
200
+ * excludes precisely the specs the docs view exists for.
201
+ */
202
+ /**
203
+ * Is this worktree a spec being WRITTEN rather than built?
204
+ *
205
+ * Returns the owned document paths when it is, and null for every other
206
+ * answer — including the ones we cannot work out. Never throws: a page is a
207
+ * convenience and a git that will not read is not evidence of anything.
208
+ */
209
+ /**
210
+ * Project bookkeeping, as opposed to somebody's unfinished work.
211
+ *
212
+ * `specs/.core/` holds the project's own configuration and whatever a tracker
213
+ * provider writes beside it — a push snapshot, most often. None of it is a
214
+ * spec's documents and none of it is code, so finding it in an authoring tree
215
+ * says nothing about whether a spec is being written there.
216
+ *
217
+ * WHAT WOULD FOOL A WIDER VERSION OF THIS: anything outside `specs/`. A source
218
+ * file in the tree IS evidence that the tree is being used for something else,
219
+ * and that answer must stay unchanged — see the stays-silent test.
220
+ */
221
+ function isProjectBookkeeping(p) {
222
+ return String(p).startsWith('specs/.core/')
223
+ }
224
+
225
+ function docsWorktree(wt, spec, config) {
226
+ if (!spec || spec.bucket !== 'backlog') return null
227
+ const paths = dirtyPaths(trimmedGitReader(wt))
228
+ // Cannot tell — not "no documents".
229
+ if (paths === null) return null
230
+ const { owned, foreign } = classifyDirtyTree(spec, paths, config)
231
+ // A TRACKER SNAPSHOT IS NOT SOMEBODY ELSE'S WORK, and requiring a
232
+ // foreign-free tree cost the whole page for the commonest authoring tree
233
+ // there is. The linking push writes `specs/.core/linear-base/<ID>.base.json`
234
+ // into this tree moments before the page is opened, and a project that never
235
+ // declared it in `spec.companionPaths` — which is the default — has one
236
+ // foreign path by construction. The view fell through to `worktree`, where
237
+ // every `specs/**` path is bookkeeping: seven files changed, seven folded
238
+ // away, nothing on screen.
239
+ //
240
+ // What the foreign test is actually for survives untouched: real work sitting
241
+ // in the tree still disqualifies it, because that is a tree being used for
242
+ // something other than writing this spec.
243
+ //
244
+ // THE RENDERED FILE SET DOES NOT WIDEN WITH IT. The page still shows `owned`,
245
+ // which is what `--docs` showed from the CLI — a served page that listed a
246
+ // file the run's own report did not would be a different page from the one
247
+ // the reader was told about.
248
+ const intruders = foreign.filter((p) => !isProjectBookkeeping(p))
249
+ if (!owned.length || intruders.length) return null
250
+ return { owned }
251
+ }
252
+
253
+ function viewFor(dir, config, spec, git, { fallback = false } = {}) {
254
+ if (!spec) return null
255
+ // LIVE COMES FIRST, because while a spec is live its worktree still exists —
256
+ // detached — and would win the check below while holding none of the work.
257
+ // `live take` checks the branch out HERE, and a fix made while live is made
258
+ // here too, so this checkout is the tree that answers.
259
+ if (spec.branch && headBranchOf(git) === spec.branch) return { kind: 'live', tree: dir }
260
+ const wt = spec.worktreePath
261
+ if (wt && wt !== dir && fs.existsSync(wt)) {
262
+ // A DOCS WORKTREE IS NOT A CODE WORKTREE, and this used to be unreachable.
263
+ //
264
+ // `/spec` once wrote into the primary checkout, so a backlog spec had no
265
+ // worktree at all and fell through to the docs branch below. It now
266
+ // provisions the spec's own `--docs` worktree and writes there — so this
267
+ // check began winning for exactly the specs the docs branch exists to
268
+ // serve, and their pages were served the COMMITTING set. A reader pressed
269
+ // `Commit & Continue` on a backlog spec that has no phase to continue.
270
+ //
271
+ // Asked as a POSITIVE SIGNAL and never as "no code yet"
272
+ // (`.claude/rules/negative-checks.md` rule 1), and it takes BOTH halves:
273
+ //
274
+ // - the spec is in the BACKLOG, which is what `authoring` means. Without
275
+ // this, the window right after `/spec-start` — where the `git mv` and
276
+ // the header edit are uncommitted and no phase code exists yet — reads
277
+ // as documents-only and would offer `Commit & Start` for a spec
278
+ // already in flight.
279
+ // - the worktree holds NOTHING BUT this spec's own documents. Without
280
+ // this, a backlog spec whose tree had been used for anything else
281
+ // would have that work rendered under an authoring verdict.
282
+ //
283
+ // Anything else is the ordinary worktree view, including every cannot-tell:
284
+ // an unresolvable bucket, a git that will not read, an empty tree.
285
+ const docs = docsWorktree(wt, spec, config)
286
+ if (docs) return { kind: 'docs', tree: wt, owned: docs.owned }
287
+ return { kind: 'worktree', tree: wt }
288
+ }
289
+ const found = specDocsIn(dir, spec, config, trimmedGitReader(dir))
290
+ // `error` is cannot-tell — not a page, and not an error page either (rule 4:
291
+ // the harmless branch).
292
+ if (found.error) return null
293
+ if (!found.empty) return { kind: 'docs', tree: found.tree, owned: found.owned }
294
+
295
+ // NOTHING UNCOMMITTED IS NOT NOTHING TO SHOW, on this route. A `--docs` page
296
+ // renders a spec's uncommitted documents, so HONOURING THE VERDICT PRESSED ON
297
+ // IT is what removes them: the reader presses `Commit`, the agent commits, and
298
+ // a reload 404s. The link was valid, the server was up and the network was
299
+ // fine — the page had been deliberately destroyed by the thing it asked for.
300
+ //
301
+ // So fall back to what the spec now IS, which mirrors the phase page's
302
+ // clean-tree fallback for the same reason: the page is rendered before the
303
+ // commit and read after it.
304
+ //
305
+ // ONLY FOR A NAMED PAGE, never for the index. The two routes ask different
306
+ // questions: the index is a menu of what AWAITS review, and every completed
307
+ // spec in the repo falling back would fill it with dozens of finished ones —
308
+ // the doorway would be wrong about a healthy repo, which is the reason it
309
+ // omits them in the first place. A direct request is someone opening an
310
+ // address they were given, and that has to keep resolving.
311
+ if (!fallback) return null
312
+ const committed = committedDocsFor(dir, config, spec)
313
+ if (!committed) return null
314
+ return { kind: 'docs-committed', tree: dir, owned: committed }
315
+ }
316
+
317
+ /**
318
+ * The spec's committed document paths at `HEAD`, or null when it has none.
319
+ *
320
+ * KEPT ONLY WHERE IT FINDS SOMETHING, exactly as the branch fallback is: a name
321
+ * that is no spec at all must stay a 404 rather than render an empty page with
322
+ * a commit button on it.
323
+ */
324
+ function committedDocsFor(dir, config, spec) {
325
+ const git = trimmedGitReader(dir)
326
+ const out = git(['ls-files', '--', `specs/*/${spec.folder}/*`])
327
+ if (out == null) return null
328
+ const paths = String(out)
329
+ .split('\n')
330
+ .map((l) => l.trim())
331
+ .filter(Boolean)
332
+ return paths.length ? paths : null
333
+ }
334
+
335
+ /**
336
+ * The tier rows the PAGE needs, from config alone.
337
+ *
338
+ * DELIBERATELY NOT `reviewTierStack`. That stack answers "where can this be
339
+ * read", which needs the server's bind, the machine's addresses and any
340
+ * published URL. The page needs only the half it can act on: which tiers are
341
+ * OFF, so it can offer the press that turns one on. An `off` tier has no URL by
342
+ * definition, so none of that machinery is required — and an `on` tier
343
+ * contributes nothing here, because the reader is standing on one of them.
344
+ */
345
+ function pageTiers(config) {
346
+ const r = (config && config.review) || {}
347
+ return [
348
+ { tier: 'network', off: !r.allowNetwork },
349
+ { tier: 'remote', off: !r.allowRemote },
350
+ ]
351
+ }
352
+
353
+ /**
354
+ * The branch name a git reader's checkout is on, or null.
355
+ *
356
+ * NULL FOR A DETACHED HEAD, which `rev-parse --abbrev-ref` spells `HEAD` — a
357
+ * name no branch has, and one that must never compare equal to a spec's.
358
+ * Returning it verbatim would make a detached checkout look like a spec called
359
+ * `HEAD`; the cannot-tell answer is null (`.claude/rules/negative-checks.md`).
360
+ */
361
+ function headBranchOf(git) {
362
+ const out = git ? git(['rev-parse', '--abbrev-ref', 'HEAD']) : null
363
+ if (out == null) return null
364
+ const name = String(out).trim()
365
+ return name && name !== 'HEAD' ? name : null
366
+ }
367
+
368
+ // `specDocsIn` wants a reader that TRIMS, because it compares whole paths.
369
+ function trimmedGitReader(treePath) {
370
+ const git = rawGitReader(treePath)
371
+ return (argv) => {
372
+ const out = git(argv)
373
+ return out == null ? null : String(out).trim()
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Resolve the specs worth listing: every spec this server can render a page for.
379
+ *
380
+ * A spec with neither a worktree nor uncommitted documents of its own is
381
+ * OMITTED, not listed as an error — that is the ordinary state of most of
382
+ * `specs/`, and a doorway listing them as failures would be wrong about a
383
+ * healthy repo.
384
+ *
385
+ * IT INCLUDES WORKTREE-LESS SPECS NOW, and both halves are needed together: a
386
+ * page that serves but is absent from the index is a page nobody finds.
387
+ */
388
+ function servableSpecs(dir, config, git, specless = {}) {
389
+ const worktreePaths = liveWorktreePaths(git)
390
+ // `allSpecs` scans `specs/**`, where a specless branch is by definition not —
391
+ // so it takes the registry's map and adds them. Defaulted to empty, because
392
+ // every caller that has no concept of them should behave exactly as before.
393
+ return allSpecs(dir, config, worktreePaths, specless)
394
+ .filter((s) => Boolean(viewFor(dir, config, s, git)))
395
+ .sort((a, b) => a.folder.localeCompare(b.folder))
396
+ }
397
+
398
+ /**
399
+ * Render one spec's page, now. Returns null when the spec has no worktree —
400
+ * there is nothing to diff, and that is a 404 rather than an error page.
401
+ *
402
+ * `mode`/`ref` follow the same rules the CLI applies, including the clean-tree
403
+ * fallback: a committed phase shows the branch range rather than an empty page.
404
+ */
405
+ /**
406
+ * Take a review pass for one spec and put it in the holding area. Returns the
407
+ * code that claims it, or an error to relay verbatim.
408
+ *
409
+ * IT WRITES ONE FILE, and it is not the review. Everything a POST can reach is
410
+ * the pending store; the sidecar the review actually reads is only ever written
411
+ * by a claim, which needs a person to read six digits off the screen. That is
412
+ * the whole containment, and it is why this endpoint can be open to the network
413
+ * at all.
414
+ *
415
+ * The `render` field keys superseding: a second pass from the same page render
416
+ * replaces the first unclaimed one, so the code on the screen is always the
417
+ * pass on the screen. It comes from the blob's own `generatedAt`, which the
418
+ * page mints per render — absent, the pass simply never supersedes anything,
419
+ * which is the harmless direction.
420
+ */
421
+ function receivePass(dir, config, spec, blob) {
422
+ // A VIEW, NOT A WORKTREE. The pending store lives in the checkout this server
423
+ // is anchored to, so accepting a pass needs no worktree — and requiring one
424
+ // rejected every verdict sent from an authoring page, which is the one page
425
+ // whose spec never has a worktree.
426
+ // `fallback: true` so the POST follows the PAGE. A reader who reloads a
427
+ // committed docs page gets buttons; rejecting what they press would be the
428
+ // failure this whole area keeps producing — a control that appears to do
429
+ // nothing. Whether the verdict is worth acting on is the routing's call.
430
+ if (!spec || !viewFor(dir, config, spec, rawGitReader(dir), { fallback: true })) return null
431
+ let parsed
432
+ try {
433
+ parsed = validateNotesBlob(blob, spec.folder)
434
+ } catch (err) {
435
+ // The engine's own message, relayed rather than paraphrased — it names the
436
+ // entry that was wrong, so there is nothing for the reader to guess.
437
+ return { error: err.message }
438
+ }
439
+ const out = reviewOutPath(dir, spec.folder, null)
440
+ const read = readPending(out, spec.folder)
441
+ if (read.corrupt) {
442
+ // Refuse rather than write over passes we could not read. Same rule the
443
+ // notes sidecar follows, for the same reason: what is in there is someone's
444
+ // work and overwriting it is unrecoverable.
445
+ return { error: 'the pending store is not readable JSON — move it aside' }
446
+ }
447
+ const added = addPending(read.pending, {
448
+ blob,
449
+ at: new Date().toISOString(),
450
+ render: blob && blob.generatedAt ? String(blob.generatedAt) : null,
451
+ })
452
+ writePending(out, added.pending)
453
+ return { code: added.code, accepted: parsed.accepted.length, comments: parsed.comments.length }
454
+ }
455
+
456
+ /**
457
+ * The button set a SERVED page shows.
458
+ *
459
+ * Two sources, and each answers only what it knows.
460
+ *
461
+ * THE TREE ANSWERS THE FAMILY, and it answers it better than any record can,
462
+ * because a record is as old as the last render while the tree is now. A spec's
463
+ * uncommitted documents are `authoring`; a branch with no spec at all is
464
+ * `nospec`; a phase in a worktree is `committing`. Those three are exactly the
465
+ * sets that describe what the work IS, so they are read off what the work is.
466
+ *
467
+ * THE RECORD ANSWERS WHAT NO TREE CAN SHOW, which is two things rather than
468
+ * one. `midrun` and `refresh` answer whether the run had FINISHED — one offers
469
+ * `Continue` in place of the committing pair, the other drops the start
470
+ * verdict. `fix` answers whether the work was ever PHASED: a single-pass bug or
471
+ * hotfix has no phase files, and a folder with no phase files is also a legacy
472
+ * inline-phase layout, so the tree cannot tell the two apart and `readPhases`
473
+ * returns `null` for both. Only the caller that rendered knew, in all three
474
+ * cases. So they, and nothing else, are taken from the record.
475
+ *
476
+ * LEAVING `fix` OFF THIS LIST IS THE BUG IT WAS ADDED TO FIX, wearing the
477
+ * daemon's clothes: the CLI render would drop `Commit & Continue` and every
478
+ * SERVED re-render of the same URL would fall through to the tree's
479
+ * `committing` family and hand it straight back. The first page anyone opens
480
+ * looks right, and each refresh is wrong.
481
+ *
482
+ * WHICH MAKES THE RECORD NARROWING-ONLY. A word it does not recognise, a word
483
+ * that would WIDEN the offer, an unreadable file, no file at all: every one of
484
+ * them falls through to the tree (`.claude/rules/negative-checks.md` rule 4).
485
+ * The cost of a stale record is therefore a reader shown `Continue` where they
486
+ * could have been shown `Commit` — one refresh away from the right page — and
487
+ * never a specless branch handed a `Commit & Start` for a spec that does not
488
+ * exist. That was the bug: `renderSpecPage` took `{ branch }` and no set at all,
489
+ * so every `/no-spec` page served over http offered to put nothing in flight,
490
+ * and the pass came back naming a verdict its own skill could not act on.
491
+ */
492
+ const NARROWING_SETS = ['midrun', 'refresh', 'fix']
493
+
494
+ function buttonsForView(viewKind, spec, recorded) {
495
+ if (NARROWING_SETS.includes(recorded)) return recorded
496
+ if (viewKind === 'docs' || viewKind === 'docs-committed') return 'authoring'
497
+ if (spec && spec.specless) return 'nospec'
498
+ return DEFAULT_BUTTON_SET
499
+ }
500
+
501
+ // What the last CLI render of this spec declared, or null when it declared
502
+ // nothing readable. Never throws: the page is a convenience, and a sidecar is
503
+ // not what it is for.
504
+ function recordedButtons(dir, spec) {
505
+ try {
506
+ return readRenderRecord(reviewOutPath(dir, spec.folder, null), spec.folder).buttons
507
+ } catch {
508
+ return null
509
+ }
510
+ }
511
+
512
+ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
513
+ const view = viewFor(dir, config, spec, rawGitReader(dir), { fallback: true })
514
+ if (!view) return null
515
+
516
+ // Resolved once, for BOTH paths below. The docs path used to hard-code
517
+ // `authoring` here and everything else took the default by saying nothing —
518
+ // which is how a specless branch ended up offering `Commit & Start`.
519
+ const buttons = buttonsForView(view.kind, spec, recordedButtons(dir, spec))
520
+
521
+ // THE DOCS VIEW IS A DIFFERENT TREE AND A DIFFERENT FILE SET. Its buttons
522
+ // come from `buttonsForView` above like every other view's — it used to name
523
+ // `authoring` here itself, which was right for this one case and left every
524
+ // other case with no answer at all.
525
+ if (view.kind === 'docs' || view.kind === 'docs-committed') {
526
+ const docsGit = rawGitReader(view.tree)
527
+ const out = reviewOutPath(dir, spec.folder, null)
528
+ const notes = readNotes(out, spec.folder).notes
529
+ const gateRead = readGate(out, spec.folder)
530
+ const data = collectReview({
531
+ spec,
532
+ git: docsGit,
533
+ mode: view.kind,
534
+ // A committed view diffs against the commit BEFORE HEAD's spec content —
535
+ // there is nothing uncommitted to compare, so the whole document is the
536
+ // content, exactly as an untracked file renders.
537
+ ref: view.kind === 'docs-committed' ? 'HEAD~1' : 'HEAD',
538
+ now: new Date().toISOString(),
539
+ notes,
540
+ gate: gateRead.corrupt ? null : gateRead.gate,
541
+ buttons,
542
+ only: view.owned,
543
+ treePath: view.tree,
544
+ })
545
+ return {
546
+ html: renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }),
547
+ totals: data.totals,
548
+ mode: data.mode,
549
+ fellBack: false,
550
+ }
551
+ }
552
+
553
+ // `view.tree` rather than `spec.worktreePath`: the two differ for a live
554
+ // spec, whose branch is checked out here instead.
555
+ const git = rawGitReader(view.tree || spec.worktreePath)
556
+ const trimmed = (argv) => {
557
+ const out = git(argv)
558
+ return out == null ? null : String(out).trim() || null
559
+ }
560
+ const base = () => spec.baseRef || resolveBaseBranch(config, trimmed)
561
+
562
+ const now = new Date().toISOString()
563
+ let mode = 'working'
564
+ let ref = 'HEAD'
565
+ let baseName = null
566
+ let fellBack = false
567
+
568
+ if (branch) {
569
+ baseName = base()
570
+ const mergeBase = trimmed(['merge-base', baseName, 'HEAD'])
571
+ if (!mergeBase) return { html: null, error: `no merge-base between ${baseName} and HEAD` }
572
+ ref = mergeBase
573
+ mode = 'branch'
574
+ }
575
+
576
+ // THE REVIEW STATE IS THE READER'S, not the artefact's. It was once left out
577
+ // here on the reasoning that this path writes no file — but a reader on a
578
+ // phone is reading THIS page, and without the sidecar their own accepts
579
+ // vanish on every refresh and the history line never appears at all. The two
580
+ // surfaces answered differently about the same review, which is worse than
581
+ // either answer. Read-only: nothing on this path writes the sidecar.
582
+ const out = reviewOutPath(dir, spec.folder, null)
583
+ const notes = readNotes(out, spec.folder).notes
584
+ const gateRead = readGate(out, spec.folder)
585
+ // A corrupt gate contributes nothing rather than failing the render. The page
586
+ // is a convenience and the gate is not what it is for.
587
+ const gate = gateRead.corrupt ? null : gateRead.gate
588
+
589
+ // WHERE ELSE THIS REVIEW LIVES, and whether it is also running — the block
590
+ // above the verdicts. Worktree views only: a `--docs` page returns above,
591
+ // because a spec with no branch has nothing to put live.
592
+ const live = liveStateFor(spec, {
593
+ isolated: true,
594
+ onBase: headBranchOf(rawGitReader(dir)) !== spec.branch,
595
+ primaryBranch: headBranchOf(rawGitReader(dir)),
596
+ receipt: null,
597
+ worktreeExists: Boolean(spec.worktreePath && fs.existsSync(spec.worktreePath)),
598
+ })
599
+ // This spec's own remote permission, read per request so a toggle shows on
600
+ // the next refresh without restarting the server.
601
+ const tiers = pageTiers(withSpecRemote(config, out))
602
+
603
+ let data = collectReview({
604
+ spec,
605
+ git,
606
+ mode,
607
+ ref,
608
+ base: baseName,
609
+ now,
610
+ notes,
611
+ gate,
612
+ buttons,
613
+ live,
614
+ tiers,
615
+ })
616
+
617
+ if (!branch && data.totals.files === 0) {
618
+ const fallbackBase = base()
619
+ const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
620
+ if (mergeBase) {
621
+ // THE SET SURVIVES THE FALLBACK, and forgetting it here is not a smaller
622
+ // version of the same mistake — it is the one that fires most. A clean
623
+ // worktree is the state a phase ENDS in: the page is rendered, the commit
624
+ // lands, and from then on every render of that spec comes through here.
625
+ const wider = collectReview({
626
+ spec,
627
+ git,
628
+ mode: 'branch',
629
+ ref: mergeBase,
630
+ base: fallbackBase,
631
+ now,
632
+ notes,
633
+ gate,
634
+ buttons,
635
+ live,
636
+ tiers,
637
+ fellBack: true,
638
+ })
639
+ if (wider.totals.files > 0) {
640
+ data = wider
641
+ fellBack = true
642
+ }
643
+ }
644
+ }
645
+
646
+ return {
647
+ html: renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }),
648
+ totals: data.totals,
649
+ mode: data.mode,
650
+ fellBack,
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Counts for the index, without building a single patch.
656
+ *
657
+ * The index shows three numbers per spec, and `collectReview` would produce
658
+ * them as a by-product of splicing every patch of every spec — making the
659
+ * doorway far and away the most expensive page here. `--numstat` answers the
660
+ * same question in one call per spec; untracked files are counted separately
661
+ * because `git diff` cannot see them.
662
+ */
663
+ /**
664
+ * Counts for a docs view: numstat over just this spec's owned paths.
665
+ *
666
+ * Untracked files are counted with `--no-index` against /dev/null, which is the
667
+ * only way git will diff a file it does not track — and a brand-new spec is
668
+ * entirely untracked, so without that half the index would show a spec with
669
+ * four documents as `0 files`.
670
+ */
671
+ function docsSummary(view) {
672
+ const git = rawGitReader(view.tree)
673
+ const totals = { files: 0, additions: 0, deletions: 0 }
674
+ const add = (row) => {
675
+ const [a, d] = String(row).split('\t')
676
+ totals.files += 1
677
+ if (a === '-' || d === '-') return
678
+ totals.additions += Number(a) || 0
679
+ totals.deletions += Number(d) || 0
680
+ }
681
+ for (const rel of view.owned) {
682
+ const tracked = git(['diff', '--numstat', 'HEAD', '--', rel])
683
+ const rows = String(tracked || '').split('\n').filter(Boolean)
684
+ if (rows.length) {
685
+ rows.forEach(add)
686
+ continue
687
+ }
688
+ const untracked = git(['diff', '--no-index', '--numstat', '--', '/dev/null', rel])
689
+ String(untracked || '')
690
+ .split('\n')
691
+ .filter(Boolean)
692
+ .forEach(add)
693
+ }
694
+ return totals
695
+ }
696
+
697
+ function specSummary(spec, dir = null, config = null) {
698
+ // The index counts whatever the page would show, so it takes the same view.
699
+ // `dir`/`config` are optional so an existing caller counting a worktree spec
700
+ // is unchanged; without them only the worktree view can be counted.
701
+ const view = dir ? viewFor(dir, config, spec, rawGitReader(dir)) : null
702
+ if (view && view.kind === 'docs') return docsSummary(view)
703
+ // The tree the page would read, which for a live spec is this checkout. The
704
+ // worktree fallback keeps the callers that pass no `dir` working unchanged.
705
+ const tree = view && view.tree ? view.tree : spec && spec.worktreePath
706
+ if (!tree || !fs.existsSync(tree)) return null
707
+ const git = rawGitReader(tree)
708
+
709
+ const totals = { files: 0, additions: 0, deletions: 0 }
710
+ const add = (row) => {
711
+ const [a, d] = row.split('\t')
712
+ totals.files += 1
713
+ // A binary file reports `-` for both; count the file, never guess its lines.
714
+ if (a === '-' || d === '-') return
715
+ totals.additions += Number(a) || 0
716
+ totals.deletions += Number(d) || 0
717
+ }
718
+
719
+ for (const row of String(git(['diff', '--numstat', 'HEAD']) || '')
720
+ .split('\n')
721
+ .filter(Boolean)) {
722
+ add(row)
723
+ }
724
+
725
+ // `git diff` cannot see untracked files, so each is measured the same way the
726
+ // page measures it — against /dev/null. One process per untracked file is what
727
+ // the page already pays; the index matching it exactly is worth more than the
728
+ // processes saved, because two different numbers for the same spec is a bug
729
+ // report waiting to happen.
730
+ for (const f of String(git(['ls-files', '--others', '--exclude-standard']) || '')
731
+ .split('\n')
732
+ .filter(Boolean)) {
733
+ const row = String(git(['diff', '--numstat', '--no-index', '/dev/null', f]) || '')
734
+ .split('\n')
735
+ .filter(Boolean)[0]
736
+ if (row) add(row)
737
+ else totals.files += 1
738
+ }
739
+
740
+ return totals
741
+ }
742
+
743
+ /**
744
+ * Build (but do not listen on) the server. `resolveEntries` and `render` are
745
+ * injected so this can be driven in a test without a git fixture, and so the
746
+ * module never has to reach back into the CLI.
747
+ */
748
+ // A review pass is JSON written by a person, not a payload. Ten megabytes is
749
+ // far past any real review and far short of anything that could hurt — the cap
750
+ // exists so a body is REFUSED BEFORE IT IS PARSED, not so a number is tuned.
751
+ const MAX_PASS_BYTES = 1_000_000
752
+
753
+ /**
754
+ * Read a request body, refusing anything over the cap without buffering it all.
755
+ *
756
+ * The check is per-chunk rather than on the finished body: a cap applied after
757
+ * the fact has already done the thing it was meant to prevent.
758
+ */
759
+ function readBody(req, limit = MAX_PASS_BYTES) {
760
+ return new Promise((resolve) => {
761
+ let size = 0
762
+ const chunks = []
763
+ let done = false
764
+ const finish = (value) => {
765
+ if (done) return
766
+ done = true
767
+ resolve(value)
768
+ }
769
+ req.on('data', (c) => {
770
+ size += c.length
771
+ if (size > limit) {
772
+ req.destroy()
773
+ return finish({ error: 'too large' })
774
+ }
775
+ chunks.push(c)
776
+ })
777
+ req.on('end', () => finish({ body: Buffer.concat(chunks).toString('utf8') }))
778
+ req.on('error', () => finish({ error: 'read failed' }))
779
+ })
780
+ }
781
+
782
+ function createReviewServer({ resolveEntries, render, receive = null, passState = null, token = null }) {
783
+ return http.createServer((req, res) => {
784
+ const send = (code, body, type = 'text/html; charset=utf-8') => {
785
+ res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' })
786
+ res.end(body)
787
+ }
788
+
789
+ const route = routeFor(req.url, { token })
790
+ if (route.kind === 'notfound') return send(404, 'not found', 'text/plain; charset=utf-8')
791
+
792
+ // THE ONE WRITE PATH, and it writes to a holding area rather than to the
793
+ // review. What a stranger on the network can do with it is queue a pass
794
+ // that nobody will claim; the code is what decides whether it ever counts.
795
+ if (req.method === 'POST') {
796
+ // A write to the index is not a write to a spec, and answering it any
797
+ // differently from a GET would make this route an enumeration oracle.
798
+ if (route.kind !== 'spec' || !receive) {
799
+ return send(404, 'not found', 'text/plain; charset=utf-8')
800
+ }
801
+ readBody(req).then((read) => {
802
+ if (read.error) return send(413, read.error, 'text/plain; charset=utf-8')
803
+ let parsed
804
+ try {
805
+ parsed = JSON.parse(read.body)
806
+ } catch {
807
+ return send(400, 'not JSON', 'text/plain; charset=utf-8')
808
+ }
809
+ // The engine's own validator, and its own message. A pass arriving here
810
+ // is exactly as untrusted as one arriving through a clipboard.
811
+ const out = receive(route.spec, parsed)
812
+ if (!out) return send(404, 'not found', 'text/plain; charset=utf-8')
813
+ if (out.error) return send(422, out.error, 'text/plain; charset=utf-8')
814
+ return send(200, JSON.stringify({ code: out.code }), 'application/json; charset=utf-8')
815
+ })
816
+ return
817
+ }
818
+
819
+ // THE READ THAT IS NOT A RENDER. It answers three words and never a diff,
820
+ // so a page polling it costs the server a file read rather than a patch.
821
+ //
822
+ // A SERVER BUILT WITHOUT THE LOOKUP SAYS SO, rather than 404ing or falling
823
+ // through to the diff page. An older daemon still running beside a newer
824
+ // page is the ordinary way here, and both of those answers would be read as
825
+ // something they are not — a 404 as "that pass does not exist", HTML as a
826
+ // parse failure. `unknown` is the one answer that keeps the command on the
827
+ // reader's screen, which is where it belongs when nothing can be
828
+ // established.
829
+ if (route.kind === 'pass') {
830
+ const out = passState ? passState(route.spec, route.code) : null
831
+ const state = out && typeof out.state === 'string' ? out.state : 'unknown'
832
+ return send(200, JSON.stringify({ state }), 'application/json; charset=utf-8')
833
+ }
834
+
835
+ try {
836
+ if (route.kind === 'index') return send(200, renderIndex(resolveEntries(), { token }))
837
+ const out = render(route.spec, { branch: route.branch })
838
+ if (!out) return send(404, 'not found', 'text/plain; charset=utf-8')
839
+ if (out.error) return send(409, out.error, 'text/plain; charset=utf-8')
840
+ return send(200, out.html)
841
+ } catch (err) {
842
+ // A render that throws is one spec's problem, not the server's — say so
843
+ // and stay up, so the other specs remain readable.
844
+ return send(500, `render failed: ${err.message}`, 'text/plain; charset=utf-8')
845
+ }
846
+ })
847
+ }
848
+
849
+ function startReviewServer(server, { port, host = '127.0.0.1' }) {
850
+ return new Promise((resolve, reject) => {
851
+ server.once('error', reject)
852
+ server.listen(port, host, () => resolve(server.address()))
853
+ })
854
+ }
855
+
856
+ /**
857
+ * The version of the package that OWNS a script — walk up to the nearest
858
+ * `package.json` from the script's own directory.
859
+ *
860
+ * WHY NOT just report the running CLI's version: the CLI and the daemon are
861
+ * routinely DIFFERENT PACKAGES. A superset distribution exposes the
862
+ * `skitterspec` binary from its own package while `daemonScript` resolves the
863
+ * daemon out of `node_modules/@skitterbyte/skitterspec`, so comparing one
864
+ * against the other reports a mismatch that is never true and never goes away —
865
+ * which, wired to a restart, is a server replaced on every single render.
866
+ * Resolving from the script means both halves of the comparison are the same
867
+ * question asked at two different times.
868
+ *
869
+ * Returns `null` rather than throwing or guessing. An unreadable package, an
870
+ * absent one, a `version` that is not a string: each is a state where the
871
+ * lookup could not see, and `staleServer` routes all of them to `unknown`.
872
+ */
873
+ function engineVersionFor(scriptPath) {
874
+ if (!scriptPath) return null
875
+ let dir = path.dirname(path.resolve(scriptPath))
876
+ // Bounded: stop at the filesystem root rather than trusting a break.
877
+ for (let i = 0; i < 40; i++) {
878
+ const candidate = path.join(dir, 'package.json')
879
+ try {
880
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'))
881
+ if (typeof parsed.version === 'string' && parsed.version) return parsed.version
882
+ return null
883
+ } catch {}
884
+ const up = path.dirname(dir)
885
+ if (up === dir) return null
886
+ dir = up
887
+ }
888
+ return null
889
+ }
890
+
891
+ /**
892
+ * Is the running server executing the engine this one would start? Pure.
893
+ *
894
+ * THREE STATES, NOT TWO (`.claude/rules/negative-checks.md` rule 4). The third
895
+ * is what this whole feature turns on: a server that recorded no version — every
896
+ * server started before this shipped — is not stale, it is UNANSWERABLE, and the
897
+ * caller must route it to inaction. Reading a missing record as "different, so
898
+ * stale" would restart every healthy server on the first render after upgrading,
899
+ * which is the accusation this check exists to avoid making.
900
+ *
901
+ * WHAT WOULD BLIND THIS: `recorded` is absent on a pre-feature server, and
902
+ * `running` is null whenever `engineVersionFor` could not read a package at all
903
+ * (a bundled build, an odd install layout). Both are answered `unknown`, and
904
+ * neither is evidence of anything.
905
+ */
906
+ function staleServer(recorded, running, recordedMtime, runningMtime) {
907
+ if (typeof recorded !== 'string' || !recorded) return 'unknown'
908
+ if (typeof running !== 'string' || !running) return 'unknown'
909
+ if (recorded !== running) return 'stale'
910
+
911
+ // SAME VERSION IS NOT SAME CODE, which is the whole of this addition. The
912
+ // daemon runs a built copy of the engine — in this repo a symlink to the
913
+ // gitignored dist — so a rebuild moves the file without moving the version,
914
+ // and a version-only comparison adopted a process running last week's code.
915
+ // It cost two confidently wrong diagnoses in one session: a page reported as
916
+ // 404ing that the new code serves, and a regression reported as unfixed that
917
+ // had already been fixed.
918
+ //
919
+ // CANNOT TELL ADOPTS. A settings file written before this existed records no
920
+ // mtime, and an unreadable script has none to offer — restarting a healthy
921
+ // server over an absent field is the destructive reading, so an absent half
922
+ // falls back to the version verdict (rule 4).
923
+ const both = Number.isFinite(recordedMtime) && Number.isFinite(runningMtime)
924
+ if (both && recordedMtime !== runningMtime) return 'stale'
925
+ return 'current'
926
+ }
927
+
928
+ /**
929
+ * The hooks `createReviewServer` needs, built once.
930
+ *
931
+ * WHY THIS IS A FUNCTION AND NOT INLINE IN THE ENTRY POINT. It used to be
932
+ * inline, and the test suite built its own copy the same way — so the two
933
+ * drifted independently, and a suite standing up its own wiring could be green
934
+ * about a server nobody was shipping. That is exactly what happened: both
935
+ * copies called `resolveSpec` with no `specless` map, every `/no-spec` page
936
+ * 404'd, and nothing failed.
937
+ *
938
+ * THE SPECLESS MAP IS THE POINT. A `/no-spec` branch has a worktree and a
939
+ * branch and no document anywhere under `specs/**`, so it can only ever be
940
+ * resolved on the registry's word — which is a POSITIVE SIGNAL, and the reason
941
+ * a typo still 404s (`.claude/rules/negative-checks.md` rule 1). Both resolvers
942
+ * already took it; the server simply never passed it.
943
+ */
944
+ function serverHooks(dir, config) {
945
+ const git = rawGitReader(dir)
946
+ const trimmedGit = (argv) => {
947
+ const out = git(argv)
948
+ return out == null ? null : String(out).trim() || null
949
+ }
950
+
951
+ // READ PER REQUEST, never cached. A branch provisioned after the daemon
952
+ // started must be servable without restarting it — the same reason `dir` is
953
+ // all the settings file stores. An unreadable registry is cannot-tell and
954
+ // yields an empty map: the specless branches lose their pages, and every
955
+ // ordinary spec carries on being served.
956
+ const specless = () => {
957
+ try {
958
+ return readRegistry(dir, config).specless || {}
959
+ } catch {
960
+ return {}
961
+ }
962
+ }
963
+
964
+ const resolveOne = (folder) => {
965
+ try {
966
+ return resolveSpec(folder, dir, config, {
967
+ searchDirs: [...liveWorktreePaths(trimmedGit)],
968
+ specless: specless(),
969
+ })
970
+ } catch {
971
+ return null
972
+ }
973
+ }
974
+
975
+ const resolveEntries = () =>
976
+ servableSpecs(dir, config, trimmedGit, specless()).map((s) => {
977
+ const one = resolveOne(s.folder)
978
+ return {
979
+ folder: s.folder,
980
+ branch: one ? one.branch : '',
981
+ totals: specSummary(one, dir, config),
982
+ }
983
+ })
984
+
985
+ return {
986
+ resolveEntries,
987
+ render: (folder, opts) => renderSpecPage(dir, config, resolveOne(folder), opts),
988
+ receive: (folder, blob) => receivePass(dir, config, resolveOne(folder), blob),
989
+ // A spec this daemon cannot resolve is not a pass that was claimed — it is
990
+ // a lookup that could not see, so it says nothing rather than reporting the
991
+ // comfortable answer. Same rule the engine's own three states follow.
992
+ passState: (folder, code) => {
993
+ const one = resolveOne(folder)
994
+ if (!one) return { state: 'unknown' }
995
+ return readPassState(reviewOutPath(dir, one.folder, null), one.folder, code)
996
+ },
997
+ }
998
+ }
999
+
1000
+ module.exports = {
1001
+ mintToken,
1002
+ readBody,
1003
+ MAX_PASS_BYTES,
1004
+ engineVersionFor,
1005
+ staleServer,
1006
+ specSummary,
1007
+ viewFor,
1008
+ headBranchOf,
1009
+ pageTiers,
1010
+ routeFor,
1011
+ renderIndex,
1012
+ servableSpecs,
1013
+ serverHooks,
1014
+ renderSpecPage,
1015
+ buttonsForView,
1016
+ receivePass,
1017
+ createReviewServer,
1018
+ startReviewServer,
1019
+ }
1020
+
1021
+ // Entry point: run detached by the CLI, reading its settings from a file so a
1022
+ // restart is a rewrite of that file — the same contract `proxy.js` uses. Only
1023
+ // `dir` is stored, never a snapshot of the specs: resolving per request is what
1024
+ // keeps a spec provisioned after the server started from being invisible to it.
1025
+ if (require.main === module) {
1026
+ const settingsFile = process.argv[2]
1027
+ if (!settingsFile) {
1028
+ process.stderr.write('serve: usage: node serve.js <settingsFile>\n')
1029
+ process.exit(1)
1030
+ }
1031
+ const { dir, port, host, token } = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
1032
+ const { config } = loadEnvConfig(dir)
1033
+ const server = createReviewServer({ ...serverHooks(dir, config), token })
1034
+ startReviewServer(server, { port, host }).then(
1035
+ () => {},
1036
+ (err) => {
1037
+ process.stderr.write(`serve: ${err.message}\n`)
1038
+ process.exit(1)
1039
+ },
1040
+ )
1041
+ const shutdown = () => server.close(() => process.exit(0))
1042
+ process.on('SIGTERM', shutdown)
1043
+ process.on('SIGINT', shutdown)
1044
+ }