@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 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
@@ -42,7 +42,8 @@
42
42
  "reader": "detect",
43
43
  "servePort": 7777,
44
44
  "serveOnRemote": true,
45
- "commitWith": "/commit"
45
+ "commitWith": "/commit",
46
+ "required": true
46
47
  },
47
48
  "live": {
48
49
  "migrations": []
@@ -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 }