@skitterbyte/skitterspec 22.0.0 → 22.1.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/README.md +14 -0
- package/assets/claude-md-section.md +13 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +83 -2
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +132 -2
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +160 -11
- package/assets/rules/spec-reports.md +50 -9
- package/assets/skills/no-spec/SKILL.md +219 -0
- package/assets/skills/spec/SKILL.md +135 -36
- package/assets/skills/spec-bug/SKILL.md +5 -0
- package/assets/skills/spec-cancel/SKILL.md +29 -0
- package/assets/skills/spec-complete/SKILL.md +25 -0
- package/assets/skills/spec-diff/SKILL.md +33 -1
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +47 -2
- package/assets/skills/spec-reviewed/SKILL.md +3 -3
- package/assets/skills/spec-start/SKILL.md +14 -0
- package/package.json +1 -1
- package/src/cli.js +702 -59
- package/src/env/config.js +121 -4
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +30 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +92 -3
- package/src/env/review.js +324 -20
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +189 -44
- package/src/env/teardown.js +12 -1
- package/src/init.js +125 -15
package/src/env/review.js
CHANGED
|
@@ -285,7 +285,7 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
|
|
|
285
285
|
// THE SURFACES BLOCK, built here so the page never has to know which tiers
|
|
286
286
|
// exist or which of them can be turned on. `null` when the caller passed
|
|
287
287
|
// neither a live state nor a stack — an absent key, not an empty one.
|
|
288
|
-
const surfaces = surfacesFor({ live, tiers })
|
|
288
|
+
const surfaces = surfacesFor({ live, tiers, buttons })
|
|
289
289
|
|
|
290
290
|
const files = []
|
|
291
291
|
for (const f of trackedFiles(git, ref)) {
|
|
@@ -494,6 +494,14 @@ const NOTES_VERSION = 1
|
|
|
494
494
|
* verdict did. So a blob from an older page, or one a reader sent without
|
|
495
495
|
* choosing, keeps doing exactly what it always did.
|
|
496
496
|
*
|
|
497
|
+
* `commit-land` is the `/no-spec` verdict: commit this work, fast-forward the
|
|
498
|
+
* base branch to it, and tear the worktree down. It is its own word for exactly
|
|
499
|
+
* the reason `commit-start` is — reusing plain `commit` would make one verdict
|
|
500
|
+
* mean "commit and stop" on a phase page and "commit, land and destroy the
|
|
501
|
+
* worktree" on a `/no-spec` page, which is the context-dependent reading this
|
|
502
|
+
* vocabulary exists to prevent. The outcome log would then record the same word
|
|
503
|
+
* for two very differently destructive actions.
|
|
504
|
+
*
|
|
497
505
|
* `continue` is the mid-run verdict: *I have read it, carry on*. It names an
|
|
498
506
|
* action, which is what separates it from the `none` verdict that was removed —
|
|
499
507
|
* `none` recorded itself and did nothing, while this one resumes the run. What
|
|
@@ -501,7 +509,15 @@ const NOTES_VERSION = 1
|
|
|
501
509
|
* below and is therefore structurally incapable of clearing an armed gate: a
|
|
502
510
|
* phase that ended still owes a committing verdict or a recorded skip.
|
|
503
511
|
*/
|
|
504
|
-
const VERDICTS = [
|
|
512
|
+
const VERDICTS = [
|
|
513
|
+
'commit',
|
|
514
|
+
'commit-continue',
|
|
515
|
+
'commit-start',
|
|
516
|
+
'commit-land',
|
|
517
|
+
'continue',
|
|
518
|
+
'changes',
|
|
519
|
+
'discuss',
|
|
520
|
+
]
|
|
505
521
|
const DEFAULT_VERDICT = 'discuss'
|
|
506
522
|
|
|
507
523
|
/**
|
|
@@ -579,7 +595,24 @@ function tierAction(tier) {
|
|
|
579
595
|
* the payload must then be byte-identical to what it was before this key
|
|
580
596
|
* existed.
|
|
581
597
|
*/
|
|
582
|
-
function surfacesFor({ live, tiers }) {
|
|
598
|
+
function surfacesFor({ live, tiers, buttons = null }) {
|
|
599
|
+
// A MID-PHASE PAGE CANNOT PUT ANYTHING LIVE, so it is not offered the press.
|
|
600
|
+
//
|
|
601
|
+
// `live take` refuses a dirty worktree, so the action commits the phase
|
|
602
|
+
// first — and `/spec-diff` §2b is explicit that half a phase is not
|
|
603
|
+
// committed to look at it running: that splits one phase across two commits
|
|
604
|
+
// and leaves a mess nobody asked for. So mid-run the press could only ever be
|
|
605
|
+
// declined, and a control that always refuses teaches the reader that the
|
|
606
|
+
// buttons on this page are decorative — the same failure
|
|
607
|
+
// `.claude/rules/spec-reports.md` records for the offer nothing was watching.
|
|
608
|
+
//
|
|
609
|
+
// The row stays, with the command in place of the button: the reader still
|
|
610
|
+
// learns the surface exists and what to type, which is exactly the trade the
|
|
611
|
+
// strip already makes on a `file://` page.
|
|
612
|
+
//
|
|
613
|
+
// ONLY `live-on`. `allow-network` and `allow-remote` write a config file
|
|
614
|
+
// and commit nothing, so they are as valid mid-phase as at the end of one.
|
|
615
|
+
const canCommit = buttons !== 'midrun'
|
|
583
616
|
const rows = []
|
|
584
617
|
|
|
585
618
|
// `unavailable` contributes nothing — the cannot-tell state, routed to
|
|
@@ -594,10 +627,12 @@ function surfacesFor({ live, tiers }) {
|
|
|
594
627
|
// `on` because handing the instance back to `main` is not this review's
|
|
595
628
|
// business, and `held` because the way out is another spec's to take and a
|
|
596
629
|
// button here would either park someone else's work or do nothing.
|
|
597
|
-
action: live.state === 'off' ? 'live-on' : null,
|
|
630
|
+
action: live.state === 'off' && canCommit ? 'live-on' : null,
|
|
598
631
|
// The command for the direction the page does not offer, so the reader is
|
|
599
|
-
// never left knowing what they want and not what to type.
|
|
600
|
-
|
|
632
|
+
// never left knowing what they want and not what to type. Mid-run that is
|
|
633
|
+
// `/spec-live` itself, since the press is withheld above.
|
|
634
|
+
command:
|
|
635
|
+
live.state === 'on' ? '/spec-live main' : live.state === 'off' && !canCommit ? '/spec-live' : null,
|
|
601
636
|
})
|
|
602
637
|
}
|
|
603
638
|
|
|
@@ -700,7 +735,7 @@ function reviewTierLine(t) {
|
|
|
700
735
|
// obligation that outlives the turn. A mid-run reader saying "carry on" has
|
|
701
736
|
// answered the offer in front of them and nothing else, so the gate a finished
|
|
702
737
|
// phase armed must survive it untouched.
|
|
703
|
-
const COMMITTING = ['commit', 'commit-continue', 'commit-start']
|
|
738
|
+
const COMMITTING = ['commit', 'commit-continue', 'commit-start', 'commit-land']
|
|
704
739
|
|
|
705
740
|
/**
|
|
706
741
|
* Which set of buttons a rendered page shows.
|
|
@@ -726,8 +761,21 @@ const COMMITTING = ['commit', 'commit-continue', 'commit-start']
|
|
|
726
761
|
* already be in progress, so offering to put it in flight would be wrong for
|
|
727
762
|
* half this set's inputs — and wrong in the expensive direction, since it would
|
|
728
763
|
* offer to provision a worktree for a spec that already has one.
|
|
764
|
+
*
|
|
765
|
+
* `nospec` is the set for work with no spec document: its committing pair is
|
|
766
|
+
* `commit-land` and `commit` — finish it now, or commit and leave the branch
|
|
767
|
+
* standing. `commit-continue` is absent because there is no next phase, and
|
|
768
|
+
* `commit-start` because there is nothing to put in flight.
|
|
769
|
+
*
|
|
770
|
+
* REUSING `refresh` HERE WAS THE OBVIOUS MOVE AND IT IS WRONG. The three
|
|
771
|
+
* remaining buttons are identical, so the temptation is to let `/no-spec` take
|
|
772
|
+
* that set and treat plain `commit` as "commit, land, tear down". That makes one
|
|
773
|
+
* verdict word mean two different things depending on which skill rendered the
|
|
774
|
+
* page — precisely what having separate words for `commit-start` and
|
|
775
|
+
* `commit-continue` exists to avoid, and worse here, because one of the two
|
|
776
|
+
* readings destroys a worktree.
|
|
729
777
|
*/
|
|
730
|
-
const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh']
|
|
778
|
+
const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh', 'nospec']
|
|
731
779
|
const DEFAULT_BUTTON_SET = 'committing'
|
|
732
780
|
|
|
733
781
|
/**
|
|
@@ -1146,6 +1194,11 @@ function passesSince(pending, since) {
|
|
|
1146
1194
|
|
|
1147
1195
|
const WAIT_POLL_MS = 400
|
|
1148
1196
|
|
|
1197
|
+
// How often a running wait says it is still running. Five minutes, against a
|
|
1198
|
+
// 400ms poll: this is not part of the poll at all, it is proof of life for
|
|
1199
|
+
// whatever is supervising the process.
|
|
1200
|
+
const WAIT_HEARTBEAT_MS = 5 * 60 * 1000
|
|
1201
|
+
|
|
1149
1202
|
/**
|
|
1150
1203
|
* Block until exactly one pass arrives inside a window. Impure only in that it
|
|
1151
1204
|
* reads the store and sleeps; it writes nothing and claims nothing.
|
|
@@ -1168,16 +1221,42 @@ const WAIT_POLL_MS = 400
|
|
|
1168
1221
|
* is the normal case rather than the edge one. An hour was picked once and a
|
|
1169
1222
|
* lunch break beat it.
|
|
1170
1223
|
*
|
|
1224
|
+
* IT SAYS IT IS STILL RUNNING, on `onHeartbeat`. A backgrounded wait produced
|
|
1225
|
+
* no output between starting and finding a pass, and over a long idle it was
|
|
1226
|
+
* killed — three sessions lost their wait over one lunch break. Whether the
|
|
1227
|
+
* trigger is process silence or session idleness was never established, so the
|
|
1228
|
+
* cheap half is done here: the process stops being silent. The other half is
|
|
1229
|
+
* the caller re-arming, which covers it either way.
|
|
1230
|
+
*
|
|
1231
|
+
* The cadence is INDEPENDENT of the poll. Polling stays at 400ms because that
|
|
1232
|
+
* is how fast a press should be noticed; the heartbeat is minutes apart because
|
|
1233
|
+
* it is addressed to a supervisor, not to a reader.
|
|
1234
|
+
*
|
|
1171
1235
|
* WHAT WOULD FOOL A LOOSER VERSION: treating an absent, empty, or older-only
|
|
1172
1236
|
* store as a reason to return. None of those is a pass — they are the ordinary
|
|
1173
1237
|
* state of a review nobody has answered yet — and a wait that ended on one
|
|
1174
1238
|
* would report "no pass" as an outcome (`.claude/rules/negative-checks.md`
|
|
1175
1239
|
* rule 3 has a test for each).
|
|
1176
1240
|
*/
|
|
1177
|
-
async function waitForPass(
|
|
1241
|
+
async function waitForPass(
|
|
1242
|
+
readStore,
|
|
1243
|
+
since,
|
|
1244
|
+
{
|
|
1245
|
+
timeoutMs = null,
|
|
1246
|
+
pollMs = WAIT_POLL_MS,
|
|
1247
|
+
sleep,
|
|
1248
|
+
onHeartbeat = null,
|
|
1249
|
+
heartbeatMs = WAIT_HEARTBEAT_MS,
|
|
1250
|
+
now = () => Date.now(),
|
|
1251
|
+
} = {},
|
|
1252
|
+
) {
|
|
1178
1253
|
if (!Number.isFinite(Date.parse(since))) return { state: 'unusable' }
|
|
1179
1254
|
const nap = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
1180
|
-
const
|
|
1255
|
+
const startedAt = now()
|
|
1256
|
+
const deadline = Number.isFinite(timeoutMs) && timeoutMs !== null ? startedAt + timeoutMs : null
|
|
1257
|
+
// Counted from the start rather than scheduled, so a slow poll cannot make
|
|
1258
|
+
// the heartbeat drift and a fast one cannot make it fire twice.
|
|
1259
|
+
let beats = 0
|
|
1181
1260
|
|
|
1182
1261
|
for (;;) {
|
|
1183
1262
|
// A store that will not parse is not an empty one, and it is not a pass
|
|
@@ -1191,9 +1270,22 @@ async function waitForPass(readStore, since, { timeoutMs = null, pollMs = WAIT_P
|
|
|
1191
1270
|
if (window.codes.length > 1) return { state: 'ambiguous', count: window.codes.length }
|
|
1192
1271
|
}
|
|
1193
1272
|
}
|
|
1194
|
-
if (deadline !== null &&
|
|
1273
|
+
if (deadline !== null && now() >= deadline) return { state: 'timeout' }
|
|
1274
|
+
|
|
1275
|
+
// SAID ON THE WAY ROUND, never on the way out. A line printed when the wait
|
|
1276
|
+
// ends would only ever appear for the waits that already worked, which is
|
|
1277
|
+
// the half that never needed proof.
|
|
1278
|
+
if (onHeartbeat && heartbeatMs > 0) {
|
|
1279
|
+
const elapsed = now() - startedAt
|
|
1280
|
+
const due = Math.floor(elapsed / heartbeatMs)
|
|
1281
|
+
if (due > beats) {
|
|
1282
|
+
beats = due
|
|
1283
|
+
onHeartbeat({ elapsedMs: elapsed, since })
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1195
1287
|
// Never overshoot the deadline by a whole poll interval.
|
|
1196
|
-
await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline -
|
|
1288
|
+
await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline - now())))
|
|
1197
1289
|
}
|
|
1198
1290
|
}
|
|
1199
1291
|
|
|
@@ -1280,6 +1372,39 @@ function waitingPasses(reviewsDirPath) {
|
|
|
1280
1372
|
|
|
1281
1373
|
const GATE_VERSION = 1
|
|
1282
1374
|
|
|
1375
|
+
/**
|
|
1376
|
+
* The reason a BYPASS records, and it is deliberately a fixed string.
|
|
1377
|
+
*
|
|
1378
|
+
* A skip normally carries what the operator typed. This one is chosen from a
|
|
1379
|
+
* picker with nothing typed, so the record has to say that for itself — and a
|
|
1380
|
+
* string nobody would write by hand is what keeps a tapped bypass tellable from
|
|
1381
|
+
* a considered one when both appear in the same log.
|
|
1382
|
+
*
|
|
1383
|
+
* It stays `by: 'skip'` rather than minting a third `DISARMED_BY` verb: the
|
|
1384
|
+
* sidecar's vocabulary is versioned, and a distinct reason already buys the only
|
|
1385
|
+
* thing a third verb would have.
|
|
1386
|
+
*/
|
|
1387
|
+
const GATE_BYPASS_REASON = 'none: chose to commit without reading the diff'
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* The way out of an armed gate, declared beside the refusal rather than left
|
|
1391
|
+
* for a caller to reconstruct.
|
|
1392
|
+
*
|
|
1393
|
+
* A BYPASS, NOT A SATISFY: the phase still owes a verdict and nobody has read
|
|
1394
|
+
* it, so this steps past a guard that is still unsatisfied — which is exactly
|
|
1395
|
+
* why it is recorded and a `satisfy` is not.
|
|
1396
|
+
*
|
|
1397
|
+
* WHAT THIS IS NOT: a `--force`. One was asked for and rejected, because a
|
|
1398
|
+
* reasonless lift is indistinguishable from nobody having looked. The exits
|
|
1399
|
+
* this gate has are unchanged — a committing verdict, or a recorded skip — and
|
|
1400
|
+
* this is the second of those two at an address a person will actually reach.
|
|
1401
|
+
*/
|
|
1402
|
+
const GATE_BYPASS_OFFER = Object.freeze({
|
|
1403
|
+
kind: 'bypass',
|
|
1404
|
+
label: 'Commit without reading the diff — recorded as such',
|
|
1405
|
+
command: `skitterspec spec-env review skip "${GATE_BYPASS_REASON}"`,
|
|
1406
|
+
})
|
|
1407
|
+
|
|
1283
1408
|
// How a disarm happened. `verdict` is a review that reached a committing
|
|
1284
1409
|
// conclusion; `skip` is the operator saying, on the record, that they are
|
|
1285
1410
|
// moving on without one.
|
|
@@ -1290,7 +1415,7 @@ function reviewGatePath(outPath) {
|
|
|
1290
1415
|
}
|
|
1291
1416
|
|
|
1292
1417
|
function emptyGate(specFolder) {
|
|
1293
|
-
return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, log: [] }
|
|
1418
|
+
return { version: GATE_VERSION, spec: specFolder, armed: false, armedAt: null, phase: null, offeredAt: null, log: [] }
|
|
1294
1419
|
}
|
|
1295
1420
|
|
|
1296
1421
|
/**
|
|
@@ -1318,6 +1443,10 @@ function readGate(outPath, specFolder) {
|
|
|
1318
1443
|
armed: parsed.armed === true,
|
|
1319
1444
|
armedAt: parsed.armedAt || null,
|
|
1320
1445
|
phase: parsed.phase === undefined ? null : parsed.phase,
|
|
1446
|
+
// A sidecar written before this field existed reads as never-offered,
|
|
1447
|
+
// which is the correct and harmless default. These files are gitignored,
|
|
1448
|
+
// so there is no fleet to migrate and `GATE_VERSION` does not move.
|
|
1449
|
+
offeredAt: parsed.offeredAt || null,
|
|
1321
1450
|
log: Array.isArray(parsed.log) ? parsed.log : [],
|
|
1322
1451
|
},
|
|
1323
1452
|
corrupt: false,
|
|
@@ -1335,6 +1464,64 @@ function writeGate(outPath, gate) {
|
|
|
1335
1464
|
return p
|
|
1336
1465
|
}
|
|
1337
1466
|
|
|
1467
|
+
/* ==========================================================================
|
|
1468
|
+
* The render record — what the caller DECLARED, for the re-render to read
|
|
1469
|
+
* ========================================================================== */
|
|
1470
|
+
|
|
1471
|
+
// Same stem as the other sidecars, so one spec's set still reads as one set.
|
|
1472
|
+
function renderRecordPath(outPath) {
|
|
1473
|
+
return outPath.replace(/\.html$/, '') + '.render.json'
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* The button set the last CLI render of this spec declared, or null.
|
|
1478
|
+
*
|
|
1479
|
+
* WHY THIS EXISTS AT ALL. `--buttons` is declared by the caller — `BUTTON_SETS`
|
|
1480
|
+
* above says why it is never derived — and the review daemon is not that
|
|
1481
|
+
* caller. It re-renders a spec's page per request from the tree, so without a
|
|
1482
|
+
* record it can only guess, and what it guessed was the default: every
|
|
1483
|
+
* `/no-spec` page served over http offered `Commit & Start`.
|
|
1484
|
+
*
|
|
1485
|
+
* WHAT WOULD FOOL A CONSUMER THAT TRUSTED THIS OUTRIGHT: it is a file on disk
|
|
1486
|
+
* and the tree moves underneath it. A spec rendered as `--docs` and then put in
|
|
1487
|
+
* flight has a day-old record saying `authoring` and a worktree saying
|
|
1488
|
+
* otherwise. So `buttonsForView` in `serve.js` takes only NARROWING words from
|
|
1489
|
+
* here and reads the family off the tree — this function's job is to report
|
|
1490
|
+
* what was written, not to decide whether it still holds.
|
|
1491
|
+
*
|
|
1492
|
+
* Three states, like `readNotes` and `readGate`: absent is the ordinary case,
|
|
1493
|
+
* and corrupt is reported rather than hidden so neither reads as a declaration.
|
|
1494
|
+
*/
|
|
1495
|
+
function readRenderRecord(outPath, specFolder) {
|
|
1496
|
+
let raw
|
|
1497
|
+
try {
|
|
1498
|
+
raw = fs.readFileSync(renderRecordPath(outPath), 'utf8')
|
|
1499
|
+
} catch {
|
|
1500
|
+
// Absent is ordinary: most pages are opened off the index without the CLI
|
|
1501
|
+
// ever having rendered that spec.
|
|
1502
|
+
return { buttons: null, corrupt: false, present: false }
|
|
1503
|
+
}
|
|
1504
|
+
try {
|
|
1505
|
+
const parsed = JSON.parse(raw)
|
|
1506
|
+
const buttons = typeof parsed.buttons === 'string' && parsed.buttons ? parsed.buttons : null
|
|
1507
|
+
// The folder is checked, not trusted: a record found under this spec's stem
|
|
1508
|
+
// that names another spec is not this spec's declaration.
|
|
1509
|
+
if (parsed.spec && specFolder && parsed.spec !== specFolder) {
|
|
1510
|
+
return { buttons: null, corrupt: false, present: true }
|
|
1511
|
+
}
|
|
1512
|
+
return { buttons, corrupt: false, present: true }
|
|
1513
|
+
} catch {
|
|
1514
|
+
return { buttons: null, corrupt: true, present: true }
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function writeRenderRecord(outPath, record) {
|
|
1519
|
+
const p = renderRecordPath(outPath)
|
|
1520
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
1521
|
+
fs.writeFileSync(p, JSON.stringify(record, null, 2) + '\n')
|
|
1522
|
+
return p
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1338
1525
|
/**
|
|
1339
1526
|
* Arm the gate. Pure.
|
|
1340
1527
|
*
|
|
@@ -1344,8 +1531,32 @@ function writeGate(outPath, gate) {
|
|
|
1344
1531
|
* for a DIFFERENT phase is re-armed, because that is a new obligation.
|
|
1345
1532
|
*/
|
|
1346
1533
|
function armGate(gate, { at, phase = null }) {
|
|
1534
|
+
// Idempotent within a phase — a re-render must not restart the clock, and it
|
|
1535
|
+
// must not hand back an offer this arming has already spent.
|
|
1347
1536
|
if (gate.armed && gate.phase === phase) return gate
|
|
1348
|
-
|
|
1537
|
+
// A NEW ARMING GETS A NEW OFFER. Each phase that ends is its own obligation,
|
|
1538
|
+
// so the once-only rule is scoped to the arming rather than to the file.
|
|
1539
|
+
return { ...gate, armed: true, armedAt: at, phase, offeredAt: null }
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Record that the bypass was offered for this arming. Pure.
|
|
1544
|
+
*
|
|
1545
|
+
* SPENT BY BEING MADE, not by being taken. A reader who declines still saw it,
|
|
1546
|
+
* and re-offering on the next refused commit is how a bypass becomes the default
|
|
1547
|
+
* rather than a deliberate step.
|
|
1548
|
+
*
|
|
1549
|
+
* NOT DONE AS A SIDE EFFECT OF READING THE GATE, deliberately. `/spec-next`
|
|
1550
|
+
* asks `review gate --json` on every single run, so a read that spent the
|
|
1551
|
+
* offer would burn it with no picker ever raised — and the operator would never
|
|
1552
|
+
* be offered the thing this exists to offer them.
|
|
1553
|
+
*
|
|
1554
|
+
* Marking an unarmed gate changes nothing: there is no obligation, so there is
|
|
1555
|
+
* nothing to have offered a way out of.
|
|
1556
|
+
*/
|
|
1557
|
+
function markGateOffered(gate, { at }) {
|
|
1558
|
+
if (!gate.armed || gate.offeredAt) return { gate, marked: false }
|
|
1559
|
+
return { gate: { ...gate, offeredAt: at }, marked: true }
|
|
1349
1560
|
}
|
|
1350
1561
|
|
|
1351
1562
|
/**
|
|
@@ -1416,7 +1627,15 @@ function gateState({ gate, corrupt, present, required }) {
|
|
|
1416
1627
|
}
|
|
1417
1628
|
}
|
|
1418
1629
|
if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
|
|
1419
|
-
|
|
1630
|
+
// The refusal is unchanged by the offer having been spent — what is withheld
|
|
1631
|
+
// is the way out, never the guard. A second refused commit still refuses, and
|
|
1632
|
+
// still names its exits in the text; the operator simply is not asked again.
|
|
1633
|
+
return {
|
|
1634
|
+
state: 'armed',
|
|
1635
|
+
reason: 'a phase is awaiting a verdict',
|
|
1636
|
+
gate,
|
|
1637
|
+
...(gate.offeredAt ? {} : { offer: GATE_BYPASS_OFFER }),
|
|
1638
|
+
}
|
|
1420
1639
|
}
|
|
1421
1640
|
|
|
1422
1641
|
/**
|
|
@@ -1871,6 +2090,7 @@ function renderReviewBlock(review) {
|
|
|
1871
2090
|
if (review.summary) {
|
|
1872
2091
|
parts.push(`<p class="review-summary">${escapeHtml(review.summary)}</p>`)
|
|
1873
2092
|
}
|
|
2093
|
+
parts.push(...renderReviewerStrip(review.reviewers))
|
|
1874
2094
|
const checks = Array.isArray(review.checks) ? review.checks : []
|
|
1875
2095
|
if (checks.length) {
|
|
1876
2096
|
parts.push('<ul class="checks">')
|
|
@@ -1879,16 +2099,31 @@ function renderReviewBlock(review) {
|
|
|
1879
2099
|
// reviewer's note because it was tagged oddly is worse than showing it
|
|
1880
2100
|
// under a neutral heading.
|
|
1881
2101
|
const level = CHECK_LEVELS.includes(c.level) ? c.level : 'confirm'
|
|
1882
|
-
|
|
2102
|
+
// `file:line` where a line is known, and the whole thing is a button so a
|
|
2103
|
+
// finding can be read against the code it is about. A check with no line
|
|
2104
|
+
// renders exactly as it always did — the written review supplies none, and
|
|
2105
|
+
// its output must not change because machine findings now exist.
|
|
2106
|
+
const hasLine = Number.isInteger(c.line) && c.line > 0
|
|
2107
|
+
const label = c.file ? escapeHtml(c.file) + (hasLine ? `:${c.line}` : '') : ''
|
|
2108
|
+
const file = c.file
|
|
2109
|
+
? `<button type="button" class="check-file" data-goto="${escapeHtml(c.file)}"` +
|
|
2110
|
+
`${hasLine ? ` data-goto-line="${c.line}"` : ''}>${label}</button>`
|
|
2111
|
+
: ''
|
|
2112
|
+
// WHO SAID IT. Absent for a written review, which is what it has always
|
|
2113
|
+
// been; present the moment two authors can appear on one page, because a
|
|
2114
|
+
// reader weighing a finding needs to know whether a person read the code
|
|
2115
|
+
// or a tool matched a pattern.
|
|
2116
|
+
const src = c.source ? `<span class="check-src">${escapeHtml(c.source)}</span>` : ''
|
|
1883
2117
|
// The id is positional and therefore stable for THIS render, which is all
|
|
1884
2118
|
// a reply needs: it travels back in the same blob the page was built from.
|
|
1885
2119
|
// `data-file` is what lets an answer land on the file the check is about.
|
|
1886
2120
|
const id = `k${i}`
|
|
1887
2121
|
const fileAttr = c.file ? ` data-file="${escapeHtml(c.file)}"` : ''
|
|
2122
|
+
const lineAttr = hasLine ? ` data-line="${c.line}"` : ''
|
|
1888
2123
|
parts.push(
|
|
1889
|
-
`<li class="check ${level}" data-check="${id}"${fileAttr}>` +
|
|
2124
|
+
`<li class="check ${level}" data-check="${id}"${fileAttr}${lineAttr}>` +
|
|
1890
2125
|
`<span class="check-level">${level}</span>` +
|
|
1891
|
-
`${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
|
|
2126
|
+
`${src}${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
|
|
1892
2127
|
)
|
|
1893
2128
|
})
|
|
1894
2129
|
parts.push('</ul>')
|
|
@@ -1897,6 +2132,65 @@ function renderReviewBlock(review) {
|
|
|
1897
2132
|
return parts.join('\n')
|
|
1898
2133
|
}
|
|
1899
2134
|
|
|
2135
|
+
/**
|
|
2136
|
+
* One line per configured reviewer, saying what its run ended as.
|
|
2137
|
+
*
|
|
2138
|
+
* PRESENT EVEN WHEN EVERY REVIEWER FOUND NOTHING, which is the whole point and
|
|
2139
|
+
* the one place `.claude/rules/negative-checks.md` inverts here. Silence is
|
|
2140
|
+
* normally the safe branch; on this page it is not, because a reviewer that was
|
|
2141
|
+
* rate-limited renders identically to one that read the diff and approved of it
|
|
2142
|
+
* — and the reader is about to decide whether to commit. So the strip reports
|
|
2143
|
+
* the RUN, which is a positive signal, rather than accusing the code.
|
|
2144
|
+
*
|
|
2145
|
+
* Absent entirely when no reviewer is configured: every project that exists
|
|
2146
|
+
* today is in that state, and a strip saying "no reviewers" would be an absence
|
|
2147
|
+
* reported at everyone.
|
|
2148
|
+
*/
|
|
2149
|
+
function renderReviewerStrip(outcomes) {
|
|
2150
|
+
const list = Array.isArray(outcomes) ? outcomes : []
|
|
2151
|
+
if (!list.length) return []
|
|
2152
|
+
const parts = ['<ul class="reviewers">']
|
|
2153
|
+
for (const o of list) {
|
|
2154
|
+
const state = typeof o.state === 'string' ? o.state : 'failed'
|
|
2155
|
+
parts.push(
|
|
2156
|
+
`<li class="reviewer ${escapeHtml(state)}">` +
|
|
2157
|
+
`<span class="reviewer-name">${escapeHtml(o.name || 'reviewer')}</span>` +
|
|
2158
|
+
`<span class="reviewer-said">${escapeHtml(reviewerSaid(o))}</span></li>`,
|
|
2159
|
+
)
|
|
2160
|
+
}
|
|
2161
|
+
parts.push('</ul>')
|
|
2162
|
+
return parts
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* What one reviewer's outcome reads as. Pure.
|
|
2167
|
+
*
|
|
2168
|
+
* A STATE THIS BUILD DOES NOT KNOW IS SHOWN, not dropped — the same rule the
|
|
2169
|
+
* check level follows. An unrecognised state means a newer engine wrote the
|
|
2170
|
+
* cache, and losing the line entirely would read as a reviewer that was never
|
|
2171
|
+
* configured.
|
|
2172
|
+
*/
|
|
2173
|
+
function reviewerSaid(o) {
|
|
2174
|
+
const n = Number.isInteger(o.count) ? o.count : 0
|
|
2175
|
+
const detail = o.detail ? ` — ${o.detail}` : ''
|
|
2176
|
+
switch (o.state) {
|
|
2177
|
+
case 'findings':
|
|
2178
|
+
return `${n} finding${n === 1 ? '' : 's'}${detail}`
|
|
2179
|
+
case 'cached':
|
|
2180
|
+
return n ? `${n} finding${n === 1 ? '' : 's'} · cached` : 'clean · cached'
|
|
2181
|
+
case 'clean':
|
|
2182
|
+
return 'clean'
|
|
2183
|
+
case 'missing':
|
|
2184
|
+
return `did not run${o.detail ? ` — ${o.detail}` : ''}`
|
|
2185
|
+
case 'timeout':
|
|
2186
|
+
return 'did not run — timed out'
|
|
2187
|
+
case 'failed':
|
|
2188
|
+
return `did not run${o.detail ? ` — ${o.detail}` : ''}`
|
|
2189
|
+
default:
|
|
2190
|
+
return `${o.state}${detail}`
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
1900
2194
|
/**
|
|
1901
2195
|
* Where a spec's published URL is remembered, and what it currently says.
|
|
1902
2196
|
*
|
|
@@ -1958,8 +2252,9 @@ function reviewServerNotice({ running, othersServed }) {
|
|
|
1958
2252
|
]
|
|
1959
2253
|
}
|
|
1960
2254
|
|
|
1961
|
-
// The publish-ready copy, beside the page it came from. Same stem, so
|
|
1962
|
-
//
|
|
2255
|
+
// The publish-ready copy, beside the page it came from. Same stem, so every
|
|
2256
|
+
// sidecar (`.notes.json`, `.url`, `.publish.html`, `.render.json`) reads as one
|
|
2257
|
+
// spec's set.
|
|
1963
2258
|
function reviewPublishPath(outPath) {
|
|
1964
2259
|
return outPath.replace(/\.html$/, '') + '.publish.html'
|
|
1965
2260
|
}
|
|
@@ -2044,6 +2339,7 @@ module.exports = {
|
|
|
2044
2339
|
passesSince,
|
|
2045
2340
|
waitingPasses,
|
|
2046
2341
|
waitForPass,
|
|
2342
|
+
WAIT_HEARTBEAT_MS,
|
|
2047
2343
|
WAIT_POLL_MS,
|
|
2048
2344
|
passState,
|
|
2049
2345
|
describePending,
|
|
@@ -2055,9 +2351,15 @@ module.exports = {
|
|
|
2055
2351
|
emptyGate,
|
|
2056
2352
|
readGate,
|
|
2057
2353
|
writeGate,
|
|
2354
|
+
renderRecordPath,
|
|
2355
|
+
readRenderRecord,
|
|
2356
|
+
writeRenderRecord,
|
|
2058
2357
|
armGate,
|
|
2059
2358
|
disarmGate,
|
|
2060
2359
|
gateState,
|
|
2360
|
+
markGateOffered,
|
|
2361
|
+
GATE_BYPASS_OFFER,
|
|
2362
|
+
GATE_BYPASS_REASON,
|
|
2061
2363
|
gateForPage,
|
|
2062
2364
|
mergeNotes,
|
|
2063
2365
|
applyResolutions,
|
|
@@ -2078,6 +2380,8 @@ module.exports = {
|
|
|
2078
2380
|
escapeIsland,
|
|
2079
2381
|
escapeHtml,
|
|
2080
2382
|
renderReviewBlock,
|
|
2383
|
+
renderReviewerStrip,
|
|
2384
|
+
reviewerSaid,
|
|
2081
2385
|
reviewUrlPath,
|
|
2082
2386
|
reviewPublishPath,
|
|
2083
2387
|
readReviewUrl,
|