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