@skitterbyte/skitterspec 22.0.0 → 22.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/assets/claude-md-section.md +13 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +83 -2
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +132 -2
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +160 -11
- package/assets/rules/spec-reports.md +66 -9
- package/assets/skills/no-spec/SKILL.md +221 -0
- package/assets/skills/spec/SKILL.md +138 -37
- package/assets/skills/spec-bug/SKILL.md +8 -2
- 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 +36 -2
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +50 -3
- package/assets/skills/spec-reviewed/SKILL.md +3 -3
- package/assets/skills/spec-start/SKILL.md +14 -0
- package/package.json +1 -1
- package/src/cli.js +749 -62
- package/src/env/config.js +121 -4
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +30 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +113 -4
- package/src/env/review.js +324 -20
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +189 -44
- package/src/env/teardown.js +12 -1
- package/src/init.js +125 -15
package/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.
|
|
@@ -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.
|
|
125
|
-
'
|
|
126
|
-
'
|
|
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
|
|
package/assets/review/page.html
CHANGED
|
@@ -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>✓ Commit & 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>✓ Commit & 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
|
-
|
|
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))
|