@skitterbyte/skitterspec 21.0.0 → 22.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +127 -6
  3. package/assets/claude-md-section.md +42 -18
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-remote-review.md +22 -0
  6. package/assets/commands/spec-skip.md +24 -0
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +183 -25
  9. package/assets/hooks/main-guard.cjs +145 -0
  10. package/assets/hooks/review-gate.cjs +8 -3
  11. package/assets/review/page.html +1174 -101
  12. package/assets/rules/offered-unblocks.md +100 -0
  13. package/assets/rules/spec-planning.md +195 -14
  14. package/assets/rules/spec-reports.md +181 -29
  15. package/assets/skills/no-spec/SKILL.md +219 -0
  16. package/assets/skills/spec/SKILL.md +248 -21
  17. package/assets/skills/spec-bug/SKILL.md +34 -24
  18. package/assets/skills/spec-cancel/SKILL.md +29 -0
  19. package/assets/skills/spec-complete/SKILL.md +25 -0
  20. package/assets/skills/spec-diff/SKILL.md +164 -37
  21. package/assets/skills/spec-hotfix/SKILL.md +22 -19
  22. package/assets/skills/spec-init/SKILL.md +4 -2
  23. package/assets/skills/spec-next/SKILL.md +166 -58
  24. package/assets/skills/spec-review/SKILL.md +87 -0
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +33 -0
  27. package/package.json +1 -1
  28. package/src/cli.js +1612 -172
  29. package/src/env/classify.js +87 -2
  30. package/src/env/config.js +333 -19
  31. package/src/env/hooks.js +102 -33
  32. package/src/env/live.js +124 -1
  33. package/src/env/mainguard.js +240 -0
  34. package/src/env/provision.js +106 -10
  35. package/src/env/registry.js +70 -6
  36. package/src/env/resolve.js +128 -5
  37. package/src/env/review.js +854 -29
  38. package/src/env/reviewers/coderabbit.js +228 -0
  39. package/src/env/reviewers.js +517 -0
  40. package/src/env/serve.js +474 -50
  41. package/src/env/supervise.js +8 -1
  42. package/src/env/teardown.js +12 -1
  43. package/src/init.js +185 -24
package/src/env/serve.js CHANGED
@@ -36,7 +36,10 @@ const {
36
36
  resolveSpec,
37
37
  resolveBaseBranch,
38
38
  } = require('./resolve.js')
39
+ const { readRegistry } = require('./registry.js')
39
40
  const { loadEnvConfig } = require('./config.js')
41
+ const { specDocsIn, classifyDirtyTree, dirtyPaths } = require('./classify.js')
42
+ const { liveStateFor } = require('./live.js')
40
43
  const {
41
44
  rawGitReader,
42
45
  collectReview,
@@ -45,10 +48,13 @@ const {
45
48
  reviewOutPath,
46
49
  validateNotesBlob,
47
50
  readPending,
51
+ passState: readPassState,
48
52
  writePending,
49
53
  addPending,
50
54
  readNotes,
51
55
  readGate,
56
+ readRenderRecord,
57
+ DEFAULT_BUTTON_SET,
52
58
  } = require('./review.js')
53
59
 
54
60
  /**
@@ -84,6 +90,15 @@ function routeFor(url, { token = null } = {}) {
84
90
  if (segments.length === 0) return { kind: 'index' }
85
91
  if (segments.length > 1) return { kind: 'notfound' }
86
92
 
93
+ // `?pass=<code>` asks what became of ONE pass, and it is checked BEFORE
94
+ // `?branch` because it is not a view of the diff at all — it is the page
95
+ // finding out whether the verdict it sent was picked up. Deliberately only
96
+ // ever one code wide: a route that listed what is waiting would hand a
97
+ // prober exactly what the six digits are bought to withhold, and the POST
98
+ // route was kept from being that oracle for the same reason.
99
+ const pass = query.get('pass')
100
+ if (pass !== null) return { kind: 'pass', spec: segments[0], code: pass }
101
+
87
102
  // `?branch` and `?branch=1` both mean the whole-spec view; `?branch=0` does
88
103
  // not, so a link can turn it off as well as on.
89
104
  const raw = query.get('branch')
@@ -167,19 +182,181 @@ ${body}
167
182
  }
168
183
 
169
184
  /**
170
- * Resolve the specs worth listing: every spec with a worktree of its own.
185
+ * WHICH VIEW A SPEC GETS, decided in one place so every route agrees.
186
+ *
187
+ * `worktree` — it has one, so the page is its branch's diff, exactly as before.
188
+ * `docs` — it has none, so the page is its own uncommitted documents read from
189
+ * the checkout this server is anchored to. That is the authoring page
190
+ * `spec-env review --docs` writes, and this is what lets it be SERVED: a
191
+ * `file://` page has no server to POST to, so without this the authoring page
192
+ * exists and its verdict buttons have nowhere to go.
193
+ * `null` — neither: no worktree and nothing uncommitted of its own, which is
194
+ * the ordinary state of most of `specs/` and a 404 rather than an error.
195
+ *
196
+ * WHAT WOULD FOOL A WORKTREE-ONLY VERSION of this — which is what shipped, and
197
+ * what 404'd a page rendered minutes earlier: an authoring page belongs to a
198
+ * spec with no worktree BY DEFINITION, so gating the route on a worktree
199
+ * excludes precisely the specs the docs view exists for.
200
+ */
201
+ /**
202
+ * Is this worktree a spec being WRITTEN rather than built?
203
+ *
204
+ * Returns the owned document paths when it is, and null for every other
205
+ * answer — including the ones we cannot work out. Never throws: a page is a
206
+ * convenience and a git that will not read is not evidence of anything.
207
+ */
208
+ function docsWorktree(wt, spec, config) {
209
+ if (!spec || spec.bucket !== 'backlog') return null
210
+ const paths = dirtyPaths(trimmedGitReader(wt))
211
+ // Cannot tell — not "no documents".
212
+ if (paths === null) return null
213
+ const { owned, foreign } = classifyDirtyTree(spec, paths, config)
214
+ if (!owned.length || foreign.length) return null
215
+ return { owned }
216
+ }
217
+
218
+ function viewFor(dir, config, spec, git, { fallback = false } = {}) {
219
+ if (!spec) return null
220
+ // LIVE COMES FIRST, because while a spec is live its worktree still exists —
221
+ // detached — and would win the check below while holding none of the work.
222
+ // `live take` checks the branch out HERE, and a fix made while live is made
223
+ // here too, so this checkout is the tree that answers.
224
+ if (spec.branch && headBranchOf(git) === spec.branch) return { kind: 'live', tree: dir }
225
+ const wt = spec.worktreePath
226
+ if (wt && wt !== dir && fs.existsSync(wt)) {
227
+ // A DOCS WORKTREE IS NOT A CODE WORKTREE, and this used to be unreachable.
228
+ //
229
+ // `/spec` once wrote into the primary checkout, so a backlog spec had no
230
+ // worktree at all and fell through to the docs branch below. It now
231
+ // provisions the spec's own `--docs` worktree and writes there — so this
232
+ // check began winning for exactly the specs the docs branch exists to
233
+ // serve, and their pages were served the COMMITTING set. A reader pressed
234
+ // `Commit & Continue` on a backlog spec that has no phase to continue.
235
+ //
236
+ // Asked as a POSITIVE SIGNAL and never as "no code yet"
237
+ // (`.claude/rules/negative-checks.md` rule 1), and it takes BOTH halves:
238
+ //
239
+ // - the spec is in the BACKLOG, which is what `authoring` means. Without
240
+ // this, the window right after `/spec-start` — where the `git mv` and
241
+ // the header edit are uncommitted and no phase code exists yet — reads
242
+ // as documents-only and would offer `Commit & Start` for a spec
243
+ // already in flight.
244
+ // - the worktree holds NOTHING BUT this spec's own documents. Without
245
+ // this, a backlog spec whose tree had been used for anything else
246
+ // would have that work rendered under an authoring verdict.
247
+ //
248
+ // Anything else is the ordinary worktree view, including every cannot-tell:
249
+ // an unresolvable bucket, a git that will not read, an empty tree.
250
+ const docs = docsWorktree(wt, spec, config)
251
+ if (docs) return { kind: 'docs', tree: wt, owned: docs.owned }
252
+ return { kind: 'worktree', tree: wt }
253
+ }
254
+ const found = specDocsIn(dir, spec, config, trimmedGitReader(dir))
255
+ // `error` is cannot-tell — not a page, and not an error page either (rule 4:
256
+ // the harmless branch).
257
+ if (found.error) return null
258
+ if (!found.empty) return { kind: 'docs', tree: found.tree, owned: found.owned }
259
+
260
+ // NOTHING UNCOMMITTED IS NOT NOTHING TO SHOW, on this route. A `--docs` page
261
+ // renders a spec's uncommitted documents, so HONOURING THE VERDICT PRESSED ON
262
+ // IT is what removes them: the reader presses `Commit`, the agent commits, and
263
+ // a reload 404s. The link was valid, the server was up and the network was
264
+ // fine — the page had been deliberately destroyed by the thing it asked for.
265
+ //
266
+ // So fall back to what the spec now IS, which mirrors the phase page's
267
+ // clean-tree fallback for the same reason: the page is rendered before the
268
+ // commit and read after it.
269
+ //
270
+ // ONLY FOR A NAMED PAGE, never for the index. The two routes ask different
271
+ // questions: the index is a menu of what AWAITS review, and every completed
272
+ // spec in the repo falling back would fill it with dozens of finished ones —
273
+ // the doorway would be wrong about a healthy repo, which is the reason it
274
+ // omits them in the first place. A direct request is someone opening an
275
+ // address they were given, and that has to keep resolving.
276
+ if (!fallback) return null
277
+ const committed = committedDocsFor(dir, config, spec)
278
+ if (!committed) return null
279
+ return { kind: 'docs-committed', tree: dir, owned: committed }
280
+ }
281
+
282
+ /**
283
+ * The spec's committed document paths at `HEAD`, or null when it has none.
284
+ *
285
+ * KEPT ONLY WHERE IT FINDS SOMETHING, exactly as the branch fallback is: a name
286
+ * that is no spec at all must stay a 404 rather than render an empty page with
287
+ * a commit button on it.
288
+ */
289
+ function committedDocsFor(dir, config, spec) {
290
+ const git = trimmedGitReader(dir)
291
+ const out = git(['ls-files', '--', `specs/*/${spec.folder}/*`])
292
+ if (out == null) return null
293
+ const paths = String(out)
294
+ .split('\n')
295
+ .map((l) => l.trim())
296
+ .filter(Boolean)
297
+ return paths.length ? paths : null
298
+ }
299
+
300
+ /**
301
+ * The tier rows the PAGE needs, from config alone.
302
+ *
303
+ * DELIBERATELY NOT `reviewTierStack`. That stack answers "where can this be
304
+ * read", which needs the server's bind, the machine's addresses and any
305
+ * published URL. The page needs only the half it can act on: which tiers are
306
+ * OFF, so it can offer the press that turns one on. An `off` tier has no URL by
307
+ * definition, so none of that machinery is required — and an `on` tier
308
+ * contributes nothing here, because the reader is standing on one of them.
309
+ */
310
+ function pageTiers(config) {
311
+ const r = (config && config.review) || {}
312
+ return [
313
+ { tier: 'network', off: !r.allowNetwork },
314
+ { tier: 'remote', off: !r.allowRemote },
315
+ ]
316
+ }
317
+
318
+ /**
319
+ * The branch name a git reader's checkout is on, or null.
320
+ *
321
+ * NULL FOR A DETACHED HEAD, which `rev-parse --abbrev-ref` spells `HEAD` — a
322
+ * name no branch has, and one that must never compare equal to a spec's.
323
+ * Returning it verbatim would make a detached checkout look like a spec called
324
+ * `HEAD`; the cannot-tell answer is null (`.claude/rules/negative-checks.md`).
325
+ */
326
+ function headBranchOf(git) {
327
+ const out = git ? git(['rev-parse', '--abbrev-ref', 'HEAD']) : null
328
+ if (out == null) return null
329
+ const name = String(out).trim()
330
+ return name && name !== 'HEAD' ? name : null
331
+ }
332
+
333
+ // `specDocsIn` wants a reader that TRIMS, because it compares whole paths.
334
+ function trimmedGitReader(treePath) {
335
+ const git = rawGitReader(treePath)
336
+ return (argv) => {
337
+ const out = git(argv)
338
+ return out == null ? null : String(out).trim()
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Resolve the specs worth listing: every spec this server can render a page for.
344
+ *
345
+ * A spec with neither a worktree nor uncommitted documents of its own is
346
+ * OMITTED, not listed as an error — that is the ordinary state of most of
347
+ * `specs/`, and a doorway listing them as failures would be wrong about a
348
+ * healthy repo.
171
349
  *
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.
350
+ * IT INCLUDES WORKTREE-LESS SPECS NOW, and both halves are needed together: a
351
+ * page that serves but is absent from the index is a page nobody finds.
175
352
  */
176
- function servableSpecs(dir, config, git) {
353
+ function servableSpecs(dir, config, git, specless = {}) {
177
354
  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
- })
355
+ // `allSpecs` scans `specs/**`, where a specless branch is by definition not —
356
+ // so it takes the registry's map and adds them. Defaulted to empty, because
357
+ // every caller that has no concept of them should behave exactly as before.
358
+ return allSpecs(dir, config, worktreePaths, specless)
359
+ .filter((s) => Boolean(viewFor(dir, config, s, git)))
183
360
  .sort((a, b) => a.folder.localeCompare(b.folder))
184
361
  }
185
362
 
@@ -207,7 +384,15 @@ function servableSpecs(dir, config, git) {
207
384
  * which is the harmless direction.
208
385
  */
209
386
  function receivePass(dir, config, spec, blob) {
210
- if (!spec || !fs.existsSync(spec.worktreePath)) return null
387
+ // A VIEW, NOT A WORKTREE. The pending store lives in the checkout this server
388
+ // is anchored to, so accepting a pass needs no worktree — and requiring one
389
+ // rejected every verdict sent from an authoring page, which is the one page
390
+ // whose spec never has a worktree.
391
+ // `fallback: true` so the POST follows the PAGE. A reader who reloads a
392
+ // committed docs page gets buttons; rejecting what they press would be the
393
+ // failure this whole area keeps producing — a control that appears to do
394
+ // nothing. Whether the verdict is worth acting on is the routing's call.
395
+ if (!spec || !viewFor(dir, config, spec, rawGitReader(dir), { fallback: true })) return null
211
396
  let parsed
212
397
  try {
213
398
  parsed = validateNotesBlob(blob, spec.folder)
@@ -233,10 +418,96 @@ function receivePass(dir, config, spec, blob) {
233
418
  return { code: added.code, accepted: parsed.accepted.length, comments: parsed.comments.length }
234
419
  }
235
420
 
421
+ /**
422
+ * The button set a SERVED page shows.
423
+ *
424
+ * Two sources, and each answers only what it knows.
425
+ *
426
+ * THE TREE ANSWERS THE FAMILY, and it answers it better than any record can,
427
+ * because a record is as old as the last render while the tree is now. A spec's
428
+ * uncommitted documents are `authoring`; a branch with no spec at all is
429
+ * `nospec`; a phase in a worktree is `committing`. Those three are exactly the
430
+ * sets that describe what the work IS, so they are read off what the work is.
431
+ *
432
+ * THE RECORD ANSWERS WHETHER THE RUN HAD FINISHED, which no tree can show.
433
+ * `midrun` and `refresh` exist for that — one offers `Continue` in place of the
434
+ * committing pair, the other drops the start verdict — and only the caller that
435
+ * rendered knew. So they, and nothing else, are taken from the record.
436
+ *
437
+ * WHICH MAKES THE RECORD NARROWING-ONLY. A word it does not recognise, a word
438
+ * that would WIDEN the offer, an unreadable file, no file at all: every one of
439
+ * them falls through to the tree (`.claude/rules/negative-checks.md` rule 4).
440
+ * The cost of a stale record is therefore a reader shown `Continue` where they
441
+ * could have been shown `Commit` — one refresh away from the right page — and
442
+ * never a specless branch handed a `Commit & Start` for a spec that does not
443
+ * exist. That was the bug: `renderSpecPage` took `{ branch }` and no set at all,
444
+ * so every `/no-spec` page served over http offered to put nothing in flight,
445
+ * and the pass came back naming a verdict its own skill could not act on.
446
+ */
447
+ const NARROWING_SETS = ['midrun', 'refresh']
448
+
449
+ function buttonsForView(viewKind, spec, recorded) {
450
+ if (NARROWING_SETS.includes(recorded)) return recorded
451
+ if (viewKind === 'docs' || viewKind === 'docs-committed') return 'authoring'
452
+ if (spec && spec.specless) return 'nospec'
453
+ return DEFAULT_BUTTON_SET
454
+ }
455
+
456
+ // What the last CLI render of this spec declared, or null when it declared
457
+ // nothing readable. Never throws: the page is a convenience, and a sidecar is
458
+ // not what it is for.
459
+ function recordedButtons(dir, spec) {
460
+ try {
461
+ return readRenderRecord(reviewOutPath(dir, spec.folder, null), spec.folder).buttons
462
+ } catch {
463
+ return null
464
+ }
465
+ }
466
+
236
467
  function renderSpecPage(dir, config, spec, { branch = false } = {}) {
237
- if (!spec || !fs.existsSync(spec.worktreePath)) return null
468
+ const view = viewFor(dir, config, spec, rawGitReader(dir), { fallback: true })
469
+ if (!view) return null
470
+
471
+ // Resolved once, for BOTH paths below. The docs path used to hard-code
472
+ // `authoring` here and everything else took the default by saying nothing —
473
+ // which is how a specless branch ended up offering `Commit & Start`.
474
+ const buttons = buttonsForView(view.kind, spec, recordedButtons(dir, spec))
475
+
476
+ // THE DOCS VIEW IS A DIFFERENT TREE AND A DIFFERENT FILE SET. Its buttons
477
+ // come from `buttonsForView` above like every other view's — it used to name
478
+ // `authoring` here itself, which was right for this one case and left every
479
+ // other case with no answer at all.
480
+ if (view.kind === 'docs' || view.kind === 'docs-committed') {
481
+ const docsGit = rawGitReader(view.tree)
482
+ const out = reviewOutPath(dir, spec.folder, null)
483
+ const notes = readNotes(out, spec.folder).notes
484
+ const gateRead = readGate(out, spec.folder)
485
+ const data = collectReview({
486
+ spec,
487
+ git: docsGit,
488
+ mode: view.kind,
489
+ // A committed view diffs against the commit BEFORE HEAD's spec content —
490
+ // there is nothing uncommitted to compare, so the whole document is the
491
+ // content, exactly as an untracked file renders.
492
+ ref: view.kind === 'docs-committed' ? 'HEAD~1' : 'HEAD',
493
+ now: new Date().toISOString(),
494
+ notes,
495
+ gate: gateRead.corrupt ? null : gateRead.gate,
496
+ buttons,
497
+ only: view.owned,
498
+ treePath: view.tree,
499
+ })
500
+ return {
501
+ html: renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }),
502
+ totals: data.totals,
503
+ mode: data.mode,
504
+ fellBack: false,
505
+ }
506
+ }
238
507
 
239
- const git = rawGitReader(spec.worktreePath)
508
+ // `view.tree` rather than `spec.worktreePath`: the two differ for a live
509
+ // spec, whose branch is checked out here instead.
510
+ const git = rawGitReader(view.tree || spec.worktreePath)
240
511
  const trimmed = (argv) => {
241
512
  const out = git(argv)
242
513
  return out == null ? null : String(out).trim() || null
@@ -270,12 +541,40 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
270
541
  // is a convenience and the gate is not what it is for.
271
542
  const gate = gateRead.corrupt ? null : gateRead.gate
272
543
 
273
- let data = collectReview({ spec, git, mode, ref, base: baseName, now, notes, gate })
544
+ // WHERE ELSE THIS REVIEW LIVES, and whether it is also running — the block
545
+ // above the verdicts. Worktree views only: a `--docs` page returns above,
546
+ // because a spec with no branch has nothing to put live.
547
+ const live = liveStateFor(spec, {
548
+ isolated: true,
549
+ onBase: headBranchOf(rawGitReader(dir)) !== spec.branch,
550
+ primaryBranch: headBranchOf(rawGitReader(dir)),
551
+ receipt: null,
552
+ worktreeExists: Boolean(spec.worktreePath && fs.existsSync(spec.worktreePath)),
553
+ })
554
+ const tiers = pageTiers(config)
555
+
556
+ let data = collectReview({
557
+ spec,
558
+ git,
559
+ mode,
560
+ ref,
561
+ base: baseName,
562
+ now,
563
+ notes,
564
+ gate,
565
+ buttons,
566
+ live,
567
+ tiers,
568
+ })
274
569
 
275
570
  if (!branch && data.totals.files === 0) {
276
571
  const fallbackBase = base()
277
572
  const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
278
573
  if (mergeBase) {
574
+ // THE SET SURVIVES THE FALLBACK, and forgetting it here is not a smaller
575
+ // version of the same mistake — it is the one that fires most. A clean
576
+ // worktree is the state a phase ENDS in: the page is rendered, the commit
577
+ // lands, and from then on every render of that spec comes through here.
279
578
  const wider = collectReview({
280
579
  spec,
281
580
  git,
@@ -285,6 +584,9 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
285
584
  now,
286
585
  notes,
287
586
  gate,
587
+ buttons,
588
+ live,
589
+ tiers,
288
590
  fellBack: true,
289
591
  })
290
592
  if (wider.totals.files > 0) {
@@ -311,9 +613,51 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
311
613
  * same question in one call per spec; untracked files are counted separately
312
614
  * because `git diff` cannot see them.
313
615
  */
314
- function specSummary(spec) {
315
- if (!spec || !fs.existsSync(spec.worktreePath)) return null
316
- const git = rawGitReader(spec.worktreePath)
616
+ /**
617
+ * Counts for a docs view: numstat over just this spec's owned paths.
618
+ *
619
+ * Untracked files are counted with `--no-index` against /dev/null, which is the
620
+ * only way git will diff a file it does not track — and a brand-new spec is
621
+ * entirely untracked, so without that half the index would show a spec with
622
+ * four documents as `0 files`.
623
+ */
624
+ function docsSummary(view) {
625
+ const git = rawGitReader(view.tree)
626
+ const totals = { files: 0, additions: 0, deletions: 0 }
627
+ const add = (row) => {
628
+ const [a, d] = String(row).split('\t')
629
+ totals.files += 1
630
+ if (a === '-' || d === '-') return
631
+ totals.additions += Number(a) || 0
632
+ totals.deletions += Number(d) || 0
633
+ }
634
+ for (const rel of view.owned) {
635
+ const tracked = git(['diff', '--numstat', 'HEAD', '--', rel])
636
+ const rows = String(tracked || '').split('\n').filter(Boolean)
637
+ if (rows.length) {
638
+ rows.forEach(add)
639
+ continue
640
+ }
641
+ const untracked = git(['diff', '--no-index', '--numstat', '--', '/dev/null', rel])
642
+ String(untracked || '')
643
+ .split('\n')
644
+ .filter(Boolean)
645
+ .forEach(add)
646
+ }
647
+ return totals
648
+ }
649
+
650
+ function specSummary(spec, dir = null, config = null) {
651
+ // The index counts whatever the page would show, so it takes the same view.
652
+ // `dir`/`config` are optional so an existing caller counting a worktree spec
653
+ // is unchanged; without them only the worktree view can be counted.
654
+ const view = dir ? viewFor(dir, config, spec, rawGitReader(dir)) : null
655
+ if (view && view.kind === 'docs') return docsSummary(view)
656
+ // The tree the page would read, which for a live spec is this checkout. The
657
+ // worktree fallback keeps the callers that pass no `dir` working unchanged.
658
+ const tree = view && view.tree ? view.tree : spec && spec.worktreePath
659
+ if (!tree || !fs.existsSync(tree)) return null
660
+ const git = rawGitReader(tree)
317
661
 
318
662
  const totals = { files: 0, additions: 0, deletions: 0 }
319
663
  const add = (row) => {
@@ -388,7 +732,7 @@ function readBody(req, limit = MAX_PASS_BYTES) {
388
732
  })
389
733
  }
390
734
 
391
- function createReviewServer({ resolveEntries, render, receive = null, token = null }) {
735
+ function createReviewServer({ resolveEntries, render, receive = null, passState = null, token = null }) {
392
736
  return http.createServer((req, res) => {
393
737
  const send = (code, body, type = 'text/html; charset=utf-8') => {
394
738
  res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' })
@@ -425,6 +769,22 @@ function createReviewServer({ resolveEntries, render, receive = null, token = nu
425
769
  return
426
770
  }
427
771
 
772
+ // THE READ THAT IS NOT A RENDER. It answers three words and never a diff,
773
+ // so a page polling it costs the server a file read rather than a patch.
774
+ //
775
+ // A SERVER BUILT WITHOUT THE LOOKUP SAYS SO, rather than 404ing or falling
776
+ // through to the diff page. An older daemon still running beside a newer
777
+ // page is the ordinary way here, and both of those answers would be read as
778
+ // something they are not — a 404 as "that pass does not exist", HTML as a
779
+ // parse failure. `unknown` is the one answer that keeps the command on the
780
+ // reader's screen, which is where it belongs when nothing can be
781
+ // established.
782
+ if (route.kind === 'pass') {
783
+ const out = passState ? passState(route.spec, route.code) : null
784
+ const state = out && typeof out.state === 'string' ? out.state : 'unknown'
785
+ return send(200, JSON.stringify({ state }), 'application/json; charset=utf-8')
786
+ }
787
+
428
788
  try {
429
789
  if (route.kind === 'index') return send(200, renderIndex(resolveEntries(), { token }))
430
790
  const out = render(route.spec, { branch: route.branch })
@@ -496,10 +856,98 @@ function engineVersionFor(scriptPath) {
496
856
  * (a bundled build, an odd install layout). Both are answered `unknown`, and
497
857
  * neither is evidence of anything.
498
858
  */
499
- function staleServer(recorded, running) {
859
+ function staleServer(recorded, running, recordedMtime, runningMtime) {
500
860
  if (typeof recorded !== 'string' || !recorded) return 'unknown'
501
861
  if (typeof running !== 'string' || !running) return 'unknown'
502
- return recorded === running ? 'current' : 'stale'
862
+ if (recorded !== running) return 'stale'
863
+
864
+ // SAME VERSION IS NOT SAME CODE, which is the whole of this addition. The
865
+ // daemon runs a built copy of the engine — in this repo a symlink to the
866
+ // gitignored dist — so a rebuild moves the file without moving the version,
867
+ // and a version-only comparison adopted a process running last week's code.
868
+ // It cost two confidently wrong diagnoses in one session: a page reported as
869
+ // 404ing that the new code serves, and a regression reported as unfixed that
870
+ // had already been fixed.
871
+ //
872
+ // CANNOT TELL ADOPTS. A settings file written before this existed records no
873
+ // mtime, and an unreadable script has none to offer — restarting a healthy
874
+ // server over an absent field is the destructive reading, so an absent half
875
+ // falls back to the version verdict (rule 4).
876
+ const both = Number.isFinite(recordedMtime) && Number.isFinite(runningMtime)
877
+ if (both && recordedMtime !== runningMtime) return 'stale'
878
+ return 'current'
879
+ }
880
+
881
+ /**
882
+ * The hooks `createReviewServer` needs, built once.
883
+ *
884
+ * WHY THIS IS A FUNCTION AND NOT INLINE IN THE ENTRY POINT. It used to be
885
+ * inline, and the test suite built its own copy the same way — so the two
886
+ * drifted independently, and a suite standing up its own wiring could be green
887
+ * about a server nobody was shipping. That is exactly what happened: both
888
+ * copies called `resolveSpec` with no `specless` map, every `/no-spec` page
889
+ * 404'd, and nothing failed.
890
+ *
891
+ * THE SPECLESS MAP IS THE POINT. A `/no-spec` branch has a worktree and a
892
+ * branch and no document anywhere under `specs/**`, so it can only ever be
893
+ * resolved on the registry's word — which is a POSITIVE SIGNAL, and the reason
894
+ * a typo still 404s (`.claude/rules/negative-checks.md` rule 1). Both resolvers
895
+ * already took it; the server simply never passed it.
896
+ */
897
+ function serverHooks(dir, config) {
898
+ const git = rawGitReader(dir)
899
+ const trimmedGit = (argv) => {
900
+ const out = git(argv)
901
+ return out == null ? null : String(out).trim() || null
902
+ }
903
+
904
+ // READ PER REQUEST, never cached. A branch provisioned after the daemon
905
+ // started must be servable without restarting it — the same reason `dir` is
906
+ // all the settings file stores. An unreadable registry is cannot-tell and
907
+ // yields an empty map: the specless branches lose their pages, and every
908
+ // ordinary spec carries on being served.
909
+ const specless = () => {
910
+ try {
911
+ return readRegistry(dir, config).specless || {}
912
+ } catch {
913
+ return {}
914
+ }
915
+ }
916
+
917
+ const resolveOne = (folder) => {
918
+ try {
919
+ return resolveSpec(folder, dir, config, {
920
+ searchDirs: [...liveWorktreePaths(trimmedGit)],
921
+ specless: specless(),
922
+ })
923
+ } catch {
924
+ return null
925
+ }
926
+ }
927
+
928
+ const resolveEntries = () =>
929
+ servableSpecs(dir, config, trimmedGit, specless()).map((s) => {
930
+ const one = resolveOne(s.folder)
931
+ return {
932
+ folder: s.folder,
933
+ branch: one ? one.branch : '',
934
+ totals: specSummary(one, dir, config),
935
+ }
936
+ })
937
+
938
+ return {
939
+ resolveEntries,
940
+ render: (folder, opts) => renderSpecPage(dir, config, resolveOne(folder), opts),
941
+ receive: (folder, blob) => receivePass(dir, config, resolveOne(folder), blob),
942
+ // A spec this daemon cannot resolve is not a pass that was claimed — it is
943
+ // a lookup that could not see, so it says nothing rather than reporting the
944
+ // comfortable answer. Same rule the engine's own three states follow.
945
+ passState: (folder, code) => {
946
+ const one = resolveOne(folder)
947
+ if (!one) return { state: 'unknown' }
948
+ return readPassState(reviewOutPath(dir, one.folder, null), one.folder, code)
949
+ },
950
+ }
503
951
  }
504
952
 
505
953
  module.exports = {
@@ -509,10 +957,15 @@ module.exports = {
509
957
  engineVersionFor,
510
958
  staleServer,
511
959
  specSummary,
960
+ viewFor,
961
+ headBranchOf,
962
+ pageTiers,
512
963
  routeFor,
513
964
  renderIndex,
514
965
  servableSpecs,
966
+ serverHooks,
515
967
  renderSpecPage,
968
+ buttonsForView,
516
969
  receivePass,
517
970
  createReviewServer,
518
971
  startReviewServer,
@@ -530,36 +983,7 @@ if (require.main === module) {
530
983
  }
531
984
  const { dir, port, host, token } = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
532
985
  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
- })
986
+ const server = createReviewServer({ ...serverHooks(dir, config), token })
563
987
  startReviewServer(server, { port, host }).then(
564
988
  () => {},
565
989
  (err) => {
@@ -147,4 +147,11 @@ async function waitHealthy(
147
147
  return false
148
148
  }
149
149
 
150
- module.exports = { startProcess, stopProcess, waitHealthy, isAlive, readPid }
150
+ // `signalGroup` is exported for TESTS that need to simulate a process dying.
151
+ // Killing the recorded pid alone is not that simulation: the pid is the detached
152
+ // `sh -c` leader, and where the shell forks rather than execs, the real server
153
+ // is its child and survives. A test that reaches for `process.kill(pid, …)`
154
+ // therefore kills the wrapper, leaves the server holding its port, and then
155
+ // blames whatever it asserts next. Killing the group is what `stopProcess` does
156
+ // and what a test must do to mean "it died".
157
+ module.exports = { startProcess, stopProcess, waitHealthy, isAlive, readPid, signalGroup }