@skitterbyte/skitterspec 18.0.0 → 20.0.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 +296 -10
- package/README.md +53 -4
- package/assets/claude-md-section.md +38 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/env.config.json.example +7 -3
- package/assets/core/env.config.md +90 -30
- package/assets/hooks/review-gate.js +141 -0
- package/assets/review/page.html +1787 -0
- package/assets/rules/spec-planning.md +250 -10
- package/assets/rules/spec-reports.md +321 -0
- package/assets/skills/spec/SKILL.md +32 -4
- package/assets/skills/spec-bug/SKILL.md +113 -8
- package/assets/skills/spec-cancel/SKILL.md +84 -19
- package/assets/skills/spec-complete/SKILL.md +80 -23
- package/assets/skills/spec-diff/SKILL.md +678 -0
- package/assets/skills/spec-hotfix/SKILL.md +113 -10
- package/assets/skills/spec-init/SKILL.md +56 -7
- package/assets/skills/spec-next/SKILL.md +408 -7
- package/assets/skills/spec-review/SKILL.md +26 -3
- package/assets/skills/spec-reviewed/SKILL.md +258 -0
- package/assets/skills/spec-start/SKILL.md +283 -106
- package/assets/skills/spec-to-main/SKILL.md +28 -6
- package/package.json +11 -7
- package/src/cli.js +1808 -89
- package/src/env/building.js +143 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +58 -9
- package/src/env/hooks.js +117 -0
- package/src/env/provision.js +54 -15
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +295 -9
- package/src/env/review.js +1536 -0
- package/src/env/serve.js +573 -0
- package/src/env/teardown.js +13 -6
- package/src/init.js +150 -1
- package/LICENSE +0 -21
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# `env.config.json` — per-spec isolation config
|
|
2
2
|
|
|
3
3
|
Opt-in config for per-spec isolation (git worktree + optional namespaced Docker
|
|
4
|
-
stack + host dev servers + a front-door proxy
|
|
5
|
-
|
|
6
|
-
`/spec-
|
|
7
|
-
`skitterspec spec-env
|
|
8
|
-
|
|
4
|
+
stack + host dev servers + a front-door proxy per in-progress spec).
|
|
5
|
+
Provisioning is folded into `/spec-start`, teardown into `/spec-complete` ·
|
|
6
|
+
`/spec-cancel`, traffic diversion is `/spec-connect`, and reading a spec's diff
|
|
7
|
+
is `/spec-diff`; the `skitterspec spec-env
|
|
8
|
+
<up|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve>` CLI is
|
|
9
|
+
the engine beneath them.
|
|
9
10
|
|
|
10
11
|
**Once this file is present, isolation is the default policy:** `/spec-start` gives
|
|
11
12
|
**every** in-progress spec its own git worktree automatically. Docker is a
|
|
@@ -31,11 +32,12 @@ no live `env.config.json` was found.
|
|
|
31
32
|
// Where a spec's branch gets built.
|
|
32
33
|
//
|
|
33
34
|
// "worktree" (default) — every spec gets its own git worktree. Several
|
|
34
|
-
// specs run side by side and `main` stays free
|
|
35
|
-
//
|
|
35
|
+
// specs run side by side and `main` stays free. `/spec-start`
|
|
36
|
+
// moves your session into the spec's worktree with a `cd`, so
|
|
37
|
+
// the terminal you are already in follows the work.
|
|
36
38
|
// "checkout" — the branch is built in the primary checkout instead. One
|
|
37
|
-
// spec at a time,
|
|
38
|
-
// terminal
|
|
39
|
+
// spec at a time, and nowhere else to stand: the work comes to
|
|
40
|
+
// your terminal rather than your terminal going to it.
|
|
39
41
|
//
|
|
40
42
|
// Pick it for how you work, not for what this repo contains — a project with
|
|
41
43
|
// no dev servers may still want several specs in flight. An unrecognised
|
|
@@ -124,24 +126,6 @@ no live `env.config.json` was found.
|
|
|
124
126
|
"host": "127.0.0.1" // bind host for the canonical ports
|
|
125
127
|
},
|
|
126
128
|
|
|
127
|
-
// Optional, editor/terminal-agnostic opener — the FALLBACK for reaching a
|
|
128
|
-
// worktree. `/spec-start` normally moves the session you typed into into the
|
|
129
|
-
// worktree, and then there is nothing to open: it runs this only when it
|
|
130
|
-
// could not switch in place (the session is already inside another worktree,
|
|
131
|
-
// or the harness cannot move it). Run after provisioning and bootstrap, so
|
|
132
|
-
// the session opens onto a tree that is ready to work in. The template is
|
|
133
|
-
// expanded with {worktreePath}, {slug}, {branch}, {projectName},
|
|
134
|
-
// {portOffset}.
|
|
135
|
-
// Empty = nothing is opened (the path is just printed), which is how you turn
|
|
136
|
-
// the auto-open off, and the right value unless you actually want a second
|
|
137
|
-
// window on the fallback path. A non-interactive run skips it either way — an
|
|
138
|
-
// opened window nobody is sitting at helps no one.
|
|
139
|
-
// Examples: "code {worktreePath}", "tmux new-window -c {worktreePath}",
|
|
140
|
-
// or a "warp://..." deeplink for Warp users.
|
|
141
|
-
"open": {
|
|
142
|
-
"command": ""
|
|
143
|
-
},
|
|
144
|
-
|
|
145
129
|
// Machine-local slot registry (spec → slot index). Resolved against the
|
|
146
130
|
// primary checkout root, shared by all worktrees, gitignored.
|
|
147
131
|
"registry": ".spec-env/registry.json",
|
|
@@ -193,9 +177,10 @@ no live `env.config.json` was found.
|
|
|
193
177
|
"refuseTeardownIfUnpushed": true
|
|
194
178
|
},
|
|
195
179
|
|
|
196
|
-
// What teardown cleans up beyond this machine.
|
|
197
|
-
// branch
|
|
198
|
-
// branch on the remote
|
|
180
|
+
// What teardown cleans up beyond this machine. Nothing publishes a spec
|
|
181
|
+
// branch for you, so a remote copy exists only because you pushed it by hand —
|
|
182
|
+
// and without this a completed spec leaves that merged branch on the remote
|
|
183
|
+
// forever. `deleteRemoteBranch`:
|
|
199
184
|
// "prompt" (default) — plan `git push <remote> --delete <branch>` in its own
|
|
200
185
|
// "confirm with the user first" section; /spec-complete and
|
|
201
186
|
// /spec-cancel ask before running it.
|
|
@@ -232,6 +217,81 @@ no live `env.config.json` was found.
|
|
|
232
217
|
"bump": "patch",
|
|
233
218
|
"cherryPickMain": true,
|
|
234
219
|
"targets": []
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
// Reading a spec's diff (`spec-env review`, `/spec-diff`).
|
|
223
|
+
//
|
|
224
|
+
// `reader` decides how the page's LOCATION IS WORDED, and — through
|
|
225
|
+
// `serveOnRemote` below — whether the engine stands its local server up so a
|
|
226
|
+
// remote reader gets a link that opens. It never decides to PUBLISH. Three
|
|
227
|
+
// values:
|
|
228
|
+
// "local" — you are at the machine holding the page; a file:// URL opens.
|
|
229
|
+
// "remote" — you are not; it does not, so the page is served instead.
|
|
230
|
+
// "detect" — work it out (the default).
|
|
231
|
+
// An explicit "local"/"remote" is BELIEVED WITHOUT SNIFFING: you know where
|
|
232
|
+
// you are reading, and no signal outranks being told. Detection is only the
|
|
233
|
+
// default, and it has three outcomes rather than two — local, remote, and
|
|
234
|
+
// unknown. Unknown behaves exactly as the tool did before any of this existed
|
|
235
|
+
// (the file:// URL, no warning), because a wrong "local" prints a dead link
|
|
236
|
+
// and a wrong "remote" warns at someone whose link works fine. An
|
|
237
|
+
// unrecognised value falls through to "detect", so a typo cannot become a
|
|
238
|
+
// confident answer. Default: detect.
|
|
239
|
+
//
|
|
240
|
+
// `servePort` is the default port for `spec-env review serve`, which renders
|
|
241
|
+
// every spec's diff per request on one local server. `--port` overrides it per
|
|
242
|
+
// run. The server binds 127.0.0.1 unless `--host 0.0.0.0` is passed, which
|
|
243
|
+
// mints an unguessable path token and prints the LAN URL including it —
|
|
244
|
+
// anyone holding that URL can read every spec's diff while it runs.
|
|
245
|
+
// Default: 7777.
|
|
246
|
+
//
|
|
247
|
+
// `serveOnRemote` is whether a "remote" reader may have that server started
|
|
248
|
+
// FOR them. On (the default) the engine brings it up, binds 0.0.0.0, and puts
|
|
249
|
+
// the served URL on the `open:` line — best-guess network address first, the
|
|
250
|
+
// rest listed under it, because the guess reads interface names and a VPN or
|
|
251
|
+
// an unusual adapter will fool it. Off, you get the file:// URL with its
|
|
252
|
+
// "will not open where you are reading" marker and the command to type.
|
|
253
|
+
// Either way NOTHING IS PUBLISHED on a detection: a server is one process
|
|
254
|
+
// ended by one flag, while a published page is one this tooling cannot
|
|
255
|
+
// remove, so that half stays an explicit ask. Teardown names a server that
|
|
256
|
+
// served the last spec, and `spec-env prune` reaps a pidfile whose process is
|
|
257
|
+
// gone. Default: true.
|
|
258
|
+
//
|
|
259
|
+
// `commitWith` names the skill a COMMITTING verdict hands off to. A review
|
|
260
|
+
// page ends in a verdict — commit, commit & continue, request changes,
|
|
261
|
+
// discuss — and the point of the first two is that the commit follows from
|
|
262
|
+
// the reading rather than costing a separate decision. Skitterspec never
|
|
263
|
+
// commits through a skill it vendored: `/commit` ships with skittership, a
|
|
264
|
+
// different package, and a copy living here would fork it. Two shapes:
|
|
265
|
+
// "/commit" — the default; hand off to skittership's commit skill.
|
|
266
|
+
// "<name>" — any other skill your project installs.
|
|
267
|
+
// With the named skill unavailable, the committing branch commits directly —
|
|
268
|
+
// stage, typecheck, test, conventional message — and SAYS it took that path,
|
|
269
|
+
// because a commit made under rules nobody configured must not read as one
|
|
270
|
+
// made under /commit.
|
|
271
|
+
//
|
|
272
|
+
// THERE IS NO OFF SWITCH. `"none"` existed and was removed: it produced a
|
|
273
|
+
// verdict that records itself and does nothing, which is the one thing a
|
|
274
|
+
// review page must not offer — a review is the guard in front of an action.
|
|
275
|
+
// Recording an approval for SOMEONE ELSE to act on is a separate mechanism,
|
|
276
|
+
// not a value of this key. Default: "/commit".
|
|
277
|
+
//
|
|
278
|
+
// `required` decides whether a phase that has ended owes a verdict before its
|
|
279
|
+
// work is committed or the next phase is built. The gate is armed when a
|
|
280
|
+
// phase's page is rendered and cleared by exactly two things: a COMMITTING
|
|
281
|
+
// verdict, or `spec-env review skip "<reason>"` — allowed, and on the record,
|
|
282
|
+
// which is the whole difference between skipping and drifting.
|
|
283
|
+
// true — the default. A review is the normal exit from a phase.
|
|
284
|
+
// false — nothing is ever owed; `review gate` answers "cannot tell" and
|
|
285
|
+
// the commit hook (if installed) defers to it, so this one key
|
|
286
|
+
// turns the whole thing off.
|
|
287
|
+
// Only a literal `false` opts out: a typo leaves a check that REFUSES in
|
|
288
|
+
// place rather than quietly disabling it. Default: true.
|
|
289
|
+
"review": {
|
|
290
|
+
"reader": "detect",
|
|
291
|
+
"servePort": 7777,
|
|
292
|
+
"serveOnRemote": true,
|
|
293
|
+
"commitWith": "/commit",
|
|
294
|
+
"required": true
|
|
235
295
|
}
|
|
236
296
|
}
|
|
237
297
|
```
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Review gate — the harness half.
|
|
6
|
+
*
|
|
7
|
+
* `/spec-next` refuses to build the next phase while a review is owed, but a
|
|
8
|
+
* skill is prose: it can be chained past, typed around, or simply not read. So
|
|
9
|
+
* a phase ending is also enforced one level down, where a `git commit` is a
|
|
10
|
+
* tool call the harness has to ask about first.
|
|
11
|
+
*
|
|
12
|
+
* THIS SCRIPT DECIDES NOTHING. It reads the tool call, hands the command line
|
|
13
|
+
* to `spec-env review gate --check --for-command`, and turns one exit status
|
|
14
|
+
* into an answer. Every judgement — is this a commit, is a verdict owed, is
|
|
15
|
+
* this project even using the gate — lives in the engine, where it is unit
|
|
16
|
+
* tested. A hook with its own opinions is a second implementation that nothing
|
|
17
|
+
* can test and nobody remembers to update.
|
|
18
|
+
*
|
|
19
|
+
* IT FAILS OPEN, EVERYWHERE. No engine on the machine, an unreadable payload, a
|
|
20
|
+
* repo with no isolation, a spec it cannot resolve, a crash of its own — all of
|
|
21
|
+
* them allow the commit. Blocking one is an accusation
|
|
22
|
+
* (`.claude/rules/negative-checks.md`), and the only thing worth accusing on is
|
|
23
|
+
* the engine saying, positively, that this spec owes a verdict. Being wrong the
|
|
24
|
+
* other way costs one unreviewed commit, on a gate the skills also enforce;
|
|
25
|
+
* being wrong this way costs someone their commit with no idea why.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs')
|
|
29
|
+
const path = require('node:path')
|
|
30
|
+
const { spawnSync } = require('node:child_process')
|
|
31
|
+
|
|
32
|
+
// Let the call through, saying nothing. Exit 0 with no JSON is "no opinion" —
|
|
33
|
+
// the tool proceeds exactly as if this hook did not exist, which is what almost
|
|
34
|
+
// every invocation must do.
|
|
35
|
+
function allow() {
|
|
36
|
+
process.exit(0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Refuse, and say what would clear it.
|
|
41
|
+
*
|
|
42
|
+
* The JSON route rather than exit-2, because the reason reaches **Claude** —
|
|
43
|
+
* which is who has to act on it. A commit blocked with no route onward is how a
|
|
44
|
+
* gate gets switched off wholesale instead of answered, so the three exits are
|
|
45
|
+
* named in the refusal itself.
|
|
46
|
+
*/
|
|
47
|
+
function deny(reason) {
|
|
48
|
+
process.stdout.write(
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
hookSpecificOutput: {
|
|
51
|
+
hookEventName: 'PreToolUse',
|
|
52
|
+
permissionDecision: 'deny',
|
|
53
|
+
permissionDecisionReason: reason,
|
|
54
|
+
},
|
|
55
|
+
}) + '\n',
|
|
56
|
+
)
|
|
57
|
+
process.exit(0)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readPayload() {
|
|
61
|
+
try {
|
|
62
|
+
const raw = fs.readFileSync(0, 'utf8')
|
|
63
|
+
const parsed = JSON.parse(raw)
|
|
64
|
+
return parsed && typeof parsed === 'object' ? parsed : null
|
|
65
|
+
} catch {
|
|
66
|
+
// Unreadable or unparseable stdin is the cannot-tell case, not evidence.
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Where the engine is, in the order most likely to be right. Pure but for the
|
|
73
|
+
* existence check.
|
|
74
|
+
*
|
|
75
|
+
* A hook process does not inherit a package manager's `node_modules/.bin` on
|
|
76
|
+
* PATH, so the local install is looked for by path first — walking up from the
|
|
77
|
+
* project, because a git worktree's checkout has its own `node_modules`. PATH
|
|
78
|
+
* is the fallback for a global install, and `SKITTERSPEC_BIN` overrides
|
|
79
|
+
* everything for anyone whose layout is neither.
|
|
80
|
+
*/
|
|
81
|
+
function findEngine(projectDir) {
|
|
82
|
+
if (process.env.SKITTERSPEC_BIN) return process.env.SKITTERSPEC_BIN
|
|
83
|
+
let at = projectDir
|
|
84
|
+
for (let i = 0; i < 6 && at; i++) {
|
|
85
|
+
for (const name of ['skitterspec', 'skitterspec-linear']) {
|
|
86
|
+
const candidate = path.join(at, 'node_modules', '.bin', name)
|
|
87
|
+
if (fs.existsSync(candidate)) return candidate
|
|
88
|
+
}
|
|
89
|
+
const up = path.dirname(at)
|
|
90
|
+
if (up === at) break
|
|
91
|
+
at = up
|
|
92
|
+
}
|
|
93
|
+
return 'skitterspec'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function main() {
|
|
97
|
+
const payload = readPayload()
|
|
98
|
+
if (!payload) allow()
|
|
99
|
+
|
|
100
|
+
// Only Bash runs a commit. A matcher is configured too, but a hook that is
|
|
101
|
+
// reached some other way must still be inert rather than guessing.
|
|
102
|
+
if (payload.tool_name !== 'Bash') allow()
|
|
103
|
+
|
|
104
|
+
const command = payload.tool_input && payload.tool_input.command
|
|
105
|
+
if (typeof command !== 'string' || !command.trim()) allow()
|
|
106
|
+
|
|
107
|
+
// The commit's own directory decides which spec this is about — a worktree is
|
|
108
|
+
// its own checkout, and the answer differs per worktree.
|
|
109
|
+
const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd()
|
|
110
|
+
|
|
111
|
+
const result = spawnSync(
|
|
112
|
+
findEngine(cwd),
|
|
113
|
+
['spec-env', 'review', 'gate', '--check', '--for-command', command, '--dir', cwd],
|
|
114
|
+
{ cwd, encoding: 'utf8', timeout: 8000 },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// No engine, a crash, a timeout — all cannot-tell. `status` is null when the
|
|
118
|
+
// process never ran or was killed, and neither is a refusal.
|
|
119
|
+
if (result.error || result.status === null) allow()
|
|
120
|
+
if (result.status !== 1) allow()
|
|
121
|
+
|
|
122
|
+
deny(
|
|
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>"',
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Guarded so the pieces above can be required and tested without the script
|
|
131
|
+
// reading stdin and exiting.
|
|
132
|
+
if (require.main === module) {
|
|
133
|
+
try {
|
|
134
|
+
main()
|
|
135
|
+
} catch {
|
|
136
|
+
// Even a bug in this file lets the commit through.
|
|
137
|
+
allow()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { findEngine }
|