@skitterbyte/skitterspec-linear 12.0.0 → 14.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 (58) hide show
  1. package/MIGRATION.md +296 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +38 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/SETUP.md +21 -3
  7. package/assets/core/env.config.json.example +7 -3
  8. package/assets/core/env.config.md +90 -30
  9. package/assets/core/linear.config.md +58 -0
  10. package/assets/hooks/review-gate.js +141 -0
  11. package/assets/review/page.html +1787 -0
  12. package/assets/rules/spec-planning.md +250 -10
  13. package/assets/rules/spec-reports.md +321 -0
  14. package/assets/skills/spec/SKILL.md +33 -5
  15. package/assets/skills/spec-bug/SKILL.md +172 -9
  16. package/assets/skills/spec-cancel/SKILL.md +98 -21
  17. package/assets/skills/spec-claim/SKILL.md +114 -0
  18. package/assets/skills/spec-complete/SKILL.md +94 -25
  19. package/assets/skills/spec-diff/SKILL.md +678 -0
  20. package/assets/skills/spec-hotfix/SKILL.md +172 -11
  21. package/assets/skills/spec-init/SKILL.md +56 -7
  22. package/assets/skills/spec-linear-setup/SKILL.md +55 -1
  23. package/assets/skills/spec-list/SKILL.md +218 -0
  24. package/assets/skills/spec-next/SKILL.md +419 -7
  25. package/assets/skills/spec-push/SKILL.md +32 -8
  26. package/assets/skills/spec-review/SKILL.md +40 -5
  27. package/assets/skills/spec-reviewed/SKILL.md +258 -0
  28. package/assets/skills/spec-start/SKILL.md +386 -106
  29. package/assets/skills/spec-status/SKILL.md +24 -2
  30. package/assets/skills/spec-sync/SKILL.md +40 -4
  31. package/assets/skills/spec-to-main/SKILL.md +28 -6
  32. package/package.json +11 -7
  33. package/src/cli.js +1808 -89
  34. package/src/env/building.js +143 -0
  35. package/src/env/commitcmd.js +108 -0
  36. package/src/env/config.js +58 -9
  37. package/src/env/hooks.js +117 -0
  38. package/src/env/provision.js +54 -15
  39. package/src/env/proxy.js +34 -1
  40. package/src/env/render.js +3 -12
  41. package/src/env/resolve.js +295 -9
  42. package/src/env/review.js +1536 -0
  43. package/src/env/serve.js +573 -0
  44. package/src/env/teardown.js +13 -6
  45. package/src/init.js +150 -1
  46. package/src/vendor/linear/api.js +104 -1
  47. package/src/vendor/linear/cli-sync.js +854 -17
  48. package/src/vendor/linear/config.js +8 -0
  49. package/src/vendor/linear/credentials.js +94 -0
  50. package/src/vendor/linear/doctor.js +35 -0
  51. package/src/vendor/linear/identity.js +105 -0
  52. package/src/vendor/linear/mcp.js +26 -0
  53. package/src/vendor/sync-core/index.js +6 -2
  54. package/src/vendor/sync-core/src/compare.js +49 -3
  55. package/src/vendor/sync-core/src/normalize.js +30 -0
  56. package/src/vendor/sync-core/src/push.js +11 -1
  57. package/src/vendor/sync-core/src/write.js +38 -0
  58. package/LICENSE +0 -21
@@ -0,0 +1,573 @@
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 { loadEnvConfig } = require('./config.js')
40
+ const {
41
+ rawGitReader,
42
+ collectReview,
43
+ renderReviewPage,
44
+ renderReviewBlock,
45
+ reviewOutPath,
46
+ validateNotesBlob,
47
+ readPending,
48
+ writePending,
49
+ addPending,
50
+ readNotes,
51
+ readGate,
52
+ } = require('./review.js')
53
+
54
+ /**
55
+ * A random path prefix, minted only when binding beyond loopback.
56
+ *
57
+ * An unguessable path is the whole guard: bound to 0.0.0.0 the server is
58
+ * reachable by anything on the network, and a diff of unreleased work is not
59
+ * something to hand to whoever else is on the coffee-shop wifi. 48 bits from
60
+ * `crypto` — not `Math.random`, which is seeded predictably enough to enumerate.
61
+ */
62
+ function mintToken() {
63
+ return crypto.randomBytes(6).toString('hex')
64
+ }
65
+
66
+ /**
67
+ * Parse a request URL into what to serve. PURE — no git, no fs, no server.
68
+ *
69
+ * With a token, every path must carry it as the first segment. A wrong or
70
+ * missing token is `notfound`, never a redirect: a redirect would confirm the
71
+ * server is here to anybody probing ports, which is the one thing the token is
72
+ * bought to prevent.
73
+ */
74
+ function routeFor(url, { token = null } = {}) {
75
+ const [rawPath, rawQuery] = String(url || '/').split('?')
76
+ const query = new URLSearchParams(rawQuery || '')
77
+ let segments = rawPath.split('/').filter(Boolean).map(decodeURIComponent)
78
+
79
+ if (token) {
80
+ if (segments[0] !== token) return { kind: 'notfound' }
81
+ segments = segments.slice(1)
82
+ }
83
+
84
+ if (segments.length === 0) return { kind: 'index' }
85
+ if (segments.length > 1) return { kind: 'notfound' }
86
+
87
+ // `?branch` and `?branch=1` both mean the whole-spec view; `?branch=0` does
88
+ // not, so a link can turn it off as well as on.
89
+ const raw = query.get('branch')
90
+ const branch = raw !== null && raw !== '0' && raw !== 'false'
91
+ return { kind: 'spec', spec: segments[0], branch }
92
+ }
93
+
94
+ function escapeHtml(s) {
95
+ return String(s).replace(
96
+ /[&<>"]/g,
97
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c],
98
+ )
99
+ }
100
+
101
+ /**
102
+ * The index page. PURE — takes already-resolved entries and returns HTML.
103
+ *
104
+ * Deliberately plain: the diff page is the designed surface, and this is a
105
+ * doorway. It carries the same theme handling as the page template (a light
106
+ * palette on bare `:root`, redefined under `prefers-color-scheme`) so the two
107
+ * do not disagree when opened side by side.
108
+ */
109
+ function renderIndex(entries, { token = null } = {}) {
110
+ const prefix = token ? `/${token}` : ''
111
+ const rows = entries
112
+ .map((e) => {
113
+ const t = e.totals || { files: 0, additions: 0, deletions: 0 }
114
+ return (
115
+ `<li><a href="${prefix}/${encodeURIComponent(e.folder)}">${escapeHtml(e.folder)}</a>` +
116
+ `<span class="b">${escapeHtml(e.branch || '')}</span>` +
117
+ `<span class="n">${t.files} file${t.files === 1 ? '' : 's'}</span>` +
118
+ `<span class="a">+${t.additions}</span><span class="d">-${t.deletions}</span>` +
119
+ `<a class="w" href="${prefix}/${encodeURIComponent(e.folder)}?branch=1">whole spec</a></li>`
120
+ )
121
+ })
122
+ .join('\n')
123
+
124
+ // An empty list is an ordinary state — no spec has a worktree yet — and says
125
+ // so rather than rendering a bare page that reads as a failure.
126
+ const body = entries.length
127
+ ? `<ul>\n${rows}\n</ul>`
128
+ : '<p class="empty">No spec has a worktree. Run <code>/spec-start &lt;name&gt;</code> to provision one.</p>'
129
+
130
+ return `<!doctype html>
131
+ <html lang="en">
132
+ <head>
133
+ <meta charset="utf-8">
134
+ <meta name="viewport" content="width=device-width, initial-scale=1">
135
+ <title>spec diffs</title>
136
+ <style>
137
+ :root { --bg:#fff; --fg:#1a1a1a; --dim:#6b6b6b; --line:#e3e3e3; --add:#0a7b34; --del:#b3261e; --link:#0b5fbd }
138
+ @media (prefers-color-scheme: dark) {
139
+ :root:not([data-theme="light"]) { --bg:#16181c; --fg:#e6e6e6; --dim:#9a9a9a; --line:#2c2f36; --add:#4ec97a; --del:#ff7b72; --link:#7cb7ff }
140
+ }
141
+ * { box-sizing:border-box }
142
+ body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
143
+ font:15px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif }
144
+ main { max-width:52rem; margin:0 auto }
145
+ h1 { font-size:1.1rem; margin:0 0 1.25rem; font-weight:600 }
146
+ ul { list-style:none; margin:0; padding:0 }
147
+ li { display:flex; flex-wrap:wrap; gap:.75rem; align-items:baseline;
148
+ padding:.7rem 0; border-top:1px solid var(--line) }
149
+ a { color:var(--link); text-decoration:none; font-weight:600 }
150
+ a:hover { text-decoration:underline }
151
+ .b { color:var(--dim); font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.85em }
152
+ .n { color:var(--dim); margin-left:auto }
153
+ .a { color:var(--add) } .d { color:var(--del) }
154
+ .w { font-weight:400; font-size:.85em }
155
+ .empty { color:var(--dim) }
156
+ code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <main>
161
+ <h1>spec diffs</h1>
162
+ ${body}
163
+ </main>
164
+ </body>
165
+ </html>
166
+ `
167
+ }
168
+
169
+ /**
170
+ * Resolve the specs worth listing: every spec with a worktree of its own.
171
+ *
172
+ * A spec with no worktree is OMITTED, not listed as an error. An unstarted spec
173
+ * has nothing to diff, which is the ordinary state of most of `specs/` — and a
174
+ * doorway that lists them as failures would be wrong about a healthy repo.
175
+ */
176
+ function servableSpecs(dir, config, git) {
177
+ const worktreePaths = liveWorktreePaths(git)
178
+ return allSpecs(dir, config, worktreePaths)
179
+ .filter((s) => {
180
+ const wt = s.worktreePath
181
+ return wt && wt !== dir && worktreePaths.has(path.resolve(wt))
182
+ })
183
+ .sort((a, b) => a.folder.localeCompare(b.folder))
184
+ }
185
+
186
+ /**
187
+ * Render one spec's page, now. Returns null when the spec has no worktree —
188
+ * there is nothing to diff, and that is a 404 rather than an error page.
189
+ *
190
+ * `mode`/`ref` follow the same rules the CLI applies, including the clean-tree
191
+ * fallback: a committed phase shows the branch range rather than an empty page.
192
+ */
193
+ /**
194
+ * Take a review pass for one spec and put it in the holding area. Returns the
195
+ * code that claims it, or an error to relay verbatim.
196
+ *
197
+ * IT WRITES ONE FILE, and it is not the review. Everything a POST can reach is
198
+ * the pending store; the sidecar the review actually reads is only ever written
199
+ * by a claim, which needs a person to read six digits off the screen. That is
200
+ * the whole containment, and it is why this endpoint can be open to the network
201
+ * at all.
202
+ *
203
+ * The `render` field keys superseding: a second pass from the same page render
204
+ * replaces the first unclaimed one, so the code on the screen is always the
205
+ * pass on the screen. It comes from the blob's own `generatedAt`, which the
206
+ * page mints per render — absent, the pass simply never supersedes anything,
207
+ * which is the harmless direction.
208
+ */
209
+ function receivePass(dir, config, spec, blob) {
210
+ if (!spec || !fs.existsSync(spec.worktreePath)) return null
211
+ let parsed
212
+ try {
213
+ parsed = validateNotesBlob(blob, spec.folder)
214
+ } catch (err) {
215
+ // The engine's own message, relayed rather than paraphrased — it names the
216
+ // entry that was wrong, so there is nothing for the reader to guess.
217
+ return { error: err.message }
218
+ }
219
+ const out = reviewOutPath(dir, spec.folder, null)
220
+ const read = readPending(out, spec.folder)
221
+ if (read.corrupt) {
222
+ // Refuse rather than write over passes we could not read. Same rule the
223
+ // notes sidecar follows, for the same reason: what is in there is someone's
224
+ // work and overwriting it is unrecoverable.
225
+ return { error: 'the pending store is not readable JSON — move it aside' }
226
+ }
227
+ const added = addPending(read.pending, {
228
+ blob,
229
+ at: new Date().toISOString(),
230
+ render: blob && blob.generatedAt ? String(blob.generatedAt) : null,
231
+ })
232
+ writePending(out, added.pending)
233
+ return { code: added.code, accepted: parsed.accepted.length, comments: parsed.comments.length }
234
+ }
235
+
236
+ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
237
+ if (!spec || !fs.existsSync(spec.worktreePath)) return null
238
+
239
+ const git = rawGitReader(spec.worktreePath)
240
+ const trimmed = (argv) => {
241
+ const out = git(argv)
242
+ return out == null ? null : String(out).trim() || null
243
+ }
244
+ const base = () => spec.baseRef || resolveBaseBranch(config, trimmed)
245
+
246
+ const now = new Date().toISOString()
247
+ let mode = 'working'
248
+ let ref = 'HEAD'
249
+ let baseName = null
250
+ let fellBack = false
251
+
252
+ if (branch) {
253
+ baseName = base()
254
+ const mergeBase = trimmed(['merge-base', baseName, 'HEAD'])
255
+ if (!mergeBase) return { html: null, error: `no merge-base between ${baseName} and HEAD` }
256
+ ref = mergeBase
257
+ mode = 'branch'
258
+ }
259
+
260
+ // THE REVIEW STATE IS THE READER'S, not the artefact's. It was once left out
261
+ // here on the reasoning that this path writes no file — but a reader on a
262
+ // phone is reading THIS page, and without the sidecar their own accepts
263
+ // vanish on every refresh and the history line never appears at all. The two
264
+ // surfaces answered differently about the same review, which is worse than
265
+ // either answer. Read-only: nothing on this path writes the sidecar.
266
+ const out = reviewOutPath(dir, spec.folder, null)
267
+ const notes = readNotes(out, spec.folder).notes
268
+ const gateRead = readGate(out, spec.folder)
269
+ // A corrupt gate contributes nothing rather than failing the render. The page
270
+ // is a convenience and the gate is not what it is for.
271
+ const gate = gateRead.corrupt ? null : gateRead.gate
272
+
273
+ let data = collectReview({ spec, git, mode, ref, base: baseName, now, notes, gate })
274
+
275
+ if (!branch && data.totals.files === 0) {
276
+ const fallbackBase = base()
277
+ const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
278
+ if (mergeBase) {
279
+ const wider = collectReview({
280
+ spec,
281
+ git,
282
+ mode: 'branch',
283
+ ref: mergeBase,
284
+ base: fallbackBase,
285
+ now,
286
+ notes,
287
+ gate,
288
+ fellBack: true,
289
+ })
290
+ if (wider.totals.files > 0) {
291
+ data = wider
292
+ fellBack = true
293
+ }
294
+ }
295
+ }
296
+
297
+ return {
298
+ html: renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }),
299
+ totals: data.totals,
300
+ mode: data.mode,
301
+ fellBack,
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Counts for the index, without building a single patch.
307
+ *
308
+ * The index shows three numbers per spec, and `collectReview` would produce
309
+ * them as a by-product of splicing every patch of every spec — making the
310
+ * doorway far and away the most expensive page here. `--numstat` answers the
311
+ * same question in one call per spec; untracked files are counted separately
312
+ * because `git diff` cannot see them.
313
+ */
314
+ function specSummary(spec) {
315
+ if (!spec || !fs.existsSync(spec.worktreePath)) return null
316
+ const git = rawGitReader(spec.worktreePath)
317
+
318
+ const totals = { files: 0, additions: 0, deletions: 0 }
319
+ const add = (row) => {
320
+ const [a, d] = row.split('\t')
321
+ totals.files += 1
322
+ // A binary file reports `-` for both; count the file, never guess its lines.
323
+ if (a === '-' || d === '-') return
324
+ totals.additions += Number(a) || 0
325
+ totals.deletions += Number(d) || 0
326
+ }
327
+
328
+ for (const row of String(git(['diff', '--numstat', 'HEAD']) || '')
329
+ .split('\n')
330
+ .filter(Boolean)) {
331
+ add(row)
332
+ }
333
+
334
+ // `git diff` cannot see untracked files, so each is measured the same way the
335
+ // page measures it — against /dev/null. One process per untracked file is what
336
+ // the page already pays; the index matching it exactly is worth more than the
337
+ // processes saved, because two different numbers for the same spec is a bug
338
+ // report waiting to happen.
339
+ for (const f of String(git(['ls-files', '--others', '--exclude-standard']) || '')
340
+ .split('\n')
341
+ .filter(Boolean)) {
342
+ const row = String(git(['diff', '--numstat', '--no-index', '/dev/null', f]) || '')
343
+ .split('\n')
344
+ .filter(Boolean)[0]
345
+ if (row) add(row)
346
+ else totals.files += 1
347
+ }
348
+
349
+ return totals
350
+ }
351
+
352
+ /**
353
+ * Build (but do not listen on) the server. `resolveEntries` and `render` are
354
+ * injected so this can be driven in a test without a git fixture, and so the
355
+ * module never has to reach back into the CLI.
356
+ */
357
+ // A review pass is JSON written by a person, not a payload. Ten megabytes is
358
+ // far past any real review and far short of anything that could hurt — the cap
359
+ // exists so a body is REFUSED BEFORE IT IS PARSED, not so a number is tuned.
360
+ const MAX_PASS_BYTES = 1_000_000
361
+
362
+ /**
363
+ * Read a request body, refusing anything over the cap without buffering it all.
364
+ *
365
+ * The check is per-chunk rather than on the finished body: a cap applied after
366
+ * the fact has already done the thing it was meant to prevent.
367
+ */
368
+ function readBody(req, limit = MAX_PASS_BYTES) {
369
+ return new Promise((resolve) => {
370
+ let size = 0
371
+ const chunks = []
372
+ let done = false
373
+ const finish = (value) => {
374
+ if (done) return
375
+ done = true
376
+ resolve(value)
377
+ }
378
+ req.on('data', (c) => {
379
+ size += c.length
380
+ if (size > limit) {
381
+ req.destroy()
382
+ return finish({ error: 'too large' })
383
+ }
384
+ chunks.push(c)
385
+ })
386
+ req.on('end', () => finish({ body: Buffer.concat(chunks).toString('utf8') }))
387
+ req.on('error', () => finish({ error: 'read failed' }))
388
+ })
389
+ }
390
+
391
+ function createReviewServer({ resolveEntries, render, receive = null, token = null }) {
392
+ return http.createServer((req, res) => {
393
+ const send = (code, body, type = 'text/html; charset=utf-8') => {
394
+ res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' })
395
+ res.end(body)
396
+ }
397
+
398
+ const route = routeFor(req.url, { token })
399
+ if (route.kind === 'notfound') return send(404, 'not found', 'text/plain; charset=utf-8')
400
+
401
+ // THE ONE WRITE PATH, and it writes to a holding area rather than to the
402
+ // review. What a stranger on the network can do with it is queue a pass
403
+ // that nobody will claim; the code is what decides whether it ever counts.
404
+ if (req.method === 'POST') {
405
+ // A write to the index is not a write to a spec, and answering it any
406
+ // differently from a GET would make this route an enumeration oracle.
407
+ if (route.kind !== 'spec' || !receive) {
408
+ return send(404, 'not found', 'text/plain; charset=utf-8')
409
+ }
410
+ readBody(req).then((read) => {
411
+ if (read.error) return send(413, read.error, 'text/plain; charset=utf-8')
412
+ let parsed
413
+ try {
414
+ parsed = JSON.parse(read.body)
415
+ } catch {
416
+ return send(400, 'not JSON', 'text/plain; charset=utf-8')
417
+ }
418
+ // The engine's own validator, and its own message. A pass arriving here
419
+ // is exactly as untrusted as one arriving through a clipboard.
420
+ const out = receive(route.spec, parsed)
421
+ if (!out) return send(404, 'not found', 'text/plain; charset=utf-8')
422
+ if (out.error) return send(422, out.error, 'text/plain; charset=utf-8')
423
+ return send(200, JSON.stringify({ code: out.code }), 'application/json; charset=utf-8')
424
+ })
425
+ return
426
+ }
427
+
428
+ try {
429
+ if (route.kind === 'index') return send(200, renderIndex(resolveEntries(), { token }))
430
+ const out = render(route.spec, { branch: route.branch })
431
+ if (!out) return send(404, 'not found', 'text/plain; charset=utf-8')
432
+ if (out.error) return send(409, out.error, 'text/plain; charset=utf-8')
433
+ return send(200, out.html)
434
+ } catch (err) {
435
+ // A render that throws is one spec's problem, not the server's — say so
436
+ // and stay up, so the other specs remain readable.
437
+ return send(500, `render failed: ${err.message}`, 'text/plain; charset=utf-8')
438
+ }
439
+ })
440
+ }
441
+
442
+ function startReviewServer(server, { port, host = '127.0.0.1' }) {
443
+ return new Promise((resolve, reject) => {
444
+ server.once('error', reject)
445
+ server.listen(port, host, () => resolve(server.address()))
446
+ })
447
+ }
448
+
449
+ /**
450
+ * The version of the package that OWNS a script — walk up to the nearest
451
+ * `package.json` from the script's own directory.
452
+ *
453
+ * WHY NOT just report the running CLI's version: the CLI and the daemon are
454
+ * routinely DIFFERENT PACKAGES. A superset distribution exposes the
455
+ * `skitterspec` binary from its own package while `daemonScript` resolves the
456
+ * daemon out of `node_modules/@skitterbyte/skitterspec`, so comparing one
457
+ * against the other reports a mismatch that is never true and never goes away —
458
+ * which, wired to a restart, is a server replaced on every single render.
459
+ * Resolving from the script means both halves of the comparison are the same
460
+ * question asked at two different times.
461
+ *
462
+ * Returns `null` rather than throwing or guessing. An unreadable package, an
463
+ * absent one, a `version` that is not a string: each is a state where the
464
+ * lookup could not see, and `staleServer` routes all of them to `unknown`.
465
+ */
466
+ function engineVersionFor(scriptPath) {
467
+ if (!scriptPath) return null
468
+ let dir = path.dirname(path.resolve(scriptPath))
469
+ // Bounded: stop at the filesystem root rather than trusting a break.
470
+ for (let i = 0; i < 40; i++) {
471
+ const candidate = path.join(dir, 'package.json')
472
+ try {
473
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'))
474
+ if (typeof parsed.version === 'string' && parsed.version) return parsed.version
475
+ return null
476
+ } catch {}
477
+ const up = path.dirname(dir)
478
+ if (up === dir) return null
479
+ dir = up
480
+ }
481
+ return null
482
+ }
483
+
484
+ /**
485
+ * Is the running server executing the engine this one would start? Pure.
486
+ *
487
+ * THREE STATES, NOT TWO (`.claude/rules/negative-checks.md` rule 4). The third
488
+ * is what this whole feature turns on: a server that recorded no version — every
489
+ * server started before this shipped — is not stale, it is UNANSWERABLE, and the
490
+ * caller must route it to inaction. Reading a missing record as "different, so
491
+ * stale" would restart every healthy server on the first render after upgrading,
492
+ * which is the accusation this check exists to avoid making.
493
+ *
494
+ * WHAT WOULD BLIND THIS: `recorded` is absent on a pre-feature server, and
495
+ * `running` is null whenever `engineVersionFor` could not read a package at all
496
+ * (a bundled build, an odd install layout). Both are answered `unknown`, and
497
+ * neither is evidence of anything.
498
+ */
499
+ function staleServer(recorded, running) {
500
+ if (typeof recorded !== 'string' || !recorded) return 'unknown'
501
+ if (typeof running !== 'string' || !running) return 'unknown'
502
+ return recorded === running ? 'current' : 'stale'
503
+ }
504
+
505
+ module.exports = {
506
+ mintToken,
507
+ readBody,
508
+ MAX_PASS_BYTES,
509
+ engineVersionFor,
510
+ staleServer,
511
+ specSummary,
512
+ routeFor,
513
+ renderIndex,
514
+ servableSpecs,
515
+ renderSpecPage,
516
+ receivePass,
517
+ createReviewServer,
518
+ startReviewServer,
519
+ }
520
+
521
+ // Entry point: run detached by the CLI, reading its settings from a file so a
522
+ // restart is a rewrite of that file — the same contract `proxy.js` uses. Only
523
+ // `dir` is stored, never a snapshot of the specs: resolving per request is what
524
+ // keeps a spec provisioned after the server started from being invisible to it.
525
+ if (require.main === module) {
526
+ const settingsFile = process.argv[2]
527
+ if (!settingsFile) {
528
+ process.stderr.write('serve: usage: node serve.js <settingsFile>\n')
529
+ process.exit(1)
530
+ }
531
+ const { dir, port, host, token } = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
532
+ const { config } = loadEnvConfig(dir)
533
+ const git = rawGitReader(dir)
534
+ const trimmedGit = (argv) => {
535
+ const out = git(argv)
536
+ return out == null ? null : String(out).trim() || null
537
+ }
538
+
539
+ const resolveOne = (folder) => {
540
+ try {
541
+ return resolveSpec(folder, dir, config, { searchDirs: [...liveWorktreePaths(trimmedGit)] })
542
+ } catch {
543
+ return null
544
+ }
545
+ }
546
+
547
+ const resolveEntries = () =>
548
+ servableSpecs(dir, config, trimmedGit).map((s) => {
549
+ const one = resolveOne(s.folder)
550
+ return {
551
+ folder: s.folder,
552
+ branch: one ? one.branch : '',
553
+ totals: specSummary(one),
554
+ }
555
+ })
556
+
557
+ const server = createReviewServer({
558
+ resolveEntries,
559
+ render: (folder, opts) => renderSpecPage(dir, config, resolveOne(folder), opts),
560
+ receive: (folder, blob) => receivePass(dir, config, resolveOne(folder), blob),
561
+ token,
562
+ })
563
+ startReviewServer(server, { port, host }).then(
564
+ () => {},
565
+ (err) => {
566
+ process.stderr.write(`serve: ${err.message}\n`)
567
+ process.exit(1)
568
+ },
569
+ )
570
+ const shutdown = () => server.close(() => process.exit(0))
571
+ process.on('SIGTERM', shutdown)
572
+ process.on('SIGINT', shutdown)
573
+ }
@@ -105,9 +105,15 @@ function planDown(spec, config, flags, ctx) {
105
105
  // different question from ours: it also declines a branch that is ahead of its
106
106
  // upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
107
107
  // even though it is merged to HEAD`. That fires on the ordinary spec flow —
108
- // `/spec-start` pushes the branch when it provisions, and the phase commits after
109
- // it are landed locally rather than pushed — so teardown meets a branch whose
110
- // every commit is on `main` and `-d` refuses it. `merged` (HEAD is an ancestor
108
+ // it fires on any branch with an upstream ref whose later commits were landed
109
+ // locally rather than pushed — the shape you get the moment someone publishes a
110
+ // spec branch by hand, which is the only way one reaches a remote now. Teardown
111
+ // then meets a branch whose every commit is on `main` and `-d` refuses it.
112
+ //
113
+ // DO NOT "simplify" this back to `-d` on the grounds that nothing pushes at
114
+ // provisioning any more. That removed the COMMON case, not the case: one
115
+ // hand-published branch is enough, and `merged` already establishes what we
116
+ // care about more strongly than `-d` checks. `merged` (HEAD is an ancestor
111
117
  // of base) already establishes what we actually care about, and establishes it
112
118
  // more strongly than `-d` checks.
113
119
  //
@@ -122,9 +128,10 @@ function planDown(spec, config, flags, ctx) {
122
128
 
123
129
  // --- delete the branch on the remote (planned, never run here) ---
124
130
  //
125
- // `/spec-start` pushes the branch at provision time, so a completed spec otherwise
126
- // leaves a merged branch on the remote forever. Cleaning that up is the goal;
127
- // doing it safely is the constraint.
131
+ // A hand-published spec branch otherwise leaves a merged branch on the remote
132
+ // forever. Cleaning that up is the goal; doing it safely is the constraint.
133
+ // Nothing publishes at provision time any more, so this plans a delete only for
134
+ // a branch the user pushed themselves — which is exactly when they want it.
128
135
  //
129
136
  // Gated on `landed` because until the branch is merged (or captured by a tag)
130
137
  // the remote copy is the ONLY backup of the work — that is the whole reason