@skitterbyte/skitterspec-linear 13.0.0 → 14.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 +88 -0
- package/assets/core/env.config.json.example +2 -1
- package/assets/core/env.config.md +14 -1
- package/assets/hooks/review-gate.js +141 -0
- package/assets/review/page.html +310 -24
- package/assets/rules/spec-planning.md +64 -12
- package/assets/rules/spec-reports.md +66 -14
- package/assets/skills/spec-diff/SKILL.md +122 -8
- package/assets/skills/spec-init/SKILL.md +22 -0
- package/assets/skills/spec-next/SKILL.md +144 -25
- package/assets/skills/spec-reviewed/SKILL.md +33 -16
- package/package.json +1 -1
- package/src/cli.js +298 -3
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +17 -1
- package/src/env/hooks.js +117 -0
- package/src/env/review.js +208 -1
- package/src/env/serve.js +28 -4
- package/src/init.js +54 -0
package/MIGRATION.md
CHANGED
|
@@ -1,5 +1,93 @@
|
|
|
1
1
|
# Migration guide
|
|
2
2
|
|
|
3
|
+
## `@skitterbyte/skitterspec` v19 → v20 (a phase owes a verdict)
|
|
4
|
+
|
|
5
|
+
### Breaking change
|
|
6
|
+
|
|
7
|
+
**A phase that has ended now refuses to go further until you send a verdict.**
|
|
8
|
+
`/spec-next` **arms** a gate when it finishes a phase and renders its review
|
|
9
|
+
page. While it is armed, two things refuse:
|
|
10
|
+
|
|
11
|
+
- `/spec-next` will not build the next phase.
|
|
12
|
+
- `git commit` inside **that spec's own worktree** is blocked by a harness hook
|
|
13
|
+
— which is what covers a bare `git commit`, a chained command, and
|
|
14
|
+
skittership's `/commit` without skitterspec editing any of them.
|
|
15
|
+
|
|
16
|
+
Exactly two things clear it, and both are one command:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
# press Commit or Commit & Continue on the review page — or:
|
|
20
|
+
skitterspec spec-env review skip "none: additive, nothing to revert"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The skip is deliberately not silent: the reason goes into the review outcome
|
|
24
|
+
log, on the same reasoning as the `Gating:` header — a reason is a decision a
|
|
25
|
+
reviewer can argue with, where silence is an oversight.
|
|
26
|
+
|
|
27
|
+
**It is on by default** wherever isolation is configured (`env.config.json`
|
|
28
|
+
present). To turn it off for a project, add to `specs/.core/env.config.json`:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{ "review": { "required": false } }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Three things keep it a push rather than a wall, and they are worth knowing
|
|
35
|
+
before you reach for that setting. It is armed **only by a phase ending**, so a
|
|
36
|
+
mid-phase `/spec-diff` owes nothing. The exit is always one command. And it
|
|
37
|
+
accuses only on a positive signal — no engine, an unreadable payload, a commit
|
|
38
|
+
on the base branch or in another spec's tree, a repo with no isolation: every
|
|
39
|
+
cannot-tell lets the commit through.
|
|
40
|
+
|
|
41
|
+
### Breaking change
|
|
42
|
+
|
|
43
|
+
**`.claude/settings.json` is now written by the installer.** `skitterspec init`
|
|
44
|
+
and `skitterspec update` copy `.claude/hooks/review-gate.js` and register it as
|
|
45
|
+
a `PreToolUse` hook in your project's **committed** settings file. That is a
|
|
46
|
+
tracked file in most repos, so expect it in `git status` after upgrading — and
|
|
47
|
+
commit it, because a hook only a fraction of the team has is a gate that holds
|
|
48
|
+
for a fraction of the team.
|
|
49
|
+
|
|
50
|
+
A settings file that cannot be parsed is **reported and left alone**, never
|
|
51
|
+
rewritten. The hook is an extra layer: the engine and `/spec-next` hold the gate
|
|
52
|
+
without it.
|
|
53
|
+
|
|
54
|
+
### The review page can now reach your session — deliberately
|
|
55
|
+
|
|
56
|
+
This inverts an invariant the docs used to state outright: *a device that
|
|
57
|
+
reaches your page cannot reach your conversation*. It no longer holds, and the
|
|
58
|
+
change is the point. `/spec-next` now ends a phase by rendering the page and
|
|
59
|
+
**waiting** on it, so the button you press is what carries the work on — there
|
|
60
|
+
is no command to remember.
|
|
61
|
+
|
|
62
|
+
What replaced the old guard is two mechanisms and one rule:
|
|
63
|
+
|
|
64
|
+
- **The serve token** — 48 random bits in the URL path, minted per server —
|
|
65
|
+
decides who can POST at all.
|
|
66
|
+
- **The wait window** — only a pass that arrives *while the session is waiting*
|
|
67
|
+
is claimed for you, and two arrivals refuse rather than pick one.
|
|
68
|
+
- Outside that window nothing is claimed unasked. `/spec-reviewed`, or
|
|
69
|
+
`/spec-reviewed 324199` to name one exactly, is still how a pass sent when
|
|
70
|
+
nobody was waiting gets picked up — and it is still user-only, so the model
|
|
71
|
+
cannot claim a pass on its own.
|
|
72
|
+
|
|
73
|
+
A `file://` page has no server to talk to, so it copies and you paste, exactly
|
|
74
|
+
as before.
|
|
75
|
+
|
|
76
|
+
### What to do
|
|
77
|
+
|
|
78
|
+
1. **Upgrade** — `npx @skitterbyte/skitterspec update`.
|
|
79
|
+
2. **Commit `.claude/settings.json` and `.claude/hooks/review-gate.js`.** Both
|
|
80
|
+
are new in your working tree after the update.
|
|
81
|
+
3. **Nothing else to configure.** `review.required` defaults to `true` and
|
|
82
|
+
`review.commitWith` defaults to `/commit`; neither needs adding unless you
|
|
83
|
+
are changing it.
|
|
84
|
+
|
|
85
|
+
## `@skitterbyte/skitterspec-linear` v13 → v14 (a phase owes a verdict)
|
|
86
|
+
|
|
87
|
+
The same change as `@skitterbyte/skitterspec` v19 → v20 above — this
|
|
88
|
+
distribution composes the same lifecycle skills. Read that entry; nothing here
|
|
89
|
+
is Linear-specific.
|
|
90
|
+
|
|
3
91
|
## `@skitterbyte/skitterspec` v18 → v19 (starting a spec offers phase 1)
|
|
4
92
|
|
|
5
93
|
### Breaking change
|
|
@@ -274,11 +274,24 @@ no live `env.config.json` was found.
|
|
|
274
274
|
// review page must not offer — a review is the guard in front of an action.
|
|
275
275
|
// Recording an approval for SOMEONE ELSE to act on is a separate mechanism,
|
|
276
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.
|
|
277
289
|
"review": {
|
|
278
290
|
"reader": "detect",
|
|
279
291
|
"servePort": 7777,
|
|
280
292
|
"serveOnRemote": true,
|
|
281
|
-
"commitWith": "/commit"
|
|
293
|
+
"commitWith": "/commit",
|
|
294
|
+
"required": true
|
|
282
295
|
}
|
|
283
296
|
}
|
|
284
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 }
|