@skitterbyte/skitterspec 19.0.0 → 20.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.
@@ -411,20 +411,103 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
411
411
  padding: .5rem;
412
412
  }
413
413
  .copy-out[hidden] { display: none; }
414
- .context { margin: 0 0 1rem; padding: .75rem .9rem; border: 1px solid var(--line); border-radius: 6px; background: var(--panel, transparent); }
415
- .context h3 { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: .9rem 0 .35rem; }
414
+ /* THE REVIEW, ONCE IT IS OVER. A verdict that has actually been handed over
415
+ ends the page: the diff fades out and what you decided takes its place. The
416
+ fade is a transition on a class rather than a timer, so nothing depends on
417
+ JavaScript finishing, and `Show the diff anyway` brings it back — read-only,
418
+ with the buttons dead. */
419
+ .reviewable { transition: opacity .35s ease; }
420
+ .is-decided .reviewable { display: none; }
421
+ .is-decided.show-diff .reviewable { display: block; opacity: .75; }
422
+ .is-decided.show-diff .reviewable:hover { opacity: 1; }
423
+ /* Bringing the diff back must not resurrect a section that was hidden for its
424
+ own reasons — a spec with no context ships `#context` hidden, and the rule
425
+ above would have shown an empty panel. The second selector is there for
426
+ specificity: `.is-decided.show-diff .reviewable` outranks a bare
427
+ `.reviewable[hidden]`. */
428
+ .reviewable[hidden],
429
+ .is-decided.show-diff .reviewable[hidden] { display: none; }
430
+
431
+ .decided {
432
+ margin: 0 0 1.25rem;
433
+ padding: 1.1rem 1.2rem;
434
+ border: 1px solid var(--line);
435
+ border-left: 4px solid var(--accent);
436
+ border-radius: 10px;
437
+ background: var(--panel);
438
+ animation: decided-in .35s ease;
439
+ }
440
+ @keyframes decided-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
441
+ .decided[hidden] { display: none; }
442
+ .decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
443
+ .decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
444
+ .decided-note:last-child { margin-bottom: 0; }
445
+ .decided-toggle { font-size: .78rem; }
446
+
447
+ /* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
448
+ to be read rather than data to be scanned, so it gets a measure, air, and a
449
+ quieter voice than the diff below it. It was once full-bleed 15px text at
450
+ 1.5 in a tight box, which read as a wall and got skipped. */
451
+ .context {
452
+ margin: 0 0 1.5rem;
453
+ padding: 1.1rem 1.3rem;
454
+ border: 1px solid var(--line);
455
+ border-radius: 10px;
456
+ background: var(--panel, transparent);
457
+ }
458
+ .context h3 {
459
+ font-size: .72rem;
460
+ text-transform: uppercase;
461
+ letter-spacing: .08em;
462
+ color: var(--muted);
463
+ font-weight: 650;
464
+ margin: 1.4rem 0 .5rem;
465
+ }
416
466
  .context h3:first-child { margin-top: 0; }
417
- .context p { margin: 0 0 .5rem; line-height: 1.5; }
467
+ /* A MEASURE. Prose set the full width of a 1400px page is measurably harder to
468
+ read — the eye loses the line it is returning to. */
469
+ .context p { margin: 0 0 .7rem; font-size: .92rem; line-height: 1.65; max-width: 72ch; }
418
470
  .context p:last-child { margin-bottom: 0; }
419
- .context table { border-collapse: collapse; width: 100%; font-size: .85rem; }
420
- .context th, .context td { text-align: left; padding: .2rem .5rem .2rem 0; vertical-align: top; }
421
- .context th { color: var(--muted); font-weight: 600; }
422
- .context ul { margin: 0; padding-left: 1.1rem; }
423
- .context li { margin: .15rem 0; line-height: 1.45; }
471
+ .context table { border-collapse: collapse; width: 100%; font-size: .84rem; margin: .2rem 0 .3rem; }
472
+ .context th, .context td { text-align: left; padding: .35rem .8rem .35rem 0; vertical-align: top; }
473
+ .context th {
474
+ color: var(--muted);
475
+ font-weight: 600;
476
+ font-size: .72rem;
477
+ text-transform: uppercase;
478
+ letter-spacing: .05em;
479
+ border-bottom: 1px solid var(--line);
480
+ padding-bottom: .3rem;
481
+ }
482
+ .context tbody tr + tr td { border-top: 1px solid var(--line); }
483
+ .context ul { margin: 0; padding-left: 1.15rem; max-width: 72ch; }
484
+ .context li { margin: .3rem 0; font-size: .92rem; line-height: 1.6; }
485
+ /* The disclosure is a CONTROL, not a panel. Full-width and boxed, it read as a
486
+ section header that happened to be clickable. */
487
+ .context-more { margin: .9rem 0 0; }
488
+ .context-more > summary {
489
+ display: inline-block;
490
+ width: auto;
491
+ cursor: pointer;
492
+ font-size: .78rem;
493
+ color: var(--muted);
494
+ padding: .25rem .7rem;
495
+ border: 1px solid var(--line);
496
+ border-radius: 999px;
497
+ list-style: none;
498
+ }
499
+ .context-more > summary::-webkit-details-marker { display: none; }
500
+ .context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
501
+ .context-more[open] > summary { margin-bottom: .6rem; }
424
502
  .context li.done { color: var(--muted); }
425
503
  .context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
426
504
  .copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
427
505
  .sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
506
+ /* A `display` rule BEATS the browser's own `[hidden] { display: none }`, so
507
+ every element this page hides in JavaScript needs its own override or the
508
+ hide silently does nothing. It shipped without one and a finished review kept
509
+ showing an empty command box. `assets-review` guards the whole class now. */
510
+ .sent-cmd[hidden] { display: none; }
428
511
  .sent-cmd-text {
429
512
  flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
430
513
  font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
@@ -437,7 +520,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
437
520
  </style>
438
521
  </head>
439
522
  <body>
440
- <div class="wrap">
523
+ <div class="wrap" id="wrap">
441
524
  <header>
442
525
  <h1 id="title"></h1>
443
526
  <p class="sub" id="sub"></p>
@@ -459,15 +542,23 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
459
542
  engine from the spec's own files — nothing here passed through the
460
543
  model, so it is free however large the diff. Absent entirely for a spec
461
544
  whose overview cannot be read: the page worked without it before. -->
462
- <section class="context" id="context" hidden>
545
+ <!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
546
+ above everything because on a re-open it is the only thing you need:
547
+ the review is over, and this says how it ended. -->
548
+ <section class="decided" id="decided" hidden>
549
+ <p class="decided-what" id="decided-what"></p>
550
+ <p class="decided-note" id="decided-note"></p>
551
+ <button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
552
+ </section>
553
+ <section class="context reviewable" id="context" hidden>
463
554
  <div class="context-why" id="context-why"></div>
464
555
  <details class="context-more" id="context-more" hidden>
465
556
  <summary id="context-more-summary">More</summary>
466
557
  <div id="context-rest"></div>
467
558
  </details>
468
559
  </section>
469
- <div id="review-block">__REVIEW_BLOCK__</div>
470
- <div id="files"></div>
560
+ <div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
561
+ <div id="files" class="reviewable"></div>
471
562
  <!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
472
563
  header and the first person to use it could not find it: you read a
473
564
  350-line diff downward, and the control asking for your conclusion was
@@ -613,6 +704,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
613
704
  // Best effort, both ways. Safari refuses storage on file:// by THROWING, and
614
705
  // a review page that fails to render because it could not autosave would be a
615
706
  // far worse bug than a lost pass.
707
+ // What this reader concluded, once it was actually HANDED OVER — never on a
708
+ // clipboard copy, which is a pass still sitting in the reader's hands.
709
+ var decided = null
616
710
  var STORE_KEY = 'skitterspec-review:' + data.spec + ':' + data.generatedAt
617
711
  function save() {
618
712
  try {
@@ -622,7 +716,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
622
716
  accepts[st.file.path] = st.accept
623
717
  st.drafts.forEach(function (d) { drafts.push(d) })
624
718
  })
625
- window.localStorage.setItem(STORE_KEY, JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies }))
719
+ window.localStorage.setItem(
720
+ STORE_KEY,
721
+ JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies, decided: decided }),
722
+ )
626
723
  } catch (e) { /* no storage, or a quota — the pass still works, it just is not saved */ }
627
724
  }
628
725
  function restore() {
@@ -633,6 +730,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
633
730
  try { saved = JSON.parse(raw) } catch (e) { return }
634
731
  seq = saved.seq || 0
635
732
  replies = saved.replies || {}
733
+ // THE DECISION OUTLIVES THE TAB. Keyed to this render like every other
734
+ // mark, so the next render — the one after the commit — is a live page
735
+ // again rather than a page that thinks it is finished.
736
+ if (saved.decided && saved.decided.verdict) decided = saved.decided
636
737
  var byPath = {}
637
738
  state.forEach(function (st) { byPath[st.file.path] = st })
638
739
  Object.keys(saved.accepts || {}).forEach(function (path) {
@@ -1122,7 +1223,15 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1122
1223
  // here, and the shared block below follows for free — the same reason the
1123
1224
  // engine keeps a `COMMITTING` list rather than a second condition.
1124
1225
  var COMMITTERS = ['commit', 'commit-continue']
1125
- var LABELS = { commit: '✓ Commit', 'commit-continue': '✓ Commit & Continue' }
1226
+ // ALL FOUR, because this is now the one place a verdict gets a reader-facing
1227
+ // name: `refresh` relabels only the committing pair, but the decided panel
1228
+ // has to name whichever verdict was actually sent.
1229
+ var LABELS = {
1230
+ commit: '✓ Commit',
1231
+ 'commit-continue': '✓ Commit & Continue',
1232
+ changes: '↺ Request changes',
1233
+ discuss: '… Discuss first',
1234
+ }
1126
1235
  var TITLES = {
1127
1236
  commit: 'Commit what you just read, and stop there',
1128
1237
  // Says what it will NOT do: a reader must not press this expecting the
@@ -1133,6 +1242,11 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1133
1242
  var verdictCount = document.getElementById('verdict-count')
1134
1243
  var verdictLog = document.getElementById('verdict-log')
1135
1244
  var copyOut = document.getElementById('copy-out')
1245
+ var wrapEl = document.getElementById('wrap')
1246
+ var decidedBox = document.getElementById('decided')
1247
+ var decidedWhat = document.getElementById('decided-what')
1248
+ var decidedNote = document.getElementById('decided-note')
1249
+ var decidedToggle = document.getElementById('decided-toggle')
1136
1250
  var copyHint = document.getElementById('copy-hint')
1137
1251
  var sentCmd = document.getElementById('sent-cmd')
1138
1252
  var sentCmdText = document.getElementById('sent-cmd-text')
@@ -1164,6 +1278,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1164
1278
  // the moment the last note goes and a phone tab switch mid-review costs
1165
1279
  // nothing.
1166
1280
  function refresh() {
1281
+ // A decided page is finished. `refresh` runs on every mark and re-enables
1282
+ // the two committing buttons unconditionally, so without this it would undo
1283
+ // the ending every time anything redrew.
1284
+ if (decided) return drawDecided()
1167
1285
  var n = pending()
1168
1286
  var open = openNotes()
1169
1287
  verdictCount.textContent = n
@@ -1204,12 +1322,104 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1204
1322
  save()
1205
1323
  }
1206
1324
 
1207
- // The last honoured verdict, as history. It is why the page can look
1208
- // untouched after an approval: the verdict was consumed, not stored.
1325
+ /**
1326
+ * End the review, and say how it ended.
1327
+ *
1328
+ * A verdict that has been handed over is the END of this page's job, and the
1329
+ * page used to carry on as if it were not: the diff stayed, the four buttons
1330
+ * stayed live, and a line of small grey text under them said "Sent." A reader
1331
+ * coming back to that tab has no way to tell a sent review from an unsent
1332
+ * one, and pressing a button again reaches an agent that stopped listening.
1333
+ *
1334
+ * So the diff goes, what was decided takes its place, and the controls die
1335
+ * with a reason on them.
1336
+ */
1337
+ function drawDecided() {
1338
+ if (!decided) return
1339
+ var said = SAID[decided.verdict] || decided.verdict
1340
+ var when = String(decided.at || '').slice(0, 10)
1341
+ decidedBox.hidden = false
1342
+ decidedWhat.textContent = 'You chose: ' + (LABELS[decided.verdict] || decided.verdict)
1343
+ decidedNote.textContent =
1344
+ 'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
1345
+ 'Nothing here reaches Claude any more, so the verdict buttons are closed.'
1346
+ wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
1347
+ // EVERY button, not only the committing pair: `refresh` re-enables those two
1348
+ // by design, and a decided page must beat it.
1349
+ Object.keys(verdictBtns).forEach(function (key) {
1350
+ var btn = verdictBtns[key]
1351
+ btn.disabled = true
1352
+ btn.title = 'Already sent — ' + said
1353
+ })
1354
+ verdictCount.textContent = 'Sent — ' + said
1355
+ // ONE STATEMENT, NOT FOUR. The panel, the bar, the history line and the
1356
+ // command box all said the same thing at once. The log line is the panel's
1357
+ // sentence again in smaller type, so it goes.
1358
+ verdictLog.hidden = true
1359
+
1360
+ // THE HAND-OFF SURVIVES THE ENDING — but only where there is something
1361
+ // worth carrying. A code is six digits to transcribe, and the box and its
1362
+ // Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
1363
+ // reader is about to type into the terminal they are already sitting in;
1364
+ // dressing it as a copyable artefact makes the ending look unfinished.
1365
+ if (decided.cmd && /\s/.test(decided.cmd)) {
1366
+ copyHint.hidden = false
1367
+ copyHint.textContent = 'Sent. Run this where Claude is:'
1368
+ showCommand(decided.cmd)
1369
+ return
1370
+ }
1371
+ copyHint.hidden = true
1372
+ sentCmd.hidden = true
1373
+ copyOut.hidden = true
1374
+ if (decided.cmd) {
1375
+ decidedNote.textContent +=
1376
+ ' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
1377
+ }
1378
+ }
1379
+
1380
+ /** Record a verdict that was actually delivered, and end the page on it. */
1381
+ function markDecided(verdict, cmd) {
1382
+ decided = { verdict: verdict, at: new Date().toISOString(), cmd: cmd || null }
1383
+ save()
1384
+ drawDecided()
1385
+ }
1386
+
1387
+ // A WAY BACK IN, read-only. "It is finished" and "I cannot see what I
1388
+ // approved" are different things, and only the first one is true.
1389
+ decidedToggle.addEventListener('click', function () {
1390
+ var showing = / \bshow-diff\b/.test(' ' + wrapEl.className)
1391
+ wrapEl.className = wrapEl.className.replace(/\s*\bshow-diff\b/g, '') + (showing ? '' : ' show-diff')
1392
+ decidedToggle.textContent = showing ? 'Show the diff anyway' : 'Hide the diff'
1393
+ })
1394
+
1395
+ // How a logged verdict reads. EVERY VERDICT NAMED, and an unknown one says
1396
+ // so: the fallback was `discussed`, which quietly relabelled `commit` — and a
1397
+ // page that tells you a commit was a discussion is worse than one that admits
1398
+ // it does not know the word.
1399
+ var SAID = {
1400
+ approve: 'committed',
1401
+ commit: 'committed',
1402
+ 'commit-continue': 'committed, then carried on',
1403
+ changes: 'changes requested',
1404
+ discuss: 'discussed',
1405
+ skip: 'moved on without a verdict',
1406
+ }
1407
+
1408
+ // The last thing that happened here, as history. It is why the page can look
1409
+ // untouched after a commit: the verdict was consumed, not stored. A SKIP is
1410
+ // history of exactly the same kind — it is the recorded decision to move on,
1411
+ // and hiding it would make a skipped review indistinguishable from one that
1412
+ // never ended.
1209
1413
  function drawLog() {
1210
1414
  var last = data.notes && data.notes.lastDecision
1415
+ var skip = data.gate && data.gate.lastSkip
1416
+ // Whichever is more recent. Both are timestamped, and comparing is what
1417
+ // stops an old verdict shadowing a skip taken after it.
1418
+ if (skip && (!last || String(skip.at || '') > String(last.at || ''))) {
1419
+ last = { verdict: 'skip', at: skip.at, note: skip.reason }
1420
+ }
1211
1421
  if (!last) return
1212
- var said = last.verdict === 'approve' ? 'approved' : last.verdict === 'changes' ? 'changes requested' : 'discussed'
1422
+ var said = SAID[last.verdict] || ('recorded as "' + last.verdict + '"')
1213
1423
  var when = String(last.at || '').slice(0, 10)
1214
1424
  verdictLog.hidden = false
1215
1425
  verdictLog.textContent = said + ' earlier' + (when ? ' · ' + when : '') + (last.note ? ' · ' + last.note : '')
@@ -1366,7 +1576,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1366
1576
  * you read six digits out, where a paste costs context in proportion to how
1367
1577
  * much you wrote.
1368
1578
  */
1369
- function post(json) {
1579
+ function post(json, verdict) {
1370
1580
  copyHint.hidden = false
1371
1581
  copyHint.textContent = 'Sending…'
1372
1582
  fetch(location.pathname, {
@@ -1392,12 +1602,14 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1392
1602
  if (code) {
1393
1603
  copyHint.textContent = 'Sent. Run this where Claude is:'
1394
1604
  showCommand('/spec-reviewed ' + code)
1605
+ markDecided(verdict, '/spec-reviewed ' + code)
1395
1606
  } else {
1396
1607
  // NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
1397
1608
  // works — it picks up the single waiting pass — but the page cannot
1398
1609
  // claim the server named THIS one, so it does not put words in its
1399
1610
  // mouth. Bare is what the reader is told, because bare is what is true.
1400
1611
  copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
1612
+ markDecided(verdict)
1401
1613
  }
1402
1614
  })
1403
1615
  }, function () {
@@ -1407,17 +1619,83 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1407
1619
  })
1408
1620
  }
1409
1621
 
1622
+ /**
1623
+ * Is this page running as a published Artifact?
1624
+ *
1625
+ * A CAPABILITY CHECK, not a hostname one. `window.claude.use` is the whole
1626
+ * contract for reaching anything the viewer grants, and it exists only where
1627
+ * a viewer is there to grant it — so its presence is what distinguishes the
1628
+ * published page from the two the engine serves, without this file knowing
1629
+ * anything about where it was published.
1630
+ */
1631
+ function isArtifact() {
1632
+ return Boolean(window.claude && typeof window.claude.use === 'function')
1633
+ }
1634
+
1635
+ /**
1636
+ * Hand the pass to the artifact's own store.
1637
+ *
1638
+ * THE THIRD TRANSPORT, for the reader no local server can reach: a phone on
1639
+ * mobile data, or anyone away from the LAN the engine serves on. The pass
1640
+ * lands in a document Claude reads back and deletes, so it is consumed
1641
+ * exactly like a claimed one.
1642
+ *
1643
+ * `use` is asked only once the reader has pressed something. Asking at load
1644
+ * would spend a consent prompt on every render, including the ones nobody
1645
+ * ends in a verdict.
1646
+ */
1647
+ function sendToStore(json, verdict) {
1648
+ copyHint.hidden = false
1649
+ copyHint.textContent = 'Sending…'
1650
+ var landed = false
1651
+ window.claude.use('db').then(function (db) {
1652
+ // `null` means this view cannot run db — not granted, not served, failed
1653
+ // to load, all indistinguishable by design. The clipboard is still there,
1654
+ // so the pass is recoverable rather than lost.
1655
+ if (!db) {
1656
+ showFallback(json, 'Could not reach the store. Copy this, then paste it to Claude.')
1657
+ return
1658
+ }
1659
+ return db
1660
+ .collection('passes')
1661
+ .add({ spec: data.spec, at: new Date().toISOString(), render: data.generatedAt, blob: JSON.parse(json) })
1662
+ .then(function () {
1663
+ landed = true
1664
+ // NO CODE HERE, and none invented. Six digits are the engine's, minted
1665
+ // by the holding area this pass never went through — so the page says
1666
+ // the one thing that is true and actionable.
1667
+ copyHint.textContent = 'Sent. Run this where Claude is:'
1668
+ showCommand('/spec-reviewed')
1669
+ markDecided(verdict, '/spec-reviewed')
1670
+ })
1671
+ }).then(null, function (err) {
1672
+ if (landed) return
1673
+ showFallback(
1674
+ json,
1675
+ 'Could not store the pass' + (err && err.code ? ' (' + err.code + ')' : '') +
1676
+ '. Copy this, then paste it to Claude.',
1677
+ )
1678
+ })
1679
+ }
1680
+
1410
1681
  function send(verdict) {
1411
1682
  var json = JSON.stringify(buildBlob(verdict), null, 2)
1412
- // DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A page opened
1413
- // over http was served by the engine and can hand the pass straight back; a
1414
- // `file://` page has no server to talk to and never will.
1683
+ // DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A published
1684
+ // page has a store and no server; a page opened over http was served by the
1685
+ // engine and can hand the pass straight back; a `file://` page has no server
1686
+ // to talk to and never will.
1415
1687
  //
1416
1688
  // NOT "try the POST and fall back on failure": a failed POST and an absent
1417
1689
  // one look identical to the reader, so the fallback would hide a server
1418
- // that is refusing passes behind a message about copying.
1690
+ // that is refusing passes behind a message about copying. The published page
1691
+ // is exactly that trap — its POST would go to claude.ai and fail — which is
1692
+ // why it is answered first, by what the page IS rather than by what failed.
1693
+ if (isArtifact()) {
1694
+ sendToStore(json, verdict)
1695
+ return
1696
+ }
1419
1697
  if (location.protocol !== 'file:') {
1420
- post(json)
1698
+ post(json, verdict)
1421
1699
  return
1422
1700
  }
1423
1701
  // `file://` is not a secure context everywhere, and the clipboard API is
@@ -1449,12 +1727,20 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
1449
1727
  // read its own version says nothing rather than claiming "unknown" — an
1450
1728
  // absence here is about the lookup, not about the page.
1451
1729
  if (data.engine) {
1452
- document.getElementById('drawn-by').textContent = 'rendered by skitterspec ' + data.engine
1730
+ // `0.0.0` is what the unpublished source package carries, so a page drawn
1731
+ // from a working copy would otherwise claim a version that has never been
1732
+ // released. This line exists to answer "which engine drew this" — and a
1733
+ // string that LOOKS like a version and is not one answers it wrongly, which
1734
+ // is worse than not answering. The field itself is untouched: the serve
1735
+ // daemon compares it to spot a stale renderer, and it must stay a version.
1736
+ document.getElementById('drawn-by').textContent =
1737
+ 'rendered by skitterspec ' + (data.engine === '0.0.0' ? '(unreleased build)' : data.engine)
1453
1738
  }
1454
1739
 
1455
1740
  restore()
1456
1741
  drawContext()
1457
1742
  drawLog()
1743
+ drawDecided()
1458
1744
  wireChecks()
1459
1745
  state.forEach(function (st) {
1460
1746
  var btn = st.details && st.details.querySelector('.accept')
@@ -29,11 +29,17 @@ rather than invoking it.
29
29
 
30
30
  `/spec-reviewed` is a **skill** and **user-only**, and there the marking is not
31
31
  convenience — it is the enforcement of `/spec-diff` step 0's rule that a waiting
32
- review pass is never claimed unasked. A pass can be POSTed by anything that
33
- reaches the page; what it cannot reach is the conversation. Because the model
34
- cannot invoke this skill, a pass is only ever picked up because a person typed
35
- the command, and typing it **is** the human signal. Prose alone did not hold
36
- that line once already.
32
+ review pass is never claimed unasked. Because the model cannot invoke this
33
+ skill, a pass named this way is only ever picked up because a person typed the
34
+ command, and typing it **is** the human signal. Prose alone did not hold that
35
+ line once already.
36
+
37
+ It is **no longer the only way in**, and the difference is worth stating
38
+ precisely. A phase that ends now *waits* on its page, and a pass arriving inside
39
+ that wait is claimed by the engine (`--claim-since`) without anyone typing
40
+ anything — scoped to the window, refusing when two arrive. `/spec-reviewed` is
41
+ what answers everything outside it: a pass sent when nobody was waiting, two
42
+ passes to choose between, and every harness with no file-watch to wait with.
37
43
 
38
44
  `/spec-to-main`, `/spec-status` and `/spec-sync` stay **skills** — each carries
39
45
  real judgment (green tests before a land; an MCP fetch and a team-key check; ten
@@ -174,13 +180,24 @@ paste, exactly as before. The clipboard path is **not legacy**: it is the whole
174
180
  story for a local reader.
175
181
 
176
182
  The code is **not a secret** — it is printed on the page, and it cannot be a
177
- gate against Claude either, because the store is a file Claude can read. What
178
- actually holds is narrower and stronger:
179
- **a device that reaches your page cannot reach your conversation.**
180
- So a pass it queues sits in the holding area
181
- forever, and the rule that keeps it there is that
182
- **Claude never claims a pass it was not asked to** stated in `/spec-diff` step
183
- 0, because nothing enforces it.
183
+ gate against Claude either, because the store is a file Claude can read.
184
+
185
+ It was once true that a device reaching your page
186
+ **could not reach your conversation**, and that fact was the whole guard: a pass sat in the holding
187
+ area until you typed `/spec-reviewed`. It is no longer true, deliberately —
188
+ a phase that ends **waits** for its verdict, so the button you press on the page
189
+ is what carries the work on. What replaced the guard is two mechanisms and one
190
+ rule. The **serve token** — 48 bits of randomness in the URL path, minted per
191
+ server — decides who can POST at all. The **wait window** decides which pass may
192
+ be claimed without you naming it: the engine's `--claim-since` takes the one
193
+ pass that arrived *while this session was waiting*, acts on nothing when none
194
+ did, and refuses to choose when two did. And outside that window the rule stands
195
+ unchanged and absolute: **Claude never claims a pass it was not asked to**
196
+ (`/spec-diff` step 0, because nothing enforces it).
197
+
198
+ What you give up is real and worth naming: the page can now act. What you get is
199
+ the loop closing without anyone remembering a command — which is the failure the
200
+ old design traded it for.
184
201
 
185
202
  What the code *does* is let you **tell two passes apart** — it is an address,
186
203
  never a password. One waiting pass is claimed and acted on the moment you type
@@ -218,6 +235,41 @@ never ticked block nothing — an unticked file is something you said nothing
218
235
  about, and requiring every one of them would be the tally this design exists to
219
236
  avoid.
220
237
 
238
+ **The gate — a phase that ended owes an answer.** That is the second refusal,
239
+ and it is a different kind: not a count, and not about the marks at all.
240
+ `/spec-next` **arms** it when a phase ends and renders its page, and exactly two
241
+ things clear it — a **committing verdict**, or
242
+ `skitterspec spec-env review skip "<reason>"`. Until one of them happens,
243
+ `/spec-next` refuses to build the next phase and (where the hook is installed)
244
+ `git commit` refuses in that worktree. `skitterspec spec-env review gate
245
+ [--check] [--json]` is what both ask.
246
+
247
+ **The hook is the half that cannot be talked past.** `skitterspec init` installs
248
+ `.claude/hooks/review-gate.js` and registers it in the project's committed
249
+ `.claude/settings.json`, so a `git commit` is refused by the harness rather than
250
+ by prose — which is what covers a bare `git commit`, a chained command, and
251
+ skittership's own `/commit` without skitterspec ever editing it. It decides
252
+ nothing itself: it hands the command line to
253
+ `spec-env review gate --check --for-command` and turns one exit status into an
254
+ answer. It refuses only where the commit is running
255
+ **inside that spec's own worktree** — a commit on the base branch, or in
256
+ another spec's tree, is not this obligation's business — and it fails **open** on everything else: no engine, an
257
+ unreadable payload, a crash, a timeout, a repo with no isolation.
258
+
259
+ Three things keep it a push rather than a wall. It is armed
260
+ **only by a phase ending**, so reading your own half-finished work mid-phase
261
+ owes nothing.
262
+ It has an **exit that is always one command**, and one of them is
263
+ *"I am moving on"* — with a reason, because `none: additive, nothing to revert` is a decision
264
+ a reviewer can argue with while silence is an oversight. And it accuses
265
+ **only on a positive signal**: an unreadable sidecar, a project that set
266
+ `review.required: false`, a spec the engine could not resolve — every
267
+ cannot-tell exits 0 and says nothing (`.claude/rules/negative-checks.md`).
268
+
269
+ It is on by default wherever isolation is configured. That is the point: the
270
+ push toward reading the diff is the normal path, and stepping off it is the
271
+ thing you have to do deliberately.
272
+
221
273
  **The verdict names the action**, and that is why it is `commit` rather than
222
274
  `approve`: a review is the guard in front of an action, and an approval that
223
275
  only recorded itself was the one control on the page that did not describe what