@skitterbyte/skitterspec 21.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/MIGRATION.md +218 -0
- package/README.md +127 -6
- package/assets/claude-md-section.md +42 -18
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +6 -3
- package/assets/core/env.config.md +183 -25
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +1174 -101
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +195 -14
- package/assets/rules/spec-reports.md +181 -29
- package/assets/skills/no-spec/SKILL.md +219 -0
- package/assets/skills/spec/SKILL.md +248 -21
- package/assets/skills/spec-bug/SKILL.md +34 -24
- 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 +164 -37
- package/assets/skills/spec-hotfix/SKILL.md +22 -19
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +166 -58
- package/assets/skills/spec-review/SKILL.md +87 -0
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +33 -0
- package/package.json +1 -1
- package/src/cli.js +1612 -172
- package/src/env/classify.js +87 -2
- package/src/env/config.js +333 -19
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +124 -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 +128 -5
- package/src/env/review.js +854 -29
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +474 -50
- package/src/env/supervise.js +8 -1
- package/src/env/teardown.js +12 -1
- package/src/init.js +185 -24
package/src/env/review.js
CHANGED
|
@@ -44,6 +44,24 @@ function isNoise(relPath) {
|
|
|
44
44
|
return relPath.startsWith('specs/') || relPath.startsWith('.spec-env/')
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Is this path one of `folder`'s own spec documents, in any bucket?
|
|
49
|
+
*
|
|
50
|
+
* Every bucket, for the same reason `classifyDirtyTree` checks every bucket: a
|
|
51
|
+
* tree mid-`git mv` is dirty in two of them at once and both halves are the
|
|
52
|
+
* same spec's.
|
|
53
|
+
*
|
|
54
|
+
* It exists because `isNoise` above is exactly wrong for the `docs` mode. That
|
|
55
|
+
* rule — everything under `specs/` is bookkeeping — is right for a phase's code
|
|
56
|
+
* diff, where the spec's own checkbox edits are not what anyone came to read.
|
|
57
|
+
* On a page whose SUBJECT is the spec, applying it folds away every file and
|
|
58
|
+
* renders a page with nothing open on it.
|
|
59
|
+
*/
|
|
60
|
+
function isSpecDocOf(relPath, folder) {
|
|
61
|
+
const parts = String(relPath).split('/')
|
|
62
|
+
return parts.length > 3 && parts[0] === 'specs' && parts[2] === folder
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
/**
|
|
48
66
|
* A git runner bound to one checkout, returning stdout **untrimmed**.
|
|
49
67
|
*
|
|
@@ -174,12 +192,21 @@ function numstatFor(git, ref, file, untracked) {
|
|
|
174
192
|
* `remote` — somewhere else, so it does not.
|
|
175
193
|
* `unknown` — CANNOT TELL, and that is a real answer rather than a soft `local`.
|
|
176
194
|
*
|
|
177
|
-
* **This decides
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* behaviour
|
|
195
|
+
* **This decides two things: the WORDING, and WHAT THE SERVER BINDS TO.** It
|
|
196
|
+
* does not decide *whether* to serve — `review.serve` does — and it never
|
|
197
|
+
* decides to publish. Being wrong therefore stays cheap in both directions: a
|
|
198
|
+
* wrong `local` binds loopback, which is reachable from the machine holding the
|
|
199
|
+
* page and says so; a wrong `remote` binds every interface, which is the
|
|
200
|
+
* existing behaviour and is announced on the render.
|
|
201
|
+
*
|
|
202
|
+
* IT DID DECIDE WHETHER TO SERVE, ONCE, and this comment claimed otherwise
|
|
203
|
+
* throughout — the sentence above used to read "nothing in the engine serves on
|
|
204
|
+
* the strength of it" while `cli.js` served only for a `remote` reader. The bill
|
|
205
|
+
* was a `file://` link on a local machine, and a `file://` page has no server to
|
|
206
|
+
* POST to, so the verdict buttons on it had nowhere to go: the loop the page
|
|
207
|
+
* exists to close was absent from the sessions easiest to use. If you are here
|
|
208
|
+
* wondering whether to make detection load-bearing again, that is what it cost
|
|
209
|
+
* the last time, and `env-serve-start-proof` now fails if you do.
|
|
183
210
|
*
|
|
184
211
|
* `env` is passed in, never read from `process` here, so a test states the world
|
|
185
212
|
* it is testing instead of inheriting the machine the suite happens to run on.
|
|
@@ -234,17 +261,44 @@ function resolveReader(config, env = {}) {
|
|
|
234
261
|
* `buttons` is the button set the page renders — see `BUTTON_SETS`. It is the
|
|
235
262
|
* caller's declaration about the work, not a reading of the gate.
|
|
236
263
|
*/
|
|
237
|
-
function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false, buttons = null }) {
|
|
264
|
+
function collectReview({ spec, git, mode = 'working', ref, base = null, now, notes = null, gate = null, fellBack = false, buttons = null, only = null, treePath = null, live = null, tiers = null }) {
|
|
265
|
+
// WHICH TREE THE SPEC'S OWN DOCUMENTS ARE READ FROM. Every render but one
|
|
266
|
+
// reads the spec's worktree, and for those the two are the same path — so
|
|
267
|
+
// `treePath` left null keeps the existing behaviour exactly. The `docs` mode
|
|
268
|
+
// has no worktree to read, which is the whole reason it exists.
|
|
269
|
+
const root = treePath || spec.worktreePath
|
|
270
|
+
|
|
271
|
+
// AN EXPLICIT FILE SET, or every changed file. `only` is a whitelist of
|
|
272
|
+
// repo-relative paths, and it exists because the `docs` mode reads a tree
|
|
273
|
+
// several sessions write into: rendering everything uncommitted there would
|
|
274
|
+
// put another spec's documents on this page and then commit them under this
|
|
275
|
+
// page's verdict. Null — every other caller — filters nothing.
|
|
276
|
+
const wanted = only ? new Set(only) : null
|
|
277
|
+
const keep = (f) => !wanted || wanted.has(f.path)
|
|
278
|
+
|
|
279
|
+
// WHAT COUNTS AS BOOKKEEPING, and it inverts for the `docs` mode. Everywhere
|
|
280
|
+
// else the spec's own documents are the bookkeeping beside the code; on a
|
|
281
|
+
// docs page they are the code, and the companions the project declared (a
|
|
282
|
+
// tracker snapshot, say) are what belongs folded away.
|
|
283
|
+
const noiseOf = (p) => (mode === 'docs' ? !isSpecDocOf(p, spec.folder) : isNoise(p))
|
|
284
|
+
|
|
285
|
+
// THE SURFACES BLOCK, built here so the page never has to know which tiers
|
|
286
|
+
// exist or which of them can be turned on. `null` when the caller passed
|
|
287
|
+
// neither a live state nor a stack — an absent key, not an empty one.
|
|
288
|
+
const surfaces = surfacesFor({ live, tiers, buttons })
|
|
289
|
+
|
|
238
290
|
const files = []
|
|
239
291
|
for (const f of trackedFiles(git, ref)) {
|
|
292
|
+
if (!keep(f)) continue
|
|
240
293
|
const { patch, whole } = patchFor(git, ref, f, false)
|
|
241
294
|
const { additions, deletions, binary } = numstatFor(git, ref, f, false)
|
|
242
|
-
files.push({ ...f, additions, deletions, binary, whole, noise:
|
|
295
|
+
files.push({ ...f, additions, deletions, binary, whole, noise: noiseOf(f.path), patch })
|
|
243
296
|
}
|
|
244
297
|
for (const f of untrackedFiles(git)) {
|
|
298
|
+
if (!keep(f)) continue
|
|
245
299
|
const { patch, whole } = patchFor(git, ref, f, true)
|
|
246
300
|
const { additions, deletions, binary } = numstatFor(git, ref, f, true)
|
|
247
|
-
files.push({ ...f, additions, deletions, binary, whole, noise:
|
|
301
|
+
files.push({ ...f, additions, deletions, binary, whole, noise: noiseOf(f.path), patch })
|
|
248
302
|
}
|
|
249
303
|
|
|
250
304
|
// Content hashes and the stored review state, folded on before the totals so
|
|
@@ -261,7 +315,7 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
|
|
|
261
315
|
// Every bucket, because a page is rendered for specs in `in-progress/` and for
|
|
262
316
|
// finished ones in `complete/` — and the finished one is the case this exists
|
|
263
317
|
// for. `null` when it cannot tell, and the page leaves its button alone.
|
|
264
|
-
const specDir =
|
|
318
|
+
const specDir = root ? findSpecDirIn(root, spec.folder) : null
|
|
265
319
|
const phases = specDir ? readPhases(specDir) : null
|
|
266
320
|
// The PR description this page never had: why the change exists, what it
|
|
267
321
|
// touches, and what this phase set out to do.
|
|
@@ -280,7 +334,11 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
|
|
|
280
334
|
spec: spec.folder,
|
|
281
335
|
title: spec.folder,
|
|
282
336
|
branch: spec.branch,
|
|
283
|
-
worktree
|
|
337
|
+
// The tree the diff was read from. Identical to the worktree for every
|
|
338
|
+
// mode but `docs`, where there is no worktree and this is the checkout the
|
|
339
|
+
// documents actually live in — saying `worktree` there would name a path
|
|
340
|
+
// that does not exist.
|
|
341
|
+
worktree: root,
|
|
284
342
|
mode,
|
|
285
343
|
base,
|
|
286
344
|
ref,
|
|
@@ -304,6 +362,27 @@ function collectReview({ spec, git, mode = 'working', ref, base = null, now, not
|
|
|
304
362
|
// Same rule again: a gate that was never armed and never skipped adds no
|
|
305
363
|
// key at all.
|
|
306
364
|
...(gateForPage(gate) ? { gate: gateForPage(gate) } : {}),
|
|
365
|
+
// THE PATHS A COMMITTING VERDICT HERE MUST COMMIT, carried on the payload
|
|
366
|
+
// so the skill that routes on the verdict never recomputes them. It matters
|
|
367
|
+
// that they travel with the page rather than being asked for again: a
|
|
368
|
+
// checkout is shared, so the set of this spec's uncommitted documents can
|
|
369
|
+
// differ between the render and the verdict — and the reader's conclusion is
|
|
370
|
+
// about what the page showed them.
|
|
371
|
+
//
|
|
372
|
+
// Absent for every other mode, which is what keeps their payloads identical.
|
|
373
|
+
...(only ? { docs: { paths: only } } : {}),
|
|
374
|
+
// WHERE ELSE THIS REVIEW CAN BE REACHED, and whether it is also running.
|
|
375
|
+
//
|
|
376
|
+
// The page had no idea the other tiers existed — it is opened AT one URL
|
|
377
|
+
// and knows nothing about the rest — so an enable press needs somewhere to
|
|
378
|
+
// live, and that is this block. It carries only what the reader can act on:
|
|
379
|
+
// the live state, each tier that is OFF with the action that turns it on,
|
|
380
|
+
// and the `network` URL when it is on, which is the one address a reader
|
|
381
|
+
// wants that is not the one they are standing on.
|
|
382
|
+
//
|
|
383
|
+
// ABSENT STAYS ABSENT, exactly as `phases` and `gate` do: a caller that
|
|
384
|
+
// passes neither renders the payload it rendered before this existed.
|
|
385
|
+
...(surfaces ? { surfaces } : {}),
|
|
307
386
|
// THE DEFAULT ADDS NO KEY, so a caller that did not ask for a button set —
|
|
308
387
|
// and a caller that asked for the default by name — renders the payload it
|
|
309
388
|
// rendered before this existed. Opting in is the only thing that shows.
|
|
@@ -401,12 +480,28 @@ const NOTES_VERSION = 1
|
|
|
401
480
|
* THE VERDICT NAMES THE ACTION. It was `approve` once, and an approval that
|
|
402
481
|
* only recorded itself is the one thing on a review page that does not describe
|
|
403
482
|
* what happens — a review is the guard in front of an action, so the word is
|
|
404
|
-
* the action: `commit`, `commit-continue`, `
|
|
483
|
+
* the action: `commit`, `commit-continue`, `commit-start`, `changes`,
|
|
484
|
+
* `discuss`.
|
|
485
|
+
*
|
|
486
|
+
* `commit-start` is the authoring verdict: commit the spec that was just
|
|
487
|
+
* written, then put it in flight. It is its own word rather than a
|
|
488
|
+
* context-dependent reading of `commit-continue` for exactly the reason above —
|
|
489
|
+
* a verdict that means `/spec-start` on one page and `/spec-next` on another
|
|
490
|
+
* names neither, and the outcome log would record the same word for two
|
|
491
|
+
* different actions with no way to tell them apart afterwards.
|
|
405
492
|
*
|
|
406
493
|
* `discuss` is the default because it is the behaviour that existed before any
|
|
407
494
|
* verdict did. So a blob from an older page, or one a reader sent without
|
|
408
495
|
* choosing, keeps doing exactly what it always did.
|
|
409
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
|
+
*
|
|
410
505
|
* `continue` is the mid-run verdict: *I have read it, carry on*. It names an
|
|
411
506
|
* action, which is what separates it from the `none` verdict that was removed —
|
|
412
507
|
* `none` recorded itself and did nothing, while this one resumes the run. What
|
|
@@ -414,9 +509,222 @@ const NOTES_VERSION = 1
|
|
|
414
509
|
* below and is therefore structurally incapable of clearing an armed gate: a
|
|
415
510
|
* phase that ended still owes a committing verdict or a recorded skip.
|
|
416
511
|
*/
|
|
417
|
-
const VERDICTS = [
|
|
512
|
+
const VERDICTS = [
|
|
513
|
+
'commit',
|
|
514
|
+
'commit-continue',
|
|
515
|
+
'commit-start',
|
|
516
|
+
'commit-land',
|
|
517
|
+
'continue',
|
|
518
|
+
'changes',
|
|
519
|
+
'discuss',
|
|
520
|
+
]
|
|
418
521
|
const DEFAULT_VERDICT = 'discuss'
|
|
419
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Actions the page can send INSTEAD of a verdict.
|
|
525
|
+
*
|
|
526
|
+
* AN ACTION IS NOT A VERDICT, and keeping the two vocabularies apart is the
|
|
527
|
+
* whole point rather than tidiness. A verdict is a conclusion about the work,
|
|
528
|
+
* consumed once by the thing it asked for. An action changes what is *running*
|
|
529
|
+
* — or which tiers are permitted — and then puts the reader back on the same
|
|
530
|
+
* page with the same options, so a run answering one has concluded nothing.
|
|
531
|
+
*
|
|
532
|
+
* THERE IS NO `live-off`, and its absence is the decision. Putting *this* change
|
|
533
|
+
* live is about the diff on screen; handing the whole instance back to `main` is
|
|
534
|
+
* a workspace decision with nothing to do with this review, and a control for it
|
|
535
|
+
* on a review page invites the reader to make it while thinking about something
|
|
536
|
+
* else. The `live:` line names `/spec-live main` instead, which is what a person
|
|
537
|
+
* types.
|
|
538
|
+
*
|
|
539
|
+
* Three consequences follow from that, and all three are structural rather than
|
|
540
|
+
* remembered:
|
|
541
|
+
*
|
|
542
|
+
* - It is absent from `VERDICTS`, so nothing that routes on a verdict can see
|
|
543
|
+
* it.
|
|
544
|
+
* - It is absent from `COMMITTING`, so it is **incapable** of clearing an armed
|
|
545
|
+
* gate: a phase that ended still owes a verdict after the reader has looked
|
|
546
|
+
* at it running. The commit that a `live-on` performs first is the mechanical
|
|
547
|
+
* precondition for `live take` (which refuses a dirty worktree), not the
|
|
548
|
+
* reader's answer.
|
|
549
|
+
* - A blob may carry a verdict or an action, never both. Two answers in one
|
|
550
|
+
* pass would need a rule about which wins, and any such rule would sometimes
|
|
551
|
+
* act on the half the reader did not mean.
|
|
552
|
+
*
|
|
553
|
+
* `allow-network`/`allow-remote` turn a review TIER on, and they exist here
|
|
554
|
+
* because the page can now act — which is the premise
|
|
555
|
+
* `feat-three-review-links` decision 5 rejected a page toggle on, having
|
|
556
|
+
* recorded it as open to reopening. Two things replace the limit it rested on.
|
|
557
|
+
* **Only a reader who can already reach the page can press one**, so widening
|
|
558
|
+
* loopback→network can only be pressed from the machine itself, where the
|
|
559
|
+
* command was available anyway, and the serve token still decides who reaches
|
|
560
|
+
* the page at all. And `allow-remote` **permits publishing without
|
|
561
|
+
* publishing** — the permanent claude.ai page still takes an explicit ask.
|
|
562
|
+
*
|
|
563
|
+
* There is no disable direction, and `tierAction` is where that is enforced:
|
|
564
|
+
* turning `network` off from a page reached over the network kills the page
|
|
565
|
+
* doing the turning.
|
|
566
|
+
*/
|
|
567
|
+
const ACTIONS = ['live-on', 'allow-network', 'allow-remote']
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The action a tier that is OFF offers, or null.
|
|
571
|
+
*
|
|
572
|
+
* ENABLE-ONLY, and that is a decision rather than an omission: turning
|
|
573
|
+
* `network` off from a page you reached over the network kills the page doing
|
|
574
|
+
* the turning, and the reader gains nothing they could not get by typing the
|
|
575
|
+
* command. `local` has no setting at all — it is the machine the page is on.
|
|
576
|
+
*/
|
|
577
|
+
function tierAction(tier) {
|
|
578
|
+
if (!tier || !tier.off) return null
|
|
579
|
+
if (tier.tier === 'network') return 'allow-network'
|
|
580
|
+
if (tier.tier === 'remote') return 'allow-remote'
|
|
581
|
+
return null
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* What the page shows above the verdicts: where this review can be reached, and
|
|
586
|
+
* whether it is also running. Pure.
|
|
587
|
+
*
|
|
588
|
+
* ONLY WHAT THE READER CAN ACT ON. An `on` tier the reader is already standing
|
|
589
|
+
* on is not information — they are reading it — so the only tier URL carried is
|
|
590
|
+
* `network`'s, which is the one address someone on a laptop wants for their
|
|
591
|
+
* phone. Everything else here is a state plus the one press that changes it.
|
|
592
|
+
*
|
|
593
|
+
* `null` RATHER THAN AN EMPTY BLOCK when there is nothing to say: an
|
|
594
|
+
* `unavailable` live state and no stack means a caller that never asked, and
|
|
595
|
+
* the payload must then be byte-identical to what it was before this key
|
|
596
|
+
* existed.
|
|
597
|
+
*/
|
|
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'
|
|
616
|
+
const rows = []
|
|
617
|
+
|
|
618
|
+
// `unavailable` contributes nothing — the cannot-tell state, routed to
|
|
619
|
+
// silence exactly as `liveStateLine` routes it (`env/live.js`).
|
|
620
|
+
if (live && live.state && live.state !== 'unavailable') {
|
|
621
|
+
rows.push({
|
|
622
|
+
kind: 'live',
|
|
623
|
+
state: live.state,
|
|
624
|
+
url: live.url || null,
|
|
625
|
+
reason: live.reason || null,
|
|
626
|
+
// ONE DIRECTION ONLY. `off` offers the press; `on` and `held` offer none —
|
|
627
|
+
// `on` because handing the instance back to `main` is not this review's
|
|
628
|
+
// business, and `held` because the way out is another spec's to take and a
|
|
629
|
+
// button here would either park someone else's work or do nothing.
|
|
630
|
+
action: live.state === 'off' && canCommit ? 'live-on' : null,
|
|
631
|
+
// The command for the direction the page does not offer, so the reader is
|
|
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,
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
for (const t of tiers || []) {
|
|
640
|
+
const action = tierAction(t)
|
|
641
|
+
// ONLY WHAT THE READER CAN ACT ON. An `on` tier contributes nothing: the
|
|
642
|
+
// reader is standing on one of them, and the terminal render is where they
|
|
643
|
+
// got the link — printing it again here would be two places naming one page,
|
|
644
|
+
// which is the split `.claude/rules/spec-reports.md` records as a failure.
|
|
645
|
+
if (!action) continue
|
|
646
|
+
rows.push({
|
|
647
|
+
kind: 'tier',
|
|
648
|
+
tier: t.tier,
|
|
649
|
+
state: 'off',
|
|
650
|
+
url: null,
|
|
651
|
+
// SAID BEFORE THE PRESS, not after it. Unlike the live action, which
|
|
652
|
+
// touches no tracked file, `allow` edits `specs/.core/env.config.json` in
|
|
653
|
+
// the primary checkout — changing behaviour for everyone who pulls and
|
|
654
|
+
// leaving that tree dirty. A setting change of that reach must not land
|
|
655
|
+
// because someone tapped a button labelled only `Allow remote`.
|
|
656
|
+
note: t.note || 'writes env.config.json — committed, and shared',
|
|
657
|
+
// The same thing from a terminal, for the reader who is nowhere near the
|
|
658
|
+
// page. Only `remote` has a command of its own: `network` is ON by
|
|
659
|
+
// default, so turning it off is a rare deliberate act and a slash command
|
|
660
|
+
// per tier is clutter for the one nobody touches.
|
|
661
|
+
command: t.tier === 'remote' ? '/spec-remote-review' : null,
|
|
662
|
+
action,
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return rows.length ? rows : null
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The three review tiers, in a FIXED ORDER, each either a URL or the reason it
|
|
671
|
+
* is not one. Pure: it takes what the caller already resolved.
|
|
672
|
+
*
|
|
673
|
+
* Fixed order matters more than it looks. A reader who has learnt which line
|
|
674
|
+
* their phone opens should not have to re-read the labels every render, and a
|
|
675
|
+
* stack that reordered itself by availability would make them.
|
|
676
|
+
*
|
|
677
|
+
* WHY A STACK AT ALL, when the rule said exactly one link: the engine cannot
|
|
678
|
+
* know where the reader is sitting, and every attempt to guess failed the
|
|
679
|
+
* moment they moved. `local` and `network` are two doors into ONE room — the
|
|
680
|
+
* page POSTs to `location.pathname`, so both reach the same server and the same
|
|
681
|
+
* pending store, and one wait covers both. `remote` is a second store, which is
|
|
682
|
+
* why it carries the line saying so.
|
|
683
|
+
*/
|
|
684
|
+
function reviewTierStack({ served, fileUrl, publishedUrl, config }) {
|
|
685
|
+
const tiers = []
|
|
686
|
+
const loopbackUrl = served && served.loopbackUrl ? served.loopbackUrl : null
|
|
687
|
+
|
|
688
|
+
tiers.push(
|
|
689
|
+
loopbackUrl
|
|
690
|
+
? { tier: 'local', url: loopbackUrl }
|
|
691
|
+
: { tier: 'local', url: fileUrl, note: 'a file:// page cannot send a verdict' },
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if (!config.review.allowNetwork) {
|
|
695
|
+
tiers.push({ tier: 'network', off: true, enable: 'skitterspec spec-env review allow network' })
|
|
696
|
+
} else if (served && !served.loopback && served.url) {
|
|
697
|
+
tiers.push({ tier: 'network', url: served.url, alternates: served.alternates || [] })
|
|
698
|
+
} else {
|
|
699
|
+
// Permitted, but there is no address to offer — a machine with no network,
|
|
700
|
+
// or a server that could not take one. Not a problem to report as an error.
|
|
701
|
+
tiers.push({ tier: 'network', unavailable: true, note: 'no network address on this machine' })
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (!config.review.allowRemote) {
|
|
705
|
+
// THE COMMAND A PERSON TYPES. `/spec-remote-review` toggles, so the same
|
|
706
|
+
// line works whichever way the tier currently is — and nobody has to
|
|
707
|
+
// reconstruct `spec-env review allow remote` from a render they are reading
|
|
708
|
+
// on a phone.
|
|
709
|
+
tiers.push({ tier: 'remote', off: true, enable: '/spec-remote-review' })
|
|
710
|
+
} else if (publishedUrl) {
|
|
711
|
+
tiers.push({ tier: 'remote', url: publishedUrl, note: 'a verdict here needs /spec-reviewed' })
|
|
712
|
+
} else {
|
|
713
|
+
tiers.push({ tier: 'remote', unavailable: true, note: 'publishing is an ask — nothing published yet' })
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return tiers
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// One tier as a line, padded so the labels form a column the eye can run down.
|
|
720
|
+
function reviewTierLine(t) {
|
|
721
|
+
const label = ` ${t.tier}:`.padEnd(11)
|
|
722
|
+
if (t.url) return `${label}${t.url}${t.note ? ` (${t.note})` : ''}`
|
|
723
|
+
if (t.off) return `${label}off — turn on with: ${t.enable}`
|
|
724
|
+
return `${label}— (${t.note})`
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
|
|
420
728
|
// The verdicts that COMMIT, and are therefore blocked by an open comment. One
|
|
421
729
|
// list, so a fifth verdict cannot become a way around the single refusal this
|
|
422
730
|
// engine makes — adding a committing verdict means adding it here, and the
|
|
@@ -427,7 +735,7 @@ const DEFAULT_VERDICT = 'discuss'
|
|
|
427
735
|
// obligation that outlives the turn. A mid-run reader saying "carry on" has
|
|
428
736
|
// answered the offer in front of them and nothing else, so the gate a finished
|
|
429
737
|
// phase armed must survive it untouched.
|
|
430
|
-
const COMMITTING = ['commit', 'commit-continue']
|
|
738
|
+
const COMMITTING = ['commit', 'commit-continue', 'commit-start', 'commit-land']
|
|
431
739
|
|
|
432
740
|
/**
|
|
433
741
|
* Which set of buttons a rendered page shows.
|
|
@@ -442,8 +750,32 @@ const COMMITTING = ['commit', 'commit-continue']
|
|
|
442
750
|
* `committing` is the default, so a caller that says nothing keeps today's page
|
|
443
751
|
* exactly — the key is left off the payload entirely rather than written out as
|
|
444
752
|
* the default, so an unchanged caller renders an unchanged page.
|
|
753
|
+
*
|
|
754
|
+
* `authoring` is the set for a spec that has just been written: its committing
|
|
755
|
+
* pair is `commit-start` and `commit` — put it in flight now, or keep it for
|
|
756
|
+
* later. `commit-continue` is absent because there is no phase in flight to
|
|
757
|
+
* continue, and `continue` is absent because the run has nothing left to resume.
|
|
758
|
+
*
|
|
759
|
+
* `refresh` is the set for a spec that was re-validated rather than written:
|
|
760
|
+
* `commit`, `changes`, `discuss`, and NO start verdict. A refreshed spec may
|
|
761
|
+
* already be in progress, so offering to put it in flight would be wrong for
|
|
762
|
+
* half this set's inputs — and wrong in the expensive direction, since it would
|
|
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.
|
|
445
777
|
*/
|
|
446
|
-
const BUTTON_SETS = ['committing', 'midrun']
|
|
778
|
+
const BUTTON_SETS = ['committing', 'midrun', 'authoring', 'refresh', 'nospec']
|
|
447
779
|
const DEFAULT_BUTTON_SET = 'committing'
|
|
448
780
|
|
|
449
781
|
/**
|
|
@@ -584,9 +916,13 @@ function writeNotes(outPath, notes) {
|
|
|
584
916
|
|
|
585
917
|
const PENDING_VERSION = 1
|
|
586
918
|
|
|
587
|
-
// Beside the page and the notes sidecar, under gitignored `.spec-env/`.
|
|
919
|
+
// Beside the page and the notes sidecar, under gitignored `.spec-env/`. The
|
|
920
|
+
// suffix is named once so the cross-spec scan recognises a store by the same
|
|
921
|
+
// spelling that writes one.
|
|
922
|
+
const PENDING_SUFFIX = '.pending.json'
|
|
923
|
+
|
|
588
924
|
function reviewPendingPath(outPath) {
|
|
589
|
-
return outPath.replace(/\.html$/, '') +
|
|
925
|
+
return outPath.replace(/\.html$/, '') + PENDING_SUFFIX
|
|
590
926
|
}
|
|
591
927
|
|
|
592
928
|
function emptyPending(specFolder) {
|
|
@@ -674,6 +1010,59 @@ function addPending(pending, { blob, at, render }, mint = mintPendingCode) {
|
|
|
674
1010
|
return { pending: next, code }
|
|
675
1011
|
}
|
|
676
1012
|
|
|
1013
|
+
/**
|
|
1014
|
+
* What became of ONE pass, by its code. Reads; writes nothing, refuses nothing.
|
|
1015
|
+
*
|
|
1016
|
+
* The served page POSTs, is handed six digits, and then has to end on one of
|
|
1017
|
+
* two sentences — Claude picked this up, or here is the command that picks it
|
|
1018
|
+
* up. It cannot know which at send time, so it asks, and this answers.
|
|
1019
|
+
*
|
|
1020
|
+
* Three states, and the third is the point:
|
|
1021
|
+
*
|
|
1022
|
+
* - `waiting` — the code IS in the holding area. Nobody has claimed it.
|
|
1023
|
+
* - `claimed` — the decision log NAMES the code. Somebody did.
|
|
1024
|
+
* - `unknown` — neither could be established.
|
|
1025
|
+
*
|
|
1026
|
+
* BOTH ANSWERS REST ON SOMETHING PRESENT, which is what stops this being the
|
|
1027
|
+
* fourth row in the table at the top of `.claude/rules/negative-checks.md`. The
|
|
1028
|
+
* tempting cheap version reads `claimed` off the code being ABSENT from the
|
|
1029
|
+
* holding area — and absence there has four causes, of which exactly one is a
|
|
1030
|
+
* claim: `--drop` removes a pass and writes no decision, a store written under
|
|
1031
|
+
* a different `--out` is a store this never opens, and a mistyped folder finds
|
|
1032
|
+
* an empty one. Three of those four would report a pass as picked up while it
|
|
1033
|
+
* sits in a file nobody is reading, which is this spec's own bug told back to
|
|
1034
|
+
* the reader with confidence.
|
|
1035
|
+
*
|
|
1036
|
+
* So `unknown` answers everything that is not a positive signal, and the caller
|
|
1037
|
+
* turns it back into the command. That direction is deliberate: a command
|
|
1038
|
+
* nobody needed to run costs a glance, and a pass nobody claims costs the
|
|
1039
|
+
* review.
|
|
1040
|
+
*
|
|
1041
|
+
* A code that is not six digits never reaches disk. It cannot be one this
|
|
1042
|
+
* engine minted, so there is nothing to look up, and answering it from the
|
|
1043
|
+
* filesystem would make a reachable endpoint into a path-shaped probe.
|
|
1044
|
+
*/
|
|
1045
|
+
function passState(outPath, specFolder, code) {
|
|
1046
|
+
const wanted = typeof code === 'string' ? code.trim() : ''
|
|
1047
|
+
if (!new RegExp(`^\\d{${PENDING_CODE_LENGTH}}$`).test(wanted)) return { state: 'unknown' }
|
|
1048
|
+
|
|
1049
|
+
const held = readPending(outPath, specFolder)
|
|
1050
|
+
// A store we could not parse is not an empty one. It holds somebody's passes
|
|
1051
|
+
// and we simply cannot see them, so neither answer is available.
|
|
1052
|
+
if (held.corrupt) return { state: 'unknown' }
|
|
1053
|
+
if ((held.pending.passes || []).some((p) => p && p.code === wanted)) return { state: 'waiting' }
|
|
1054
|
+
|
|
1055
|
+
const stored = readNotes(outPath, specFolder)
|
|
1056
|
+
if (stored.corrupt) return { state: 'unknown' }
|
|
1057
|
+
const decisions = Array.isArray(stored.notes.decisions) ? stored.notes.decisions : []
|
|
1058
|
+
if (decisions.some((d) => d && d.code === wanted)) return { state: 'claimed' }
|
|
1059
|
+
|
|
1060
|
+
// Gone from the holding area and unnamed in the log. It may have been
|
|
1061
|
+
// dropped, it may predate this field, the store may not be the one the page
|
|
1062
|
+
// was served from. Say so.
|
|
1063
|
+
return { state: 'unknown' }
|
|
1064
|
+
}
|
|
1065
|
+
|
|
677
1066
|
/**
|
|
678
1067
|
* What is waiting, as the render should describe it. Pure.
|
|
679
1068
|
*
|
|
@@ -695,6 +1084,11 @@ function describePending(pending) {
|
|
|
695
1084
|
// `approve`, and the operator must be offered the word that describes
|
|
696
1085
|
// what claiming it would do.
|
|
697
1086
|
verdict: readVerdict((p.blob && p.blob.verdict) || null) || null,
|
|
1087
|
+
// AN ACTION IS REPORTED AS AN ACTION. A pass carrying one has no verdict,
|
|
1088
|
+
// and listing it as `no verdict` would describe the instruction it does
|
|
1089
|
+
// carry as an absence — which is the reading that makes a reader claim it
|
|
1090
|
+
// expecting a conclusion.
|
|
1091
|
+
action: (p.blob && p.blob.action) || null,
|
|
698
1092
|
at: p.at || null,
|
|
699
1093
|
}))
|
|
700
1094
|
.sort((a, b) => String(a.at).localeCompare(String(b.at)) || a.code.localeCompare(b.code))
|
|
@@ -782,6 +1176,185 @@ function passesSince(pending, since) {
|
|
|
782
1176
|
return { codes, usable: true }
|
|
783
1177
|
}
|
|
784
1178
|
|
|
1179
|
+
/* ==========================================================================
|
|
1180
|
+
* The wait — one implementation, because improvised ones cannot be tested
|
|
1181
|
+
*
|
|
1182
|
+
* The skills used to say "watch the pending store and end your turn" and stop
|
|
1183
|
+
* there, so every run wrote its own watcher in shell. Three failed in two days,
|
|
1184
|
+
* each reaching the operator as "I pressed the button and nothing happened" —
|
|
1185
|
+
* and the worst of them was `until [ -f "$P" ] && [ "$x" \\> "$y" ]`, valid bash
|
|
1186
|
+
* and a syntax error in zsh, which spun for five minutes writing to a stderr
|
|
1187
|
+
* nobody reads.
|
|
1188
|
+
*
|
|
1189
|
+
* What made that expensive was not the typo. It is that SILENCE WAS THE SUCCESS
|
|
1190
|
+
* SIGNAL: from outside, a watcher that can never fire and one patiently working
|
|
1191
|
+
* look exactly alike. So the comparison lives here, in one place, where a test
|
|
1192
|
+
* can hand it a store that gains a pass mid-flight and watch it return.
|
|
1193
|
+
* ========================================================================== */
|
|
1194
|
+
|
|
1195
|
+
const WAIT_POLL_MS = 400
|
|
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
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Block until exactly one pass arrives inside a window. Impure only in that it
|
|
1204
|
+
* reads the store and sleeps; it writes nothing and claims nothing.
|
|
1205
|
+
*
|
|
1206
|
+
* Four outcomes, and they are distinct because the caller acts differently on
|
|
1207
|
+
* each:
|
|
1208
|
+
*
|
|
1209
|
+
* - `{ state: 'arrived', code }` — one pass, inside the window. Claim it.
|
|
1210
|
+
* - `{ state: 'ambiguous', count }` — more than one. NAMES NO CODE, because
|
|
1211
|
+
* choosing between two is exactly the guess `claimPending` refuses to make.
|
|
1212
|
+
* - `{ state: 'timeout' }` — only when a timeout was asked for.
|
|
1213
|
+
* - `{ state: 'unusable' }` — the window could not be parsed, so there is
|
|
1214
|
+
* nothing to wait inside. Returns AT ONCE rather than blocking forever on a
|
|
1215
|
+
* comparison that can never be satisfied — which is the failure this whole
|
|
1216
|
+
* function exists to stop.
|
|
1217
|
+
*
|
|
1218
|
+
* NO TIMEOUT UNLESS ASKED. `timeoutMs` omitted means wait as long as the
|
|
1219
|
+
* process lives, and that is the default the skills use: any fixed number is a
|
|
1220
|
+
* guess about how long someone reads, and a reader who walks away from a diff
|
|
1221
|
+
* is the normal case rather than the edge one. An hour was picked once and a
|
|
1222
|
+
* lunch break beat it.
|
|
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
|
+
*
|
|
1235
|
+
* WHAT WOULD FOOL A LOOSER VERSION: treating an absent, empty, or older-only
|
|
1236
|
+
* store as a reason to return. None of those is a pass — they are the ordinary
|
|
1237
|
+
* state of a review nobody has answered yet — and a wait that ended on one
|
|
1238
|
+
* would report "no pass" as an outcome (`.claude/rules/negative-checks.md`
|
|
1239
|
+
* rule 3 has a test for each).
|
|
1240
|
+
*/
|
|
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
|
+
) {
|
|
1253
|
+
if (!Number.isFinite(Date.parse(since))) return { state: 'unusable' }
|
|
1254
|
+
const nap = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
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
|
|
1260
|
+
|
|
1261
|
+
for (;;) {
|
|
1262
|
+
// A store that will not parse is not an empty one, and it is not a pass
|
|
1263
|
+
// either — keep waiting rather than reading someone's unreadable passes as
|
|
1264
|
+
// an answer. `readPending` already reports `corrupt` rather than throwing.
|
|
1265
|
+
const read = readStore()
|
|
1266
|
+
if (read && !read.corrupt) {
|
|
1267
|
+
const window = passesSince(read.pending, since)
|
|
1268
|
+
if (window.usable) {
|
|
1269
|
+
if (window.codes.length === 1) return { state: 'arrived', code: window.codes[0] }
|
|
1270
|
+
if (window.codes.length > 1) return { state: 'ambiguous', count: window.codes.length }
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
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
|
+
|
|
1287
|
+
// Never overshoot the deadline by a whole poll interval.
|
|
1288
|
+
await nap(deadline === null ? pollMs : Math.max(0, Math.min(pollMs, deadline - now())))
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Every waiting pass, across every spec. Reads; writes, claims and refuses
|
|
1294
|
+
* nothing.
|
|
1295
|
+
*
|
|
1296
|
+
* A wait that never fired, a session cleared, a terminal closed overnight —
|
|
1297
|
+
* none of those is recoverable by any watcher, however good. This is what finds
|
|
1298
|
+
* the pass afterwards, so recovery stops depending on the operator happening to
|
|
1299
|
+
* suspect something.
|
|
1300
|
+
*
|
|
1301
|
+
* IT READS THE SIDECAR DIRECTORY, NEVER THE PROVISIONED LIST, and that is the
|
|
1302
|
+
* load-bearing decision rather than an implementation detail. `specEnvStatus`
|
|
1303
|
+
* walks specs that have a **worktree**; the ten passes that motivated this all
|
|
1304
|
+
* belonged to specs that had been completed and torn down. Scoping the scan to
|
|
1305
|
+
* provisioned specs would therefore make it blind to precisely the case that
|
|
1306
|
+
* produced it. A `<spec>.pending.json` is here because a pass was received —
|
|
1307
|
+
* a positive signal (`.claude/rules/negative-checks.md` rule 1), and one that
|
|
1308
|
+
* outlives the spec's worktree, its branch and its folder.
|
|
1309
|
+
*
|
|
1310
|
+
* WHAT WOULD FOOL THIS: a store that will not parse. It holds someone's passes
|
|
1311
|
+
* and reading it as empty would report "nothing waiting", which is the one
|
|
1312
|
+
* answer that is certainly wrong — so it comes back named in `unreadable`
|
|
1313
|
+
* rather than counted as zero (rule 4).
|
|
1314
|
+
*
|
|
1315
|
+
* Oldest first and stable, ties broken on the code, exactly as
|
|
1316
|
+
* `describePending` orders one spec's own.
|
|
1317
|
+
*/
|
|
1318
|
+
function waitingPasses(reviewsDirPath) {
|
|
1319
|
+
let entries
|
|
1320
|
+
try {
|
|
1321
|
+
entries = fs.readdirSync(reviewsDirPath)
|
|
1322
|
+
} catch {
|
|
1323
|
+
// No directory means no review has ever been rendered here. That is the
|
|
1324
|
+
// ordinary state of a fresh repo, not a failure to report.
|
|
1325
|
+
return { passes: [], unreadable: [] }
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const passes = []
|
|
1329
|
+
const unreadable = []
|
|
1330
|
+
for (const name of entries.sort()) {
|
|
1331
|
+
if (!name.endsWith(PENDING_SUFFIX)) continue
|
|
1332
|
+
const folder = name.slice(0, -PENDING_SUFFIX.length)
|
|
1333
|
+
// The engine's own reader, so "corrupt" means here what it means everywhere.
|
|
1334
|
+
const read = readPending(path.join(reviewsDirPath, `${folder}.html`), folder)
|
|
1335
|
+
if (read.corrupt) {
|
|
1336
|
+
unreadable.push(folder)
|
|
1337
|
+
continue
|
|
1338
|
+
}
|
|
1339
|
+
for (const pass of read.pending.passes || []) {
|
|
1340
|
+
if (pass && pass.code) {
|
|
1341
|
+
passes.push({
|
|
1342
|
+
spec: folder,
|
|
1343
|
+
code: pass.code,
|
|
1344
|
+
verdict: readVerdict(pass.blob && pass.blob.verdict),
|
|
1345
|
+
action: (pass.blob && pass.blob.action) || null,
|
|
1346
|
+
at: pass.at || null,
|
|
1347
|
+
})
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
passes.sort((a, b) => {
|
|
1352
|
+
const at = String(a.at || '').localeCompare(String(b.at || ''))
|
|
1353
|
+
return at !== 0 ? at : a.code.localeCompare(b.code)
|
|
1354
|
+
})
|
|
1355
|
+
return { passes, unreadable }
|
|
1356
|
+
}
|
|
1357
|
+
|
|
785
1358
|
/* ==========================================================================
|
|
786
1359
|
* The gate — a standing obligation to review, not a message in flight
|
|
787
1360
|
*
|
|
@@ -799,6 +1372,39 @@ function passesSince(pending, since) {
|
|
|
799
1372
|
|
|
800
1373
|
const GATE_VERSION = 1
|
|
801
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
|
+
|
|
802
1408
|
// How a disarm happened. `verdict` is a review that reached a committing
|
|
803
1409
|
// conclusion; `skip` is the operator saying, on the record, that they are
|
|
804
1410
|
// moving on without one.
|
|
@@ -809,7 +1415,7 @@ function reviewGatePath(outPath) {
|
|
|
809
1415
|
}
|
|
810
1416
|
|
|
811
1417
|
function emptyGate(specFolder) {
|
|
812
|
-
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: [] }
|
|
813
1419
|
}
|
|
814
1420
|
|
|
815
1421
|
/**
|
|
@@ -837,6 +1443,10 @@ function readGate(outPath, specFolder) {
|
|
|
837
1443
|
armed: parsed.armed === true,
|
|
838
1444
|
armedAt: parsed.armedAt || null,
|
|
839
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,
|
|
840
1450
|
log: Array.isArray(parsed.log) ? parsed.log : [],
|
|
841
1451
|
},
|
|
842
1452
|
corrupt: false,
|
|
@@ -854,6 +1464,64 @@ function writeGate(outPath, gate) {
|
|
|
854
1464
|
return p
|
|
855
1465
|
}
|
|
856
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
|
+
|
|
857
1525
|
/**
|
|
858
1526
|
* Arm the gate. Pure.
|
|
859
1527
|
*
|
|
@@ -863,8 +1531,32 @@ function writeGate(outPath, gate) {
|
|
|
863
1531
|
* for a DIFFERENT phase is re-armed, because that is a new obligation.
|
|
864
1532
|
*/
|
|
865
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.
|
|
866
1536
|
if (gate.armed && gate.phase === phase) return gate
|
|
867
|
-
|
|
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 }
|
|
868
1560
|
}
|
|
869
1561
|
|
|
870
1562
|
/**
|
|
@@ -935,7 +1627,15 @@ function gateState({ gate, corrupt, present, required }) {
|
|
|
935
1627
|
}
|
|
936
1628
|
}
|
|
937
1629
|
if (!gate.armed) return { state: 'clear', reason: 'nothing is awaiting a verdict', gate }
|
|
938
|
-
|
|
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
|
+
}
|
|
939
1639
|
}
|
|
940
1640
|
|
|
941
1641
|
/**
|
|
@@ -1005,7 +1705,20 @@ function validateNotesBlob(blob, specFolder) {
|
|
|
1005
1705
|
}
|
|
1006
1706
|
verdict = named
|
|
1007
1707
|
}
|
|
1008
|
-
|
|
1708
|
+
// AN ACTION, refused by name for the same reason a misspelt verdict is: a
|
|
1709
|
+
// dropped `action: "live-onn"` would read as a pass carrying no instruction
|
|
1710
|
+
// at all, reported as if the press had been honoured.
|
|
1711
|
+
let action = null
|
|
1712
|
+
if (blob.action !== undefined && blob.action !== null) {
|
|
1713
|
+
if (typeof blob.action !== 'string' || !ACTIONS.includes(blob.action)) {
|
|
1714
|
+
fail(`action ${JSON.stringify(blob.action)} is not one of ${ACTIONS.join(', ')}`)
|
|
1715
|
+
}
|
|
1716
|
+
action = blob.action
|
|
1717
|
+
}
|
|
1718
|
+
if (action && verdict) {
|
|
1719
|
+
fail(`carries both a verdict (${verdict}) and an action (${action}) — send one`)
|
|
1720
|
+
}
|
|
1721
|
+
return { accepted, unaccepted, comments, verdict, action }
|
|
1009
1722
|
}
|
|
1010
1723
|
|
|
1011
1724
|
/**
|
|
@@ -1032,7 +1745,15 @@ function validateNotesBlob(blob, specFolder) {
|
|
|
1032
1745
|
* failure we can afford.
|
|
1033
1746
|
*/
|
|
1034
1747
|
function judgeVerdict(verdict, notes) {
|
|
1035
|
-
const
|
|
1748
|
+
const read = readVerdict(verdict) || null
|
|
1749
|
+
// A WORD THIS ENGINE DOES NOT KNOW IS NOT HONOURED AS ONE. Every door into
|
|
1750
|
+
// here validates first — `validateNotesBlob` refuses an unknown verdict by
|
|
1751
|
+
// name and refuses an action in the verdict slot, and `--verdict` checks the
|
|
1752
|
+
// list — so nothing reaches this today. It used to pass such a word straight
|
|
1753
|
+
// through with `honoured: true`, which routes cannot-tell to the branch that
|
|
1754
|
+
// acts (`.claude/rules/negative-checks.md` rule 4 inverted). Closing it costs
|
|
1755
|
+
// nothing and means a fifth door added later cannot reopen it.
|
|
1756
|
+
const sent = read && VERDICTS.includes(read) ? read : null
|
|
1036
1757
|
const asked = sent || DEFAULT_VERDICT
|
|
1037
1758
|
const open = (notes.comments || []).filter((c) => !c.resolved)
|
|
1038
1759
|
const openFiles = [...new Set(open.map((c) => c.file))]
|
|
@@ -1060,9 +1781,18 @@ function judgeVerdict(verdict, notes) {
|
|
|
1060
1781
|
* commit something nobody read. What is kept is the account of what was
|
|
1061
1782
|
* decided, when, and eventually what it produced.
|
|
1062
1783
|
*/
|
|
1063
|
-
function appendDecision(notes, { verdict, at, note = null }) {
|
|
1784
|
+
function appendDecision(notes, { verdict, at, note = null, code = null }) {
|
|
1064
1785
|
const decisions = Array.isArray(notes.decisions) ? notes.decisions.slice() : []
|
|
1065
|
-
|
|
1786
|
+
// `code` NAMES THE PASS this decision came out of, and it is the only present
|
|
1787
|
+
// thing anyone can assert to conclude that a pass was picked up. Four
|
|
1788
|
+
// different things make a code vanish from the holding area and only one of
|
|
1789
|
+
// them is a claim — `--drop` is another, a moved store and a mistyped folder
|
|
1790
|
+
// are two more — so `passState` reads this, and never reads an absence.
|
|
1791
|
+
//
|
|
1792
|
+
// `null` for every other path in: a pasted blob and a `--verdict` word carry
|
|
1793
|
+
// no code, and inventing one would put a pass in the record that never
|
|
1794
|
+
// existed.
|
|
1795
|
+
decisions.push({ verdict, at, note: note === undefined ? null : note, code: code || null })
|
|
1066
1796
|
return { ...notes, updatedAt: at, decisions }
|
|
1067
1797
|
}
|
|
1068
1798
|
|
|
@@ -1360,6 +2090,7 @@ function renderReviewBlock(review) {
|
|
|
1360
2090
|
if (review.summary) {
|
|
1361
2091
|
parts.push(`<p class="review-summary">${escapeHtml(review.summary)}</p>`)
|
|
1362
2092
|
}
|
|
2093
|
+
parts.push(...renderReviewerStrip(review.reviewers))
|
|
1363
2094
|
const checks = Array.isArray(review.checks) ? review.checks : []
|
|
1364
2095
|
if (checks.length) {
|
|
1365
2096
|
parts.push('<ul class="checks">')
|
|
@@ -1368,16 +2099,31 @@ function renderReviewBlock(review) {
|
|
|
1368
2099
|
// reviewer's note because it was tagged oddly is worse than showing it
|
|
1369
2100
|
// under a neutral heading.
|
|
1370
2101
|
const level = CHECK_LEVELS.includes(c.level) ? c.level : 'confirm'
|
|
1371
|
-
|
|
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>` : ''
|
|
1372
2117
|
// The id is positional and therefore stable for THIS render, which is all
|
|
1373
2118
|
// a reply needs: it travels back in the same blob the page was built from.
|
|
1374
2119
|
// `data-file` is what lets an answer land on the file the check is about.
|
|
1375
2120
|
const id = `k${i}`
|
|
1376
2121
|
const fileAttr = c.file ? ` data-file="${escapeHtml(c.file)}"` : ''
|
|
2122
|
+
const lineAttr = hasLine ? ` data-line="${c.line}"` : ''
|
|
1377
2123
|
parts.push(
|
|
1378
|
-
`<li class="check ${level}" data-check="${id}"${fileAttr}>` +
|
|
2124
|
+
`<li class="check ${level}" data-check="${id}"${fileAttr}${lineAttr}>` +
|
|
1379
2125
|
`<span class="check-level">${level}</span>` +
|
|
1380
|
-
`${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
|
|
2126
|
+
`${src}${file}<span class="check-note">${escapeHtml(c.note || '')}</span></li>`,
|
|
1381
2127
|
)
|
|
1382
2128
|
})
|
|
1383
2129
|
parts.push('</ul>')
|
|
@@ -1386,6 +2132,65 @@ function renderReviewBlock(review) {
|
|
|
1386
2132
|
return parts.join('\n')
|
|
1387
2133
|
}
|
|
1388
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
|
+
|
|
1389
2194
|
/**
|
|
1390
2195
|
* Where a spec's published URL is remembered, and what it currently says.
|
|
1391
2196
|
*
|
|
@@ -1447,8 +2252,9 @@ function reviewServerNotice({ running, othersServed }) {
|
|
|
1447
2252
|
]
|
|
1448
2253
|
}
|
|
1449
2254
|
|
|
1450
|
-
// The publish-ready copy, beside the page it came from. Same stem, so
|
|
1451
|
-
//
|
|
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.
|
|
1452
2258
|
function reviewPublishPath(outPath) {
|
|
1453
2259
|
return outPath.replace(/\.html$/, '') + '.publish.html'
|
|
1454
2260
|
}
|
|
@@ -1501,6 +2307,11 @@ module.exports = {
|
|
|
1501
2307
|
WHOLE_FILE_CONTEXT,
|
|
1502
2308
|
NOTES_VERSION,
|
|
1503
2309
|
VERDICTS,
|
|
2310
|
+
ACTIONS,
|
|
2311
|
+
tierAction,
|
|
2312
|
+
surfacesFor,
|
|
2313
|
+
reviewTierStack,
|
|
2314
|
+
reviewTierLine,
|
|
1504
2315
|
COMMITTING,
|
|
1505
2316
|
BUTTON_SETS,
|
|
1506
2317
|
DEFAULT_BUTTON_SET,
|
|
@@ -1518,6 +2329,7 @@ module.exports = {
|
|
|
1518
2329
|
appendDecision,
|
|
1519
2330
|
annotateLastDecision,
|
|
1520
2331
|
reviewPendingPath,
|
|
2332
|
+
PENDING_SUFFIX,
|
|
1521
2333
|
emptyPending,
|
|
1522
2334
|
readPending,
|
|
1523
2335
|
writePending,
|
|
@@ -1525,6 +2337,11 @@ module.exports = {
|
|
|
1525
2337
|
addPending,
|
|
1526
2338
|
claimPending,
|
|
1527
2339
|
passesSince,
|
|
2340
|
+
waitingPasses,
|
|
2341
|
+
waitForPass,
|
|
2342
|
+
WAIT_HEARTBEAT_MS,
|
|
2343
|
+
WAIT_POLL_MS,
|
|
2344
|
+
passState,
|
|
1528
2345
|
describePending,
|
|
1529
2346
|
pendingAge,
|
|
1530
2347
|
PENDING_CODE_LENGTH,
|
|
@@ -1534,9 +2351,15 @@ module.exports = {
|
|
|
1534
2351
|
emptyGate,
|
|
1535
2352
|
readGate,
|
|
1536
2353
|
writeGate,
|
|
2354
|
+
renderRecordPath,
|
|
2355
|
+
readRenderRecord,
|
|
2356
|
+
writeRenderRecord,
|
|
1537
2357
|
armGate,
|
|
1538
2358
|
disarmGate,
|
|
1539
2359
|
gateState,
|
|
2360
|
+
markGateOffered,
|
|
2361
|
+
GATE_BYPASS_OFFER,
|
|
2362
|
+
GATE_BYPASS_REASON,
|
|
1540
2363
|
gateForPage,
|
|
1541
2364
|
mergeNotes,
|
|
1542
2365
|
applyResolutions,
|
|
@@ -1557,6 +2380,8 @@ module.exports = {
|
|
|
1557
2380
|
escapeIsland,
|
|
1558
2381
|
escapeHtml,
|
|
1559
2382
|
renderReviewBlock,
|
|
2383
|
+
renderReviewerStrip,
|
|
2384
|
+
reviewerSaid,
|
|
1560
2385
|
reviewUrlPath,
|
|
1561
2386
|
reviewPublishPath,
|
|
1562
2387
|
readReviewUrl,
|