@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.
- package/assets/core/env.config.md +1 -0
- package/assets/review/page.html +438 -6
- package/assets/skills/spec-bug/SKILL.md +14 -1
- package/assets/skills/spec-diff/SKILL.md +10 -0
- package/assets/skills/spec-hotfix/SKILL.md +7 -1
- package/package.json +1 -1
- package/src/cli.js +120 -9
- package/src/env/review.js +131 -4
- package/src/env/serve.js +50 -6
|
@@ -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
|
},
|
package/assets/review/page.html
CHANGED
|
@@ -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
|
-
|
|
1168
|
-
|
|
1312
|
+
body.appendChild(el('span', 'note-meta', meta))
|
|
1313
|
+
body.appendChild(el('span', 'note-text', c.note))
|
|
1169
1314
|
if (c.resolved) {
|
|
1170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
5047
|
-
//
|
|
5048
|
-
//
|
|
5049
|
-
specEnvReviewSkip(dir, config, positional
|
|
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>"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
433
|
-
* `midrun` and `refresh`
|
|
434
|
-
* committing pair, the other drops the start
|
|
435
|
-
*
|
|
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
|