@skitterbyte/skitterspec 19.0.0 → 21.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 +170 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +17 -3
- package/assets/hooks/review-gate.cjs +141 -0
- package/assets/review/page.html +366 -30
- package/assets/rules/spec-planning.md +65 -13
- package/assets/rules/spec-reports.md +136 -16
- package/assets/skills/spec-bug/SKILL.md +38 -23
- package/assets/skills/spec-diff/SKILL.md +171 -8
- package/assets/skills/spec-hotfix/SKILL.md +31 -20
- package/assets/skills/spec-init/SKILL.md +34 -0
- package/assets/skills/spec-next/SKILL.md +167 -26
- package/assets/skills/spec-reviewed/SKILL.md +33 -16
- package/package.json +1 -1
- package/src/cli.js +326 -4
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +17 -1
- package/src/env/hooks.js +157 -0
- package/src/env/review.js +249 -3
- package/src/env/serve.js +28 -4
- package/src/init.js +78 -0
package/MIGRATION.md
CHANGED
|
@@ -1,5 +1,175 @@
|
|
|
1
1
|
# Migration guide
|
|
2
2
|
|
|
3
|
+
## `@skitterbyte/skitterspec` v20 → v21 (the gate installs, and asking implies waiting)
|
|
4
|
+
|
|
5
|
+
If you upgraded to v20 and the commit gate never once fired, this is why. Two
|
|
6
|
+
independent bugs, either of which alone made it a no-op.
|
|
7
|
+
|
|
8
|
+
### Breaking change
|
|
9
|
+
|
|
10
|
+
**The hook ships as `.claude/hooks/review-gate.cjs`**, renamed from
|
|
11
|
+
`review-gate.js`. The script is CommonJS and it is copied *into your* project,
|
|
12
|
+
where your `package.json` decides how node parses a `.js` — so in any
|
|
13
|
+
`"type": "module"` project it died on its own first `require`, printing a stack
|
|
14
|
+
trace on **every Bash tool call**. `.cjs` settles the parse mode at the file,
|
|
15
|
+
which is the only place independent of the one file skitterspec does not
|
|
16
|
+
control. An ESM rewrite would have inverted the same problem onto CommonJS
|
|
17
|
+
projects, which are still the default for anything with no `"type"` set.
|
|
18
|
+
|
|
19
|
+
The upgrade migrates you: your existing `PreToolUse` entry has its path
|
|
20
|
+
**rewritten in place** — so a wrapper, a flag or a different interpreter you
|
|
21
|
+
added all survive — and the retired `review-gate.js` is deleted. If you edited
|
|
22
|
+
that file yourself it is **kept**, with a warning, and left for you to remove.
|
|
23
|
+
|
|
24
|
+
### Bug fix
|
|
25
|
+
|
|
26
|
+
**`skitterspec update` now registers the hook.** v20's notes said `init` and
|
|
27
|
+
`update` both did; only `init` did. `update` copied the script, reported
|
|
28
|
+
`created: .claude/hooks/review-gate.js`, and wired nothing — so every project
|
|
29
|
+
that upgraded into v20 got a hook file and no hook, with nothing saying so.
|
|
30
|
+
|
|
31
|
+
### What to do
|
|
32
|
+
|
|
33
|
+
1. **Upgrade** — `npx @skitterbyte/skitterspec update`.
|
|
34
|
+
2. **Commit `.claude/settings.json` and `.claude/hooks/review-gate.cjs`**, and
|
|
35
|
+
the deletion of `.claude/hooks/review-gate.js`. A hook only a fraction of the
|
|
36
|
+
team has is a gate that holds for a fraction of the team.
|
|
37
|
+
3. **Check it is actually on** — the update reports
|
|
38
|
+
`updated: .claude/settings.json (review-gate hook)` the first time, and
|
|
39
|
+
`unchanged` afterwards. If it says neither, your settings file could not be
|
|
40
|
+
parsed; it was left untouched and the hook is not registered.
|
|
41
|
+
|
|
42
|
+
### Behaviour change — a bug fix and a hotfix now owe a verdict
|
|
43
|
+
|
|
44
|
+
**`/spec-bug` and `/spec-hotfix` now arm the review gate**, as `/spec-next`
|
|
45
|
+
already did. Both render the page at the end of red→green work, and both now
|
|
46
|
+
wait for your verdict instead of finishing — so in that spec's worktree a
|
|
47
|
+
`git commit` is **refused** until one of two things happens:
|
|
48
|
+
|
|
49
|
+
- you send a verdict from the page (or `/spec-reviewed` picks up one already
|
|
50
|
+
waiting), or
|
|
51
|
+
- you run `skitterspec spec-env review skip "<reason>"`, which records the
|
|
52
|
+
decision to move on.
|
|
53
|
+
|
|
54
|
+
The gate is still only armed by work that **finished**, it still fails open on
|
|
55
|
+
every cannot-tell, and `review.required: false` in `specs/.core/env.config.json`
|
|
56
|
+
still turns it off for a project.
|
|
57
|
+
|
|
58
|
+
**Why.** Those two skills used to render the page, ask *"want a written review
|
|
59
|
+
before you commit?"* and finish with nothing watching. A verdict pressed on that
|
|
60
|
+
page sat in the holding area until someone typed `/spec-reviewed` — which they
|
|
61
|
+
had no reason to do, because the run had said the page was *ready* rather than
|
|
62
|
+
that it was *waiting*. Two verdicts were stranded that way on one spec, and the
|
|
63
|
+
second existed only because the first appeared to do nothing.
|
|
64
|
+
|
|
65
|
+
### New, and not breaking — the `Continue` verdict
|
|
66
|
+
|
|
67
|
+
**A page rendered part-way through a run offers `Continue`** — *I have read it,
|
|
68
|
+
carry on* — instead of the committing buttons, via
|
|
69
|
+
`spec-env review <spec> --buttons midrun`. The default is unchanged, so a caller
|
|
70
|
+
that passes nothing renders exactly the page it rendered before. `Continue` can
|
|
71
|
+
never clear an armed gate: that still takes a committing verdict or a recorded
|
|
72
|
+
skip.
|
|
73
|
+
|
|
74
|
+
Nothing else changes: `review.required` still defaults to `true`, and the engine
|
|
75
|
+
and `/spec-next` held the gate throughout regardless of the hook.
|
|
76
|
+
|
|
77
|
+
## `@skitterbyte/skitterspec-linear` v14 → v15 (the gate installs, and asking implies waiting)
|
|
78
|
+
|
|
79
|
+
The same change as `@skitterbyte/skitterspec` v20 → v21 above — this
|
|
80
|
+
distribution composes the same lifecycle skills. Read that entry; nothing here
|
|
81
|
+
is Linear-specific.
|
|
82
|
+
|
|
83
|
+
## `@skitterbyte/skitterspec` v19 → v20 (a phase owes a verdict)
|
|
84
|
+
|
|
85
|
+
### Breaking change
|
|
86
|
+
|
|
87
|
+
**A phase that has ended now refuses to go further until you send a verdict.**
|
|
88
|
+
`/spec-next` **arms** a gate when it finishes a phase and renders its review
|
|
89
|
+
page. While it is armed, two things refuse:
|
|
90
|
+
|
|
91
|
+
- `/spec-next` will not build the next phase.
|
|
92
|
+
- `git commit` inside **that spec's own worktree** is blocked by a harness hook
|
|
93
|
+
— which is what covers a bare `git commit`, a chained command, and
|
|
94
|
+
skittership's `/commit` without skitterspec editing any of them.
|
|
95
|
+
|
|
96
|
+
Exactly two things clear it, and both are one command:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
# press Commit or Commit & Continue on the review page — or:
|
|
100
|
+
skitterspec spec-env review skip "none: additive, nothing to revert"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The skip is deliberately not silent: the reason goes into the review outcome
|
|
104
|
+
log, on the same reasoning as the `Gating:` header — a reason is a decision a
|
|
105
|
+
reviewer can argue with, where silence is an oversight.
|
|
106
|
+
|
|
107
|
+
**It is on by default** wherever isolation is configured (`env.config.json`
|
|
108
|
+
present). To turn it off for a project, add to `specs/.core/env.config.json`:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{ "review": { "required": false } }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Three things keep it a push rather than a wall, and they are worth knowing
|
|
115
|
+
before you reach for that setting. It is armed **only by a phase ending**, so a
|
|
116
|
+
mid-phase `/spec-diff` owes nothing. The exit is always one command. And it
|
|
117
|
+
accuses only on a positive signal — no engine, an unreadable payload, a commit
|
|
118
|
+
on the base branch or in another spec's tree, a repo with no isolation: every
|
|
119
|
+
cannot-tell lets the commit through.
|
|
120
|
+
|
|
121
|
+
### Breaking change
|
|
122
|
+
|
|
123
|
+
**`.claude/settings.json` is now written by the installer.** `skitterspec init`
|
|
124
|
+
and `skitterspec update` copy the hook script and register it as a `PreToolUse`
|
|
125
|
+
hook in your project's **committed** settings file. (In v20 `update` copied
|
|
126
|
+
without registering, and the script was named `review-gate.js` — both fixed in
|
|
127
|
+
v21; read that entry above if you are landing on the current release.) That is a
|
|
128
|
+
tracked file in most repos, so expect it in `git status` after upgrading — and
|
|
129
|
+
commit it, because a hook only a fraction of the team has is a gate that holds
|
|
130
|
+
for a fraction of the team.
|
|
131
|
+
|
|
132
|
+
A settings file that cannot be parsed is **reported and left alone**, never
|
|
133
|
+
rewritten. The hook is an extra layer: the engine and `/spec-next` hold the gate
|
|
134
|
+
without it.
|
|
135
|
+
|
|
136
|
+
### The review page can now reach your session — deliberately
|
|
137
|
+
|
|
138
|
+
This inverts an invariant the docs used to state outright: *a device that
|
|
139
|
+
reaches your page cannot reach your conversation*. It no longer holds, and the
|
|
140
|
+
change is the point. `/spec-next` now ends a phase by rendering the page and
|
|
141
|
+
**waiting** on it, so the button you press is what carries the work on — there
|
|
142
|
+
is no command to remember.
|
|
143
|
+
|
|
144
|
+
What replaced the old guard is two mechanisms and one rule:
|
|
145
|
+
|
|
146
|
+
- **The serve token** — 48 random bits in the URL path, minted per server —
|
|
147
|
+
decides who can POST at all.
|
|
148
|
+
- **The wait window** — only a pass that arrives *while the session is waiting*
|
|
149
|
+
is claimed for you, and two arrivals refuse rather than pick one.
|
|
150
|
+
- Outside that window nothing is claimed unasked. `/spec-reviewed`, or
|
|
151
|
+
`/spec-reviewed 324199` to name one exactly, is still how a pass sent when
|
|
152
|
+
nobody was waiting gets picked up — and it is still user-only, so the model
|
|
153
|
+
cannot claim a pass on its own.
|
|
154
|
+
|
|
155
|
+
A `file://` page has no server to talk to, so it copies and you paste, exactly
|
|
156
|
+
as before.
|
|
157
|
+
|
|
158
|
+
### What to do
|
|
159
|
+
|
|
160
|
+
1. **Upgrade** — `npx @skitterbyte/skitterspec update`.
|
|
161
|
+
2. **Commit `.claude/settings.json` and the hook script.** Both are new in your
|
|
162
|
+
working tree after the update.
|
|
163
|
+
3. **Nothing else to configure.** `review.required` defaults to `true` and
|
|
164
|
+
`review.commitWith` defaults to `/commit`; neither needs adding unless you
|
|
165
|
+
are changing it.
|
|
166
|
+
|
|
167
|
+
## `@skitterbyte/skitterspec-linear` v13 → v14 (a phase owes a verdict)
|
|
168
|
+
|
|
169
|
+
The same change as `@skitterbyte/skitterspec` v19 → v20 above — this
|
|
170
|
+
distribution composes the same lifecycle skills. Read that entry; nothing here
|
|
171
|
+
is Linear-specific.
|
|
172
|
+
|
|
3
173
|
## `@skitterbyte/skitterspec` v18 → v19 (starting a spec offers phase 1)
|
|
4
174
|
|
|
5
175
|
### Breaking change
|
|
@@ -74,8 +74,9 @@ no live `env.config.json` was found.
|
|
|
74
74
|
// `spec-env up`, right after `git worktree add` and BEFORE `setup` runs — so a
|
|
75
75
|
// fresh linked worktree (which starts with none of the repo's gitignored files)
|
|
76
76
|
// has the .env / local secret overrides / local config that setup steps and
|
|
77
|
-
// git hooks depend on. Without this
|
|
78
|
-
//
|
|
77
|
+
// git hooks depend on. Without this any setup step that reads .env — a schema
|
|
78
|
+
// or client generator, a codegen pass — hard-fails in the new worktree
|
|
79
|
+
// because the file it expects isn't there.
|
|
79
80
|
// mode "symlink" (default) points the worktree file at the main file, so it
|
|
80
81
|
// stays in sync; "copy" makes an independent copy.
|
|
81
82
|
// files repo-relative paths to seed. A source absent in main is a printed
|
|
@@ -274,11 +275,24 @@ no live `env.config.json` was found.
|
|
|
274
275
|
// review page must not offer — a review is the guard in front of an action.
|
|
275
276
|
// Recording an approval for SOMEONE ELSE to act on is a separate mechanism,
|
|
276
277
|
// not a value of this key. Default: "/commit".
|
|
278
|
+
//
|
|
279
|
+
// `required` decides whether a phase that has ended owes a verdict before its
|
|
280
|
+
// work is committed or the next phase is built. The gate is armed when a
|
|
281
|
+
// phase's page is rendered and cleared by exactly two things: a COMMITTING
|
|
282
|
+
// verdict, or `spec-env review skip "<reason>"` — allowed, and on the record,
|
|
283
|
+
// which is the whole difference between skipping and drifting.
|
|
284
|
+
// true — the default. A review is the normal exit from a phase.
|
|
285
|
+
// false — nothing is ever owed; `review gate` answers "cannot tell" and
|
|
286
|
+
// the commit hook (if installed) defers to it, so this one key
|
|
287
|
+
// turns the whole thing off.
|
|
288
|
+
// Only a literal `false` opts out: a typo leaves a check that REFUSES in
|
|
289
|
+
// place rather than quietly disabling it. Default: true.
|
|
277
290
|
"review": {
|
|
278
291
|
"reader": "detect",
|
|
279
292
|
"servePort": 7777,
|
|
280
293
|
"serveOnRemote": true,
|
|
281
|
-
"commitWith": "/commit"
|
|
294
|
+
"commitWith": "/commit",
|
|
295
|
+
"required": true
|
|
282
296
|
}
|
|
283
297
|
}
|
|
284
298
|
```
|
|
@@ -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 }
|