@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.
Files changed (37) hide show
  1. package/README.md +14 -0
  2. package/assets/claude-md-section.md +13 -0
  3. package/assets/commands/allow-main.md +33 -0
  4. package/assets/commands/spec-skip.md +24 -0
  5. package/assets/core/env.config.json.example +2 -1
  6. package/assets/core/env.config.md +83 -2
  7. package/assets/hooks/main-guard.cjs +145 -0
  8. package/assets/hooks/review-gate.cjs +8 -3
  9. package/assets/review/page.html +132 -2
  10. package/assets/rules/offered-unblocks.md +100 -0
  11. package/assets/rules/spec-planning.md +160 -11
  12. package/assets/rules/spec-reports.md +50 -9
  13. package/assets/skills/no-spec/SKILL.md +219 -0
  14. package/assets/skills/spec/SKILL.md +135 -36
  15. package/assets/skills/spec-bug/SKILL.md +5 -0
  16. package/assets/skills/spec-cancel/SKILL.md +29 -0
  17. package/assets/skills/spec-complete/SKILL.md +25 -0
  18. package/assets/skills/spec-diff/SKILL.md +33 -1
  19. package/assets/skills/spec-init/SKILL.md +4 -2
  20. package/assets/skills/spec-next/SKILL.md +47 -2
  21. package/assets/skills/spec-reviewed/SKILL.md +3 -3
  22. package/assets/skills/spec-start/SKILL.md +14 -0
  23. package/package.json +1 -1
  24. package/src/cli.js +702 -59
  25. package/src/env/config.js +121 -4
  26. package/src/env/hooks.js +102 -33
  27. package/src/env/live.js +30 -1
  28. package/src/env/mainguard.js +240 -0
  29. package/src/env/provision.js +106 -10
  30. package/src/env/registry.js +70 -6
  31. package/src/env/resolve.js +92 -3
  32. package/src/env/review.js +324 -20
  33. package/src/env/reviewers/coderabbit.js +228 -0
  34. package/src/env/reviewers.js +517 -0
  35. package/src/env/serve.js +189 -44
  36. package/src/env/teardown.js +12 -1
  37. package/src/init.js +125 -15
package/README.md CHANGED
@@ -18,6 +18,7 @@ demand, host dev servers on reserved ports — and everything below.
18
18
  | `/spec` | Grill to a shared understanding, then write a groomed spec |
19
19
  | `/spec-bug` | Reproduce with a failing test, then drive it red→green |
20
20
  | `/spec-hotfix` | Fix the released version: fork from a tag, land by tag + cherry-pick |
21
+ | `/no-spec` | Work that genuinely has no spec — on its own branch all the same |
21
22
  | `/spec-review` | Re-validate a spec against the code; refresh what drifted |
22
23
  | `/spec-start` | Put a spec in flight — provision its branch, then build phase 1 |
23
24
  | `/spec-next` | Build the next phase of the spec in flight |
@@ -30,6 +31,8 @@ demand, host dev servers on reserved ports — and everything below.
30
31
  | `/spec-connect` | Point your canonical `localhost` ports at one spec's stack |
31
32
  | `/spec-live` | Check one spec out in the primary checkout, so your running server reloads it |
32
33
  | `/spec-remote-review` | Permit (or forbid) reviewing from off your network |
34
+ | `/allow-main` | Allow writes on the base branch for this session — yours to run, never Claude's |
35
+ | `/spec-skip` | Move on from a phase without a verdict, reason recorded — yours to run, never Claude's |
33
36
  <!-- commands:end -->
34
37
 
35
38
  The last three are **slash commands** rather than skills: each pre-executes one
@@ -184,6 +187,17 @@ All optional, in `specs/.core/env.config.json`:
184
187
  | `review.commitWith` | `"/commit"` | the skill a committing verdict hands off to |
185
188
  | `review.reader` | `"detect"` | where you are reading — `local`, `remote`, or detect it |
186
189
  | `review.servePort` | derived | the port, normally derived from the repo's path |
190
+ | `review.reviewers` | `[]` | external code reviewers whose findings render as checks. Empty by default — adding one sends the worktree's diff to whatever the command talks to |
191
+
192
+ **`review.reviewers` is a seam, not a vendor.** An entry is either
193
+ `{ "use": "<adapter>" }` for one skitterspec bundles, or
194
+ `{ "name": "…", "command": "…", "format": "rdjsonl" }` for anything that prints
195
+ [rdjsonl](https://github.com/reviewdog/reviewdog) — most linters already do, so
196
+ an adapter is a twenty-line script. Findings arrive as **checks**, which never
197
+ block a committing verdict; reply to one on the page and the reply is a
198
+ **comment**, which does, because now a person asked. A reviewer that could not
199
+ run is reported as `did not run: <why>` rather than passing as clean. See
200
+ `specs/.core/env.config.md` for the full field reference and the privacy note.
187
201
 
188
202
  `/spec-next` writes the page at the end of every phase. Nothing about it depends
189
203
  on where your shell is.
@@ -34,6 +34,19 @@ An accept remembers the file's content, so it lapses by itself when that file
34
34
  changes again. The marks are information — nothing gates on them; the verdict is
35
35
  the one thing you choose, once.
36
36
 
37
+ **A second opinion, when you want one** (`review.reviewers`, empty by default).
38
+ Point it at a code reviewer that runs from your terminal — CodeRabbit's CLI has
39
+ a free tier and `{ "use": "coderabbit" }` is the whole setup; anything printing
40
+ [rdjsonl](https://github.com/reviewdog/reviewdog) works too — and its findings
41
+ land on the page beside your own notes, each badged with the tool that found it
42
+ and linked to the line it is about. `/spec-next` runs them once per phase;
43
+ `/spec-diff --reviewers` is the mid-phase ask. **They never block a commit** —
44
+ reply to one and your reply does, because then a person has asked for something.
45
+ A reviewer that could not run says `did not run — <why>` rather than passing as
46
+ clean, because on the page you commit from those two must not look alike.
47
+ Adding one sends your diff to whatever the command talks to, which is why the
48
+ list starts empty and `init` never writes one.
49
+
37
50
  **Above the verdicts sits one line that is not a verdict.** `▶ Put it live`
38
51
  commits the phase, checks the branch out where your dev server can see it, and
39
52
  hands you back the same page with the same options — so you can judge the change
@@ -0,0 +1,33 @@
1
+ ---
2
+ description: Allow writes on the base branch for this session — bare allows it with no reason, a reason records why, `off` clears it
3
+ argument-hint: "[<reason> | off]"
4
+ allowed-tools: Bash({{exec}} skitterspec spec-env main allow:*)
5
+ disable-model-invocation: true
6
+ ---
7
+ !`{{exec}} skitterspec spec-env main allow $ARGUMENTS`
8
+
9
+ Relay the engine output above verbatim. Add nothing and run nothing else.
10
+
11
+ **Only you can run this, and that is the whole guard.** The main-guard hook
12
+ refuses an `Edit` or a `Write` in the primary checkout while it sits on the base
13
+ branch, and it names two exits: move the work with `/no-spec` or `/spec-start`,
14
+ or have you lift the guard. Claude can take the first exit on its own and can
15
+ never take the second — a guard the model can lift is decoration, which is the
16
+ same line `/spec-reviewed` draws.
17
+
18
+ **Give it a reason.** `/allow-main fixing the lockfile by hand` records why; bare
19
+ records `none given`. The reason is the part a reviewer can argue with, exactly
20
+ as with a `Gating:` header — and it is what you will read when you wonder later
21
+ why the base branch has an uncommitted file on it.
22
+
23
+ **It lapses with the session where it can.** The allow is recorded against this
24
+ session, so it ends when the session does. Where the harness exposes no session
25
+ id it degrades to a repo-wide allow that outlives the session and is cleared only
26
+ by `/allow-main off`; the engine says which kind you got, so do not read a bare
27
+ "allowed" as temporary.
28
+
29
+ **It is machine-local and gitignored.** Unlike `/spec-remote-review`, this writes
30
+ nothing anyone else pulls: it is your decision about your checkout, not the
31
+ project's policy. The project-wide switch is `guards.mainIsLandingZone` in
32
+ `specs/.core/env.config.json`, and turning that off is a different, committed
33
+ decision.
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: Move on from a phase without a verdict, with the reason on the record — clears the review gate
3
+ argument-hint: "\"<reason>\""
4
+ allowed-tools: Bash({{exec}} skitterspec spec-env review skip:*)
5
+ disable-model-invocation: true
6
+ ---
7
+ !`{{exec}} skitterspec spec-env review skip "$ARGUMENTS"`
8
+
9
+ Relay the engine output above verbatim. Add nothing and run nothing else.
10
+
11
+ **It refuses with no reason, and that refusal is the feature.** A skip with no
12
+ reason is indistinguishable from nobody having looked — where
13
+ `none: additive, nothing to revert` is a decision a reviewer can argue with. If
14
+ the engine asked for a reason, relay that and stop; do not supply one on the
15
+ operator's behalf.
16
+
17
+ **It is one of exactly two exits**, and the other is a committing verdict on the
18
+ page. Nothing else discharges a phase's obligation — not a mid-run `Continue`,
19
+ not an action, not a render.
20
+
21
+ **Only you can run it.** Like `/allow-main`, this lifts a guard aimed at Claude,
22
+ so Claude must not be able to lift it. `/spec-connect` and `/spec-live` are
23
+ user-only for the milder reason that there is no judgement to apply; this one is
24
+ user-only because of who the guard is for.
@@ -45,7 +45,8 @@
45
45
  "allowNetwork": true,
46
46
  "allowRemote": false,
47
47
  "commitWith": "/commit",
48
- "required": true
48
+ "required": true,
49
+ "reviewers": []
49
50
  },
50
51
  "live": {
51
52
  "migrations": []
@@ -5,7 +5,7 @@ stack + host dev servers + a front-door proxy per in-progress spec).
5
5
  Provisioning is folded into `/spec-start`, teardown into `/spec-complete` ·
6
6
  `/spec-cancel`, traffic diversion is `/spec-connect`, and reading a spec's diff
7
7
  is `/spec-diff`; the `skitterspec spec-env
8
- <up|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve>` CLI is
8
+ <up|nospec|main|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve>` CLI is
9
9
  the engine beneath them.
10
10
 
11
11
  **Once this file is present, isolation is the default policy:** `/spec-start` gives
@@ -80,6 +80,12 @@ through to a documented conservative default; see the field notes below.
80
80
  // worktree-only. false = every spec is worktree-only and the escalation is
81
81
  // hidden. (Was "always provision Docker" in the pre-Stack engine.)
82
82
  "enabled": true,
83
+ // Passed as `-f` to `docker compose` on up and down, but ONLY when the file
84
+ // is actually there — a project whose file has a name docker finds by
85
+ // itself keeps working untouched. It is also the signal that lets a spec
86
+ // with no `Stack:` header inherit `enabled` above: the switch is a
87
+ // project-wide default, the file is evidence there is a stack to bring up.
88
+ // An explicit header is checked first and never consults it.
83
89
  "composeFile": "docker-compose.yml",
84
90
  "projectNamePattern": "{repoSlug}_{slug}", // → COMPOSE_PROJECT_NAME
85
91
  "portBase": 3000, // first port of slot 0's block
@@ -104,6 +110,10 @@ through to a documented conservative default; see the field notes below.
104
110
  // worktree). The main checkout is resolved robustly at run time via
105
111
  // `git rev-parse --git-common-dir` — no hardcoded repo name or path.
106
112
  // Shorthand: `"seedFiles": [".env", …]` == `{ "mode": "symlink", "files": […] }`.
113
+ // `.env` is an ILLUSTRATION, not an expectation — skitterspec never reads any
114
+ // of these files, it only links or copies them. Name whatever your stack keeps
115
+ // out of git: `appsettings.Development.json` or a user-secrets file, `.envrc`,
116
+ // `local.settings.json`, `config/local.yml`.
107
117
  // Seeded files are gitignored, so they never make the worktree "dirty" and
108
118
  // never block teardown; they vanish with the worktree at `spec-env down`.
109
119
  // [] (or absent) = seed nothing (current behaviour).
@@ -364,12 +374,83 @@ through to a documented conservative default; see the field notes below.
364
374
  // turns the whole thing off.
365
375
  // Only a literal `false` opts out: a typo leaves a check that REFUSES in
366
376
  // place rather than quietly disabling it. Default: true.
377
+ //
378
+ // `reviewers` lists EXTERNAL CODE REVIEWERS whose findings render on the page
379
+ // as CHECKS, beside (or instead of) a written review. Empty by default, and
380
+ // `init` never writes one.
381
+ //
382
+ // READ THIS BEFORE ADDING ONE. A reviewer is a command run in the spec's
383
+ // worktree, and every hosted one sends that worktree's diff to a third
384
+ // party. Nothing else in skitterspec leaves your machine — the page is a
385
+ // local file, the server binds your own network, and the diff never reaches
386
+ // a model. Configuring a reviewer changes that, deliberately and only
387
+ // because you asked. Every finding on the page is badged with the reviewer
388
+ // that produced it, so it stays visible where the code went.
389
+ //
390
+ // Two entry shapes, told apart by which key is present:
391
+ // { "use": "<adapter>" }
392
+ // A bundled adapter. The engine owns its command line and its parser,
393
+ // so this is the whole configuration. One ships today: "coderabbit",
394
+ // which drives `cr review --agent` (a free tier, rate-limited per
395
+ // hour; `cr auth login` first). A `use` this build does not have is
396
+ // reported on stderr and does not run.
397
+ // Its adapter also does the one thing a shell script cannot: it tells
398
+ // ITS OWN refusals — not authenticated, rate limited, no network —
399
+ // apart from a review that ran and found nothing.
400
+ // { "name": "<label>", "command": "<shell>", "format": "rdjsonl" }
401
+ // Bring your own. `name` is what the page badges findings with, and
402
+ // `command` is a shell line run with the worktree as its cwd.
403
+ // Both take an optional `timeout` in seconds (default 180).
404
+ //
405
+ // `format` is the output contract, and today there is one: `rdjsonl` — one
406
+ // JSON object per line, reviewdog's diagnostic shape:
407
+ // {"path":"src/a.js","range":{"start":{"line":12}},"severity":"ERROR","message":"…"}
408
+ // A flat `"line": 12` is read too. Most linters already emit this, so a
409
+ // project gets non-AI checks on the same page for free, and an adapter for
410
+ // anything else is a twenty-line script that prints rdjsonl.
411
+ //
412
+ // `command` may carry `${scope}` (`working` | `branch`), `${base}`,
413
+ // `${spec}` and `${worktree}`. `scope` matches the render's own two modes —
414
+ // a phase-end render is `working` (uncommitted + untracked in the worktree)
415
+ // and `--branch` is the whole spec against its base — so what the page shows
416
+ // is what was reviewed. An unknown `${…}` is left verbatim rather than
417
+ // blanked, so a typo shows up in the command that failed instead of silently
418
+ // becoming an empty argument.
419
+ //
420
+ // `severity` maps to the page's levels: `ERROR` → `flag`, everything else →
421
+ // `confirm`. Never `good` — that means "I read this and it is right", which
422
+ // is a thing a person says.
423
+ //
424
+ // FINDINGS NEVER GATE A COMMIT. They are checks, and `judgeVerdict` refuses a
425
+ // committing verdict only while a COMMENT is unresolved. A comment is
426
+ // something a person asked for; twelve machine findings are not. Reply to one
427
+ // on the page and that reply IS a comment — which does gate, because now
428
+ // someone asked. There is deliberately no severity threshold that blocks:
429
+ // that is the counting gate this whole design exists against, and it makes a
430
+ // reviewer's bad day into a wall.
431
+ //
432
+ // A REVIEWER THAT COULD NOT RUN SAYS SO, rather than staying silent — the one
433
+ // place `.claude/rules/negative-checks.md` inverts. Silence is normally the
434
+ // safe branch; here a rate-limited or unauthenticated reviewer would render
435
+ // identically to one that read the diff and found nothing, on the page a
436
+ // commit decision is made from. So every configured reviewer gets a line:
437
+ // `12 findings` · `clean` · `cached` · `did not run — <why>`. Nothing exits
438
+ // non-zero and nothing refuses — a reviewer that could not run never blocks a
439
+ // render, a verdict or a commit.
440
+ //
441
+ // They run in SEQUENCE, at the end of a phase and on demand (`/spec-diff
442
+ // --reviewers`), and the results are cached against a hash of the diff — so
443
+ // re-rendering unchanged work reuses them rather than spending another review
444
+ // against an hourly limit. An entry that is neither shape is dropped and
445
+ // REPORTED on stderr; it cannot be caught by the unknown-key check, because
446
+ // this default is an array. Default: [].
367
447
  "review": {
368
448
  "reader": "detect",
369
449
  "servePort": "auto",
370
450
  "serve": "always",
371
451
  "commitWith": "/commit",
372
- "required": true
452
+ "required": true,
453
+ "reviewers": []
373
454
  }
374
455
  }
375
456
  ```
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ /**
5
+ * Main guard — the harness half.
6
+ *
7
+ * `main` is where work **lands**, not where it happens. The skills say so, and
8
+ * a skill is prose: it can be chained past, typed around, or simply not read.
9
+ * So the rule is also enforced one level down, where an `Edit` or a `Write` is
10
+ * a tool call the harness has to ask about first.
11
+ *
12
+ * IT REFUSES THE FIRST WRITE, NOT THE COMMIT, and that is the whole reason it
13
+ * is a different hook from `review-gate.cjs` rather than another branch inside
14
+ * it. Nothing has been written when this fires, so the fix costs one command —
15
+ * `/no-spec <name>` and carry on. A commit-time gate fires once the work
16
+ * already exists in the primary checkout, and in worktree mode moving it is the
17
+ * genuinely painful part: the primary checkout cannot `switch -c` out from
18
+ * under the other worktrees.
19
+ *
20
+ * THIS SCRIPT DECIDES NOTHING. It reads the tool call, hands the directory to
21
+ * `spec-env main check`, and turns one exit status into an answer. Every
22
+ * judgement — is isolation configured, is this the primary checkout, is HEAD
23
+ * the base branch, is an allow in force — lives in the engine, where it is unit
24
+ * tested. A hook with its own opinions is a second implementation that nothing
25
+ * can test and nobody remembers to update.
26
+ *
27
+ * IT FAILS OPEN, EVERYWHERE, for the same reasons `review-gate.cjs` does and
28
+ * with more at stake, because this one fires far more often. No engine, an
29
+ * unreadable payload, a repo with no isolation, a crash of its own — all of
30
+ * them allow the write. Being wrong the permissive way costs one edit on a rule
31
+ * the skills also carry; being wrong the other way costs someone their work
32
+ * with no idea why (`.claude/rules/negative-checks.md`).
33
+ */
34
+
35
+ const fs = require('node:fs')
36
+ const path = require('node:path')
37
+ const { spawnSync } = require('node:child_process')
38
+
39
+ // Let the call through, saying nothing. Exit 0 with no JSON is "no opinion" —
40
+ // the tool proceeds exactly as if this hook did not exist, which is what the
41
+ // overwhelming majority of invocations must do.
42
+ function allow() {
43
+ process.exit(0)
44
+ }
45
+
46
+ /**
47
+ * Refuse, and say what to do instead.
48
+ *
49
+ * The JSON route rather than exit-2, because the reason reaches **Claude** —
50
+ * which is who has to act on it, by moving the work rather than by asking the
51
+ * user to switch the guard off. The exits are named in the refusal itself.
52
+ */
53
+ function deny(reason) {
54
+ process.stdout.write(
55
+ JSON.stringify({
56
+ hookSpecificOutput: {
57
+ hookEventName: 'PreToolUse',
58
+ permissionDecision: 'deny',
59
+ permissionDecisionReason: reason,
60
+ },
61
+ }) + '\n',
62
+ )
63
+ process.exit(0)
64
+ }
65
+
66
+ function readPayload() {
67
+ try {
68
+ const raw = fs.readFileSync(0, 'utf8')
69
+ const parsed = JSON.parse(raw)
70
+ return parsed && typeof parsed === 'object' ? parsed : null
71
+ } catch {
72
+ // Unreadable or unparseable stdin is the cannot-tell case, not evidence.
73
+ return null
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Where the engine is, in the order most likely to be right. Pure but for the
79
+ * existence check. Identical to `review-gate.cjs`'s, deliberately: a hook
80
+ * process does not inherit a package manager's `node_modules/.bin` on PATH, so
81
+ * the local install is looked for by path first — walking up from the project,
82
+ * because a git worktree's checkout has its own `node_modules`.
83
+ */
84
+ function findEngine(projectDir) {
85
+ if (process.env.SKITTERSPEC_BIN) return process.env.SKITTERSPEC_BIN
86
+ let at = projectDir
87
+ for (let i = 0; i < 6 && at; i++) {
88
+ for (const name of ['skitterspec', 'skitterspec-linear']) {
89
+ const candidate = path.join(at, 'node_modules', '.bin', name)
90
+ if (fs.existsSync(candidate)) return candidate
91
+ }
92
+ const up = path.dirname(at)
93
+ if (up === at) break
94
+ at = up
95
+ }
96
+ return 'skitterspec'
97
+ }
98
+
99
+ // The tools that write a file. A matcher is configured too, but a hook that is
100
+ // reached some other way must still be inert rather than guessing.
101
+ //
102
+ // WHAT WOULD FOOL THIS: a write that does not go through a write TOOL — a shell
103
+ // heredoc, `sed -i`, an editor outside the session. Those are left unguarded on
104
+ // purpose. Covering them would mean parsing arbitrary shell for filesystem
105
+ // effects, which cannot be done reliably, and the failure mode of getting it
106
+ // wrong is refusing a command that has nothing to do with writing files. The
107
+ // skills carry the rule for everything this matcher cannot see.
108
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
109
+
110
+ function main() {
111
+ const payload = readPayload()
112
+ if (!payload) allow()
113
+
114
+ if (!WRITE_TOOLS.has(payload.tool_name)) allow()
115
+
116
+ // The write's own directory decides the answer — a worktree is its own
117
+ // checkout, and only the primary one is this guard's business.
118
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd()
119
+ const session = typeof payload.session_id === 'string' ? payload.session_id : ''
120
+
121
+ const argv = ['spec-env', 'main', 'check', '--dir', cwd]
122
+ if (session) argv.push('--session', session)
123
+
124
+ const result = spawnSync(findEngine(cwd), argv, { cwd, encoding: 'utf8', timeout: 8000 })
125
+
126
+ // No engine, a crash, a timeout — all cannot-tell. `status` is null when the
127
+ // process never ran or was killed, and neither is a refusal.
128
+ if (result.error || result.status === null) allow()
129
+ if (result.status !== 1) allow()
130
+
131
+ deny((result.stdout || '').trim())
132
+ }
133
+
134
+ // Guarded so the pieces above can be required and tested without the script
135
+ // reading stdin and exiting.
136
+ if (require.main === module) {
137
+ try {
138
+ main()
139
+ } catch {
140
+ // Even a bug in this file lets the write through.
141
+ allow()
142
+ }
143
+ }
144
+
145
+ module.exports = { findEngine, WRITE_TOOLS }
@@ -121,9 +121,14 @@ function main() {
121
121
 
122
122
  deny(
123
123
  (result.stdout || '').trim() +
124
- '\n\nThis phase is waiting on a verdict. Read the page and send one, ' +
125
- 'type /spec-reviewed if a pass is already waiting, or record why you are ' +
126
- 'moving on:\n skitterspec spec-env review skip "<reason>"',
124
+ '\n\nThis phase is waiting on a verdict. Relay these to the operator ' +
125
+ 'rather than choosing one: press a verdict on the page, /spec-reviewed if ' +
126
+ 'a pass is already waiting, or /spec-skip "<reason>" to move on with the ' +
127
+ 'reason recorded.\n\nAll three are theirs to type. /spec-skip is the same ' +
128
+ 'exit as \'skitterspec spec-env review skip "<reason>"\' — a command ' +
129
+ 'rather than an incantation, because the long form is what nobody retypes ' +
130
+ 'and a gate with an unreachable exit gets switched off wholesale instead ' +
131
+ 'of answered.',
127
132
  )
128
133
  }
129
134
 
@@ -322,8 +322,63 @@ tr.band button { font-size: .72rem; padding: .1rem .5rem; margin-right: .3rem; }
322
322
  font-size: .78rem;
323
323
  color: var(--muted);
324
324
  overflow-wrap: anywhere;
325
+ /* A button, because it jumps to the code — but it must keep reading as the
326
+ quiet path label it has always been, not gain a control's chrome. */
327
+ background: none;
328
+ border: 0;
329
+ padding: 0;
330
+ text-align: left;
331
+ cursor: pointer;
332
+ text-decoration: underline;
333
+ text-decoration-style: dotted;
334
+ text-underline-offset: 2px;
335
+ }
336
+ .check-file:hover { color: inherit; }
337
+ /* WHO SAID IT. Absent on a written review's checks, which is why it is a
338
+ separate span rather than a prefix on the note: a page with one author looks
339
+ exactly as it always did. */
340
+ .check-src {
341
+ grid-column: 2;
342
+ font-size: .68rem;
343
+ text-transform: uppercase;
344
+ letter-spacing: .04em;
345
+ font-weight: 700;
346
+ color: var(--muted);
347
+ margin-right: .4rem;
325
348
  }
349
+ .check-src + .check-file { grid-column: 2; }
326
350
  .check-note { grid-column: 2; }
351
+
352
+ /* --- the reviewer strip --------------------------------------------------
353
+ One line per configured reviewer, present even when every one found nothing.
354
+ A reviewer that was rate-limited and one that read the diff and approved are
355
+ the same page without it, and the reader is about to decide whether to
356
+ commit. */
357
+ .reviewers { list-style: none; margin: 0 0 .8rem; padding: 0; display: grid; gap: .25rem; }
358
+ .reviewer {
359
+ display: flex;
360
+ flex-wrap: wrap;
361
+ gap: .5rem;
362
+ align-items: baseline;
363
+ font-size: .8rem;
364
+ color: var(--muted);
365
+ }
366
+ .reviewer-name {
367
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
368
+ font-weight: 700;
369
+ color: var(--fg);
370
+ }
371
+ .reviewer-name::after { content: ' —'; color: var(--muted); font-weight: 400; }
372
+ /* A reviewer that could not run is marked, not hidden — the distinction between
373
+ "clean" and "never answered" is the whole reason this strip exists. */
374
+ .reviewer.failed .reviewer-said,
375
+ .reviewer.missing .reviewer-said,
376
+ .reviewer.timeout .reviewer-said { color: var(--flag-fg); }
377
+ .reviewer.findings .reviewer-said { color: var(--fg); }
378
+
379
+ /* A line a check pointed at, so a jump lands somewhere visible rather than
380
+ leaving the reader to find the row themselves. */
381
+ tr.goto-hit > td { background: var(--confirm-bg); }
327
382
  .check.flag { background: var(--flag-bg); }
328
383
  .check.flag .check-level { color: var(--flag-fg); }
329
384
  .check.confirm { background: var(--confirm-bg); }
@@ -634,10 +689,12 @@ a.ticket:hover { border-color: var(--accent); }
634
689
  exactly this pair. */
635
690
  .cmd-row[data-verdict="commit"],
636
691
  .cmd-row[data-verdict="commit-start"],
692
+ .cmd-row[data-verdict="commit-land"],
637
693
  .cmd-row[data-verdict="commit-continue"],
638
694
  .cmd-row[data-verdict="continue"] { border-left: 3px solid var(--good-fg); }
639
695
  .cmd-row[data-verdict="commit"] .cmd-label,
640
696
  .cmd-row[data-verdict="commit-start"] .cmd-label,
697
+ .cmd-row[data-verdict="commit-land"] .cmd-label,
641
698
  .cmd-row[data-verdict="commit-continue"] .cmd-label,
642
699
  .cmd-row[data-verdict="continue"] .cmd-label { color: var(--good-fg); }
643
700
  .cmd-row[data-verdict="changes"] { border-left: 3px solid var(--flag-fg); }
@@ -795,6 +852,13 @@ a.ticket:hover { border-color: var(--accent); }
795
852
  spec and puts it IN FLIGHT, which is the `commit && /spec-start`
796
853
  that was typed by hand until now. -->
797
854
  <button id="verdict-commit-start" class="v-commit" type="button" hidden>&#10003; Commit &amp; Start</button>
855
+ <!-- THE /no-spec VERDICT, hidden unless the render asked for it. Work with
856
+ no spec has no next phase and nothing to put in flight, so neither
857
+ committing verb above fits: this one commits, fast-forwards the base
858
+ branch, and tears the worktree down. Its own word rather than a
859
+ context-dependent reading of "Commit", because one of those two
860
+ readings destroys a worktree. -->
861
+ <button id="verdict-commit-land" class="v-commit" type="button" hidden>&#10003; Commit &amp; Land</button>
798
862
  <button id="verdict-changes" class="v-changes" type="button">↺ Request changes</button>
799
863
  <button id="verdict-discuss" type="button">… Discuss first</button>
800
864
  <span class="verdict-count" id="verdict-count" aria-live="polite"></span>
@@ -1064,6 +1128,10 @@ a.ticket:hover { border-color: var(--accent); }
1064
1128
  } else {
1065
1129
  tr.appendChild(gutter(row, st, i))
1066
1130
  tr.appendChild(el('td', 'src', row.text === '' ? ' ' : row.text))
1131
+ // The line number, so a check can point at this row. Set here rather
1132
+ // than derived on demand because the file re-renders on every expand,
1133
+ // and a lookup built once would go stale the first time a band opened.
1134
+ if (row.n != null) tr.setAttribute('data-n', String(row.n))
1067
1135
  }
1068
1136
  body.appendChild(tr)
1069
1137
  // Anything said about this line sits directly beneath it — a note you
@@ -1310,6 +1378,54 @@ a.ticket:hover { border-color: var(--accent); }
1310
1378
  d.scrollIntoView({ block: 'start' })
1311
1379
  }
1312
1380
 
1381
+ /**
1382
+ * Jump to a file, and to one line inside it when a check named one.
1383
+ *
1384
+ * THREE STATES, AND ONLY ONE OF THEM IS A JUMP. A file that is not in this
1385
+ * diff, and a line that is not in that file's patch, both do nothing beyond
1386
+ * revealing what IS there — a reviewer's finding can name a line that has
1387
+ * since moved, and a page that scrolled somewhere arbitrary would be worse
1388
+ * than one that scrolled nowhere.
1389
+ */
1390
+ function gotoLine(path, line) {
1391
+ var st = null
1392
+ state.forEach(function (s) { if (s.file.path === path) st = s })
1393
+ if (!st) return
1394
+ reveal(st.file)
1395
+ if (line == null) return
1396
+ // A collapsed band hides the row, so drop any band covering it first. The
1397
+ // file re-renders anyway; this just decides what is on screen when it does.
1398
+ var idx = -1
1399
+ for (var i = 0; i < st.rows.length; i++) {
1400
+ if (st.rows[i].n === line) { idx = i; break }
1401
+ }
1402
+ if (idx < 0) return
1403
+ st.bands = st.bands.filter(function (b) { return !(idx >= b.from && idx < b.to) })
1404
+ renderFile(st)
1405
+ if (!st.el) return
1406
+ // Attribute-only, no tag prefix: within one file's element nothing but a
1407
+ // code row carries `data-n`, and the narrower selector buys nothing.
1408
+ var tr = st.el.querySelector('[data-n="' + line + '"]')
1409
+ if (!tr) return
1410
+ var prev = document.querySelector('.goto-hit')
1411
+ if (prev) prev.className = prev.className.replace(/ ?goto-hit/, '')
1412
+ tr.className += ' goto-hit'
1413
+ tr.scrollIntoView({ block: 'center' })
1414
+ }
1415
+
1416
+ // Wired per button rather than delegated from `document`. The review block is
1417
+ // spliced in as markup by the engine, so it is all present by the time this
1418
+ // runs — and a listener on each element is what the rest of this page does.
1419
+ function wireGotos() {
1420
+ var btns = document.querySelectorAll('[data-goto]')
1421
+ Array.prototype.forEach.call(btns, function (btn) {
1422
+ btn.addEventListener('click', function () {
1423
+ var n = btn.getAttribute('data-goto-line')
1424
+ gotoLine(btn.getAttribute('data-goto'), n == null ? null : parseInt(n, 10))
1425
+ })
1426
+ })
1427
+ }
1428
+
1313
1429
  // ONE FLAT LIST, indented by per-row padding rather than by nesting <ul>s.
1314
1430
  //
1315
1431
  // That is not a tidy-up: the change counts are `position: sticky; right: 0`,
@@ -1478,6 +1594,7 @@ a.ticket:hover { border-color: var(--accent); }
1478
1594
  commit: document.getElementById('verdict-commit'),
1479
1595
  'commit-continue': document.getElementById('verdict-commit-continue'),
1480
1596
  'commit-start': document.getElementById('verdict-commit-start'),
1597
+ 'commit-land': document.getElementById('verdict-commit-land'),
1481
1598
  continue: document.getElementById('verdict-continue'),
1482
1599
  changes: document.getElementById('verdict-changes'),
1483
1600
  discuss: document.getElementById('verdict-discuss'),
@@ -1485,7 +1602,7 @@ a.ticket:hover { border-color: var(--accent); }
1485
1602
  // The committing pair, named once. A fifth verdict that commits is added
1486
1603
  // here, and the shared block below follows for free — the same reason the
1487
1604
  // engine keeps a `COMMITTING` list rather than a second condition.
1488
- var COMMITTERS = ['commit', 'commit-continue', 'commit-start']
1605
+ var COMMITTERS = ['commit', 'commit-continue', 'commit-start', 'commit-land']
1489
1606
 
1490
1607
  /*
1491
1608
  * WHICH BUTTONS THIS PAGE OFFERS, and it comes from the render rather than
@@ -1506,6 +1623,7 @@ a.ticket:hover { border-color: var(--accent); }
1506
1623
  midrun: ['continue', 'changes', 'discuss'],
1507
1624
  authoring: ['commit-start', 'commit', 'changes', 'discuss'],
1508
1625
  refresh: ['commit', 'changes', 'discuss'],
1626
+ nospec: ['commit-land', 'commit', 'changes', 'discuss'],
1509
1627
  committing: ['commit', 'commit-continue', 'changes', 'discuss'],
1510
1628
  }
1511
1629
  var OFFERED = OFFERS[data.buttons] || OFFERS.committing
@@ -1522,6 +1640,7 @@ a.ticket:hover { border-color: var(--accent); }
1522
1640
  commit: '✓ Commit',
1523
1641
  'commit-continue': '✓ Commit & Continue',
1524
1642
  'commit-start': '✓ Commit & Start',
1643
+ 'commit-land': '✓ Commit & Land',
1525
1644
  continue: '→ Continue',
1526
1645
  changes: '↺ Request changes',
1527
1646
  discuss: '… Discuss first',
@@ -1534,6 +1653,10 @@ a.ticket:hover { border-color: var(--accent); }
1534
1653
  // Says what it WILL do, because this is the one verdict that provisions:
1535
1654
  // a worktree appears and phase 1 starts.
1536
1655
  'commit-start': 'Commit this spec, then put it in flight and build phase 1',
1656
+ // Says what it WILL do, and every destructive part of it: this is the only
1657
+ // verdict that removes a worktree, so the reader must not learn that from
1658
+ // the outcome afterwards.
1659
+ 'commit-land': 'Commit this, fast-forward the base branch to it, and remove the worktree',
1537
1660
  // Says what it will NOT do, for the same reason: nothing is committed by
1538
1661
  // this, so the work it resumes is still uncommitted afterwards.
1539
1662
  continue: 'You have read it — carry on with the run; nothing is committed',
@@ -1751,6 +1874,7 @@ a.ticket:hover { border-color: var(--accent); }
1751
1874
  commit: 'committed',
1752
1875
  'commit-continue': 'committed, then carried on',
1753
1876
  'commit-start': 'committed, then put in flight',
1877
+ 'commit-land': 'committed, landed, and torn down',
1754
1878
  continue: 'read it and carried on',
1755
1879
  changes: 'changes requested',
1756
1880
  discuss: 'discussed',
@@ -2492,7 +2616,12 @@ a.ticket:hover { border-color: var(--accent); }
2492
2616
  * never offers a disable and this renders whatever it is given.
2493
2617
  */
2494
2618
  var ACTION_LABEL = {
2495
- 'live-on': '▶ Put it live',
2619
+ // NAMES THE COMMIT IT PERFORMS. `live take` refuses a dirty worktree, so
2620
+ // the action commits the phase before taking the instance — and a press
2621
+ // that commits behind a label reading only "put it live" is a side effect
2622
+ // nobody agreed to. The reader has still approved nothing: this is a
2623
+ // precondition of the thing they asked for, not a verdict on the diff.
2624
+ 'live-on': '▶ Commit & put it live',
2496
2625
  'allow-network': '▶ Allow network',
2497
2626
  'allow-remote': '▶ Allow remote',
2498
2627
  }
@@ -2684,6 +2813,7 @@ a.ticket:hover { border-color: var(--accent); }
2684
2813
  drawLog()
2685
2814
  drawDecided()
2686
2815
  wireChecks()
2816
+ wireGotos()
2687
2817
  state.forEach(function (st) {
2688
2818
  var btn = st.details && st.details.querySelector('.accept')
2689
2819
  if (btn) btn.setAttribute('aria-pressed', String(st.accept))