@skitterbyte/skitterspec 22.2.0 → 22.3.1

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.
@@ -188,6 +188,7 @@ through to a documented conservative default; see the field notes below.
188
188
  // deliberate: the file it names then belongs to some other spec, and the safe
189
189
  // failure is a refusal you clear with /commit, not a stranger's file swept
190
190
  // into your commit. Default: none — a spec owns only its own folder.
191
+
191
192
  "spec": {
192
193
  "companionPaths": []
193
194
  },
@@ -222,6 +222,22 @@ input:focus-visible,
222
222
  }
223
223
  .tree-file.is-noise .nm { color: var(--muted); }
224
224
 
225
+ /* THE FILE YOU ARE READING — the card crossing the top of the viewport.
226
+ An INSET SHADOW rather than a border or a left padding: either of those
227
+ would change the row's size, so the highlight moving down the list would
228
+ shunt every name sideways as it went. A shadow paints inside the box the
229
+ row already occupies.
230
+
231
+ A DIFFERENT CHANNEL FROM HOVER, deliberately. Hover owns the `--panel-2`
232
+ background, and the current row is very often NOT the row under your
233
+ finger — you scroll with one and point with the other. Sharing the
234
+ treatment would make the two indistinguishable at exactly the moment they
235
+ disagree, so this is a bar and a colour, and the background is left alone.
236
+ Leaving it alone also keeps `.ct` masking the names scrolling under it,
237
+ which is a background the row inherits. */
238
+ .tree-file.is-current { box-shadow: inset 3px 0 0 var(--accent); }
239
+ .tree-file.is-current .nm { color: var(--accent); font-weight: 600; }
240
+
225
241
  .file {
226
242
  border: 1px solid var(--line);
227
243
  border-radius: 10px;
@@ -417,6 +433,26 @@ tr.note-row td {
417
433
  border-top: 1px solid var(--note-line);
418
434
  border-bottom: 1px solid var(--note-line);
419
435
  }
436
+ /* A NOTE IS PROSE, AND IT WRAPS TO THE PANE — never to the file.
437
+ The cell it sits in is a column of `table.diff`, so its width is the longest
438
+ line of source in this file (`td.src` is white-space:pre): 4,900px for a
439
+ wrapped markdown spec is ordinary, and a `width:100%` note inside that is four
440
+ screens wide. It scrolled sideways instead of wrapping, which is what a reader
441
+ sees as a box that keeps growing.
442
+ Sticky for the same reason `td.gutter` is: the row can be reached while the
443
+ pane is scrolled right, and a note you cannot see while typing it is no
444
+ better. `--pane-w`/`--gutter-w` are measured per code box by `sizeNotes` —
445
+ measured rather than summed from `--ln-w`, because the gutter is ONE sticky
446
+ cell precisely so nobody has to do that arithmetic.
447
+ `max-width` caps it at the cell, so the note can never be the thing that
448
+ widens the table. */
449
+ .note-body {
450
+ position: sticky;
451
+ left: var(--gutter-w, 0px);
452
+ width: var(--pane-w, 100%);
453
+ max-width: 100%;
454
+ box-sizing: border-box;
455
+ }
420
456
  .note-meta { font-size: .72rem; color: var(--muted); display: block; margin-bottom: .15rem; }
421
457
  .note-text { overflow-wrap: anywhere; }
422
458
  .note-row.is-resolved .note-text { text-decoration: line-through; opacity: .75; }
@@ -517,6 +553,19 @@ button:disabled { color: var(--muted); background: var(--panel-2); border-style:
517
553
  button:disabled:hover { color: var(--muted); border-color: var(--line); }
518
554
  .verdict-log { font-size: .8rem; color: var(--muted); margin: .45rem 0 0; }
519
555
  .verdict-log[hidden] { display: none; }
556
+ /* Not muted, unlike the log beneath it: this one is the reason a press did
557
+ nothing, and a grey line under a bar that appeared to ignore you reads as a
558
+ broken page. */
559
+ .verdict-warn {
560
+ font-size: .85rem;
561
+ color: var(--flag-fg);
562
+ background: var(--flag-bg);
563
+ border-left: 3px solid var(--flag-fg);
564
+ border-radius: 0 6px 6px 0;
565
+ padding: .45rem .6rem;
566
+ margin: .5rem 0 0;
567
+ }
568
+ .verdict-warn[hidden] { display: none; }
520
569
  /* Which engine drew this page. A render is always current and a stale RENDERER
521
570
  is invisible without it — the counts move, the date moves, the diff is right. */
522
571
  .drawn-by { font-size: .75rem; color: var(--muted); margin: .9rem 0 0; }
@@ -570,6 +619,37 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
570
619
  border it read as part of it rather than as the way back to the diff. */
571
620
  .decided-toggle { font-size: .78rem; margin-top: .8rem; }
572
621
 
622
+ /* STILL ASKING. The window between the pass landing on the server and the
623
+ engine saying whether a session took it — up to seven seconds, and until now
624
+ the page filled it with the pessimistic answer and retracted it afterwards.
625
+ A spinner is the honest shape for a question with no answer yet. */
626
+ .pickup-wait {
627
+ display: flex;
628
+ align-items: center;
629
+ gap: .5rem;
630
+ margin: 0 0 .8rem;
631
+ color: var(--muted);
632
+ font-size: .88rem;
633
+ }
634
+ .pickup-wait[hidden] { display: none; }
635
+ .pickup-spin {
636
+ width: .85rem;
637
+ height: .85rem;
638
+ flex: none;
639
+ border: 2px solid var(--line);
640
+ /* One side in the accent is what makes a ring read as turning. */
641
+ border-top-color: var(--accent);
642
+ border-radius: 50%;
643
+ animation: pickup-spin .8s linear infinite;
644
+ }
645
+ @keyframes pickup-spin { to { transform: rotate(360deg); } }
646
+ /* A STATIC RING, not a hidden one. Someone who asked for less motion still
647
+ needs to see that something is in progress, and the sentence beside it is
648
+ doing most of that work anyway. */
649
+ @media (prefers-reduced-motion: reduce) {
650
+ .pickup-spin { animation: none; border-top-color: var(--accent); }
651
+ }
652
+
573
653
  /* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
574
654
  to be read rather than data to be scanned, so it gets a measure, air, and a
575
655
  quieter voice than the diff below it. It was once full-bleed 15px text at
@@ -794,6 +874,14 @@ a.ticket:hover { border-color: var(--accent); }
794
874
  <section class="decided" id="decided" hidden>
795
875
  <p class="decided-what" id="decided-what"></p>
796
876
  <p class="decided-note" id="decided-note"></p>
877
+ <!-- SHOWN ONLY WHILE THE PAGE IS ACTUALLY ASKING. A tab reopened
878
+ tomorrow over a stored `null` is reading history, not watching a
879
+ live poll, so it gets the command instead — a spinner that can never
880
+ resolve is worse than no spinner. -->
881
+ <p class="pickup-wait" id="pickup-wait" hidden>
882
+ <span class="pickup-spin" aria-hidden="true"></span>
883
+ <span id="pickup-wait-text">Sent — waiting for Claude to pick it up…</span>
884
+ </p>
797
885
  <!-- THE ONE THING LEFT TO DO, and it lives HERE rather than under the
798
886
  verdict bar because that is where it was being missed: a grey line
799
887
  below a bar that had just closed, in the type size of a footnote,
@@ -863,6 +951,12 @@ a.ticket:hover { border-color: var(--accent); }
863
951
  <button id="verdict-discuss" type="button">… Discuss first</button>
864
952
  <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
865
953
  </div>
954
+ <!-- UNSENT TEXT, named at the moment it would have been destroyed. It sits
955
+ directly under the bar rather than beside the editor, because the
956
+ press that raises it happens here and the editor may be a screen
957
+ away. `assertive`: the reader is mid-decision and a polite region
958
+ would be read out after they have pressed again. -->
959
+ <p class="verdict-warn" id="verdict-warn" hidden aria-live="assertive"></p>
866
960
  <!-- WHERE THE BUTTONS CANNOT DELIVER, the commands stand in their place.
867
961
  A `file://` page has no server to POST to and no store to write to,
868
962
  so a verdict button there only ever built a blob for the reader to
@@ -1145,6 +1239,56 @@ a.ticket:hover { border-color: var(--accent); }
1145
1239
  table.appendChild(body)
1146
1240
  box.appendChild(table)
1147
1241
  host.appendChild(box)
1242
+ sizeNotes(box)
1243
+ watchPane(host)
1244
+ }
1245
+
1246
+ /**
1247
+ * Publish this code box's visible width, for the note bodies inside it.
1248
+ *
1249
+ * A note has to wrap at the width of what the reader can SEE, and the cell it
1250
+ * lives in is a diff-table column sized by the longest line of source in the
1251
+ * file. So the pane's own width is measured here and handed to the CSS.
1252
+ *
1253
+ * MEASURED, NEVER SUMMED. The gutter's width is `--ln-w` plus the +/- column
1254
+ * plus padding plus a border, in whatever font the reader has — and `td.gutter`
1255
+ * is one sticky cell rather than three precisely so that nobody has to get
1256
+ * that arithmetic right. Asking the element is the same decision one layer up.
1257
+ *
1258
+ * A BOX MEASURED AT NOTHING PUBLISHES NOTHING. A file whose <details> has
1259
+ * never been opened reports 0 for everything, and 0 is not a narrow pane —
1260
+ * it is a box that has not been laid out. Writing it would give every note in
1261
+ * that file a zero-width editor on the one render that matters, so the
1262
+ * cannot-tell case leaves whatever was there alone and the CSS falls back to
1263
+ * its own defaults.
1264
+ */
1265
+ function sizeNotes(box) {
1266
+ if (!box || !box.style) return
1267
+ var g = box.querySelector('td.gutter')
1268
+ var gw = (g && g.offsetWidth) || 0
1269
+ var pane = box.clientWidth || 0
1270
+ if (!pane || !gw) return
1271
+ box.style.setProperty('--gutter-w', gw + 'px')
1272
+ box.style.setProperty('--pane-w', Math.max(0, pane - gw) + 'px')
1273
+ }
1274
+
1275
+ // Re-measure when the layout moves under it — a rotated phone, a window
1276
+ // dragged narrower, the file tree folding away. The HOST is watched rather
1277
+ // than the box: a box is rebuilt on every band expansion, and an observer
1278
+ // holding the old ones would keep a whole file's worth of detached rows alive
1279
+ // for the life of the page. Observing the same host twice replaces the
1280
+ // observation, so re-renders cost nothing.
1281
+ //
1282
+ // A browser without ResizeObserver keeps the width it opened at, which is
1283
+ // wrong only for someone who then resizes. Nothing accuses, nothing breaks.
1284
+ var paneWatch = typeof ResizeObserver === 'function'
1285
+ ? new ResizeObserver(function (entries) {
1286
+ entries.forEach(function (e) { sizeNotes(e.target.querySelector('.code')) })
1287
+ })
1288
+ : null
1289
+
1290
+ function watchPane(host) {
1291
+ if (paneWatch && host) paneWatch.observe(host)
1148
1292
  }
1149
1293
 
1150
1294
  // Every note about one line: what the engine stored, plus anything written on
@@ -1161,13 +1305,14 @@ a.ticket:hover { border-color: var(--accent); }
1161
1305
  var tr = el('tr', 'note-row' + (c.resolved ? ' is-resolved' : ''))
1162
1306
  tr.appendChild(el('td', 'gutter', '💬'))
1163
1307
  var td = el('td', 'src')
1308
+ var body = el('div', 'note-body')
1164
1309
  var meta = c.line == null ? 'on this file' : 'line ' + c.line
1165
1310
  if (c.check) meta += ' · answering the review'
1166
1311
  if (!c.raisedAt) meta += ' · not sent yet'
1167
- td.appendChild(el('span', 'note-meta', meta))
1168
- td.appendChild(el('span', 'note-text', c.note))
1312
+ body.appendChild(el('span', 'note-meta', meta))
1313
+ body.appendChild(el('span', 'note-text', c.note))
1169
1314
  if (c.resolved) {
1170
- td.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
1315
+ body.appendChild(el('span', 'note-resolution', '↳ ' + (c.resolved.note || 'resolved')))
1171
1316
  }
1172
1317
  if (!c.raisedAt) {
1173
1318
  var actions = el('div', 'note-actions')
@@ -1179,8 +1324,9 @@ a.ticket:hover { border-color: var(--accent); }
1179
1324
  refresh()
1180
1325
  })
1181
1326
  actions.appendChild(del)
1182
- td.appendChild(actions)
1327
+ body.appendChild(actions)
1183
1328
  }
1329
+ td.appendChild(body)
1184
1330
  tr.appendChild(td)
1185
1331
  return tr
1186
1332
  }
@@ -1190,8 +1336,9 @@ a.ticket:hover { border-color: var(--accent); }
1190
1336
  var tr = el('tr', 'note-row')
1191
1337
  tr.appendChild(el('td', 'gutter', '💬'))
1192
1338
  var td = el('td', 'src')
1193
- td.appendChild(el('span', 'note-meta', row ? 'line ' + (row.n == null ? '—' : row.n) : 'on this file'))
1194
- td.appendChild(noteEditor(function (text) {
1339
+ var body = el('div', 'note-body')
1340
+ body.appendChild(el('span', 'note-meta', row ? 'line ' + (row.n == null ? '—' : row.n) : 'on this file'))
1341
+ body.appendChild(noteEditor(function (text) {
1195
1342
  addDraft(st, text, row ? row.n : null, row ? row.text : null, null)
1196
1343
  st.draftAt = null
1197
1344
  renderFile(st)
@@ -1199,6 +1346,7 @@ a.ticket:hover { border-color: var(--accent); }
1199
1346
  st.draftAt = null
1200
1347
  renderFile(st)
1201
1348
  }))
1349
+ td.appendChild(body)
1202
1350
  tr.appendChild(td)
1203
1351
  return tr
1204
1352
  }
@@ -1287,6 +1435,11 @@ a.ticket:hover { border-color: var(--accent); }
1287
1435
  var f = st.file
1288
1436
  var d = el('details', 'file')
1289
1437
  d.id = 'f-' + encodeURIComponent(f.path)
1438
+ // Read back by the current-file observer, which is handed elements rather
1439
+ // than files. The id would do — it is the same path, encoded — but decoding
1440
+ // an id to recover data the element could simply carry is a round trip
1441
+ // through a format that exists for a different reason.
1442
+ d.setAttribute('data-path', f.path)
1290
1443
  var s = el('summary')
1291
1444
  s.appendChild(el('span', 'path', f.path))
1292
1445
  s.appendChild(el('span', 'chip', f.status))
@@ -1370,12 +1523,126 @@ a.ticket:hover { border-color: var(--accent); }
1370
1523
  return root
1371
1524
  }
1372
1525
 
1526
+ // --- which file am I reading? --------------------------------------------
1527
+ // The tree row for each path, captured as the tree is built. A lookup rather
1528
+ // than a query, because this is answered on every scroll: a `querySelector`
1529
+ // per event would walk the whole sidebar to find something the build already
1530
+ // had in its hand.
1531
+ var rowByPath = {}
1532
+ var currentPath = null
1533
+
1534
+ /**
1535
+ * Mark one file as the one being read.
1536
+ *
1537
+ * ONE ROW AT A TIME, and the previous one is cleared through the same map
1538
+ * rather than by querying for `.is-current`. A map cannot report a row the
1539
+ * tree no longer has, and it cannot miss one a selector spelled slightly
1540
+ * differently.
1541
+ */
1542
+ function setCurrent(path) {
1543
+ if (path === currentPath) return
1544
+ var prev = rowByPath[currentPath]
1545
+ if (prev) prev.className = prev.className.replace(/ ?is-current/, '')
1546
+ currentPath = path
1547
+ var row = rowByPath[path]
1548
+ if (!row) return
1549
+ row.className += ' is-current'
1550
+ followCurrent(row)
1551
+ }
1552
+
1553
+ /**
1554
+ * Drop the highlight entirely.
1555
+ *
1556
+ * For the decided page, where the diff is hidden and no card can qualify —
1557
+ * leaving a row lit there would point at something the reader can no longer
1558
+ * see.
1559
+ */
1560
+ function clearCurrent() {
1561
+ var prev = rowByPath[currentPath]
1562
+ if (prev) prev.className = prev.className.replace(/ ?is-current/, '')
1563
+ currentPath = null
1564
+ }
1565
+
1566
+ /**
1567
+ * Scroll the tree ONLY when the marked row has left its own scrollport.
1568
+ *
1569
+ * The sidebar is a bounded box (`max-height: calc(100vh - 2rem)`), so on a
1570
+ * long diff the highlight is invisible unless the row can be brought in —
1571
+ * and on a short one nothing should ever move. `block: 'nearest'` keeps the
1572
+ * nudge a nudge: re-centring would put the sidebar in constant motion beside
1573
+ * a reader who is already moving, which is the thing a fixed map is for.
1574
+ *
1575
+ * A browser that withholds `getBoundingClientRect` gets the highlight and no
1576
+ * follow. Guessing a position from nothing is how a sidebar ends up scrolled
1577
+ * somewhere arbitrary.
1578
+ */
1579
+ function followCurrent(row) {
1580
+ var box = document.getElementById('tree-wrap')
1581
+ if (!box || !row.getBoundingClientRect || !box.getBoundingClientRect) return
1582
+ var r = row.getBoundingClientRect()
1583
+ var b = box.getBoundingClientRect()
1584
+ if (r.top >= b.top && r.bottom <= b.bottom) return
1585
+ if (row.scrollIntoView) row.scrollIntoView({ block: 'nearest' })
1586
+ }
1587
+
1588
+ // What is currently crossing the band, keyed by path. A SET rather than a
1589
+ // running answer: entries arrive for what CHANGED, so the observer never
1590
+ // hands over the whole picture and the page has to keep it.
1591
+ var onScreen = {}
1592
+
1593
+ /**
1594
+ * The topmost file on screen, in document order.
1595
+ *
1596
+ * NOTHING ON SCREEN IS NOT AN ANSWER, and this returning without calling
1597
+ * `setCurrent` is the whole of that. Scrolled above the first card, past the
1598
+ * last, or with the current file hidden by the noise filter, the previous
1599
+ * row stays lit — clearing it would flicker the highlight off every time a
1600
+ * card boundary crossed the band, and a stale "you were here" is worth more
1601
+ * than nothing at all.
1602
+ */
1603
+ function pickCurrent() {
1604
+ for (var i = 0; i < state.length; i++) {
1605
+ if (onScreen[state[i].file.path]) return setCurrent(state[i].file.path)
1606
+ }
1607
+ }
1608
+
1609
+ /**
1610
+ * Watch the cards against a band at the top of the viewport.
1611
+ *
1612
+ * THE BOTTOM MARGIN IS THE WHOLE TRICK. Pulled up by three quarters of the
1613
+ * viewport, only the top slice counts as "on screen" — so the answer changes
1614
+ * as you scroll. Without it every card on a tall screen intersects at once
1615
+ * and the topmost is whatever is furthest up the document, which is the same
1616
+ * file from the first scroll to the last.
1617
+ *
1618
+ * A browser without IntersectionObserver keeps a page with no highlight and
1619
+ * no error — the shape `paneWatch` takes for ResizeObserver above. Nothing
1620
+ * accuses and nothing breaks; the tree is still a tree.
1621
+ */
1622
+ var currentWatch = typeof IntersectionObserver === 'function'
1623
+ ? new IntersectionObserver(function (entries) {
1624
+ entries.forEach(function (e) {
1625
+ var p = e.target.getAttribute('data-path')
1626
+ if (!p) return
1627
+ if (e.isIntersecting) onScreen[p] = true
1628
+ else delete onScreen[p]
1629
+ })
1630
+ pickCurrent()
1631
+ }, { rootMargin: '0px 0px -75% 0px' })
1632
+ : null
1633
+
1373
1634
  function reveal(f) {
1374
1635
  if (f.noise && !noiseBox.checked) { noiseBox.checked = true; applyNoise() }
1375
1636
  var d = document.getElementById('f-' + encodeURIComponent(f.path))
1376
1637
  if (!d) return
1377
1638
  d.open = true
1378
1639
  d.scrollIntoView({ block: 'start' })
1640
+ // Mark it NOW rather than waiting for the scroll to settle. A click on the
1641
+ // tree is a reader saying which file they mean, and the observer would
1642
+ // agree a moment later — but the moment is visible, and a row that lights
1643
+ // up after you have already looked away reads as a lag rather than an
1644
+ // answer.
1645
+ setCurrent(f.path)
1379
1646
  }
1380
1647
 
1381
1648
  /**
@@ -1472,6 +1739,9 @@ a.ticket:hover { border-color: var(--accent); }
1472
1739
  ct.appendChild(el('span', 'd', '-' + entry.file.deletions))
1473
1740
  b.appendChild(ct)
1474
1741
  b.addEventListener('click', function () { reveal(entry.file) })
1742
+ // The path, not the name: two directories can hold a `README.md`, and
1743
+ // the tree deliberately shows the leaf only.
1744
+ rowByPath[entry.file.path] = b
1475
1745
  li.appendChild(b)
1476
1746
  ul.appendChild(li)
1477
1747
  })
@@ -1487,6 +1757,17 @@ a.ticket:hover { border-color: var(--accent); }
1487
1757
  document.getElementById('tree-wrap').open = false
1488
1758
  }
1489
1759
 
1760
+ // AFTER the tree, because a mark with no row to put it on is nothing at all.
1761
+ //
1762
+ // It runs on a narrow screen too, where the tree is a collapsed <details> and
1763
+ // nothing is visible: the class is still correct, so a reader who opens the
1764
+ // sidebar half-way down a diff finds it already pointing at where they are,
1765
+ // and `scrollIntoView` inside a closed <details> is a no-op rather than a
1766
+ // special case to write.
1767
+ if (currentWatch) {
1768
+ state.forEach(function (st) { if (st.details) currentWatch.observe(st.details) })
1769
+ }
1770
+
1490
1771
  // --- page-level controls -------------------------------------------------
1491
1772
  document.getElementById('expand-all').addEventListener('click', function () {
1492
1773
  state.forEach(function (st) {
@@ -1625,6 +1906,11 @@ a.ticket:hover { border-color: var(--accent); }
1625
1906
  refresh: ['commit', 'changes', 'discuss'],
1626
1907
  nospec: ['commit-land', 'commit', 'changes', 'discuss'],
1627
1908
  committing: ['commit', 'commit-continue', 'changes', 'discuss'],
1909
+ // A SINGLE-PASS FIX: the committing set minus the verb it cannot honour.
1910
+ // `/spec-bug` and `/spec-hotfix` render this when the repair took one pass,
1911
+ // because `Commit & Continue` names a next phase a spec with no phase files
1912
+ // does not have. Same three buttons as `refresh`, a different subject.
1913
+ fix: ['commit', 'changes', 'discuss'],
1628
1914
  }
1629
1915
  var OFFERED = OFFERS[data.buttons] || OFFERS.committing
1630
1916
  Object.keys(verdictBtns).forEach(function (key) {
@@ -1665,6 +1951,7 @@ a.ticket:hover { border-color: var(--accent); }
1665
1951
  var verdictEl = document.getElementById('verdict')
1666
1952
  var verdictCount = document.getElementById('verdict-count')
1667
1953
  var verdictLog = document.getElementById('verdict-log')
1954
+ var verdictWarn = document.getElementById('verdict-warn')
1668
1955
  var copyOut = document.getElementById('copy-out')
1669
1956
  var wrapEl = document.getElementById('wrap')
1670
1957
  var decidedBox = document.getElementById('decided')
@@ -1683,6 +1970,52 @@ a.ticket:hover { border-color: var(--accent); }
1683
1970
  var failLead = document.getElementById('send-failed-lead')
1684
1971
  var failText = document.getElementById('send-failed-text')
1685
1972
  var failCopy = document.getElementById('send-failed-copy')
1973
+ var pickupWait = document.getElementById('pickup-wait')
1974
+
1975
+ // IS A POLL RUNNING RIGHT NOW? Deliberately not persisted, and that is the
1976
+ // whole of what it buys: a stored decision reloaded tomorrow carries the same
1977
+ // `picked: null` — nothing was ever established — but nothing is asking any
1978
+ // more, so it must not spin. Session state answers a question about the
1979
+ // session.
1980
+ var asking = false
1981
+
1982
+ // Has the close already been scheduled? `drawDecided` runs on every redraw
1983
+ // once a page is decided, so without this a redraw would queue another timer.
1984
+ var closing = false
1985
+
1986
+ // Long enough for the confirmation to register, short enough not to read as
1987
+ // a hang. It only matters where the close is actually permitted.
1988
+ var CLOSE_AFTER_MS = 900
1989
+
1990
+ /**
1991
+ * This tab's job is over — best-effort, and only from the one state that
1992
+ * earns it.
1993
+ *
1994
+ * ONLY ON CONFIRMED PICKUP. Reached from the branch where the engine has
1995
+ * SAID a session claimed the pass, which is the only ending with provably
1996
+ * nothing left on the page to read: no code to quote, no command to run, no
1997
+ * blob to paste. Every other one — still asking, established-nobody-did, a
1998
+ * poll that failed, a `file://` page whose pass is on the clipboard, a
1999
+ * published page whose verdict needs `/spec-reviewed` — keeps the tab,
2000
+ * because each leaves something the reader still needs.
2001
+ *
2002
+ * BEST-EFFORT BY CONSTRUCTION, not by caution. Browsers refuse
2003
+ * `window.close()` on a tab the script did not open, and that is every tab
2004
+ * here: somebody typed the URL or tapped a link. So the confirmation is
2005
+ * drawn FIRST and the close attempted after it — a refused close leaves
2006
+ * exactly the right ending standing, and a permitted one still reads as
2007
+ * acknowledgement rather than a tab vanishing under the press.
2008
+ */
2009
+ function closeWhenDone() {
2010
+ if (closing) return
2011
+ closing = true
2012
+ if (typeof window.close !== 'function' || !window.setTimeout) return
2013
+ window.setTimeout(function () {
2014
+ // A refusal can throw as well as do nothing, depending on the browser.
2015
+ // Neither is a failure of the review, so neither reaches the reader.
2016
+ try { window.close() } catch (e) {}
2017
+ }, CLOSE_AFTER_MS)
2018
+ }
1686
2019
 
1687
2020
  function pending() {
1688
2021
  var b = buildBlob()
@@ -1764,6 +2097,10 @@ a.ticket:hover { border-color: var(--accent); }
1764
2097
  verdictBtns.continue.disabled = false
1765
2098
  verdictBtns.changes.disabled = false
1766
2099
  verdictBtns.discuss.disabled = false
2100
+ // Adding the note is the ordinary way out of a warning, and every mark comes
2101
+ // through here — so the line goes when the situation it described does,
2102
+ // rather than standing over a page where nothing is unsent any more.
2103
+ clearWarn()
1767
2104
  save()
1768
2105
  }
1769
2106
 
@@ -1789,6 +2126,10 @@ a.ticket:hover { border-color: var(--accent); }
1789
2126
  'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
1790
2127
  'Nothing here reaches Claude any more, so the verdict buttons are closed.'
1791
2128
  wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
2129
+ // The diff is hidden from here on, so there is no card to be reading and
2130
+ // nothing for the tree to point at. `drawDecided` runs on every redraw once
2131
+ // a page is decided, and clearing something already cleared is free.
2132
+ clearCurrent()
1792
2133
  // The commands go with the bar. A decided page offers no way to decide
1793
2134
  // again, whichever shape it was offering.
1794
2135
  if (cmdList) cmdList.hidden = true
@@ -1811,12 +2152,34 @@ a.ticket:hover { border-color: var(--accent); }
1811
2152
  // quietly changing its mind a day later.
1812
2153
  copyHint.hidden = true
1813
2154
  copyOut.hidden = true
2155
+ pickupWait.hidden = true
2156
+ // STILL ASKING — and saying so, which is the state this panel used not to
2157
+ // have. `watchPass` takes up to seven seconds to establish whether a
2158
+ // session claimed the pass, and the page used to spend that window
2159
+ // asserting the pessimistic answer: "still waiting to be picked up", with
2160
+ // a command under it, retracted a moment later on the usual outcome. The
2161
+ // reader was being told to run something they almost never needed, in the
2162
+ // same words used when running it is the only thing that saves their
2163
+ // review.
2164
+ //
2165
+ // `asking` and not merely `picked === null`. A tab reopened over a stored
2166
+ // decision has the same `null` — nothing was ever established — but no
2167
+ // poll is running to resolve it, so it falls through to the command below.
2168
+ // A spinner that can never stop is worse than no spinner.
2169
+ // (`.claude/rules/negative-checks.md` rule 4: the cannot-tell case takes
2170
+ // the harmless branch, which here is claiming neither answer.)
2171
+ if (decided.picked === null && asking) {
2172
+ sentCmd.hidden = true
2173
+ pickupWait.hidden = false
2174
+ return
2175
+ }
1814
2176
  if (decided.picked === true) {
1815
2177
  // NOTHING TO RUN, and so nothing offered. This is the whole point of
1816
2178
  // asking: the command used to appear here on every single ending, which
1817
2179
  // taught the reader it was decoration.
1818
2180
  sentCmd.hidden = true
1819
2181
  decidedNote.textContent += ' Claude picked this up — there is nothing to run.'
2182
+ closeWhenDone()
1820
2183
  return
1821
2184
  }
1822
2185
  // THE HAND-OFF SURVIVES THE ENDING — but only where there is something
@@ -2318,6 +2681,9 @@ a.ticket:hover { border-color: var(--accent); }
2318
2681
  // command here unconditionally — the same sentence whether a session
2319
2682
  // was waiting to claim the pass or nothing was — because it had no
2320
2683
  // way to find out. It has one now.
2684
+ // Set BEFORE the draw, so the first render of the decided panel is
2685
+ // already the waiting shape rather than the pessimistic one.
2686
+ asking = true
2321
2687
  markDecided(verdict, '/spec-reviewed ' + code, null)
2322
2688
  watchPass(code, verdict)
2323
2689
  } else {
@@ -2475,6 +2841,10 @@ a.ticket:hover { border-color: var(--accent); }
2475
2841
  var url = location.pathname + '?pass=' + encodeURIComponent(code)
2476
2842
 
2477
2843
  function stop(picked) {
2844
+ // Every exit from the poll comes through here — claimed, out of rounds,
2845
+ // a server that stopped answering — so this is the one place the spinner
2846
+ // can be retired without listing the ways it might end.
2847
+ asking = false
2478
2848
  markDecided(verdict, '/spec-reviewed ' + code, picked)
2479
2849
  }
2480
2850
 
@@ -2781,6 +3151,67 @@ a.ticket:hover { border-color: var(--accent); }
2781
3151
  ask()
2782
3152
  }
2783
3153
 
3154
+ /**
3155
+ * TEXT STILL IN AN EDITOR — the one thing on this page a verdict destroys.
3156
+ *
3157
+ * Typing in the box is not a note; `Add note` is. So a reader who writes three
3158
+ * lines and then presses a verdict sends a pass that does not contain them,
3159
+ * `drawDecided` takes the diff away, and the text goes with it. Nothing said
3160
+ * so, and there is nothing to go back to.
3161
+ *
3162
+ * WARNED, NOT REFUSED, and the distinction is the whole design. Discarding it
3163
+ * is often exactly what the reader means — half a sentence, a note they
3164
+ * thought better of — and this page makes ONE refusal on purpose (an open
3165
+ * note blocks a committing verdict, mirroring `judgeVerdict`) and must not
3166
+ * grow a second one it invented. So the first press says what would be lost
3167
+ * and sends nothing; the same press again sends.
3168
+ *
3169
+ * THE CONFIRMATION IS OF WHAT THE READER WAS SHOWN, which is why the text
3170
+ * itself is remembered and not just the verdict. Otherwise a warning raised
3171
+ * over one note would silently carry away a second one written after it.
3172
+ */
3173
+ var warned = null
3174
+
3175
+ function unsentEditors() {
3176
+ var out = []
3177
+ Array.prototype.forEach.call(document.querySelectorAll('.note-input'), function (b) {
3178
+ if (String(b.value || '').trim()) out.push(b)
3179
+ })
3180
+ return out
3181
+ }
3182
+
3183
+ function clearWarn() {
3184
+ warned = null
3185
+ if (!verdictWarn) return
3186
+ verdictWarn.hidden = true
3187
+ verdictWarn.textContent = ''
3188
+ }
3189
+
3190
+ function mayLose(verdict) {
3191
+ var open = unsentEditors()
3192
+ // Whitespace is not unfinished work, and neither is an editor opened and
3193
+ // left empty. A page that stopped on those would teach the reader to press
3194
+ // everything twice, which is how a warning stops being read.
3195
+ if (!open.length) { clearWarn(); return true }
3196
+ var text = open.map(function (b) { return b.value }).join('\u0000')
3197
+ if (warned && warned.verdict === verdict && warned.text === text) { clearWarn(); return true }
3198
+ warned = { verdict: verdict, text: text }
3199
+ if (verdictWarn) {
3200
+ verdictWarn.hidden = false
3201
+ verdictWarn.textContent =
3202
+ open.length + (open.length === 1 ? ' note is' : ' notes are') +
3203
+ ' still in the box and not added. Press “Add note” to keep ' +
3204
+ (open.length === 1 ? 'it' : 'them') + ', or press “' + (LABELS[verdict] || verdict) +
3205
+ '” again to send without ' + (open.length === 1 ? 'it' : 'them') + '.'
3206
+ }
3207
+ // Take the reader to the first one. The bar is at the foot of the page and
3208
+ // the editor can be a screen away, so a line naming a note they cannot see
3209
+ // is only half an answer.
3210
+ open[0].scrollIntoView()
3211
+ open[0].focus()
3212
+ return false
3213
+ }
3214
+
2784
3215
  Object.keys(verdictBtns).forEach(function (verdict) {
2785
3216
  verdictBtns[verdict].addEventListener('click', function () {
2786
3217
  // A disabled or hidden button does not fire in a browser; the guard is
@@ -2788,6 +3219,7 @@ a.ticket:hover { border-color: var(--accent); }
2788
3219
  // facts rather than styles — a verdict this render never offered must not
2789
3220
  // be reachable by dispatching at it.
2790
3221
  if (verdictBtns[verdict].disabled || verdictBtns[verdict].hidden) return
3222
+ if (!mayLose(verdict)) return
2791
3223
  send(verdict)
2792
3224
  })
2793
3225
  })
@@ -218,12 +218,25 @@ The fix is green and nothing is committed yet. That is the moment the page is
218
218
  about, so render it now — **after** the tests pass and **before** the commit:
219
219
 
220
220
  ```
221
- skitterspec spec-env review <spec>
221
+ skitterspec spec-env review <spec> --buttons fix
222
222
  ```
223
223
 
224
224
  **This is free.** The engine reads git and splices the patches into a template;
225
225
  the diff never passes through you, so a 266KB patch costs nothing.
226
226
 
227
+ **`--buttons fix` drops `Commit & Continue`**, because a fix that took one pass
228
+ has no next phase and that verb names work which does not exist. The page has a
229
+ guard for this and cannot see these specs: it dims the button on
230
+ `data.phases.hasNextPhase === false`, and `readPhases` returns `null` for a
231
+ folder with no phase files — deliberately, since that is also a legacy layout
232
+ whose phases live inline in the overview. The set is the caller's declaration
233
+ about the work, so the declaration is here.
234
+
235
+ **Where §5 split the fix into phase files, drop the flag.** Then a next phase
236
+ genuinely exists, `/spec-next` will build it, and the committing set is right —
237
+ so take the default by passing no `--buttons` at all, exactly as `/spec-next`
238
+ does.
239
+
227
240
  **Then arm the gate**, so the fix now owes a verdict:
228
241
 
229
242
  ```
@@ -385,6 +385,16 @@ uncommitted work stays behind in the worktree — so what went live would be the
385
385
  `review.commitWith` exactly as §2a does (do not restate its rules here), then
386
386
  take the instance. The reader has not approved anything by pressing it.
387
387
 
388
+ **The gate permits that one commit, and nothing more.** A phase that ended
389
+ armed the gate, so the commit this action needs is the very commit the gate
390
+ refuses — which made the press unachievable in the only state it is offered in.
391
+ Claiming a `live-on` pass therefore records a **permit** on the gate, bound to
392
+ the worktree's HEAD, and the commit hook honours it. It dies the moment that
393
+ commit lands, so a second one is refused exactly as before, and the gate stays
394
+ **armed** throughout: the phase still owes a verdict and `/spec-next` still
395
+ refuses to build the next one. Nothing is typed to get it — if a commit here is
396
+ still refused, that is the ordinary gate and the exits are the ordinary three.
397
+
388
398
  **It re-renders `--branch` afterwards**, because the working view is now empty:
389
399
  the commit just happened, and the branch range is what still answers "what am I
390
400
  looking at". §4's clean-tree fallback reaches the same place on its own, so
@@ -240,12 +240,18 @@ The fix is green and nothing is committed yet. That is the moment the page is
240
240
  about, so render it now — **after** the tests pass and **before** the commit:
241
241
 
242
242
  ```
243
- skitterspec spec-env review <spec>
243
+ skitterspec spec-env review <spec> --buttons fix
244
244
  ```
245
245
 
246
246
  **This is free.** The engine reads git and splices the patches into a template;
247
247
  the diff never passes through you, so a 266KB patch costs nothing.
248
248
 
249
+ **`--buttons fix` drops `Commit & Continue`**, because a hotfix has no next
250
+ phase for `/spec-next` to build and that verb would name work which does not
251
+ exist. There is no exception to this the way there is in `/spec-bug`: a hotfix
252
+ is a single pass against a released tag by construction, so it never has phase
253
+ files and the flag is unconditional here.
254
+
249
255
  **Then arm the gate**, so the fix now owes a verdict:
250
256
 
251
257
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "22.2.0",
3
+ "version": "22.3.1",
4
4
  "description": "Spec-driven development for Claude Code \u2014 a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -131,6 +131,8 @@ const {
131
131
  disarmGate,
132
132
  gateState,
133
133
  markGateOffered,
134
+ grantPermit,
135
+ permitHonoured,
134
136
  } = require('./env/review.js')
135
137
  const {
136
138
  CHECKS_VERSION,
@@ -2354,6 +2356,22 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
2354
2356
  reason: `this command is not running inside ${target.spec.folder}'s worktree`,
2355
2357
  gate: judged.gate,
2356
2358
  }
2359
+ } else if (permitHonoured(judged.gate, { head: gitReader(target.spec.worktreePath)(['rev-parse', 'HEAD']) })) {
2360
+ // A PRECONDITION, NOT AN ANSWER. The operator pressed `live-on`, whose
2361
+ // handling commits the phase before `live take` will touch it, so this
2362
+ // commit is the thing they asked for rather than a way around them. The
2363
+ // permit is bound to the HEAD it was granted against, so it covers this
2364
+ // one commit and dies the moment it lands — `grantPermit` carries the
2365
+ // reasoning.
2366
+ //
2367
+ // `permitted` rather than `clear`: the obligation is untouched and the
2368
+ // word should not say otherwise. It exits 0 like every other non-`armed`
2369
+ // state, and only `armed` ever sets a non-zero status.
2370
+ judged = {
2371
+ state: 'permitted',
2372
+ reason: `a claimed ${judged.gate.permit.for} press needs this commit first`,
2373
+ gate: judged.gate,
2374
+ }
2357
2375
  }
2358
2376
  }
2359
2377
 
@@ -2385,6 +2403,10 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
2385
2403
  armedAt: judged.gate ? judged.gate.armedAt : null,
2386
2404
  phase: judged.gate ? judged.gate.phase : null,
2387
2405
  required: config.review.required,
2406
+ // Absent stays absent, so a gate with no permit is byte-identical to
2407
+ // what it was before this existed. Present, it is the visible form of
2408
+ // the one commit an armed gate will not refuse.
2409
+ ...(judged.gate && judged.gate.permit ? { permit: judged.gate.permit } : {}),
2388
2410
  // Absent stays absent: only an `armed` gate has a way out to declare,
2389
2411
  // and every cannot-tell state carries none.
2390
2412
  ...(judged.offer ? { offer: judged.offer } : {}),
@@ -2405,6 +2427,14 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
2405
2427
  )
2406
2428
  } else if (judged.state === 'clear') {
2407
2429
  process.stdout.write(`spec-env review gate: ${target.spec.folder} owes nothing — ${judged.reason}\n`)
2430
+ } else if (judged.state === 'permitted') {
2431
+ // Says what it let through AND what is still owed, in that order. A line
2432
+ // that only said "permitted" would read as the gate having been cleared,
2433
+ // which is the one thing this state does not do.
2434
+ process.stdout.write(
2435
+ `spec-env review gate: ${target.spec.folder} — ${judged.reason}.\n` +
2436
+ ' the phase still owes a verdict; this permits the commit and nothing else.\n',
2437
+ )
2408
2438
  } else {
2409
2439
  process.stdout.write(
2410
2440
  `spec-env review gate: cannot tell — ${judged.reason}.\n` +
@@ -2546,18 +2576,58 @@ function specEnvReviewAllow(dir, config, tier, flags) {
2546
2576
  }
2547
2577
 
2548
2578
  // `review skip` — move on without a verdict, on the record.
2549
- function specEnvReviewSkip(dir, config, reason, flags) {
2579
+ /**
2580
+ * Split `skip`'s positionals into a spec and a reason.
2581
+ *
2582
+ * WHY IT IS NOT SIMPLY `positional[1]`. It was, and it made the gate's own
2583
+ * documented exit unreachable: a spec whose worktree is gone resolves neither
2584
+ * from the cwd nor from a sole provisioned spec, so the refusal told the
2585
+ * operator to "name the one you mean" — a thing the command could not accept —
2586
+ * while its worktree, the other half of that advice, no longer existed. The
2587
+ * gate was then permanent, and clearing it meant hand-editing a gitignored
2588
+ * sidecar. `review gate <spec>` and `review <spec> --claim-since` both take a
2589
+ * name; this was the one that did not.
2590
+ *
2591
+ * TWO POSITIONALS are unambiguous: spec, then reason.
2592
+ *
2593
+ * ONE IS DECIDED BY A POSITIVE SIGNAL (`.claude/rules/negative-checks.md`
2594
+ * rule 1) — it is a spec only where it RESOLVES to one. `findSpecFolder`
2595
+ * matches a bucket folder by exact basename, so nothing fuzzy gets in: a reason
2596
+ * has to be spelled exactly like a spec folder to be read as one, and a reason
2597
+ * that is spelled exactly like a spec folder is a person naming a spec. Every
2598
+ * cannot-tell — an unknown name, an unresolvable repo, a thrown resolver —
2599
+ * falls to the reason, which is the behaviour this command has always had and
2600
+ * the harmless branch.
2601
+ *
2602
+ * THE PROBE NEVER DECIDES THE OUTCOME, only which slot the word fills. What
2603
+ * happens next is the ordinary resolution, refusal text and all.
2604
+ */
2605
+ function splitSkipArgs(dir, config, rest) {
2606
+ const args = (rest || []).filter((a) => a !== undefined && a !== null)
2607
+ if (args.length >= 2) return { specArg: args[0], reason: args[1] }
2608
+ if (args.length === 1) {
2609
+ return gateTarget(dir, config, args[0]).spec
2610
+ ? { specArg: args[0], reason: null }
2611
+ : { specArg: null, reason: args[0] }
2612
+ }
2613
+ return { specArg: null, reason: null }
2614
+ }
2615
+
2616
+ function specEnvReviewSkip(dir, config, rest, flags) {
2617
+ const { specArg, reason } = splitSkipArgs(dir, config, rest)
2550
2618
  const said = String(reason || '').trim()
2551
2619
  if (!said) {
2552
2620
  // The reason IS the feature. A skip with no reason is the silence this
2553
- // whole gate exists to replace, so it is refused rather than defaulted.
2621
+ // whole gate exists to replace, so it is refused rather than defaulted —
2622
+ // and naming the spec is not giving one, which is why the probe above puts
2623
+ // a lone spec name in the spec slot and leaves this empty.
2554
2624
  process.stdout.write(
2555
- 'spec-env review skip: needs a reason — skitterspec spec-env review skip "<why>"\n',
2625
+ 'spec-env review skip: needs a reason — skitterspec spec-env review skip [<spec>] "<why>"\n',
2556
2626
  )
2557
2627
  process.exitCode = 1
2558
2628
  return
2559
2629
  }
2560
- const target = gateTarget(dir, config, null)
2630
+ const target = gateTarget(dir, config, specArg)
2561
2631
  if (!target.spec) {
2562
2632
  process.stdout.write(`spec-env review skip: cannot tell which spec — ${target.reason}\n`)
2563
2633
  process.exitCode = 1
@@ -2959,6 +3029,47 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2959
3029
  action: parsed.action || null,
2960
3030
  }
2961
3031
  merged = { accepted: claimed.accepted, unaccepted: claimed.unaccepted, comments: claimed.comments }
3032
+
3033
+ // THE ONE COMMIT THIS ACTION DEPENDS ON, permitted here because the press
3034
+ // is the positive signal. `live take` refuses a dirty worktree, so
3035
+ // `/spec-diff` §2b commits the phase first — and the gate this phase armed
3036
+ // would otherwise deny exactly that commit, which made the button
3037
+ // unachievable in the only state it is offered in.
3038
+ //
3039
+ // IT IS NOT A VERDICT AND CLEARS NOTHING. The gate stays armed, so
3040
+ // `/spec-next` still refuses the next phase; `grantPermit` is where the
3041
+ // three signals it needs are documented.
3042
+ //
3043
+ // Read from the SPEC'S WORKTREE rather than `readFrom`, which follows the
3044
+ // work to the primary checkout while a spec is live. The gate guards the
3045
+ // worktree, so that is the tree whose HEAD the permit is bound to — and a
3046
+ // spec that is already live has nothing to put live anyway.
3047
+ //
3048
+ // Best-effort throughout: an unreadable tree, an unwritable sidecar, a gate
3049
+ // we could not parse all grant nothing and say nothing. Being wrong that
3050
+ // way costs the refusal that already happens today; being wrong the other
3051
+ // way is a hole in the one thing here that refuses.
3052
+ if (claimed.action) {
3053
+ const wtGit = gitReader(spec.worktreePath)
3054
+ const status = wtGit(['status', '--porcelain'])
3055
+ const head = wtGit(['rev-parse', 'HEAD'])
3056
+ const gateRead = readGate(out, spec.folder)
3057
+ if (!gateRead.corrupt) {
3058
+ const granted = grantPermit(gateRead.gate, {
3059
+ at: new Date().toISOString(),
3060
+ action: claimed.action,
3061
+ head,
3062
+ dirty: status !== null && status.length > 0,
3063
+ })
3064
+ if (granted.granted) {
3065
+ try {
3066
+ writeGate(out, granted.gate)
3067
+ } catch {
3068
+ /* the refusal it would have lifted is the one that happens anyway */
3069
+ }
3070
+ }
3071
+ }
3072
+ }
2962
3073
  }
2963
3074
 
2964
3075
 
@@ -5043,10 +5154,10 @@ async function specEnv(rest) {
5043
5154
  break
5044
5155
  }
5045
5156
  if (positional[0] === 'skip') {
5046
- // The one positional is the REASON, not a spec: the two are
5047
- // indistinguishable as free text, and the spec is the one thing this
5048
- // engine can already resolve from where you are standing.
5049
- specEnvReviewSkip(dir, config, positional[1], flags)
5157
+ // `skip [<spec>] "<why>"`. The spec is usually resolvable from where you
5158
+ // are standing, which is why it stays optional — but not always, and
5159
+ // `splitSkipArgs` carries why that mattered enough to change.
5160
+ specEnvReviewSkip(dir, config, positional.slice(1), flags)
5050
5161
  break
5051
5162
  }
5052
5163
  await specEnvReview(dir, config, positional[0], flags, invokedFrom)
@@ -5063,7 +5174,7 @@ async function specEnv(rest) {
5063
5174
  ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
5064
5175
  ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
5065
5176
  ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +
5066
- ' review skip "<reason>" move on without one, on the record\n' +
5177
+ ' review skip [spec] "<reason>" move on without one, on the record\n' +
5067
5178
  ' review gate [spec] --offered record that the bypass was offered (spends it)\n' +
5068
5179
  ' review wait [spec] --heartbeat <secs> proof-of-life cadence on stderr; 0 disables\n' +
5069
5180
  ' review wait [spec] --since <iso> block until a verdict arrives ([--timeout <s>])\n' +
package/src/env/review.js CHANGED
@@ -301,6 +301,21 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
301
301
  files.push({ ...f, additions, deletions, binary, whole, noise: noiseOf(f.path), patch })
302
302
  }
303
303
 
304
+ // NOTHING TO BE BOOKKEEPING BESIDE. The flag is relative — a spec's own
305
+ // checkbox edits are not what anyone came to read WHEN THERE IS A DIFF TO
306
+ // READ — and applying it with no relative left folds the whole change away,
307
+ // rendering a page whose subject is invisible until the reader happens to
308
+ // click a file in the tree. A change that is entirely bookkeeping IS the
309
+ // change, so it opens.
310
+ //
311
+ // WHAT WOULD FOOL A LOOSER VERSION OF THIS: one source file in the diff. The
312
+ // test is EVERY file, so the moment there is something else to read the
313
+ // margin notes go back to being margin notes — which is the flag's whole
314
+ // point and has its own stays-silent test.
315
+ if (files.length && files.every((f) => f.noise)) {
316
+ for (const f of files) f.noise = false
317
+ }
318
+
304
319
  // Content hashes and the stored review state, folded on before the totals so
305
320
  // the page and `--json` see one shape.
306
321
  const store = notes || emptyNotes(spec.folder)
@@ -774,8 +789,27 @@ const COMMITTING = ['commit', 'commit-continue', 'commit-start', 'commit-land']
774
789
  * page — precisely what having separate words for `commit-start` and
775
790
  * `commit-continue` exists to avoid, and worse here, because one of the two
776
791
  * readings destroys a worktree.
792
+ *
793
+ * `fix` is the set for a SINGLE-PASS fix — a bug or hotfix spec whose whole
794
+ * repair is one `## Fix` list and no phase files. It is `committing` minus
795
+ * `commit-continue`, because there is no next phase for `/spec-next` to build:
796
+ * the page used to offer that verb anyway, the press committed correctly, and
797
+ * `/spec-next` then refused with nothing to do.
798
+ *
799
+ * THE PAGE ALREADY HAS A GUARD FOR THIS AND IT CANNOT SEE THESE SPECS. It dims
800
+ * the button on `data.phases.hasNextPhase === false`, and `readPhases` returns
801
+ * `null` for a folder with no phase files — deliberately, because that is also
802
+ * a legacy layout whose phases live inline in the overview. So the caller says
803
+ * it instead, which is what the `buttons` parameter is for.
804
+ *
805
+ * REUSING `refresh` WAS THE OBVIOUS MOVE HERE TOO, and is wrong for the reason
806
+ * above one line up rather than the one above the paragraph: the three buttons
807
+ * are identical, but `refresh` means a re-validated spec DOCUMENT and this is a
808
+ * render of code. The word is stored in the render record and read back by the
809
+ * serve daemon, so one name covering two renders would be ambiguous exactly
810
+ * where it is used to decide something.
777
811
  */
778
- const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh', 'nospec']
812
+ const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh', 'nospec', 'fix']
779
813
  const DEFAULT_BUTTON_SET = 'committing'
780
814
 
781
815
  /**
@@ -1415,7 +1449,7 @@ function reviewGatePath(outPath) {
1415
1449
  }
1416
1450
 
1417
1451
  function emptyGate(specFolder) {
1418
- return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, offeredAt: null, log: [] }
1452
+ return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, offeredAt: null, permit: null, log: [] }
1419
1453
  }
1420
1454
 
1421
1455
  /**
@@ -1447,6 +1481,11 @@ function readGate(outPath, specFolder) {
1447
1481
  // which is the correct and harmless default. These files are gitignored,
1448
1482
  // so there is no fleet to migrate and `GATE_VERSION` does not move.
1449
1483
  offeredAt: parsed.offeredAt || null,
1484
+ // Same rule as `offeredAt`: a sidecar written before this existed reads
1485
+ // as no permit, which is the state that refuses. Anything that is not
1486
+ // an object is read as absent rather than trusted — this file is the
1487
+ // one thing standing between an armed gate and a commit.
1488
+ permit: parsed.permit && typeof parsed.permit === 'object' ? parsed.permit : null,
1450
1489
  log: Array.isArray(parsed.log) ? parsed.log : [],
1451
1490
  },
1452
1491
  corrupt: false,
@@ -1536,7 +1575,13 @@ function armGate(gate, { at, phase = null }) {
1536
1575
  if (gate.armed && gate.phase === phase) return gate
1537
1576
  // A NEW ARMING GETS A NEW OFFER. Each phase that ends is its own obligation,
1538
1577
  // 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 }
1578
+ //
1579
+ // AND A NEW OBLIGATION STARTS WITH NO WAY PAST IT. A permit is granted
1580
+ // against one arming — the commit one pressed `live-on` depends on — so
1581
+ // carrying it into the next phase would hand that phase a hole nobody asked
1582
+ // for. It is bound to a HEAD as well, so this is belt and braces; both are
1583
+ // cheap and only one of them has to hold.
1584
+ return { ...gate, armed: true, armedAt: at, phase, offeredAt: null, permit: null }
1540
1585
  }
1541
1586
 
1542
1587
  /**
@@ -1559,6 +1604,82 @@ function markGateOffered(gate, { at }) {
1559
1604
  return { gate: { ...gate, offeredAt: at }, marked: true }
1560
1605
  }
1561
1606
 
1607
+ /**
1608
+ * The actions whose handling REQUIRES a commit in the spec's own worktree.
1609
+ *
1610
+ * One, and the list exists so the answer is looked up rather than remembered.
1611
+ * `allow-network` and `allow-remote` write a config key and commit nothing, so
1612
+ * neither has a precondition to permit — and a permit granted for an action
1613
+ * that never commits is a hole with no commit to close it.
1614
+ */
1615
+ const PERMITTED_ACTIONS = ['live-on']
1616
+
1617
+ /**
1618
+ * Record that ONE commit is a claimed action's mechanical precondition. Pure.
1619
+ *
1620
+ * WHY THIS EXISTS. The page offers `live-on` only on a non-midrun render, which
1621
+ * is what a finished phase produces — and a finished phase arms the gate. The
1622
+ * action is deliberately not a verdict, so it clears nothing; but `live take`
1623
+ * refuses a dirty worktree, so handling the press means committing the phase
1624
+ * first. That commit is a `git commit` in the spec's own worktree, which is
1625
+ * exactly what the armed gate denies. Without this the button could never
1626
+ * succeed in the only state it was ever offered in.
1627
+ *
1628
+ * WHAT IT IS NOT: a verdict, a skip, or a lift. The gate stays `armed`, so
1629
+ * `/spec-next` still refuses the next phase and the obligation is still
1630
+ * discharged by a committing verdict or a recorded skip and by nothing else.
1631
+ * This widens one commit, not the guard.
1632
+ *
1633
+ * THREE POSITIVE SIGNALS, all required (`.claude/rules/negative-checks.md`
1634
+ * rule 1), and every cannot-tell grants nothing:
1635
+ *
1636
+ * - The gate is ARMED. Nothing is refusing otherwise, so there is nothing to
1637
+ * permit past — and a permit written onto a clear gate would sit there
1638
+ * waiting for the next phase to arm.
1639
+ * - The action is one that COMMITS. See `PERMITTED_ACTIONS` above.
1640
+ * - The worktree is DIRTY, and a `head` was readable. A clean tree has no
1641
+ * precondition, so granting one would leave a permit lying around to cover
1642
+ * some later commit made for an entirely different reason.
1643
+ *
1644
+ * BOUND TO A HEAD, which is what makes it exactly one commit rather than a
1645
+ * standing permission. `permitHonoured` compares the worktree's HEAD against
1646
+ * the one recorded here, so the permit dies the instant its commit lands — and
1647
+ * a commit that FAILED (red tests, a rejected message) leaves HEAD alone, so
1648
+ * the retry is still covered. Spending it on the check instead would have made
1649
+ * that retry a refusal.
1650
+ */
1651
+ function grantPermit(gate, { at, action, head, dirty }) {
1652
+ if (!gate || !gate.armed) return { gate, granted: false }
1653
+ if (!PERMITTED_ACTIONS.includes(action)) return { gate, granted: false }
1654
+ if (dirty !== true || !head) return { gate, granted: false }
1655
+ return {
1656
+ gate: { ...gate, permit: { for: action, at, head, phase: gate.phase === undefined ? null : gate.phase } },
1657
+ granted: true,
1658
+ }
1659
+ }
1660
+
1661
+ /**
1662
+ * Does this gate's permit cover a commit happening at `head`? Pure.
1663
+ *
1664
+ * WHAT WOULD FOOL THIS: nothing here checks WHERE the commit is running. That
1665
+ * is deliberate — the caller has already established that it is inside this
1666
+ * spec's own worktree, which is the same positive signal `--for-command` wants
1667
+ * before it refuses at all, and duplicating it here would be a second copy free
1668
+ * to drift from the first.
1669
+ *
1670
+ * A permit for an action not in `PERMITTED_ACTIONS` is ignored rather than
1671
+ * trusted. The sidecar is gitignored and hand-editable, and an older engine
1672
+ * meeting a newer word should refuse, not guess.
1673
+ */
1674
+ function permitHonoured(gate, { head }) {
1675
+ if (!gate || !gate.armed) return false
1676
+ const permit = gate.permit
1677
+ if (!permit || typeof permit !== 'object') return false
1678
+ if (!PERMITTED_ACTIONS.includes(permit.for)) return false
1679
+ if (!permit.head || !head) return false
1680
+ return permit.head === head
1681
+ }
1682
+
1562
1683
  /**
1563
1684
  * Disarm it, and say how. Pure.
1564
1685
  *
@@ -1571,7 +1692,10 @@ function disarmGate(gate, { at, by, reason = null }) {
1571
1692
  if (!gate.armed) return { gate, logged: false }
1572
1693
  const log = Array.isArray(gate.log) ? gate.log.slice() : []
1573
1694
  log.push({ by, at, phase: gate.phase === undefined ? null : gate.phase, reason })
1574
- return { gate: { ...gate, armed: false, armedAt: null, phase: null, log }, logged: true }
1695
+ // The permit goes with the obligation it was a way through. Nothing refuses
1696
+ // once the gate is clear, so a surviving one could only ever apply to the
1697
+ // next phase's arming — which is not what anybody pressed.
1698
+ return { gate: { ...gate, armed: false, armedAt: null, phase: null, permit: null, log }, logged: true }
1575
1699
  }
1576
1700
 
1577
1701
  /**
@@ -2358,6 +2482,9 @@ module.exports = {
2358
2482
  disarmGate,
2359
2483
  gateState,
2360
2484
  markGateOffered,
2485
+ PERMITTED_ACTIONS,
2486
+ grantPermit,
2487
+ permitHonoured,
2361
2488
  GATE_BYPASS_OFFER,
2362
2489
  GATE_BYPASS_REASON,
2363
2490
  gateForPage,
package/src/env/serve.js CHANGED
@@ -205,13 +205,47 @@ ${body}
205
205
  * answer — including the ones we cannot work out. Never throws: a page is a
206
206
  * convenience and a git that will not read is not evidence of anything.
207
207
  */
208
+ /**
209
+ * Project bookkeeping, as opposed to somebody's unfinished work.
210
+ *
211
+ * `specs/.core/` holds the project's own configuration and whatever a tracker
212
+ * provider writes beside it — a push snapshot, most often. None of it is a
213
+ * spec's documents and none of it is code, so finding it in an authoring tree
214
+ * says nothing about whether a spec is being written there.
215
+ *
216
+ * WHAT WOULD FOOL A WIDER VERSION OF THIS: anything outside `specs/`. A source
217
+ * file in the tree IS evidence that the tree is being used for something else,
218
+ * and that answer must stay unchanged — see the stays-silent test.
219
+ */
220
+ function isProjectBookkeeping(p) {
221
+ return String(p).startsWith('specs/.core/')
222
+ }
223
+
208
224
  function docsWorktree(wt, spec, config) {
209
225
  if (!spec || spec.bucket !== 'backlog') return null
210
226
  const paths = dirtyPaths(trimmedGitReader(wt))
211
227
  // Cannot tell — not "no documents".
212
228
  if (paths === null) return null
213
229
  const { owned, foreign } = classifyDirtyTree(spec, paths, config)
214
- if (!owned.length || foreign.length) return null
230
+ // A TRACKER SNAPSHOT IS NOT SOMEBODY ELSE'S WORK, and requiring a
231
+ // foreign-free tree cost the whole page for the commonest authoring tree
232
+ // there is. The linking push writes `specs/.core/linear-base/<ID>.base.json`
233
+ // into this tree moments before the page is opened, and a project that never
234
+ // declared it in `spec.companionPaths` — which is the default — has one
235
+ // foreign path by construction. The view fell through to `worktree`, where
236
+ // every `specs/**` path is bookkeeping: seven files changed, seven folded
237
+ // away, nothing on screen.
238
+ //
239
+ // What the foreign test is actually for survives untouched: real work sitting
240
+ // in the tree still disqualifies it, because that is a tree being used for
241
+ // something other than writing this spec.
242
+ //
243
+ // THE RENDERED FILE SET DOES NOT WIDEN WITH IT. The page still shows `owned`,
244
+ // which is what `--docs` showed from the CLI — a served page that listed a
245
+ // file the run's own report did not would be a different page from the one
246
+ // the reader was told about.
247
+ const intruders = foreign.filter((p) => !isProjectBookkeeping(p))
248
+ if (!owned.length || intruders.length) return null
215
249
  return { owned }
216
250
  }
217
251
 
@@ -429,10 +463,20 @@ function receivePass(dir, config, spec, blob) {
429
463
  * `nospec`; a phase in a worktree is `committing`. Those three are exactly the
430
464
  * sets that describe what the work IS, so they are read off what the work is.
431
465
  *
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.
466
+ * THE RECORD ANSWERS WHAT NO TREE CAN SHOW, which is two things rather than
467
+ * one. `midrun` and `refresh` answer whether the run had FINISHED — one offers
468
+ * `Continue` in place of the committing pair, the other drops the start
469
+ * verdict. `fix` answers whether the work was ever PHASED: a single-pass bug or
470
+ * hotfix has no phase files, and a folder with no phase files is also a legacy
471
+ * inline-phase layout, so the tree cannot tell the two apart and `readPhases`
472
+ * returns `null` for both. Only the caller that rendered knew, in all three
473
+ * cases. So they, and nothing else, are taken from the record.
474
+ *
475
+ * LEAVING `fix` OFF THIS LIST IS THE BUG IT WAS ADDED TO FIX, wearing the
476
+ * daemon's clothes: the CLI render would drop `Commit & Continue` and every
477
+ * SERVED re-render of the same URL would fall through to the tree's
478
+ * `committing` family and hand it straight back. The first page anyone opens
479
+ * looks right, and each refresh is wrong.
436
480
  *
437
481
  * WHICH MAKES THE RECORD NARROWING-ONLY. A word it does not recognise, a word
438
482
  * that would WIDEN the offer, an unreadable file, no file at all: every one of
@@ -444,7 +488,7 @@ function receivePass(dir, config, spec, blob) {
444
488
  * so every `/no-spec` page served over http offered to put nothing in flight,
445
489
  * and the pass came back naming a verdict its own skill could not act on.
446
490
  */
447
- const NARROWING_SETS = ['midrun', 'refresh']
491
+ const NARROWING_SETS = ['midrun', 'refresh', 'fix']
448
492
 
449
493
  function buttonsForView(viewKind, spec, recorded) {
450
494
  if (NARROWING_SETS.includes(recorded)) return recorded