@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
|
@@ -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
|
|
@@ -21,8 +21,27 @@ port block, and no `.env`.
|
|
|
21
21
|
exactly as it does today.
|
|
22
22
|
|
|
23
23
|
The loader (`src/env/config.js` → `loadEnvConfig`) merges your file over the
|
|
24
|
-
frozen defaults below and returns `{ config, present }`;
|
|
25
|
-
no live `env.config.json` was found.
|
|
24
|
+
frozen defaults below and returns `{ config, present, unknown }`;
|
|
25
|
+
`present:false` means no live `env.config.json` was found.
|
|
26
|
+
|
|
27
|
+
**A key not listed below is ignored — and says so.** Every `spec-env` command
|
|
28
|
+
prints one advisory line per unrecognised key, top-level or nested:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
spec-env: env.config.json — unknown key "docker.portbase" is ignored.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It is advisory in the strongest sense: the key is dropped exactly as it always
|
|
35
|
+
was, nothing refuses, and the exit status is unchanged. It exists because the
|
|
36
|
+
two things an unknown key can be — a deliberate forward-compat entry and a typo
|
|
37
|
+
— are indistinguishable from here, and only one of them is a mistake you would
|
|
38
|
+
want to hear about. A mis-typed `review.required` leaves the commit gate on and
|
|
39
|
+
a mis-typed `teardown.deleteRemoteBranch` reverts to `prompt`; before this, the
|
|
40
|
+
only signal either gave was that nothing happened.
|
|
41
|
+
|
|
42
|
+
A **known** key whose value is rejected — `mode: "Checkout"`,
|
|
43
|
+
`teardown.deleteRemoteBranch: "yes"` — is not reported here. Each of those falls
|
|
44
|
+
through to a documented conservative default; see the field notes below.
|
|
26
45
|
|
|
27
46
|
## Fields
|
|
28
47
|
|
|
@@ -61,6 +80,12 @@ no live `env.config.json` was found.
|
|
|
61
80
|
// worktree-only. false = every spec is worktree-only and the escalation is
|
|
62
81
|
// hidden. (Was "always provision Docker" in the pre-Stack engine.)
|
|
63
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.
|
|
64
89
|
"composeFile": "docker-compose.yml",
|
|
65
90
|
"projectNamePattern": "{repoSlug}_{slug}", // → COMPOSE_PROJECT_NAME
|
|
66
91
|
"portBase": 3000, // first port of slot 0's block
|
|
@@ -85,6 +110,10 @@ no live `env.config.json` was found.
|
|
|
85
110
|
// worktree). The main checkout is resolved robustly at run time via
|
|
86
111
|
// `git rev-parse --git-common-dir` — no hardcoded repo name or path.
|
|
87
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`.
|
|
88
117
|
// Seeded files are gitignored, so they never make the worktree "dirty" and
|
|
89
118
|
// never block teardown; they vanish with the worktree at `spec-env down`.
|
|
90
119
|
// [] (or absent) = seed nothing (current behaviour).
|
|
@@ -222,13 +251,15 @@ no live `env.config.json` was found.
|
|
|
222
251
|
|
|
223
252
|
// Reading a spec's diff (`spec-env review`, `/spec-diff`).
|
|
224
253
|
//
|
|
225
|
-
// `reader` decides how the page's LOCATION IS WORDED
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
// "
|
|
254
|
+
// `reader` decides how the page's LOCATION IS WORDED. It no longer decides
|
|
255
|
+
// the bind — `allowNetwork` does, see below — and it no longer decides which
|
|
256
|
+
// tiers are offered, because every tier is now listed whatever it says.
|
|
257
|
+
// WHAT THE SERVER
|
|
258
|
+
// BINDS TO — and nothing else. It never decides whether to serve (`serve`
|
|
259
|
+
// does) and never decides to PUBLISH. Three values:
|
|
260
|
+
// "local" — you are at the machine holding the page; it binds 127.0.0.1.
|
|
261
|
+
// "remote" — you are not; it binds every interface so the page opens.
|
|
262
|
+
// "detect" — work it out (the default); cannot-tell binds 127.0.0.1.
|
|
232
263
|
// An explicit "local"/"remote" is BELIEVED WITHOUT SNIFFING: you know where
|
|
233
264
|
// you are reading, and no signal outranks being told. Detection is only the
|
|
234
265
|
// default, and it has three outcomes rather than two — local, remote, and
|
|
@@ -238,25 +269,81 @@ no live `env.config.json` was found.
|
|
|
238
269
|
// unrecognised value falls through to "detect", so a typo cannot become a
|
|
239
270
|
// confident answer. Default: detect.
|
|
240
271
|
//
|
|
241
|
-
// `servePort` is the
|
|
242
|
-
//
|
|
243
|
-
// run. The server binds 127.0.0.1 unless `--host 0.0.0.0` is passed, which
|
|
244
|
-
// mints an unguessable path token and prints the LAN URL including it —
|
|
245
|
-
// anyone holding that URL can read every spec's diff while it runs.
|
|
246
|
-
// Default: 7777.
|
|
272
|
+
// `servePort` is the port for `spec-env review serve`, which renders every
|
|
273
|
+
// spec's diff per request on one local server. Two forms:
|
|
247
274
|
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
275
|
+
// "auto" (the default) — derive it from this repo's path, as
|
|
276
|
+
// 7700 + hash(realpath(repoRoot)) % 100. Two repos on one machine stop
|
|
277
|
+
// competing for one shared port without anyone configuring anything, and
|
|
278
|
+
// — the part that matters — THE SAME REPO GETS THE SAME PORT EVERY TIME.
|
|
279
|
+
// The derivation reads nothing on disk, so the port survives a restart, a
|
|
280
|
+
// reboot and a `--stop`, which is what lets a link handed out yesterday
|
|
281
|
+
// still resolve. A symlinked spelling of the tree resolves first, so one
|
|
282
|
+
// repo never lands on two ports.
|
|
283
|
+
// <a number> — pin it. An explicit number always wins, and pinning is what
|
|
284
|
+
// you do when you want a port you can memorise, or when two repos derive
|
|
285
|
+
// the same one.
|
|
286
|
+
//
|
|
287
|
+
// A derived port CAN still collide — a hundred slots is a small chance, not
|
|
288
|
+
// no chance — and the server refuses rather than moving itself aside. The fix
|
|
289
|
+
// it names is `servePort`, because that is what the next link is built from;
|
|
290
|
+
// `--port` moves one run and leaves every link already handed out pointing at
|
|
291
|
+
// the busy port. `spec-env review serve --status` prints the port and which
|
|
292
|
+
// of the three chose it.
|
|
293
|
+
//
|
|
294
|
+
// An unrecognised value falls through to "auto", like every other typed key
|
|
295
|
+
// here. Default: "auto".
|
|
296
|
+
//
|
|
297
|
+
// The server binds 127.0.0.1 unless `--host 0.0.0.0` is passed, which mints
|
|
298
|
+
// an unguessable path token and prints the LAN URL including it — anyone
|
|
299
|
+
// holding that URL can read every spec's diff while it runs.
|
|
300
|
+
//
|
|
301
|
+
// `serve` is whether a render stands the local server up at all:
|
|
302
|
+
// "always" — every render does (the default), so `local:` and `network:`
|
|
303
|
+
// are both http URLs the page can POST a verdict back to. The
|
|
304
|
+
// LAN address comes first with the rest listed under it, because
|
|
305
|
+
// the guess reads interface names and a VPN or an unusual
|
|
306
|
+
// adapter will fool it.
|
|
307
|
+
// "never" — `local:` is a file:// URL instead. Note what that costs: a
|
|
308
|
+
// file:// page has no server to POST to, so its verdict buttons
|
|
309
|
+
// copy a command for you to paste rather than sending anything.
|
|
310
|
+
// It replaced `serveOnRemote`, which gated serving on the reader — and so
|
|
311
|
+
// handed a local machine a page it could read and not answer. A legacy
|
|
312
|
+
// `serveOnRemote: false` is still read as `serve: "never"`.
|
|
254
313
|
// Either way NOTHING IS PUBLISHED on a detection: a server is one process
|
|
255
314
|
// ended by one flag, while a published page is one this tooling cannot
|
|
256
315
|
// remove, so that half stays an explicit ask. Teardown names a server that
|
|
257
316
|
// served the last spec, and `spec-env prune` reaps a pidfile whose process is
|
|
258
317
|
// gone. Default: true.
|
|
259
318
|
//
|
|
319
|
+
// `allowNetwork` and `allowRemote` decide WHICH TIERS a render offers, and
|
|
320
|
+
// they replaced the engine guessing where the reader was sitting. It guessed
|
|
321
|
+
// for a while and got it wrong three separate ways in one day: a file:// page
|
|
322
|
+
// on a session detected `unknown`, a LAN URL for a phone that had left the
|
|
323
|
+
// network, and an address that changed underneath a reader mid-session. So
|
|
324
|
+
// every tier is listed, labelled, and either a URL or the one command that
|
|
325
|
+
// turns it on — and you pick the one that reaches you.
|
|
326
|
+
//
|
|
327
|
+
// `allowNetwork` — whether the review server binds EVERY INTERFACE, so the
|
|
328
|
+
// page opens on your phone, or loopback only. THIS IS WHAT
|
|
329
|
+
// CHOOSES THE BIND; `reader` no longer does. Default: true,
|
|
330
|
+
// which matches what the engine already did.
|
|
331
|
+
// `allowRemote` — whether PUBLISHING is permitted at all. It permits it; it
|
|
332
|
+
// publishes nothing. Default: FALSE, because a published
|
|
333
|
+
// page is one skitterspec cannot delete, so it must never
|
|
334
|
+
// happen unasked.
|
|
335
|
+
//
|
|
336
|
+
// `local` and `network` are two doors into ONE ROOM — the page POSTs to
|
|
337
|
+
// `location.pathname`, so both reach the same server and the same waiting
|
|
338
|
+
// verdict, and one wait covers both. `remote` is a second store: a verdict
|
|
339
|
+
// pressed on a published page needs `/spec-reviewed`, because nothing pushes
|
|
340
|
+
// from an artifact's store into a conversation.
|
|
341
|
+
//
|
|
342
|
+
// Both are toggled by `spec-env review allow <tier> --set [on|off]`, where an
|
|
343
|
+
// empty value toggles — which is what `/spec-remote-review` runs. It writes
|
|
344
|
+
// THIS FILE in the primary checkout, so it changes for everyone who pulls and
|
|
345
|
+
// leaves that tree dirty; the engine says so when it does.
|
|
346
|
+
//
|
|
260
347
|
// `commitWith` names the skill a COMMITTING verdict hands off to. A review
|
|
261
348
|
// page ends in a verdict — commit, commit & continue, request changes,
|
|
262
349
|
// discuss — and the point of the first two is that the commit follows from
|
|
@@ -287,12 +374,83 @@ no live `env.config.json` was found.
|
|
|
287
374
|
// turns the whole thing off.
|
|
288
375
|
// Only a literal `false` opts out: a typo leaves a check that REFUSES in
|
|
289
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: [].
|
|
290
447
|
"review": {
|
|
291
448
|
"reader": "detect",
|
|
292
|
-
"servePort":
|
|
293
|
-
"
|
|
449
|
+
"servePort": "auto",
|
|
450
|
+
"serve": "always",
|
|
294
451
|
"commitWith": "/commit",
|
|
295
|
-
"required": true
|
|
452
|
+
"required": true,
|
|
453
|
+
"reviewers": []
|
|
296
454
|
}
|
|
297
455
|
}
|
|
298
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
|
|