@skitterbyte/skitterspec-linear 13.0.0 → 15.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 +170 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +17 -3
- package/assets/hooks/review-gate.cjs +141 -0
- package/assets/review/page.html +366 -30
- package/assets/rules/spec-planning.md +65 -13
- package/assets/rules/spec-reports.md +136 -16
- package/assets/skills/spec-bug/SKILL.md +38 -23
- package/assets/skills/spec-diff/SKILL.md +171 -8
- package/assets/skills/spec-hotfix/SKILL.md +31 -20
- package/assets/skills/spec-init/SKILL.md +34 -0
- package/assets/skills/spec-next/SKILL.md +167 -26
- package/assets/skills/spec-reviewed/SKILL.md +33 -16
- package/package.json +1 -1
- package/src/cli.js +326 -4
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +17 -1
- package/src/env/hooks.js +157 -0
- package/src/env/review.js +249 -3
- package/src/env/serve.js +28 -4
- package/src/init.js +78 -0
- package/src/vendor/sync-core/src/retarget.js +1 -1
- package/src/vendor/sync-core/src/task-block.js +2 -2
package/assets/review/page.html
CHANGED
|
@@ -386,7 +386,13 @@ tr.note-row td {
|
|
|
386
386
|
.verdict-title { font-size: .8rem; color: var(--muted); }
|
|
387
387
|
.verdict-count { font-size: .8rem; color: var(--muted); }
|
|
388
388
|
.v-commit { color: var(--good-fg); background: var(--good-bg); }
|
|
389
|
+
/* Continue borrows the same affirmative pair: it is the mid-run reader saying
|
|
390
|
+
carry on, and it never appears beside a commit button to be confused with. */
|
|
391
|
+
.v-continue { color: var(--good-fg); background: var(--good-bg); }
|
|
389
392
|
.v-changes { color: var(--flag-fg); background: var(--flag-bg); }
|
|
393
|
+
/* A button the render did not ask for is GONE, not dimmed — a disabled control
|
|
394
|
+
invites a reader to work out how to enable it, and there is nothing to do. */
|
|
395
|
+
.verdict button[hidden] { display: none; }
|
|
390
396
|
/* A blocked control is dashed as well as dimmed, so the block survives a
|
|
391
397
|
greyscale screen — and the reason rides in the label, never in a tooltip
|
|
392
398
|
nobody hovers on a phone. */
|
|
@@ -411,20 +417,103 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
411
417
|
padding: .5rem;
|
|
412
418
|
}
|
|
413
419
|
.copy-out[hidden] { display: none; }
|
|
414
|
-
|
|
415
|
-
|
|
420
|
+
/* THE REVIEW, ONCE IT IS OVER. A verdict that has actually been handed over
|
|
421
|
+
ends the page: the diff fades out and what you decided takes its place. The
|
|
422
|
+
fade is a transition on a class rather than a timer, so nothing depends on
|
|
423
|
+
JavaScript finishing, and `Show the diff anyway` brings it back — read-only,
|
|
424
|
+
with the buttons dead. */
|
|
425
|
+
.reviewable { transition: opacity .35s ease; }
|
|
426
|
+
.is-decided .reviewable { display: none; }
|
|
427
|
+
.is-decided.show-diff .reviewable { display: block; opacity: .75; }
|
|
428
|
+
.is-decided.show-diff .reviewable:hover { opacity: 1; }
|
|
429
|
+
/* Bringing the diff back must not resurrect a section that was hidden for its
|
|
430
|
+
own reasons — a spec with no context ships `#context` hidden, and the rule
|
|
431
|
+
above would have shown an empty panel. The second selector is there for
|
|
432
|
+
specificity: `.is-decided.show-diff .reviewable` outranks a bare
|
|
433
|
+
`.reviewable[hidden]`. */
|
|
434
|
+
.reviewable[hidden],
|
|
435
|
+
.is-decided.show-diff .reviewable[hidden] { display: none; }
|
|
436
|
+
|
|
437
|
+
.decided {
|
|
438
|
+
margin: 0 0 1.25rem;
|
|
439
|
+
padding: 1.1rem 1.2rem;
|
|
440
|
+
border: 1px solid var(--line);
|
|
441
|
+
border-left: 4px solid var(--accent);
|
|
442
|
+
border-radius: 10px;
|
|
443
|
+
background: var(--panel);
|
|
444
|
+
animation: decided-in .35s ease;
|
|
445
|
+
}
|
|
446
|
+
@keyframes decided-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
447
|
+
.decided[hidden] { display: none; }
|
|
448
|
+
.decided-what { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 650; line-height: 1.4; }
|
|
449
|
+
.decided-note { margin: 0 0 .8rem; color: var(--muted); font-size: .88rem; line-height: 1.5; max-width: 62ch; }
|
|
450
|
+
.decided-note:last-child { margin-bottom: 0; }
|
|
451
|
+
.decided-toggle { font-size: .78rem; }
|
|
452
|
+
|
|
453
|
+
/* WHY THE CHANGE EXISTS — the PR description this page never had. It is prose
|
|
454
|
+
to be read rather than data to be scanned, so it gets a measure, air, and a
|
|
455
|
+
quieter voice than the diff below it. It was once full-bleed 15px text at
|
|
456
|
+
1.5 in a tight box, which read as a wall and got skipped. */
|
|
457
|
+
.context {
|
|
458
|
+
margin: 0 0 1.5rem;
|
|
459
|
+
padding: 1.1rem 1.3rem;
|
|
460
|
+
border: 1px solid var(--line);
|
|
461
|
+
border-radius: 10px;
|
|
462
|
+
background: var(--panel, transparent);
|
|
463
|
+
}
|
|
464
|
+
.context h3 {
|
|
465
|
+
font-size: .72rem;
|
|
466
|
+
text-transform: uppercase;
|
|
467
|
+
letter-spacing: .08em;
|
|
468
|
+
color: var(--muted);
|
|
469
|
+
font-weight: 650;
|
|
470
|
+
margin: 1.4rem 0 .5rem;
|
|
471
|
+
}
|
|
416
472
|
.context h3:first-child { margin-top: 0; }
|
|
417
|
-
.
|
|
473
|
+
/* A MEASURE. Prose set the full width of a 1400px page is measurably harder to
|
|
474
|
+
read — the eye loses the line it is returning to. */
|
|
475
|
+
.context p { margin: 0 0 .7rem; font-size: .92rem; line-height: 1.65; max-width: 72ch; }
|
|
418
476
|
.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
|
-
|
|
477
|
+
.context table { border-collapse: collapse; width: 100%; font-size: .84rem; margin: .2rem 0 .3rem; }
|
|
478
|
+
.context th, .context td { text-align: left; padding: .35rem .8rem .35rem 0; vertical-align: top; }
|
|
479
|
+
.context th {
|
|
480
|
+
color: var(--muted);
|
|
481
|
+
font-weight: 600;
|
|
482
|
+
font-size: .72rem;
|
|
483
|
+
text-transform: uppercase;
|
|
484
|
+
letter-spacing: .05em;
|
|
485
|
+
border-bottom: 1px solid var(--line);
|
|
486
|
+
padding-bottom: .3rem;
|
|
487
|
+
}
|
|
488
|
+
.context tbody tr + tr td { border-top: 1px solid var(--line); }
|
|
489
|
+
.context ul { margin: 0; padding-left: 1.15rem; max-width: 72ch; }
|
|
490
|
+
.context li { margin: .3rem 0; font-size: .92rem; line-height: 1.6; }
|
|
491
|
+
/* The disclosure is a CONTROL, not a panel. Full-width and boxed, it read as a
|
|
492
|
+
section header that happened to be clickable. */
|
|
493
|
+
.context-more { margin: .9rem 0 0; }
|
|
494
|
+
.context-more > summary {
|
|
495
|
+
display: inline-block;
|
|
496
|
+
width: auto;
|
|
497
|
+
cursor: pointer;
|
|
498
|
+
font-size: .78rem;
|
|
499
|
+
color: var(--muted);
|
|
500
|
+
padding: .25rem .7rem;
|
|
501
|
+
border: 1px solid var(--line);
|
|
502
|
+
border-radius: 999px;
|
|
503
|
+
list-style: none;
|
|
504
|
+
}
|
|
505
|
+
.context-more > summary::-webkit-details-marker { display: none; }
|
|
506
|
+
.context-more > summary:hover { color: var(--accent); border-color: var(--accent); }
|
|
507
|
+
.context-more[open] > summary { margin-bottom: .6rem; }
|
|
424
508
|
.context li.done { color: var(--muted); }
|
|
425
509
|
.context-more > summary { cursor: pointer; color: var(--muted); font-size: .85rem; margin-top: .6rem; }
|
|
426
510
|
.copy-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 0; }
|
|
427
511
|
.sent-cmd { display: flex; gap: .4rem; align-items: stretch; margin: .4rem 0 0; }
|
|
512
|
+
/* A `display` rule BEATS the browser's own `[hidden] { display: none }`, so
|
|
513
|
+
every element this page hides in JavaScript needs its own override or the
|
|
514
|
+
hide silently does nothing. It shipped without one and a finished review kept
|
|
515
|
+
showing an empty command box. `assets-review` guards the whole class now. */
|
|
516
|
+
.sent-cmd[hidden] { display: none; }
|
|
428
517
|
.sent-cmd-text {
|
|
429
518
|
flex: 1; min-width: 0; font-family: var(--mono, ui-monospace, monospace);
|
|
430
519
|
font-size: .85rem; padding: .35rem .5rem; color: var(--fg);
|
|
@@ -437,7 +526,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
437
526
|
</style>
|
|
438
527
|
</head>
|
|
439
528
|
<body>
|
|
440
|
-
<div class="wrap">
|
|
529
|
+
<div class="wrap" id="wrap">
|
|
441
530
|
<header>
|
|
442
531
|
<h1 id="title"></h1>
|
|
443
532
|
<p class="sub" id="sub"></p>
|
|
@@ -459,15 +548,23 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
459
548
|
engine from the spec's own files — nothing here passed through the
|
|
460
549
|
model, so it is free however large the diff. Absent entirely for a spec
|
|
461
550
|
whose overview cannot be read: the page worked without it before. -->
|
|
462
|
-
|
|
551
|
+
<!-- WHAT YOU DECIDED, once the pass has actually been handed over. It sits
|
|
552
|
+
above everything because on a re-open it is the only thing you need:
|
|
553
|
+
the review is over, and this says how it ended. -->
|
|
554
|
+
<section class="decided" id="decided" hidden>
|
|
555
|
+
<p class="decided-what" id="decided-what"></p>
|
|
556
|
+
<p class="decided-note" id="decided-note"></p>
|
|
557
|
+
<button type="button" class="decided-toggle" id="decided-toggle">Show the diff anyway</button>
|
|
558
|
+
</section>
|
|
559
|
+
<section class="context reviewable" id="context" hidden>
|
|
463
560
|
<div class="context-why" id="context-why"></div>
|
|
464
561
|
<details class="context-more" id="context-more" hidden>
|
|
465
562
|
<summary id="context-more-summary">More</summary>
|
|
466
563
|
<div id="context-rest"></div>
|
|
467
564
|
</details>
|
|
468
565
|
</section>
|
|
469
|
-
<div id="review-block">__REVIEW_BLOCK__</div>
|
|
470
|
-
<div id="files"></div>
|
|
566
|
+
<div id="review-block" class="reviewable">__REVIEW_BLOCK__</div>
|
|
567
|
+
<div id="files" class="reviewable"></div>
|
|
471
568
|
<!-- THE VERDICT SITS AT THE END, where reading finishes. It began in the
|
|
472
569
|
header and the first person to use it could not find it: you read a
|
|
473
570
|
350-line diff downward, and the control asking for your conclusion was
|
|
@@ -479,6 +576,12 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
479
576
|
<span class="verdict-title">Your verdict</span>
|
|
480
577
|
<button id="verdict-commit" class="v-commit" type="button" aria-live="polite">✓ Commit</button>
|
|
481
578
|
<button id="verdict-commit-continue" class="v-commit" type="button" aria-live="polite">✓ Commit & Continue</button>
|
|
579
|
+
<!-- THE MID-RUN VERDICT, hidden unless the render asked for it. "Commit"
|
|
580
|
+
is the wrong verb for unfinished work, so a page rendered part-way
|
|
581
|
+
through a run offers this instead of the committing pair: I have
|
|
582
|
+
read it, carry on. It is not the removed `none` verdict — that
|
|
583
|
+
recorded itself and did nothing, while this one resumes the run. -->
|
|
584
|
+
<button id="verdict-continue" class="v-continue" type="button" hidden>→ Continue</button>
|
|
482
585
|
<button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
|
|
483
586
|
<button id="verdict-discuss" type="button">… Discuss first</button>
|
|
484
587
|
<span class="verdict-count" id="verdict-count" aria-live="polite"></span>
|
|
@@ -613,6 +716,9 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
613
716
|
// Best effort, both ways. Safari refuses storage on file:// by THROWING, and
|
|
614
717
|
// a review page that fails to render because it could not autosave would be a
|
|
615
718
|
// far worse bug than a lost pass.
|
|
719
|
+
// What this reader concluded, once it was actually HANDED OVER — never on a
|
|
720
|
+
// clipboard copy, which is a pass still sitting in the reader's hands.
|
|
721
|
+
var decided = null
|
|
616
722
|
var STORE_KEY = 'skitterspec-review:' + data.spec + ':' + data.generatedAt
|
|
617
723
|
function save() {
|
|
618
724
|
try {
|
|
@@ -622,7 +728,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
622
728
|
accepts[st.file.path] = st.accept
|
|
623
729
|
st.drafts.forEach(function (d) { drafts.push(d) })
|
|
624
730
|
})
|
|
625
|
-
window.localStorage.setItem(
|
|
731
|
+
window.localStorage.setItem(
|
|
732
|
+
STORE_KEY,
|
|
733
|
+
JSON.stringify({ seq: seq, accepts: accepts, drafts: drafts, replies: replies, decided: decided }),
|
|
734
|
+
)
|
|
626
735
|
} catch (e) { /* no storage, or a quota — the pass still works, it just is not saved */ }
|
|
627
736
|
}
|
|
628
737
|
function restore() {
|
|
@@ -633,6 +742,10 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
633
742
|
try { saved = JSON.parse(raw) } catch (e) { return }
|
|
634
743
|
seq = saved.seq || 0
|
|
635
744
|
replies = saved.replies || {}
|
|
745
|
+
// THE DECISION OUTLIVES THE TAB. Keyed to this render like every other
|
|
746
|
+
// mark, so the next render — the one after the commit — is a live page
|
|
747
|
+
// again rather than a page that thinks it is finished.
|
|
748
|
+
if (saved.decided && saved.decided.verdict) decided = saved.decided
|
|
636
749
|
var byPath = {}
|
|
637
750
|
state.forEach(function (st) { byPath[st.file.path] = st })
|
|
638
751
|
Object.keys(saved.accepts || {}).forEach(function (path) {
|
|
@@ -1115,6 +1228,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1115
1228
|
var verdictBtns = {
|
|
1116
1229
|
commit: document.getElementById('verdict-commit'),
|
|
1117
1230
|
'commit-continue': document.getElementById('verdict-commit-continue'),
|
|
1231
|
+
continue: document.getElementById('verdict-continue'),
|
|
1118
1232
|
changes: document.getElementById('verdict-changes'),
|
|
1119
1233
|
discuss: document.getElementById('verdict-discuss'),
|
|
1120
1234
|
}
|
|
@@ -1122,17 +1236,55 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1122
1236
|
// here, and the shared block below follows for free — the same reason the
|
|
1123
1237
|
// engine keeps a `COMMITTING` list rather than a second condition.
|
|
1124
1238
|
var COMMITTERS = ['commit', 'commit-continue']
|
|
1125
|
-
|
|
1239
|
+
|
|
1240
|
+
/*
|
|
1241
|
+
* WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
|
|
1242
|
+
* from the gate. Deriving it — mid-run iff nothing is armed — would take the
|
|
1243
|
+
* committing buttons away from every page in a project running
|
|
1244
|
+
* `review.required: false`, which never arms and whose readers must still be
|
|
1245
|
+
* able to commit from the page. The engine's `BUTTON_SETS` is the same
|
|
1246
|
+
* decision on the other side of the wire.
|
|
1247
|
+
*
|
|
1248
|
+
* ABSENT IS THE COMMITTING SET. A page rendered before any of this existed,
|
|
1249
|
+
* and a caller that did not ask, both land here — so nothing about their page
|
|
1250
|
+
* changes.
|
|
1251
|
+
*/
|
|
1252
|
+
var MIDRUN = data.buttons === 'midrun'
|
|
1253
|
+
var OFFERED = MIDRUN ? ['continue', 'changes', 'discuss'] : ['commit', 'commit-continue', 'changes', 'discuss']
|
|
1254
|
+
Object.keys(verdictBtns).forEach(function (key) {
|
|
1255
|
+
verdictBtns[key].hidden = OFFERED.indexOf(key) === -1
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
// EVERY VERDICT, because this is the one place a verdict gets a reader-facing
|
|
1259
|
+
// name: `refresh` relabels only the committing pair, but the decided panel
|
|
1260
|
+
// has to name whichever verdict was actually sent — including one this page
|
|
1261
|
+
// never offered, since a decision restored from storage outlives the render
|
|
1262
|
+
// that made it.
|
|
1263
|
+
var LABELS = {
|
|
1264
|
+
commit: '✓ Commit',
|
|
1265
|
+
'commit-continue': '✓ Commit & Continue',
|
|
1266
|
+
continue: '→ Continue',
|
|
1267
|
+
changes: '↺ Request changes',
|
|
1268
|
+
discuss: '… Discuss first',
|
|
1269
|
+
}
|
|
1126
1270
|
var TITLES = {
|
|
1127
1271
|
commit: 'Commit what you just read, and stop there',
|
|
1128
1272
|
// Says what it will NOT do: a reader must not press this expecting the
|
|
1129
1273
|
// spec to be finished and landed.
|
|
1130
1274
|
'commit-continue': 'Commit what you just read, then build the next phase — nothing is landed',
|
|
1275
|
+
// Says what it will NOT do, for the same reason: nothing is committed by
|
|
1276
|
+
// this, so the work it resumes is still uncommitted afterwards.
|
|
1277
|
+
continue: 'You have read it — carry on with the run; nothing is committed',
|
|
1131
1278
|
}
|
|
1132
1279
|
|
|
1133
1280
|
var verdictCount = document.getElementById('verdict-count')
|
|
1134
1281
|
var verdictLog = document.getElementById('verdict-log')
|
|
1135
1282
|
var copyOut = document.getElementById('copy-out')
|
|
1283
|
+
var wrapEl = document.getElementById('wrap')
|
|
1284
|
+
var decidedBox = document.getElementById('decided')
|
|
1285
|
+
var decidedWhat = document.getElementById('decided-what')
|
|
1286
|
+
var decidedNote = document.getElementById('decided-note')
|
|
1287
|
+
var decidedToggle = document.getElementById('decided-toggle')
|
|
1136
1288
|
var copyHint = document.getElementById('copy-hint')
|
|
1137
1289
|
var sentCmd = document.getElementById('sent-cmd')
|
|
1138
1290
|
var sentCmdText = document.getElementById('sent-cmd-text')
|
|
@@ -1164,11 +1316,17 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1164
1316
|
// the moment the last note goes and a phone tab switch mid-review costs
|
|
1165
1317
|
// nothing.
|
|
1166
1318
|
function refresh() {
|
|
1319
|
+
// A decided page is finished. `refresh` runs on every mark and re-enables
|
|
1320
|
+
// the two committing buttons unconditionally, so without this it would undo
|
|
1321
|
+
// the ending every time anything redrew.
|
|
1322
|
+
if (decided) return drawDecided()
|
|
1167
1323
|
var n = pending()
|
|
1168
1324
|
var open = openNotes()
|
|
1169
1325
|
verdictCount.textContent = n
|
|
1170
1326
|
? n + (n === 1 ? ' mark to send' : ' marks to send')
|
|
1171
|
-
|
|
1327
|
+
// Names the action this page actually offers. A mid-run page saying "a
|
|
1328
|
+
// clean read still commits" would promise the one thing it cannot do.
|
|
1329
|
+
: MIDRUN ? 'Nothing marked — a clean read still carries on' : 'Nothing marked — a clean read still commits'
|
|
1172
1330
|
// ONE COUNT, BOTH BUTTONS. Two committing controls blocked by two separate
|
|
1173
1331
|
// reads is how a page ends up with one disabled and the other not — which
|
|
1174
1332
|
// would be a way around the single refusal this page makes.
|
|
@@ -1197,19 +1355,119 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1197
1355
|
: TITLES[key]
|
|
1198
1356
|
})
|
|
1199
1357
|
// Said rather than left unset: the committing pair is the ONLY thing this
|
|
1200
|
-
// page ever blocks, and the
|
|
1201
|
-
//
|
|
1358
|
+
// page ever blocks, and the others are always available on purpose — a note
|
|
1359
|
+
// you want acted on, and a question, are never the wrong thing to send.
|
|
1360
|
+
//
|
|
1361
|
+
// CONTINUE IS NOT BLOCKED, and that mirrors the engine rather than softening
|
|
1362
|
+
// it: `judgeVerdict` refuses a COMMITTING verdict against an open note, and
|
|
1363
|
+
// `continue` is deliberately not one. A disabled button here would be a
|
|
1364
|
+
// second opinion about a refusal the engine does not make, which is the one
|
|
1365
|
+
// thing this bar must never be.
|
|
1366
|
+
verdictBtns.continue.disabled = false
|
|
1202
1367
|
verdictBtns.changes.disabled = false
|
|
1203
1368
|
verdictBtns.discuss.disabled = false
|
|
1204
1369
|
save()
|
|
1205
1370
|
}
|
|
1206
1371
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1372
|
+
/**
|
|
1373
|
+
* End the review, and say how it ended.
|
|
1374
|
+
*
|
|
1375
|
+
* A verdict that has been handed over is the END of this page's job, and the
|
|
1376
|
+
* page used to carry on as if it were not: the diff stayed, the four buttons
|
|
1377
|
+
* stayed live, and a line of small grey text under them said "Sent." A reader
|
|
1378
|
+
* coming back to that tab has no way to tell a sent review from an unsent
|
|
1379
|
+
* one, and pressing a button again reaches an agent that stopped listening.
|
|
1380
|
+
*
|
|
1381
|
+
* So the diff goes, what was decided takes its place, and the controls die
|
|
1382
|
+
* with a reason on them.
|
|
1383
|
+
*/
|
|
1384
|
+
function drawDecided() {
|
|
1385
|
+
if (!decided) return
|
|
1386
|
+
var said = SAID[decided.verdict] || decided.verdict
|
|
1387
|
+
var when = String(decided.at || '').slice(0, 10)
|
|
1388
|
+
decidedBox.hidden = false
|
|
1389
|
+
decidedWhat.textContent = 'You chose: ' + (LABELS[decided.verdict] || decided.verdict)
|
|
1390
|
+
decidedNote.textContent =
|
|
1391
|
+
'Sent to Claude' + (when ? ' on ' + when : '') + ' — this review is ' + said + '. ' +
|
|
1392
|
+
'Nothing here reaches Claude any more, so the verdict buttons are closed.'
|
|
1393
|
+
wrapEl.className = wrapEl.className.replace(/\s*\bis-decided\b/g, '') + ' is-decided'
|
|
1394
|
+
// EVERY button, not only the committing pair: `refresh` re-enables those two
|
|
1395
|
+
// by design, and a decided page must beat it.
|
|
1396
|
+
Object.keys(verdictBtns).forEach(function (key) {
|
|
1397
|
+
var btn = verdictBtns[key]
|
|
1398
|
+
btn.disabled = true
|
|
1399
|
+
btn.title = 'Already sent — ' + said
|
|
1400
|
+
})
|
|
1401
|
+
verdictCount.textContent = 'Sent — ' + said
|
|
1402
|
+
// ONE STATEMENT, NOT FOUR. The panel, the bar, the history line and the
|
|
1403
|
+
// command box all said the same thing at once. The log line is the panel's
|
|
1404
|
+
// sentence again in smaller type, so it goes.
|
|
1405
|
+
verdictLog.hidden = true
|
|
1406
|
+
|
|
1407
|
+
// THE HAND-OFF SURVIVES THE ENDING — but only where there is something
|
|
1408
|
+
// worth carrying. A code is six digits to transcribe, and the box and its
|
|
1409
|
+
// Copy button exist for exactly that. A bare `/spec-reviewed` is a word the
|
|
1410
|
+
// reader is about to type into the terminal they are already sitting in;
|
|
1411
|
+
// dressing it as a copyable artefact makes the ending look unfinished.
|
|
1412
|
+
if (decided.cmd && /\s/.test(decided.cmd)) {
|
|
1413
|
+
copyHint.hidden = false
|
|
1414
|
+
copyHint.textContent = 'Sent. Run this where Claude is:'
|
|
1415
|
+
showCommand(decided.cmd)
|
|
1416
|
+
return
|
|
1417
|
+
}
|
|
1418
|
+
copyHint.hidden = true
|
|
1419
|
+
sentCmd.hidden = true
|
|
1420
|
+
copyOut.hidden = true
|
|
1421
|
+
if (decided.cmd) {
|
|
1422
|
+
decidedNote.textContent +=
|
|
1423
|
+
' If Claude was not already waiting, run ' + decided.cmd + ' where it is.'
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/** Record a verdict that was actually delivered, and end the page on it. */
|
|
1428
|
+
function markDecided(verdict, cmd) {
|
|
1429
|
+
decided = { verdict: verdict, at: new Date().toISOString(), cmd: cmd || null }
|
|
1430
|
+
save()
|
|
1431
|
+
drawDecided()
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// A WAY BACK IN, read-only. "It is finished" and "I cannot see what I
|
|
1435
|
+
// approved" are different things, and only the first one is true.
|
|
1436
|
+
decidedToggle.addEventListener('click', function () {
|
|
1437
|
+
var showing = / \bshow-diff\b/.test(' ' + wrapEl.className)
|
|
1438
|
+
wrapEl.className = wrapEl.className.replace(/\s*\bshow-diff\b/g, '') + (showing ? '' : ' show-diff')
|
|
1439
|
+
decidedToggle.textContent = showing ? 'Show the diff anyway' : 'Hide the diff'
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
// How a logged verdict reads. EVERY VERDICT NAMED, and an unknown one says
|
|
1443
|
+
// so: the fallback was `discussed`, which quietly relabelled `commit` — and a
|
|
1444
|
+
// page that tells you a commit was a discussion is worse than one that admits
|
|
1445
|
+
// it does not know the word.
|
|
1446
|
+
var SAID = {
|
|
1447
|
+
approve: 'committed',
|
|
1448
|
+
commit: 'committed',
|
|
1449
|
+
'commit-continue': 'committed, then carried on',
|
|
1450
|
+
continue: 'read it and carried on',
|
|
1451
|
+
changes: 'changes requested',
|
|
1452
|
+
discuss: 'discussed',
|
|
1453
|
+
skip: 'moved on without a verdict',
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// The last thing that happened here, as history. It is why the page can look
|
|
1457
|
+
// untouched after a commit: the verdict was consumed, not stored. A SKIP is
|
|
1458
|
+
// history of exactly the same kind — it is the recorded decision to move on,
|
|
1459
|
+
// and hiding it would make a skipped review indistinguishable from one that
|
|
1460
|
+
// never ended.
|
|
1209
1461
|
function drawLog() {
|
|
1210
1462
|
var last = data.notes && data.notes.lastDecision
|
|
1463
|
+
var skip = data.gate && data.gate.lastSkip
|
|
1464
|
+
// Whichever is more recent. Both are timestamped, and comparing is what
|
|
1465
|
+
// stops an old verdict shadowing a skip taken after it.
|
|
1466
|
+
if (skip && (!last || String(skip.at || '') > String(last.at || ''))) {
|
|
1467
|
+
last = { verdict: 'skip', at: skip.at, note: skip.reason }
|
|
1468
|
+
}
|
|
1211
1469
|
if (!last) return
|
|
1212
|
-
var said = last.verdict
|
|
1470
|
+
var said = SAID[last.verdict] || ('recorded as "' + last.verdict + '"')
|
|
1213
1471
|
var when = String(last.at || '').slice(0, 10)
|
|
1214
1472
|
verdictLog.hidden = false
|
|
1215
1473
|
verdictLog.textContent = said + ' earlier' + (when ? ' · ' + when : '') + (last.note ? ' · ' + last.note : '')
|
|
@@ -1366,7 +1624,7 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1366
1624
|
* you read six digits out, where a paste costs context in proportion to how
|
|
1367
1625
|
* much you wrote.
|
|
1368
1626
|
*/
|
|
1369
|
-
function post(json) {
|
|
1627
|
+
function post(json, verdict) {
|
|
1370
1628
|
copyHint.hidden = false
|
|
1371
1629
|
copyHint.textContent = 'Sending…'
|
|
1372
1630
|
fetch(location.pathname, {
|
|
@@ -1392,12 +1650,14 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1392
1650
|
if (code) {
|
|
1393
1651
|
copyHint.textContent = 'Sent. Run this where Claude is:'
|
|
1394
1652
|
showCommand('/spec-reviewed ' + code)
|
|
1653
|
+
markDecided(verdict, '/spec-reviewed ' + code)
|
|
1395
1654
|
} else {
|
|
1396
1655
|
// NO CODE, NO COMMAND. A `/spec-reviewed` with nothing after it still
|
|
1397
1656
|
// works — it picks up the single waiting pass — but the page cannot
|
|
1398
1657
|
// claim the server named THIS one, so it does not put words in its
|
|
1399
1658
|
// mouth. Bare is what the reader is told, because bare is what is true.
|
|
1400
1659
|
copyHint.textContent = 'Sent — run /spec-reviewed to pick it up.'
|
|
1660
|
+
markDecided(verdict)
|
|
1401
1661
|
}
|
|
1402
1662
|
})
|
|
1403
1663
|
}, function () {
|
|
@@ -1407,17 +1667,83 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1407
1667
|
})
|
|
1408
1668
|
}
|
|
1409
1669
|
|
|
1670
|
+
/**
|
|
1671
|
+
* Is this page running as a published Artifact?
|
|
1672
|
+
*
|
|
1673
|
+
* A CAPABILITY CHECK, not a hostname one. `window.claude.use` is the whole
|
|
1674
|
+
* contract for reaching anything the viewer grants, and it exists only where
|
|
1675
|
+
* a viewer is there to grant it — so its presence is what distinguishes the
|
|
1676
|
+
* published page from the two the engine serves, without this file knowing
|
|
1677
|
+
* anything about where it was published.
|
|
1678
|
+
*/
|
|
1679
|
+
function isArtifact() {
|
|
1680
|
+
return Boolean(window.claude && typeof window.claude.use === 'function')
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* Hand the pass to the artifact's own store.
|
|
1685
|
+
*
|
|
1686
|
+
* THE THIRD TRANSPORT, for the reader no local server can reach: a phone on
|
|
1687
|
+
* mobile data, or anyone away from the LAN the engine serves on. The pass
|
|
1688
|
+
* lands in a document Claude reads back and deletes, so it is consumed
|
|
1689
|
+
* exactly like a claimed one.
|
|
1690
|
+
*
|
|
1691
|
+
* `use` is asked only once the reader has pressed something. Asking at load
|
|
1692
|
+
* would spend a consent prompt on every render, including the ones nobody
|
|
1693
|
+
* ends in a verdict.
|
|
1694
|
+
*/
|
|
1695
|
+
function sendToStore(json, verdict) {
|
|
1696
|
+
copyHint.hidden = false
|
|
1697
|
+
copyHint.textContent = 'Sending…'
|
|
1698
|
+
var landed = false
|
|
1699
|
+
window.claude.use('db').then(function (db) {
|
|
1700
|
+
// `null` means this view cannot run db — not granted, not served, failed
|
|
1701
|
+
// to load, all indistinguishable by design. The clipboard is still there,
|
|
1702
|
+
// so the pass is recoverable rather than lost.
|
|
1703
|
+
if (!db) {
|
|
1704
|
+
showFallback(json, 'Could not reach the store. Copy this, then paste it to Claude.')
|
|
1705
|
+
return
|
|
1706
|
+
}
|
|
1707
|
+
return db
|
|
1708
|
+
.collection('passes')
|
|
1709
|
+
.add({ spec: data.spec, at: new Date().toISOString(), render: data.generatedAt, blob: JSON.parse(json) })
|
|
1710
|
+
.then(function () {
|
|
1711
|
+
landed = true
|
|
1712
|
+
// NO CODE HERE, and none invented. Six digits are the engine's, minted
|
|
1713
|
+
// by the holding area this pass never went through — so the page says
|
|
1714
|
+
// the one thing that is true and actionable.
|
|
1715
|
+
copyHint.textContent = 'Sent. Run this where Claude is:'
|
|
1716
|
+
showCommand('/spec-reviewed')
|
|
1717
|
+
markDecided(verdict, '/spec-reviewed')
|
|
1718
|
+
})
|
|
1719
|
+
}).then(null, function (err) {
|
|
1720
|
+
if (landed) return
|
|
1721
|
+
showFallback(
|
|
1722
|
+
json,
|
|
1723
|
+
'Could not store the pass' + (err && err.code ? ' (' + err.code + ')' : '') +
|
|
1724
|
+
'. Copy this, then paste it to Claude.',
|
|
1725
|
+
)
|
|
1726
|
+
})
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1410
1729
|
function send(verdict) {
|
|
1411
1730
|
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
|
|
1731
|
+
// DECIDED FROM WHAT THE PAGE IS, not from what it can guess. A published
|
|
1732
|
+
// page has a store and no server; a page opened over http was served by the
|
|
1733
|
+
// engine and can hand the pass straight back; a `file://` page has no server
|
|
1734
|
+
// to talk to and never will.
|
|
1415
1735
|
//
|
|
1416
1736
|
// NOT "try the POST and fall back on failure": a failed POST and an absent
|
|
1417
1737
|
// one look identical to the reader, so the fallback would hide a server
|
|
1418
|
-
// that is refusing passes behind a message about copying.
|
|
1738
|
+
// that is refusing passes behind a message about copying. The published page
|
|
1739
|
+
// is exactly that trap — its POST would go to claude.ai and fail — which is
|
|
1740
|
+
// why it is answered first, by what the page IS rather than by what failed.
|
|
1741
|
+
if (isArtifact()) {
|
|
1742
|
+
sendToStore(json, verdict)
|
|
1743
|
+
return
|
|
1744
|
+
}
|
|
1419
1745
|
if (location.protocol !== 'file:') {
|
|
1420
|
-
post(json)
|
|
1746
|
+
post(json, verdict)
|
|
1421
1747
|
return
|
|
1422
1748
|
}
|
|
1423
1749
|
// `file://` is not a secure context everywhere, and the clipboard API is
|
|
@@ -1438,9 +1764,11 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1438
1764
|
|
|
1439
1765
|
Object.keys(verdictBtns).forEach(function (verdict) {
|
|
1440
1766
|
verdictBtns[verdict].addEventListener('click', function () {
|
|
1441
|
-
// A disabled button does not fire in a browser; the guard is
|
|
1442
|
-
// other caller, and it keeps the block
|
|
1443
|
-
|
|
1767
|
+
// A disabled or hidden button does not fire in a browser; the guard is
|
|
1768
|
+
// for every other caller, and it keeps both the block and the button set
|
|
1769
|
+
// facts rather than styles — a verdict this render never offered must not
|
|
1770
|
+
// be reachable by dispatching at it.
|
|
1771
|
+
if (verdictBtns[verdict].disabled || verdictBtns[verdict].hidden) return
|
|
1444
1772
|
send(verdict)
|
|
1445
1773
|
})
|
|
1446
1774
|
})
|
|
@@ -1449,12 +1777,20 @@ button:disabled:hover { color: var(--muted); border-color: var(--line); }
|
|
|
1449
1777
|
// read its own version says nothing rather than claiming "unknown" — an
|
|
1450
1778
|
// absence here is about the lookup, not about the page.
|
|
1451
1779
|
if (data.engine) {
|
|
1452
|
-
|
|
1780
|
+
// `0.0.0` is what the unpublished source package carries, so a page drawn
|
|
1781
|
+
// from a working copy would otherwise claim a version that has never been
|
|
1782
|
+
// released. This line exists to answer "which engine drew this" — and a
|
|
1783
|
+
// string that LOOKS like a version and is not one answers it wrongly, which
|
|
1784
|
+
// is worse than not answering. The field itself is untouched: the serve
|
|
1785
|
+
// daemon compares it to spot a stale renderer, and it must stay a version.
|
|
1786
|
+
document.getElementById('drawn-by').textContent =
|
|
1787
|
+
'rendered by skitterspec ' + (data.engine === '0.0.0' ? '(unreleased build)' : data.engine)
|
|
1453
1788
|
}
|
|
1454
1789
|
|
|
1455
1790
|
restore()
|
|
1456
1791
|
drawContext()
|
|
1457
1792
|
drawLog()
|
|
1793
|
+
drawDecided()
|
|
1458
1794
|
wireChecks()
|
|
1459
1795
|
state.forEach(function (st) {
|
|
1460
1796
|
var btn = st.details && st.details.querySelector('.accept')
|