@skitterbyte/skitterspec-linear 12.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.
Files changed (58) hide show
  1. package/MIGRATION.md +296 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +38 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/SETUP.md +21 -3
  7. package/assets/core/env.config.json.example +7 -3
  8. package/assets/core/env.config.md +90 -30
  9. package/assets/core/linear.config.md +58 -0
  10. package/assets/hooks/review-gate.js +141 -0
  11. package/assets/review/page.html +1787 -0
  12. package/assets/rules/spec-planning.md +250 -10
  13. package/assets/rules/spec-reports.md +321 -0
  14. package/assets/skills/spec/SKILL.md +33 -5
  15. package/assets/skills/spec-bug/SKILL.md +172 -9
  16. package/assets/skills/spec-cancel/SKILL.md +98 -21
  17. package/assets/skills/spec-claim/SKILL.md +114 -0
  18. package/assets/skills/spec-complete/SKILL.md +94 -25
  19. package/assets/skills/spec-diff/SKILL.md +678 -0
  20. package/assets/skills/spec-hotfix/SKILL.md +172 -11
  21. package/assets/skills/spec-init/SKILL.md +56 -7
  22. package/assets/skills/spec-linear-setup/SKILL.md +55 -1
  23. package/assets/skills/spec-list/SKILL.md +218 -0
  24. package/assets/skills/spec-next/SKILL.md +419 -7
  25. package/assets/skills/spec-push/SKILL.md +32 -8
  26. package/assets/skills/spec-review/SKILL.md +40 -5
  27. package/assets/skills/spec-reviewed/SKILL.md +258 -0
  28. package/assets/skills/spec-start/SKILL.md +386 -106
  29. package/assets/skills/spec-status/SKILL.md +24 -2
  30. package/assets/skills/spec-sync/SKILL.md +40 -4
  31. package/assets/skills/spec-to-main/SKILL.md +28 -6
  32. package/package.json +11 -7
  33. package/src/cli.js +1808 -89
  34. package/src/env/building.js +143 -0
  35. package/src/env/commitcmd.js +108 -0
  36. package/src/env/config.js +58 -9
  37. package/src/env/hooks.js +117 -0
  38. package/src/env/provision.js +54 -15
  39. package/src/env/proxy.js +34 -1
  40. package/src/env/render.js +3 -12
  41. package/src/env/resolve.js +295 -9
  42. package/src/env/review.js +1536 -0
  43. package/src/env/serve.js +573 -0
  44. package/src/env/teardown.js +13 -6
  45. package/src/init.js +150 -1
  46. package/src/vendor/linear/api.js +104 -1
  47. package/src/vendor/linear/cli-sync.js +854 -17
  48. package/src/vendor/linear/config.js +8 -0
  49. package/src/vendor/linear/credentials.js +94 -0
  50. package/src/vendor/linear/doctor.js +35 -0
  51. package/src/vendor/linear/identity.js +105 -0
  52. package/src/vendor/linear/mcp.js +26 -0
  53. package/src/vendor/sync-core/index.js +6 -2
  54. package/src/vendor/sync-core/src/compare.js +49 -3
  55. package/src/vendor/sync-core/src/normalize.js +30 -0
  56. package/src/vendor/sync-core/src/push.js +11 -1
  57. package/src/vendor/sync-core/src/write.js +38 -0
  58. 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 + an optional opener per
5
- in-progress spec). Provisioning is folded into `/spec-start`, teardown into
6
- `/spec-complete` · `/spec-cancel`, and traffic diversion is `/spec-connect`; the
7
- `skitterspec spec-env <up|down|prune|dev|connect|integrate>` CLI is the engine
8
- beneath them.
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, at the cost of
35
- // one terminal session per spec (`/spec-start` sets it up for you).
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, but no second session and no hand-off: the
38
- // terminal you are already in follows the work.
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. `/spec-start` pushes the spec
197
- // branch when it provisions, so without this a completed spec leaves a merged
198
- // branch on the remote forever. `deleteRemoteBranch`:
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
  ```
@@ -133,6 +133,64 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
133
133
  }
134
134
  ```
135
135
 
136
+ ## Assignment (`sync.fieldOwnership.assignee`)
137
+
138
+ Off by default. Add one key and the spec issue is assigned to whoever is
139
+ building it:
140
+
141
+ ```jsonc
142
+ "fieldOwnership": {
143
+ "description": "push",
144
+ "subIssues": "push",
145
+ "workflowState": "push",
146
+ "assignee": "push" // <- the whole opt-in
147
+ }
148
+ ```
149
+
150
+ It is deliberately **not** a config key of its own. `fieldOwnership` is already
151
+ the documented extension point — "any key you add joins the pushed projection" —
152
+ and assignment is exactly one more field the repo owns.
153
+
154
+ - **Absent = inert.** No writes, no prompts, no drift line, and no assignee hash
155
+ in any snapshot. A project that never opts in cannot tell the feature exists,
156
+ which is why it is missing from `linear.config.json.example`: that file is
157
+ copied verbatim into new projects, and shipping the key there would opt
158
+ everyone in by default.
159
+ - **The bucket decides.** The spec's `linear_assignee_id` is pushed while the
160
+ spec is live (`backlog`, `in-progress`) and cleared once it reaches `complete`
161
+ or `cancelled` — so finishing a spec hands the issue back with no unassign
162
+ step for anyone to remember. The stamp stays in the file, and so does
163
+ `> **Developer:**`: they record who *actioned* the work, which outlives who is
164
+ holding it.
165
+ - **Unset means don't touch.** A spec that records nobody sends no assignee at
166
+ all, so an issue a PM assigned in Linear is never overwritten. Only an assignee
167
+ the repo itself pushed is ever cleared — and a snapshot written before you
168
+ opted in counts as "never pushed", not as "was nobody".
169
+ - **Only the spec issue.** Phase sub-issues are never assigned: one person builds
170
+ a spec, and N assigned sub-issues is N notifications for one piece of work.
171
+ They stay independently assignable in Linear.
172
+
173
+ **Who you are** is not configured here, and cannot be — this file is committed,
174
+ so a user id in it would follow the repo to every teammate who clones it. It is
175
+ derived from your own API key (`viewer`) and cached per machine in
176
+ `~/.config/skitterspec/credentials.json` beside the key. `spec-sync whoami`
177
+ shows it, `--set` overrides it when the key is shared or a bot's, and
178
+ `spec-sync users` looks somebody up by name or email.
179
+
180
+ Ownership moves with **`/spec-claim`** — take it, `--release` it, or `--to` a
181
+ teammate.
182
+
183
+ **Reading it back** is **`/spec-list`** (engine: `spec-sync list`), the one
184
+ command that asks Linear what exists instead of the repo. It lists parentless
185
+ issues — a phase sub-issue carries a parent, a spec issue does not — joined to
186
+ the local spec folder that owns each one, so every row carries the name you
187
+ paste into `/spec-start`. Scope is the live states by default
188
+ (`--state`/`--all`/`--in-progress` to change it, `--next N` for the top of the
189
+ backlog in Linear's own order), and `--mine`/`--by <user>` filter by assignee,
190
+ reusing the identity above rather than a second copy of it. It writes nothing,
191
+ and it says what it did not show — the count, the archived exclusion, and any
192
+ cap — rather than implying a completeness it never checked.
193
+
136
194
  ## The deployment ladder (`release.stages`)
137
195
 
138
196
  A spec's lifecycle stops at `complete`. Where a ticket goes **after** that —
@@ -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 }