@skitterbyte/skitterspec 22.1.0 → 22.3.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.
@@ -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
  })
@@ -201,6 +201,16 @@ escape hatch is what let a whole class of runs go on asking questions nobody was
201
201
  listening for. Where a watch is unavailable the wait is the turn ending, which
202
202
  every harness can do.
203
203
 
204
+ **Run it under a persistent watch primitive where the harness has one** — one
205
+ that lives for the whole session and whose deliberate stop is an explicit act
206
+ (Claude Code: the `Monitor` tool with `persistent: true`, ended by `TaskStop`).
207
+ A backgrounded shell is reclaimed on a long idle — that is the death the next
208
+ paragraph recovers from — where a session-length primitive is designed to
209
+ survive it. Where the harness has none, background the command exactly as
210
+ before. The engine's wait stays the one watcher either way: its heartbeat goes
211
+ to stderr, so stdout carries only the start line and the arrival, and the exit
212
+ ends the watch.
213
+
204
214
  **A watch that dies is re-armed, silently, on the same window.** A backgrounded
205
215
  wait does not always survive a long idle: three sessions lost theirs over one
206
216
  lunch break, and two in another were killed at 43 and 30 minutes. So a wait that
@@ -212,11 +222,17 @@ would strand exactly that pass.
212
222
  **Say nothing when you do it.** Nothing happened and the window is unchanged; a
213
223
  line on every death turns a working loop into a stream of non-events.
214
224
 
215
- **An operator who stopped it is not a death.** Re-arming over a deliberate stop
216
- makes the wait unstoppable, which is worse than a wait that stops. Where the two
217
- cannot be told apart, treat it as deliberate and leave it
218
- (`.claude/rules/negative-checks.md` rule 4): the cost of being wrong is one
219
- `/spec-reviewed`, against a loop nobody can break.
225
+ **An operator who stopped it is not a death** — re-arming over a deliberate
226
+ stop makes the wait unstoppable, which is worse than a wait that stops. But a
227
+ deliberate stop is an **explicit act with positive evidence**: this session
228
+ called TaskStop, or the operator said to stop waiting. A bare killed-task
229
+ notification carries neither and is read as a **death**. This clause once said
230
+ to treat the ambiguous kill as deliberate, and that safe branch ate the
231
+ recovery whole: every observed kill was the harness reclaiming an idle shell
232
+ and none was a person, so the case it protected against does not occur — while
233
+ the `/spec-reviewed` it charged was paid on every idle review. The deliberate
234
+ stop is the positive signal, so its absence is what a death looks like
235
+ (`.claude/rules/negative-checks.md` rule 1).
220
236
 
221
237
  **It is bounded by the age of the window, not by a count of retries.** Keep
222
238
  re-arming while the window is younger than **12 hours**, then stop. A count means
@@ -113,7 +113,9 @@ because writing it means reading the diff, and that spend is the user's call.
113
113
 
114
114
  1. **Note the moment you start waiting**, as an ISO timestamp. That instant is
115
115
  the whole scope of what you may claim.
116
- 2. **Run the engine's wait in the background, and end your turn:**
116
+ 2. **Run the engine's wait, and end your turn** — under the harness's
117
+ persistent watch primitive where one exists, else in the background
118
+ (`.claude/rules/spec-reports.md` carries the preference):
117
119
 
118
120
  ```
119
121
  skitterspec spec-env review wait <name> --since <the timestamp>
@@ -396,7 +396,9 @@ the verdict — and the reader's conclusion is about what the page showed them.
396
396
 
397
397
  1. **Note the moment you start waiting**, as an ISO timestamp. That instant is
398
398
  the whole scope of what you may claim.
399
- 2. **Run the engine's wait in the background, and end your turn:**
399
+ 2. **Run the engine's wait, and end your turn** — under the harness's
400
+ persistent watch primitive where one exists, else in the background
401
+ (`.claude/rules/spec-reports.md` carries the preference):
400
402
 
401
403
  ```
402
404
  skitterspec spec-env review wait <spec> --since <the timestamp>
@@ -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
  ```
@@ -232,8 +245,9 @@ skitterspec spec-env review arm <spec>
232
245
 
233
246
  **Then wait**, and `/spec-next` §5 owns the sequence — follow it there rather
234
247
  than reading a second copy here: note the moment, run
235
- `skitterspec spec-env review wait <spec> --since <that moment>` in the
236
- background, end your turn, and let `--claim-since` pick the one pass that
248
+ `skitterspec spec-env review wait <spec> --since <that moment>` under the
249
+ harness's persistent watch primitive where one exists (else in the
250
+ background), end your turn, and let `--claim-since` pick the one pass that
237
251
  arrived inside the window. **Do not compose a watcher**, and do not give the
238
252
  wait a timeout — the reasoning is in `/spec-next` §5 and is not repeated here.
239
253
  The banner it describes is what this skill emits in place of a `Review` row,
@@ -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
@@ -614,7 +624,9 @@ already arrived. Those get the `Review` row, no question, and nothing is owed.
614
624
 
615
625
  1. **Note the moment**, as an ISO timestamp, before you start. That instant is
616
626
  the entire scope of what may be claimed without a person naming it.
617
- 2. **Run the engine's wait in the background, and end the turn:**
627
+ 2. **Run the engine's wait, and end your turn** — under the harness's
628
+ persistent watch primitive where one exists, else in the background
629
+ (`.claude/rules/spec-reports.md` carries the preference):
618
630
 
619
631
  ```
620
632
  skitterspec spec-env review wait <spec> --since <timestamp>
@@ -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
  ```
@@ -436,7 +436,9 @@ reading — and that is the gap the whole gate exists to close.
436
436
 
437
437
  1. **Note the moment you start waiting**, as an ISO timestamp. That instant is
438
438
  the whole scope of what you may claim.
439
- 2. **Run the engine's wait in the background, and end your turn:**
439
+ 2. **Run the engine's wait, and end your turn** — under the harness's
440
+ persistent watch primitive where one exists, else in the background
441
+ (`.claude/rules/spec-reports.md` carries the preference):
440
442
 
441
443
  ```
442
444
  skitterspec spec-env review wait <spec> --since <the timestamp>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "22.1.0",
3
+ "version": "22.3.0",
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,
@@ -856,9 +858,42 @@ function specEnvNospec(dir, config, name, flags = {}) {
856
858
  }
857
859
 
858
860
  function specEnvUp(dir, config, specArg, flags = {}) {
859
- const spec = resolveSpecWithWorktree(dir, config, specArg)
860
861
  const docs = flags.docs === true
861
862
 
863
+ // THE AUTHORING LANE — `up <name> --docs` for a spec that is not written yet.
864
+ // `/spec` provisions BEFORE it writes a line (feat-main-is-a-landing-zone
865
+ // phase 2), so the name resolves to nothing at exactly the moment this runs.
866
+ // It opens only on a positive signal, all three parts: the not-found refusal
867
+ // itself (by code, never by message), `--docs`, and a spec-shaped name — so a
868
+ // typo'd `up` without the flag, or with a name no spec could have, refuses
869
+ // exactly as before. Worktree mode only: in checkout mode `/spec` writes
870
+ // where the session stands and there is nothing to provision first.
871
+ let spec
872
+ let authoring = false
873
+ try {
874
+ spec = resolveSpecWithWorktree(dir, config, specArg)
875
+ } catch (err) {
876
+ const specShaped = typeof specArg === 'string' && /^(feat|bug|hotfix)-./.test(specArg)
877
+ if (err.code !== 'SPEC_NOT_FOUND' || !docs || !specShaped || config.mode === 'checkout') {
878
+ throw err
879
+ }
880
+ // Record first, then plan — the same order `nospec` keeps, for the same
881
+ // reason: the record is what makes the name resolvable (a bare `resolve`
882
+ // in the fresh worktree must name this spec before its folder exists), so
883
+ // a caller that runs the printed commands must never own a worktree the
884
+ // engine cannot name. `spec: true` is what makes the record resolve with
885
+ // the spec's own type and slug rather than as `/no-spec` work.
886
+ const beforeAuth = readRegistry(dir, config)
887
+ const resolved = resolveSpecless(specArg, dir, config, { spec: true })
888
+ writeRegistry(
889
+ dir,
890
+ config,
891
+ recordSpecless(beforeAuth, specArg, { branch: resolved.branch, spec: true }),
892
+ )
893
+ spec = resolved
894
+ authoring = true
895
+ }
896
+
862
897
  // Checkout mode: the branch is built in the primary checkout, so none of the
863
898
  // worktree machinery below applies — no slot, no trust entry and no bootstrap.
864
899
  // Handled first precisely so none of that runs by accident.
@@ -898,8 +933,13 @@ function specEnvUp(dir, config, specArg, flags = {}) {
898
933
  const upGit = gitReader(dir)
899
934
  const upStatus = upGit(['status', '--porcelain'])
900
935
  const upDirtyPaths = dirtyPaths(upGit)
901
- const upOnFork = specOnForkPoint(dir, upGit, spec)
902
- const upSpecUntracked = specIsUntracked(dir, upGit, spec)
936
+ // An authoring run does not ask whether the spec is on the fork point — it is
937
+ // known to be nowhere, and that is the healthy state of the lane, not the
938
+ // "would fork without the spec it is for" refusal. `onFork: undefined` is the
939
+ // gate's cannot-tell branch, which allows (negative-checks rule 4). The
940
+ // untracked read is skipped the same way: there is no folder to ask about.
941
+ const upOnFork = authoring ? { onFork: undefined, foundOn: null } : specOnForkPoint(dir, upGit, spec)
942
+ const upSpecUntracked = authoring ? false : specIsUntracked(dir, upGit, spec)
903
943
 
904
944
  // Trust the shared worktree root so edits into the freshly-provisioned worktree
905
945
  // don't prompt. One absolute entry (the root) covers every spec; self-heals on
@@ -988,6 +1028,12 @@ function specEnvUp(dir, config, specArg, flags = {}) {
988
1028
  if (plan.docs) {
989
1029
  out.push(' docs: documents only — no setup commands, no docker')
990
1030
  }
1031
+ // Positive, so an operator reading the plan knows the missing spec folder is
1032
+ // the lane working rather than something the engine failed to find.
1033
+ if (authoring) {
1034
+ out.push(' authoring: no spec document yet — /spec writes it in the worktree')
1035
+ out.push(` recorded: added to ${config.registry} until the spec is written`)
1036
+ }
991
1037
  if (trust.reason === 'malformed') {
992
1038
  out.push(
993
1039
  ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
@@ -2310,6 +2356,22 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
2310
2356
  reason: `this command is not running inside ${target.spec.folder}'s worktree`,
2311
2357
  gate: judged.gate,
2312
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
+ }
2313
2375
  }
2314
2376
  }
2315
2377
 
@@ -2341,6 +2403,10 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
2341
2403
  armedAt: judged.gate ? judged.gate.armedAt : null,
2342
2404
  phase: judged.gate ? judged.gate.phase : null,
2343
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 } : {}),
2344
2410
  // Absent stays absent: only an `armed` gate has a way out to declare,
2345
2411
  // and every cannot-tell state carries none.
2346
2412
  ...(judged.offer ? { offer: judged.offer } : {}),
@@ -2361,6 +2427,14 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
2361
2427
  )
2362
2428
  } else if (judged.state === 'clear') {
2363
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
+ )
2364
2438
  } else {
2365
2439
  process.stdout.write(
2366
2440
  `spec-env review gate: cannot tell — ${judged.reason}.\n` +
@@ -2915,6 +2989,47 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2915
2989
  action: parsed.action || null,
2916
2990
  }
2917
2991
  merged = { accepted: claimed.accepted, unaccepted: claimed.unaccepted, comments: claimed.comments }
2992
+
2993
+ // THE ONE COMMIT THIS ACTION DEPENDS ON, permitted here because the press
2994
+ // is the positive signal. `live take` refuses a dirty worktree, so
2995
+ // `/spec-diff` §2b commits the phase first — and the gate this phase armed
2996
+ // would otherwise deny exactly that commit, which made the button
2997
+ // unachievable in the only state it is offered in.
2998
+ //
2999
+ // IT IS NOT A VERDICT AND CLEARS NOTHING. The gate stays armed, so
3000
+ // `/spec-next` still refuses the next phase; `grantPermit` is where the
3001
+ // three signals it needs are documented.
3002
+ //
3003
+ // Read from the SPEC'S WORKTREE rather than `readFrom`, which follows the
3004
+ // work to the primary checkout while a spec is live. The gate guards the
3005
+ // worktree, so that is the tree whose HEAD the permit is bound to — and a
3006
+ // spec that is already live has nothing to put live anyway.
3007
+ //
3008
+ // Best-effort throughout: an unreadable tree, an unwritable sidecar, a gate
3009
+ // we could not parse all grant nothing and say nothing. Being wrong that
3010
+ // way costs the refusal that already happens today; being wrong the other
3011
+ // way is a hole in the one thing here that refuses.
3012
+ if (claimed.action) {
3013
+ const wtGit = gitReader(spec.worktreePath)
3014
+ const status = wtGit(['status', '--porcelain'])
3015
+ const head = wtGit(['rev-parse', 'HEAD'])
3016
+ const gateRead = readGate(out, spec.folder)
3017
+ if (!gateRead.corrupt) {
3018
+ const granted = grantPermit(gateRead.gate, {
3019
+ at: new Date().toISOString(),
3020
+ action: claimed.action,
3021
+ head,
3022
+ dirty: status !== null && status.length > 0,
3023
+ })
3024
+ if (granted.granted) {
3025
+ try {
3026
+ writeGate(out, granted.gate)
3027
+ } catch {
3028
+ /* the refusal it would have lifted is the one that happens anyway */
3029
+ }
3030
+ }
3031
+ }
3032
+ }
2918
3033
  }
2919
3034
 
2920
3035
 
@@ -488,17 +488,26 @@ function assertPrimaryOnMain(config, git) {
488
488
  */
489
489
  function resolveSpecless(name, dir, config, entry = {}) {
490
490
  const { repo, repoSlug } = repoInfo(dir)
491
- const slug = name
491
+ // AN AUTHORING RECORD RESOLVES AS THE SPEC IT WILL BE. `spec: true` marks a
492
+ // record written by `up --docs` for a spec not written yet (the `/spec`
493
+ // authoring lane), so type and slug come from the name's own prefix — the
494
+ // branch and worktree it answers with MUST equal what the written spec will
495
+ // later resolve to, or the confirm step lands in one tree and the documents
496
+ // in another. A plain `/no-spec` record keeps the whole name as its slug,
497
+ // byte-identical to before.
498
+ const authored = entry && entry.spec === true
499
+ const slug = authored ? splitPrefix(name).slug : name
492
500
  const tokens = { repo, repoSlug, slug }
493
501
  const spec = {
494
502
  folder: name,
495
503
  bucket: null,
496
504
  path: null,
497
- type: (entry && entry.type) || 'chore',
505
+ type: authored ? splitPrefix(name).type : (entry && entry.type) || 'chore',
498
506
  slug,
499
507
  stack: 'worktree',
500
508
  baseRef: null,
501
509
  specless: true,
510
+ unwritten: authored,
502
511
  }
503
512
  const branch = (entry && entry.branch) || branchFor(spec, config)
504
513
  const worktreeRoot = expandTokens(config.worktree.root, tokens)
@@ -532,10 +541,14 @@ function resolveSpec(specArg, dir, config, opts = {}) {
532
541
  }
533
542
  // Name the roots we looked under: the usual cause is a spec that only exists
534
543
  // on its own branch, and the message should say where we didn't find it.
535
- throw new Error(
544
+ // The code is what lets `up --docs` catch exactly this refusal and open the
545
+ // authoring lane — matching on the message would make its wording API.
546
+ const err = new Error(
536
547
  `spec not found under specs/**: ${specArg} ` +
537
548
  `(searched: ${[...preferDirs, dir, ...searchDirs].join(', ')})`,
538
549
  )
550
+ err.code = 'SPEC_NOT_FOUND'
551
+ throw err
539
552
  }
540
553
 
541
554
  const { type, slug } = splitPrefix(found.folder)
@@ -643,7 +656,14 @@ function allSpecs(dir, config, worktreePaths, specless = {}) {
643
656
  // not. Leaving them out is what would make a `/no-spec` worktree invisible to
644
657
  // `status` and to every bare resolution — the outcome decision 7 rejected for
645
658
  // a plain git branch, arrived at by omission instead.
659
+ //
660
+ // A FOLDER SHADOWS ITS OWN RECORD. An authoring record (`up --docs` on an
661
+ // unwritten spec) shares its name with the folder `/spec` then writes, and
662
+ // once that folder exists it is the answer — listing the record beside it
663
+ // would show one spec twice.
664
+ const collected = new Set(specs.map((s) => s.folder))
646
665
  for (const name of Object.keys(specless).sort()) {
666
+ if (collected.has(name)) continue
647
667
  const spec = resolveSpecless(name, dir, config, specless[name])
648
668
  specs.push({
649
669
  folder: spec.folder,
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