@skitterbyte/skitterspec 22.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 (37) hide show
  1. package/README.md +14 -0
  2. package/assets/claude-md-section.md +13 -0
  3. package/assets/commands/allow-main.md +33 -0
  4. package/assets/commands/spec-skip.md +24 -0
  5. package/assets/core/env.config.json.example +2 -1
  6. package/assets/core/env.config.md +83 -2
  7. package/assets/hooks/main-guard.cjs +145 -0
  8. package/assets/hooks/review-gate.cjs +8 -3
  9. package/assets/review/page.html +132 -2
  10. package/assets/rules/offered-unblocks.md +100 -0
  11. package/assets/rules/spec-planning.md +160 -11
  12. package/assets/rules/spec-reports.md +50 -9
  13. package/assets/skills/no-spec/SKILL.md +219 -0
  14. package/assets/skills/spec/SKILL.md +135 -36
  15. package/assets/skills/spec-bug/SKILL.md +5 -0
  16. package/assets/skills/spec-cancel/SKILL.md +29 -0
  17. package/assets/skills/spec-complete/SKILL.md +25 -0
  18. package/assets/skills/spec-diff/SKILL.md +33 -1
  19. package/assets/skills/spec-init/SKILL.md +4 -2
  20. package/assets/skills/spec-next/SKILL.md +47 -2
  21. package/assets/skills/spec-reviewed/SKILL.md +3 -3
  22. package/assets/skills/spec-start/SKILL.md +14 -0
  23. package/package.json +1 -1
  24. package/src/cli.js +702 -59
  25. package/src/env/config.js +121 -4
  26. package/src/env/hooks.js +102 -33
  27. package/src/env/live.js +30 -1
  28. package/src/env/mainguard.js +240 -0
  29. package/src/env/provision.js +106 -10
  30. package/src/env/registry.js +70 -6
  31. package/src/env/resolve.js +92 -3
  32. package/src/env/review.js +324 -20
  33. package/src/env/reviewers/coderabbit.js +228 -0
  34. package/src/env/reviewers.js +517 -0
  35. package/src/env/serve.js +189 -44
  36. package/src/env/teardown.js +12 -1
  37. package/src/init.js +125 -15
package/src/env/review.js CHANGED
@@ -285,7 +285,7 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
285
285
  // THE SURFACES BLOCK, built here so the page never has to know which tiers
286
286
  // exist or which of them can be turned on. `null` when the caller passed
287
287
  // neither a live state nor a stack — an absent key, not an empty one.
288
- const surfaces = surfacesFor({ live, tiers })
288
+ const surfaces = surfacesFor({ live, tiers, buttons })
289
289
 
290
290
  const files = []
291
291
  for (const f of trackedFiles(git, ref)) {
@@ -494,6 +494,14 @@ const NOTES_VERSION = 1
494
494
  * verdict did. So a blob from an older page, or one a reader sent without
495
495
  * choosing, keeps doing exactly what it always did.
496
496
  *
497
+ * `commit-land` is the `/no-spec` verdict: commit this work, fast-forward the
498
+ * base branch to it, and tear the worktree down. It is its own word for exactly
499
+ * the reason `commit-start` is — reusing plain `commit` would make one verdict
500
+ * mean "commit and stop" on a phase page and "commit, land and destroy the
501
+ * worktree" on a `/no-spec` page, which is the context-dependent reading this
502
+ * vocabulary exists to prevent. The outcome log would then record the same word
503
+ * for two very differently destructive actions.
504
+ *
497
505
  * `continue` is the mid-run verdict: *I have read it, carry on*. It names an
498
506
  * action, which is what separates it from the `none` verdict that was removed —
499
507
  * `none` recorded itself and did nothing, while this one resumes the run. What
@@ -501,7 +509,15 @@ const NOTES_VERSION = 1
501
509
  * below and is therefore structurally incapable of clearing an armed gate: a
502
510
  * phase that ended still owes a committing verdict or a recorded skip.
503
511
  */
504
- const VERDICTS = ['commit', 'commit-continue', 'commit-start', 'continue', 'changes', 'discuss']
512
+ const VERDICTS = [
513
+ 'commit',
514
+ 'commit-continue',
515
+ 'commit-start',
516
+ 'commit-land',
517
+ 'continue',
518
+ 'changes',
519
+ 'discuss',
520
+ ]
505
521
  const DEFAULT_VERDICT = 'discuss'
506
522
 
507
523
  /**
@@ -579,7 +595,24 @@ function tierAction(tier) {
579
595
  * the payload must then be byte-identical to what it was before this key
580
596
  * existed.
581
597
  */
582
- function surfacesFor({ live, tiers }) {
598
+ function surfacesFor({ live, tiers, buttons = null }) {
599
+ // A MID-PHASE PAGE CANNOT PUT ANYTHING LIVE, so it is not offered the press.
600
+ //
601
+ // `live take` refuses a dirty worktree, so the action commits the phase
602
+ // first — and `/spec-diff` §2b is explicit that half a phase is not
603
+ // committed to look at it running: that splits one phase across two commits
604
+ // and leaves a mess nobody asked for. So mid-run the press could only ever be
605
+ // declined, and a control that always refuses teaches the reader that the
606
+ // buttons on this page are decorative — the same failure
607
+ // `.claude/rules/spec-reports.md` records for the offer nothing was watching.
608
+ //
609
+ // The row stays, with the command in place of the button: the reader still
610
+ // learns the surface exists and what to type, which is exactly the trade the
611
+ // strip already makes on a `file://` page.
612
+ //
613
+ // ONLY `live-on`. `allow-network` and `allow-remote` write a config file
614
+ // and commit nothing, so they are as valid mid-phase as at the end of one.
615
+ const canCommit = buttons !== 'midrun'
583
616
  const rows = []
584
617
 
585
618
  // `unavailable` contributes nothing — the cannot-tell state, routed to
@@ -594,10 +627,12 @@ function surfacesFor({ live, tiers }) {
594
627
  // `on` because handing the instance back to `main` is not this review's
595
628
  // business, and `held` because the way out is another spec's to take and a
596
629
  // button here would either park someone else's work or do nothing.
597
- action: live.state === 'off' ? 'live-on' : null,
630
+ action: live.state === 'off' && canCommit ? 'live-on' : null,
598
631
  // The command for the direction the page does not offer, so the reader is
599
- // never left knowing what they want and not what to type.
600
- command: live.state === 'on' ? '/spec-live main' : null,
632
+ // never left knowing what they want and not what to type. Mid-run that is
633
+ // `/spec-live` itself, since the press is withheld above.
634
+ command:
635
+ live.state === 'on' ? '/spec-live main' : live.state === 'off' && !canCommit ? '/spec-live' : null,
601
636
  })
602
637
  }
603
638
 
@@ -700,7 +735,7 @@ function reviewTierLine(t) {
700
735
  // obligation that outlives the turn. A mid-run reader saying "carry on" has
701
736
  // answered the offer in front of them and nothing else, so the gate a finished
702
737
  // phase armed must survive it untouched.
703
- const COMMITTING = ['commit', 'commit-continue', 'commit-start']
738
+ const COMMITTING = ['commit', 'commit-continue', 'commit-start', 'commit-land']
704
739
 
705
740
  /**
706
741
  * Which set of buttons a rendered page shows.
@@ -726,8 +761,21 @@ const COMMITTING = ['commit', 'commit-continue', 'commit-start']
726
761
  * already be in progress, so offering to put it in flight would be wrong for
727
762
  * half this set's inputs — and wrong in the expensive direction, since it would
728
763
  * offer to provision a worktree for a spec that already has one.
764
+ *
765
+ * `nospec` is the set for work with no spec document: its committing pair is
766
+ * `commit-land` and `commit` — finish it now, or commit and leave the branch
767
+ * standing. `commit-continue` is absent because there is no next phase, and
768
+ * `commit-start` because there is nothing to put in flight.
769
+ *
770
+ * REUSING `refresh` HERE WAS THE OBVIOUS MOVE AND IT IS WRONG. The three
771
+ * remaining buttons are identical, so the temptation is to let `/no-spec` take
772
+ * that set and treat plain `commit` as "commit, land, tear down". That makes one
773
+ * verdict word mean two different things depending on which skill rendered the
774
+ * page — precisely what having separate words for `commit-start` and
775
+ * `commit-continue` exists to avoid, and worse here, because one of the two
776
+ * readings destroys a worktree.
729
777
  */
730
- const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh']
778
+ const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh', 'nospec']
731
779
  const DEFAULT_BUTTON_SET = 'committing'
732
780
 
733
781
  /**
@@ -1146,6 +1194,11 @@ function passesSince(pending, since) {
1146
1194
 
1147
1195
  const WAIT_POLL_MS = 400
1148
1196
 
1197
+ // How often a running wait says it is still running. Five minutes, against a
1198
+ // 400ms poll: this is not part of the poll at all, it is proof of life for
1199
+ // whatever is supervising the process.
1200
+ const WAIT_HEARTBEAT_MS = 5 * 60 * 1000
1201
+
1149
1202
  /**
1150
1203
  * Block until exactly one pass arrives inside a window. Impure only in that it
1151
1204
  * reads the store and sleeps; it writes nothing and claims nothing.
@@ -1168,16 +1221,42 @@ const WAIT_POLL_MS = 400
1168
1221
  * is the normal case rather than the edge one. An hour was picked once and a
1169
1222
  * lunch break beat it.
1170
1223
  *
1224
+ * IT SAYS IT IS STILL RUNNING, on `onHeartbeat`. A backgrounded wait produced
1225
+ * no output between starting and finding a pass, and over a long idle it was
1226
+ * killed — three sessions lost their wait over one lunch break. Whether the
1227
+ * trigger is process silence or session idleness was never established, so the
1228
+ * cheap half is done here: the process stops being silent. The other half is
1229
+ * the caller re-arming, which covers it either way.
1230
+ *
1231
+ * The cadence is INDEPENDENT of the poll. Polling stays at 400ms because that
1232
+ * is how fast a press should be noticed; the heartbeat is minutes apart because
1233
+ * it is addressed to a supervisor, not to a reader.
1234
+ *
1171
1235
  * WHAT WOULD FOOL A LOOSER VERSION: treating an absent, empty, or older-only
1172
1236
  * store as a reason to return. None of those is a pass — they are the ordinary
1173
1237
  * state of a review nobody has answered yet — and a wait that ended on one
1174
1238
  * would report "no pass" as an outcome (`.claude/rules/negative-checks.md`
1175
1239
  * rule 3 has a test for each).
1176
1240
  */
1177
- async function waitForPass(readStore, since, { timeoutMs = null, pollMs = WAIT_POLL_MS, sleep } = {}) {
1241
+ async function waitForPass(
1242
+ readStore,
1243
+ since,
1244
+ {
1245
+ timeoutMs = null,
1246
+ pollMs = WAIT_POLL_MS,
1247
+ sleep,
1248
+ onHeartbeat = null,
1249
+ heartbeatMs = WAIT_HEARTBEAT_MS,
1250
+ now = () => Date.now(),
1251
+ } = {},
1252
+ ) {
1178
1253
  if (!Number.isFinite(Date.parse(since))) return { state: 'unusable' }
1179
1254
  const nap = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
1180
- const deadline = Number.isFinite(timeoutMs) && timeoutMs !== null ? Date.now() + timeoutMs : null
1255
+ const startedAt = now()
1256
+ const deadline = Number.isFinite(timeoutMs) && timeoutMs !== null ? startedAt + timeoutMs : null
1257
+ // Counted from the start rather than scheduled, so a slow poll cannot make
1258
+ // the heartbeat drift and a fast one cannot make it fire twice.
1259
+ let beats = 0
1181
1260
 
1182
1261
  for (;;) {
1183
1262
  // A store that will not parse is not an empty one, and it is not a pass
@@ -1191,9 +1270,22 @@ async function waitForPass(readStore, since, { timeoutMs = null, pollMs = WAIT_P
1191
1270
  if (window.codes.length > 1) return { state: 'ambiguous', count: window.codes.length }
1192
1271
  }
1193
1272
  }
1194
- if (deadline !== null && Date.now() >= deadline) return { state: 'timeout' }
1273
+ if (deadline !== null && now() >= deadline) return { state: 'timeout' }
1274
+
1275
+ // SAID ON THE WAY ROUND, never on the way out. A line printed when the wait
1276
+ // ends would only ever appear for the waits that already worked, which is
1277
+ // the half that never needed proof.
1278
+ if (onHeartbeat && heartbeatMs > 0) {
1279
+ const elapsed = now() - startedAt
1280
+ const due = Math.floor(elapsed / heartbeatMs)
1281
+ if (due > beats) {
1282
+ beats = due
1283
+ onHeartbeat({ elapsedMs: elapsed, since })
1284
+ }
1285
+ }
1286
+
1195
1287
  // Never overshoot the deadline by a whole poll interval.
1196
- await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline - Date.now())))
1288
+ await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline - now())))
1197
1289
  }
1198
1290
  }
1199
1291
 
@@ -1280,6 +1372,39 @@ function waitingPasses(reviewsDirPath) {
1280
1372
 
1281
1373
  const GATE_VERSION = 1
1282
1374
 
1375
+ /**
1376
+ * The reason a BYPASS records, and it is deliberately a fixed string.
1377
+ *
1378
+ * A skip normally carries what the operator typed. This one is chosen from a
1379
+ * picker with nothing typed, so the record has to say that for itself — and a
1380
+ * string nobody would write by hand is what keeps a tapped bypass tellable from
1381
+ * a considered one when both appear in the same log.
1382
+ *
1383
+ * It stays `by: 'skip'` rather than minting a third `DISARMED_BY` verb: the
1384
+ * sidecar's vocabulary is versioned, and a distinct reason already buys the only
1385
+ * thing a third verb would have.
1386
+ */
1387
+ const GATE_BYPASS_REASON = 'none: chose to commit without reading the diff'
1388
+
1389
+ /**
1390
+ * The way out of an armed gate, declared beside the refusal rather than left
1391
+ * for a caller to reconstruct.
1392
+ *
1393
+ * A BYPASS, NOT A SATISFY: the phase still owes a verdict and nobody has read
1394
+ * it, so this steps past a guard that is still unsatisfied — which is exactly
1395
+ * why it is recorded and a `satisfy` is not.
1396
+ *
1397
+ * WHAT THIS IS NOT: a `--force`. One was asked for and rejected, because a
1398
+ * reasonless lift is indistinguishable from nobody having looked. The exits
1399
+ * this gate has are unchanged — a committing verdict, or a recorded skip — and
1400
+ * this is the second of those two at an address a person will actually reach.
1401
+ */
1402
+ const GATE_BYPASS_OFFER = Object.freeze({
1403
+ kind: 'bypass',
1404
+ label: 'Commit without reading the diff — recorded as such',
1405
+ command: `skitterspec spec-env review skip "${GATE_BYPASS_REASON}"`,
1406
+ })
1407
+
1283
1408
  // How a disarm happened. `verdict` is a review that reached a committing
1284
1409
  // conclusion; `skip` is the operator saying, on the record, that they are
1285
1410
  // moving on without one.
@@ -1290,7 +1415,7 @@ function reviewGatePath(outPath) {
1290
1415
  }
1291
1416
 
1292
1417
  function emptyGate(specFolder) {
1293
- return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, log: [] }
1418
+ return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, offeredAt: null, log: [] }
1294
1419
  }
1295
1420
 
1296
1421
  /**
@@ -1318,6 +1443,10 @@ function readGate(outPath, specFolder) {
1318
1443
  armed: parsed.armed === true,
1319
1444
  armedAt: parsed.armedAt || null,
1320
1445
  phase: parsed.phase === undefined ? null : parsed.phase,
1446
+ // A sidecar written before this field existed reads as never-offered,
1447
+ // which is the correct and harmless default. These files are gitignored,
1448
+ // so there is no fleet to migrate and `GATE_VERSION` does not move.
1449
+ offeredAt: parsed.offeredAt || null,
1321
1450
  log: Array.isArray(parsed.log) ? parsed.log : [],
1322
1451
  },
1323
1452
  corrupt: false,
@@ -1335,6 +1464,64 @@ function writeGate(outPath, gate) {
1335
1464
  return p
1336
1465
  }
1337
1466
 
1467
+ /* ==========================================================================
1468
+ * The render record — what the caller DECLARED, for the re-render to read
1469
+ * ========================================================================== */
1470
+
1471
+ // Same stem as the other sidecars, so one spec's set still reads as one set.
1472
+ function renderRecordPath(outPath) {
1473
+ return outPath.replace(/\.html$/, '') + '.render.json'
1474
+ }
1475
+
1476
+ /**
1477
+ * The button set the last CLI render of this spec declared, or null.
1478
+ *
1479
+ * WHY THIS EXISTS AT ALL. `--buttons` is declared by the caller — `BUTTON_SETS`
1480
+ * above says why it is never derived — and the review daemon is not that
1481
+ * caller. It re-renders a spec's page per request from the tree, so without a
1482
+ * record it can only guess, and what it guessed was the default: every
1483
+ * `/no-spec` page served over http offered `Commit & Start`.
1484
+ *
1485
+ * WHAT WOULD FOOL A CONSUMER THAT TRUSTED THIS OUTRIGHT: it is a file on disk
1486
+ * and the tree moves underneath it. A spec rendered as `--docs` and then put in
1487
+ * flight has a day-old record saying `authoring` and a worktree saying
1488
+ * otherwise. So `buttonsForView` in `serve.js` takes only NARROWING words from
1489
+ * here and reads the family off the tree — this function's job is to report
1490
+ * what was written, not to decide whether it still holds.
1491
+ *
1492
+ * Three states, like `readNotes` and `readGate`: absent is the ordinary case,
1493
+ * and corrupt is reported rather than hidden so neither reads as a declaration.
1494
+ */
1495
+ function readRenderRecord(outPath, specFolder) {
1496
+ let raw
1497
+ try {
1498
+ raw = fs.readFileSync(renderRecordPath(outPath), 'utf8')
1499
+ } catch {
1500
+ // Absent is ordinary: most pages are opened off the index without the CLI
1501
+ // ever having rendered that spec.
1502
+ return { buttons: null, corrupt: false, present: false }
1503
+ }
1504
+ try {
1505
+ const parsed = JSON.parse(raw)
1506
+ const buttons = typeof parsed.buttons === 'string' && parsed.buttons ? parsed.buttons : null
1507
+ // The folder is checked, not trusted: a record found under this spec's stem
1508
+ // that names another spec is not this spec's declaration.
1509
+ if (parsed.spec && specFolder && parsed.spec !== specFolder) {
1510
+ return { buttons: null, corrupt: false, present: true }
1511
+ }
1512
+ return { buttons, corrupt: false, present: true }
1513
+ } catch {
1514
+ return { buttons: null, corrupt: true, present: true }
1515
+ }
1516
+ }
1517
+
1518
+ function writeRenderRecord(outPath, record) {
1519
+ const p = renderRecordPath(outPath)
1520
+ fs.mkdirSync(path.dirname(p), { recursive: true })
1521
+ fs.writeFileSync(p, JSON.stringify(record, null, 2) + '\n')
1522
+ return p
1523
+ }
1524
+
1338
1525
  /**
1339
1526
  * Arm the gate. Pure.
1340
1527
  *
@@ -1344,8 +1531,32 @@ function writeGate(outPath, gate) {
1344
1531
  * for a DIFFERENT phase is re-armed, because that is a new obligation.
1345
1532
  */
1346
1533
  function armGate(gate, { at, phase = null }) {
1534
+ // Idempotent within a phase — a re-render must not restart the clock, and it
1535
+ // must not hand back an offer this arming has already spent.
1347
1536
  if (gate.armed && gate.phase === phase) return gate
1348
- return { ...gate, armed: true, armedAt: at, phase }
1537
+ // A NEW ARMING GETS A NEW OFFER. Each phase that ends is its own obligation,
1538
+ // so the once-only rule is scoped to the arming rather than to the file.
1539
+ return { ...gate, armed: true, armedAt: at, phase, offeredAt: null }
1540
+ }
1541
+
1542
+ /**
1543
+ * Record that the bypass was offered for this arming. Pure.
1544
+ *
1545
+ * SPENT BY BEING MADE, not by being taken. A reader who declines still saw it,
1546
+ * and re-offering on the next refused commit is how a bypass becomes the default
1547
+ * rather than a deliberate step.
1548
+ *
1549
+ * NOT DONE AS A SIDE EFFECT OF READING THE GATE, deliberately. `/spec-next`
1550
+ * asks `review gate --json` on every single run, so a read that spent the
1551
+ * offer would burn it with no picker ever raised — and the operator would never
1552
+ * be offered the thing this exists to offer them.
1553
+ *
1554
+ * Marking an unarmed gate changes nothing: there is no obligation, so there is
1555
+ * nothing to have offered a way out of.
1556
+ */
1557
+ function markGateOffered(gate, { at }) {
1558
+ if (!gate.armed || gate.offeredAt) return { gate, marked: false }
1559
+ return { gate: { ...gate, offeredAt: at }, marked: true }
1349
1560
  }
1350
1561
 
1351
1562
  /**
@@ -1416,7 +1627,15 @@ function gateState({ gate, corrupt, present, required }) {
1416
1627
  }
1417
1628
  }
1418
1629
  if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
1419
- return { state: 'armed', reason: 'a phase is awaiting a verdict', gate }
1630
+ // The refusal is unchanged by the offer having been spent — what is withheld
1631
+ // is the way out, never the guard. A second refused commit still refuses, and
1632
+ // still names its exits in the text; the operator simply is not asked again.
1633
+ return {
1634
+ state: 'armed',
1635
+ reason: 'a phase is awaiting a verdict',
1636
+ gate,
1637
+ ...(gate.offeredAt ? {} : { offer: GATE_BYPASS_OFFER }),
1638
+ }
1420
1639
  }
1421
1640
 
1422
1641
  /**
@@ -1871,6 +2090,7 @@ function renderReviewBlock(review) {
1871
2090
  if (review.summary) {
1872
2091
  parts.push(`<p class="review-summary">${escapeHtml(review.summary)}</p>`)
1873
2092
  }
2093
+ parts.push(...renderReviewerStrip(review.reviewers))
1874
2094
  const checks = Array.isArray(review.checks) ? review.checks : []
1875
2095
  if (checks.length) {
1876
2096
  parts.push('<ul class="checks">')
@@ -1879,16 +2099,31 @@ function renderReviewBlock(review) {
1879
2099
  // reviewer's note because it was tagged oddly is worse than showing it
1880
2100
  // under a neutral heading.
1881
2101
  const level = CHECK_LEVELS.includes(c.level) ? c.level : 'confirm'
1882
- const file = c.file ? `<span class="check-file">${escapeHtml(c.file)}</span>` : ''
2102
+ // `file:line` where a line is known, and the whole thing is a button so a
2103
+ // finding can be read against the code it is about. A check with no line
2104
+ // renders exactly as it always did — the written review supplies none, and
2105
+ // its output must not change because machine findings now exist.
2106
+ const hasLine = Number.isInteger(c.line) && c.line > 0
2107
+ const label = c.file ? escapeHtml(c.file) + (hasLine ? `:${c.line}` : '') : ''
2108
+ const file = c.file
2109
+ ? `<button type="button" class="check-file" data-goto="${escapeHtml(c.file)}"` +
2110
+ `${hasLine ? ` data-goto-line="${c.line}"` : ''}>${label}</button>`
2111
+ : ''
2112
+ // WHO SAID IT. Absent for a written review, which is what it has always
2113
+ // been; present the moment two authors can appear on one page, because a
2114
+ // reader weighing a finding needs to know whether a person read the code
2115
+ // or a tool matched a pattern.
2116
+ const src = c.source ? `<span class="check-src">${escapeHtml(c.source)}</span>` : ''
1883
2117
  // The id is positional and therefore stable for THIS render, which is all
1884
2118
  // a reply needs: it travels back in the same blob the page was built from.
1885
2119
  // `data-file` is what lets an answer land on the file the check is about.
1886
2120
  const id = `k${i}`
1887
2121
  const fileAttr = c.file ? ` data-file="${escapeHtml(c.file)}"` : ''
2122
+ const lineAttr = hasLine ? ` data-line="${c.line}"` : ''
1888
2123
  parts.push(
1889
- `<li class="check ${level}" data-check="${id}"${fileAttr}>` +
2124
+ `<li class="check ${level}" data-check="${id}"${fileAttr}${lineAttr}>` +
1890
2125
  `<span class="check-level">${level}</span>` +
1891
- `${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
2126
+ `${src}${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
1892
2127
  )
1893
2128
  })
1894
2129
  parts.push('</ul>')
@@ -1897,6 +2132,65 @@ function renderReviewBlock(review) {
1897
2132
  return parts.join('\n')
1898
2133
  }
1899
2134
 
2135
+ /**
2136
+ * One line per configured reviewer, saying what its run ended as.
2137
+ *
2138
+ * PRESENT EVEN WHEN EVERY REVIEWER FOUND NOTHING, which is the whole point and
2139
+ * the one place `.claude/rules/negative-checks.md` inverts here. Silence is
2140
+ * normally the safe branch; on this page it is not, because a reviewer that was
2141
+ * rate-limited renders identically to one that read the diff and approved of it
2142
+ * — and the reader is about to decide whether to commit. So the strip reports
2143
+ * the RUN, which is a positive signal, rather than accusing the code.
2144
+ *
2145
+ * Absent entirely when no reviewer is configured: every project that exists
2146
+ * today is in that state, and a strip saying "no reviewers" would be an absence
2147
+ * reported at everyone.
2148
+ */
2149
+ function renderReviewerStrip(outcomes) {
2150
+ const list = Array.isArray(outcomes) ? outcomes : []
2151
+ if (!list.length) return []
2152
+ const parts = ['<ul class="reviewers">']
2153
+ for (const o of list) {
2154
+ const state = typeof o.state === 'string' ? o.state : 'failed'
2155
+ parts.push(
2156
+ `<li class="reviewer ${escapeHtml(state)}">` +
2157
+ `<span class="reviewer-name">${escapeHtml(o.name || 'reviewer')}</span>` +
2158
+ `<span class="reviewer-said">${escapeHtml(reviewerSaid(o))}</span></li>`,
2159
+ )
2160
+ }
2161
+ parts.push('</ul>')
2162
+ return parts
2163
+ }
2164
+
2165
+ /**
2166
+ * What one reviewer's outcome reads as. Pure.
2167
+ *
2168
+ * A STATE THIS BUILD DOES NOT KNOW IS SHOWN, not dropped — the same rule the
2169
+ * check level follows. An unrecognised state means a newer engine wrote the
2170
+ * cache, and losing the line entirely would read as a reviewer that was never
2171
+ * configured.
2172
+ */
2173
+ function reviewerSaid(o) {
2174
+ const n = Number.isInteger(o.count) ? o.count : 0
2175
+ const detail = o.detail ? ` — ${o.detail}` : ''
2176
+ switch (o.state) {
2177
+ case 'findings':
2178
+ return `${n} finding${n === 1 ? '' : 's'}${detail}`
2179
+ case 'cached':
2180
+ return n ? `${n} finding${n === 1 ? '' : 's'} · cached` : 'clean · cached'
2181
+ case 'clean':
2182
+ return 'clean'
2183
+ case 'missing':
2184
+ return `did not run${o.detail ? ` — ${o.detail}` : ''}`
2185
+ case 'timeout':
2186
+ return 'did not run — timed out'
2187
+ case 'failed':
2188
+ return `did not run${o.detail ? ` — ${o.detail}` : ''}`
2189
+ default:
2190
+ return `${o.state}${detail}`
2191
+ }
2192
+ }
2193
+
1900
2194
  /**
1901
2195
  * Where a spec's published URL is remembered, and what it currently says.
1902
2196
  *
@@ -1958,8 +2252,9 @@ function reviewServerNotice({ running, othersServed }) {
1958
2252
  ]
1959
2253
  }
1960
2254
 
1961
- // The publish-ready copy, beside the page it came from. Same stem, so the three
1962
- // sidecars (`.notes.json`, `.url`, `.publish.html`) all read as one spec's set.
2255
+ // The publish-ready copy, beside the page it came from. Same stem, so every
2256
+ // sidecar (`.notes.json`, `.url`, `.publish.html`, `.render.json`) reads as one
2257
+ // spec's set.
1963
2258
  function reviewPublishPath(outPath) {
1964
2259
  return outPath.replace(/\.html$/, '') + '.publish.html'
1965
2260
  }
@@ -2044,6 +2339,7 @@ module.exports = {
2044
2339
  passesSince,
2045
2340
  waitingPasses,
2046
2341
  waitForPass,
2342
+ WAIT_HEARTBEAT_MS,
2047
2343
  WAIT_POLL_MS,
2048
2344
  passState,
2049
2345
  describePending,
@@ -2055,9 +2351,15 @@ module.exports = {
2055
2351
  emptyGate,
2056
2352
  readGate,
2057
2353
  writeGate,
2354
+ renderRecordPath,
2355
+ readRenderRecord,
2356
+ writeRenderRecord,
2058
2357
  armGate,
2059
2358
  disarmGate,
2060
2359
  gateState,
2360
+ markGateOffered,
2361
+ GATE_BYPASS_OFFER,
2362
+ GATE_BYPASS_REASON,
2061
2363
  gateForPage,
2062
2364
  mergeNotes,
2063
2365
  applyResolutions,
@@ -2078,6 +2380,8 @@ module.exports = {
2078
2380
  escapeIsland,
2079
2381
  escapeHtml,
2080
2382
  renderReviewBlock,
2383
+ renderReviewerStrip,
2384
+ reviewerSaid,
2081
2385
  reviewUrlPath,
2082
2386
  reviewPublishPath,
2083
2387
  readReviewUrl,