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