@skitterbyte/skitterspec-linear 10.3.0 → 10.5.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/assets/core/SETUP.md +13 -1
- package/assets/core/linear.config.md +80 -1
- package/assets/rules/negative-checks.md +69 -0
- package/assets/skills/spec-linear-setup/SKILL.md +35 -4
- package/assets/skills/spec-status/SKILL.md +18 -0
- package/assets/skills/spec-sync/SKILL.md +167 -0
- package/package.json +1 -1
- package/src/init.js +20 -5
- package/src/vendor/linear/api.js +62 -8
- package/src/vendor/linear/cli-sync.js +677 -2
- package/src/vendor/linear/credentials.js +299 -0
- package/src/vendor/linear/doctor.js +179 -0
- package/src/vendor/sync-core/index.js +6 -0
- package/src/vendor/sync-core/src/compare.js +4 -2
- package/src/vendor/sync-core/src/normalize.js +17 -2
- package/src/vendor/sync-core/src/retarget.js +274 -0
- package/src/vendor/sync-core/src/verify.js +5 -0
package/assets/core/SETUP.md
CHANGED
|
@@ -237,7 +237,19 @@ the repo.
|
|
|
237
237
|
|
|
238
238
|
## 8. Smoke test (verify your setup)
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
**Start here — one command answers "did it work?":**
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
skitterspec spec-sync doctor # every layer, offline
|
|
244
|
+
skitterspec spec-sync doctor --check-remote # …and prove the key reaches Linear
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
It reports the scaffold, per-spec isolation, the tracker config and the API key
|
|
248
|
+
in one table, and every row that needs attention names the command that fixes
|
|
249
|
+
it. It exits non-zero only when something is **broken** (configured but wrong) —
|
|
250
|
+
a `missing` row is an opt-in you have not taken, which is fine.
|
|
251
|
+
|
|
252
|
+
Then, with a linked spec, confirm push end-to-end:
|
|
241
253
|
|
|
242
254
|
1. `/spec-status` → shows what would push (`pending — N to create, M to update`).
|
|
243
255
|
2. `/spec-push` → creates the spec issue and its phase sub-issues and sets the
|
|
@@ -29,7 +29,8 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
|
|
|
29
29
|
// IDs are read by the MCP adapter; leave blank until you connect the `linear`
|
|
30
30
|
// MCP server.
|
|
31
31
|
"linear": {
|
|
32
|
-
"teamKey": "", // human-facing key, e.g. "ENG"
|
|
32
|
+
"teamKey": "", // human-facing key, e.g. "ENG" — the RECORDED key;
|
|
33
|
+
// see "Renaming a team" below (optional)
|
|
33
34
|
"teamId": "", // Linear team UUID (the issue's team)
|
|
34
35
|
"projectId": "" // DEFAULT for the project picker (see below)
|
|
35
36
|
},
|
|
@@ -189,6 +190,59 @@ instead:
|
|
|
189
190
|
|
|
190
191
|
```bash
|
|
191
192
|
export LINEAR_API_KEY=lin_api_… # from Linear → Settings → Security & access
|
|
193
|
+
|
|
194
|
+
Or store it once, for every repo on the machine, instead of exporting it in each:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
skitterspec spec-sync credentials set # prompts; input is hidden
|
|
198
|
+
skitterspec spec-sync credentials status # readiness only, never the value
|
|
199
|
+
skitterspec spec-sync credentials unset # remove this team's key
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
That writes `$XDG_CONFIG_HOME/skitterspec/credentials.json` (else
|
|
203
|
+
`~/.config/…`) at mode `600`, keyed by team id. Resolution order is **environment
|
|
204
|
+
variable first, then the store**, so CI is unaffected. A store readable by other
|
|
205
|
+
users is refused with the `chmod` to run rather than used.
|
|
206
|
+
|
|
207
|
+
`set` reads the key from a hidden prompt, or from a pipe with `--stdin`. There is
|
|
208
|
+
deliberately **no `--key <value>` flag**: a secret in the command line is visible
|
|
209
|
+
in shell history and to `ps`. For the same reason, run `set` yourself — never
|
|
210
|
+
paste a key into an assistant conversation, where it would enter the transcript.
|
|
211
|
+
`status` exists so an assistant can confirm readiness without ever seeing the
|
|
212
|
+
key.
|
|
213
|
+
|
|
214
|
+
### Delegating to a password manager
|
|
215
|
+
|
|
216
|
+
Rather than storing the key at all, record a **command** that prints it. A
|
|
217
|
+
command is not a secret, so unlike `--key` it is safe as an argument:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
# 1Password CLI
|
|
221
|
+
skitterspec spec-sync credentials set --command 'op read op://Private/linear/token'
|
|
222
|
+
|
|
223
|
+
# pass
|
|
224
|
+
skitterspec spec-sync credentials set --command 'pass show linear/api-key'
|
|
225
|
+
|
|
226
|
+
# macOS Keychain (add once: security add-generic-password -s skitterspec -a linear -w)
|
|
227
|
+
skitterspec spec-sync credentials set --command 'security find-generic-password -w -s skitterspec -a linear'
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The command runs on each resolution; its stdout, trimmed, is the key. A non-zero
|
|
231
|
+
exit or empty output means "no key", which simply falls back to MCP — with the
|
|
232
|
+
reason shown by `credentials status`, so a broken command is never silently
|
|
233
|
+
inert. It has 60 seconds to complete, enough for a biometric or master-password
|
|
234
|
+
prompt. Recording a command replaces any stored key for that team, so the
|
|
235
|
+
command actually runs.
|
|
236
|
+
|
|
237
|
+
A command with a key written into it (`--command 'echo lin_api_…'`) is
|
|
238
|
+
**refused**: commands are displayed by `status` and stored in clear, so that is
|
|
239
|
+
strictly worse than storing the key. Use `credentials set` for a key.
|
|
240
|
+
|
|
241
|
+
> **`keyCommand` is honoured only from the user-level store — never from
|
|
242
|
+
> `specs/.core/linear.config.json`.** That file is committed and travels with the
|
|
243
|
+
> repo, so a command named there would run on the machine of anyone who cloned it
|
|
244
|
+
> and ran `spec-sync`. If one is found there it is ignored, and `credentials
|
|
245
|
+
> status` says so.
|
|
192
246
|
```
|
|
193
247
|
|
|
194
248
|
```json
|
|
@@ -430,3 +484,28 @@ Linear-native triage and are never touched. No base merge, no conflicts, no
|
|
|
430
484
|
last-pushed snapshot is content hashes of the last push, so `/spec-push` knows
|
|
431
485
|
what changed without reading Linear back; each worktree carries its own, so it
|
|
432
486
|
must travel with the branch.
|
|
487
|
+
|
|
488
|
+
## Renaming a team
|
|
489
|
+
|
|
490
|
+
Renaming a Linear team rewrites the key in every issue identifier (`ENG-7` →
|
|
491
|
+
`PLT-7`). The repo stamps identifiers in three places — spec frontmatter, the
|
|
492
|
+
`linear-base` snapshot filenames, and the `subIssues` keys inside them — and
|
|
493
|
+
nothing moves them, so afterwards `/spec-push` fails with
|
|
494
|
+
`no Linear issue found for ENG-7`.
|
|
495
|
+
|
|
496
|
+
```
|
|
497
|
+
skitterspec spec-sync retarget # what would change
|
|
498
|
+
skitterspec spec-sync retarget --yes # apply it
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
`teamId` survives a rename, so the command asks Linear for that team's *current*
|
|
502
|
+
key and compares it with `teamKey` — which is why `teamKey` is worth setting even
|
|
503
|
+
though nothing else reads it. With `teamKey` empty it falls back to the prefix
|
|
504
|
+
observed in the stamps, and refuses if those disagree rather than guessing.
|
|
505
|
+
|
|
506
|
+
Before reporting a plan as safe it resolves one remapped identifier and compares
|
|
507
|
+
its **title** to the spec's: a team rename preserves issue numbers, and this is
|
|
508
|
+
what proves it did. `--yes` refuses on a dirty tree so the rewrite lands as one
|
|
509
|
+
revertable change, and it rewrites machine-read fields only — identifiers in
|
|
510
|
+
spec prose are the historical record and are left alone. It pushes nothing; the
|
|
511
|
+
next ordinary `/spec-push` reconciles content once its issues resolve again.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Checks That Accuse
|
|
2
|
+
|
|
3
|
+
A check **accuses** when being wrong costs something: it deletes, it exits
|
|
4
|
+
non-zero, or it tells the user their code is broken. Those checks earn the four
|
|
5
|
+
rules below. An ordinary conditional does not — this is about the ones that act
|
|
6
|
+
on what they conclude.
|
|
7
|
+
|
|
8
|
+
Almost every accusation begins as an **absence**: a name not in a list, a
|
|
9
|
+
directory not on disk, a version not in a response. An absence is evidence only
|
|
10
|
+
once you have established that the lookup could have seen the thing. Three
|
|
11
|
+
times in one day, in unrelated code, we established nothing and acted anyway:
|
|
12
|
+
|
|
13
|
+
| Absence observed | Concluded | What had blinded the lookup |
|
|
14
|
+
|------------------|-----------|-----------------------------|
|
|
15
|
+
| ref not in the issue list | "it does not exist" | the query excluded archived issues, and capped at 250 |
|
|
16
|
+
| version not in the registry's list | "the publish failed" | the registry is eventually consistent |
|
|
17
|
+
| lifecycle folder not on disk | "half-installed" | git does not store an empty directory |
|
|
18
|
+
|
|
19
|
+
The bills: 146 healthy refs accused, a valid release tag deleted, a non-zero
|
|
20
|
+
exit on a healthy repo. Each blind spot was knowable in advance.
|
|
21
|
+
|
|
22
|
+
## 1. Prefer a positive signal to an absence
|
|
23
|
+
|
|
24
|
+
Assert something that must be **present**, not something that must not be
|
|
25
|
+
missing. A positive signal fails loudly when you are wrong about it; an absence
|
|
26
|
+
fails silently whenever the lookup was narrower than you assumed.
|
|
27
|
+
|
|
28
|
+
The scaffold check above stopped asking "is the lifecycle folder there?" — a
|
|
29
|
+
folder git drops as soon as it empties — and started asking whether the config
|
|
30
|
+
folder the installer always writes into is there. Same intent, and the new
|
|
31
|
+
question has an answer.
|
|
32
|
+
|
|
33
|
+
Where no positive signal exists, widen the lookup until absence means
|
|
34
|
+
something (include the archived rows, ask the API for the one id rather than
|
|
35
|
+
scanning a page) — or do not conclude.
|
|
36
|
+
|
|
37
|
+
## 2. Name the blind spot beside the check
|
|
38
|
+
|
|
39
|
+
A comment naming what could make this lookup lie is what makes the next reader
|
|
40
|
+
check it. Not what the code does — what would fool it:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
// A LIFECYCLE FOLDER IS NOT CHECKED, deliberately. git does not track empty
|
|
44
|
+
// directories, so it disappears whenever the bucket empties and returns the
|
|
45
|
+
// moment something lands in it.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Write it when you write the check, while you still know why it is safe.
|
|
49
|
+
|
|
50
|
+
## 3. Pair every accusation with a stays-silent test
|
|
51
|
+
|
|
52
|
+
For each accusing check, a test that feeds it a **healthy but unusual** input
|
|
53
|
+
and asserts it says nothing: the empty bucket, the archived record, the
|
|
54
|
+
just-published version, the file the user edited on purpose. The positive test
|
|
55
|
+
proves the check can fire; only this one proves it does not fire at everyone
|
|
56
|
+
else. All three incidents would have been caught by it, and prose alone had
|
|
57
|
+
already failed to prevent them.
|
|
58
|
+
|
|
59
|
+
## 4. Bias the unknown case toward inaction
|
|
60
|
+
|
|
61
|
+
Three states, not two: yes, no, and *cannot tell*. Route the third to the
|
|
62
|
+
harmless branch — skip, warn, keep, retry — never to the destructive one.
|
|
63
|
+
|
|
64
|
+
The install manifest classifies a file whose hash it does not recognise as
|
|
65
|
+
`customized` rather than stale, so a resync **keeps** it (`managedState`,
|
|
66
|
+
`packages/common/src/init.js`). An unrecognised hash could mean a user's edit or
|
|
67
|
+
a lost manifest; only one of those readings is safe to act on, so it takes that
|
|
68
|
+
one. Being wrong there costs a redundant file on disk. The opposite default
|
|
69
|
+
costs the user their work.
|
|
@@ -159,14 +159,45 @@ paraphrase it into "done".
|
|
|
159
159
|
|
|
160
160
|
## 8. Report and hand off
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
**Finish by checking, not by describing.** Run:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
skitterspec spec-sync doctor
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
and relay its table. That is the difference between a summary of what setup
|
|
169
|
+
*meant* to do and a check of what is actually true — including the layers this
|
|
170
|
+
skill never touched (the scaffold, isolation) and the one it deliberately does
|
|
171
|
+
not set (the key). Every row that needs attention names its own fix, so there is
|
|
172
|
+
nothing to paraphrase.
|
|
173
|
+
|
|
174
|
+
It exits non-zero only when a layer is **broken** — configured but wrong. A
|
|
175
|
+
`missing` row is an opt-in nobody took, which is fine; report it, don't treat it
|
|
176
|
+
as a failure.
|
|
177
|
+
|
|
178
|
+
Then name the next step:
|
|
163
179
|
|
|
164
180
|
- `/spec` — write a spec; with Linear configured it offers the project picker,
|
|
165
181
|
creates the linked issue and stamps the id.
|
|
166
182
|
- `/spec-status` — read-only drift report, the safe way to prove the link works.
|
|
167
183
|
- `/spec-push` — send a spec up.
|
|
168
184
|
|
|
185
|
+
**Never ask for the API key.** `doctor`'s `key` row already reports whether one
|
|
186
|
+
is set and where from, masked — `spec-sync credentials status` says the same in
|
|
187
|
+
more detail if you need it. If the key is missing, tell the user to run this
|
|
188
|
+
**themselves, in their own terminal**:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
skitterspec spec-sync credentials set
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Do not ask the user to paste an API key into this conversation, and do not
|
|
195
|
+
offer to store one for them.** A key pasted into a chat enters the transcript,
|
|
196
|
+
is sent to the model, and may be logged — moving where a key is stored is
|
|
197
|
+
pointless if it travels through the conversation to get there. Your job is to
|
|
198
|
+
confirm the setup is ready and name the command; the value is theirs alone. The
|
|
199
|
+
key is optional: without one, pushes go over MCP.
|
|
200
|
+
|
|
169
201
|
Mention what setup did **not** configure, so the defaults aren't mistaken for
|
|
170
|
-
decisions: phase mapping (`mapping.phases` — sub-issue per phase by default)
|
|
171
|
-
field ownership
|
|
172
|
-
fast path and never lives in the config). Point at `linear.config.md` for those.
|
|
202
|
+
decisions: phase mapping (`mapping.phases` — sub-issue per phase by default) and
|
|
203
|
+
field ownership. Point at `linear.config.md` for those.
|
|
@@ -46,6 +46,24 @@ skitterspec spec-sync status <spec> [--remote <issuefile>] [--workspace-states <
|
|
|
46
46
|
- With `--workspace-states`, fails loudly if a configured state name isn't in the
|
|
47
47
|
workspace (Linear would silently no-op it).
|
|
48
48
|
|
|
49
|
+
## 3b. A key mismatch is a different problem
|
|
50
|
+
|
|
51
|
+
If the spec's `linear_identifier` carries a **different team key** than
|
|
52
|
+
`linear.teamKey` in `specs/.core/linear.config.json` — or the issue read in step
|
|
53
|
+
2 came back under another key — the team was renamed and the repo's stamps are
|
|
54
|
+
stale. That is not push drift and `/spec-push` cannot fix it: it will fail with
|
|
55
|
+
`no Linear issue found for <old>-<n>`.
|
|
56
|
+
|
|
57
|
+
Point the user at the CLI and stop:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
pnpm exec skitterspec-linear spec-sync retarget
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Read-only until `--yes`. Do not attempt the rewrite by hand — the identifiers
|
|
64
|
+
live in frontmatter, snapshot filenames and the keys inside those snapshots, and
|
|
65
|
+
a hand edit misses some (it has, twice).
|
|
66
|
+
|
|
49
67
|
## 4. Report
|
|
50
68
|
|
|
51
69
|
Relay the engine's output verbatim. Suggest `/spec-push` if a push is pending.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spec-sync
|
|
3
|
+
description: Run the repo-wide spec-sync operations against Linear — what's linked, the workspace's states and projects, read-back verification, manual stamping, and bulk `apply --all <bucket>`. Run it bare for the repo-wide overview. Wraps the `spec-sync` CLI, which is a local devDependency and never on PATH, so this skill always states the full invocation. Defers to /spec-push and /spec-status for per-spec work. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-sync", "run spec-sync", "what's linked to Linear", "which states does the workspace have", or "mirror the whole backlog".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /spec-sync — the repo-wide Linear sync operations
|
|
7
|
+
|
|
8
|
+
`/spec-push` and `/spec-status` cover **one spec**. This skill covers everything
|
|
9
|
+
**repo-wide**, and is the answer to a bare "run spec-sync" — which is not itself
|
|
10
|
+
a command.
|
|
11
|
+
|
|
12
|
+
**Opt-in**: only runs when `specs/.core/linear.config.json` exists. If absent,
|
|
13
|
+
tell the user how to enable Linear sync and stop.
|
|
14
|
+
|
|
15
|
+
## The invocation — always state it
|
|
16
|
+
|
|
17
|
+
`spec-sync` is a subcommand of a binary that is a **local devDependency**, so it
|
|
18
|
+
is never on `PATH`. Typing `spec-sync` gives `command not found`, which reads as
|
|
19
|
+
a broken install rather than a wrong invocation. Always run it — and always show
|
|
20
|
+
the user you ran it — in full:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pnpm exec skitterspec-linear spec-sync <subcommand> [args]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
In a project that installs the Linear superset the binary is
|
|
27
|
+
`skitterspec-linear` (it also answers to `skitterspec`). Never print a bare
|
|
28
|
+
`spec-sync …` as if the user could type it.
|
|
29
|
+
|
|
30
|
+
## 1. Route the ask
|
|
31
|
+
|
|
32
|
+
| The user asks | Run |
|
|
33
|
+
|---------------|-----|
|
|
34
|
+
| bare `/spec-sync`, "what's linked?", "how much is mirrored?" | `linked` |
|
|
35
|
+
| "what states / projects does the workspace have?" | `states`, `projects` |
|
|
36
|
+
| "did the mirror survive the push?" | `verify <spec> --stored <file>` |
|
|
37
|
+
| "link this spec to KEY-1 by hand" | `stamp <spec> --issue KEY-1` |
|
|
38
|
+
| "mirror the whole backlog / every complete spec" | `apply --all <bucket>` — **confirm first** |
|
|
39
|
+
| "is the team key stale?", "did Linear get renamed?" | `retarget` |
|
|
40
|
+
| push one spec, or "what would push?" | **defer** — see below |
|
|
41
|
+
|
|
42
|
+
**With no argument, run `linked`.** It is the repo-wide overview, it is
|
|
43
|
+
read-only, and it is what a bare "run spec-sync" means.
|
|
44
|
+
|
|
45
|
+
**When the ask is genuinely ambiguous between a read and a write — ask.**
|
|
46
|
+
"Sync the backlog" could mean `linked` (show me) or `apply --all backlog`
|
|
47
|
+
(write dozens of issues). Never resolve that guess in favour of the write.
|
|
48
|
+
|
|
49
|
+
## 2. Defer, don't duplicate
|
|
50
|
+
|
|
51
|
+
Two front doors to one write path is worse than none:
|
|
52
|
+
|
|
53
|
+
- **Push one spec** → `/spec-push`. It owns the transport choice, the
|
|
54
|
+
workspace-state gate, the plan, the apply, the stamping and the snapshot.
|
|
55
|
+
- **Per-spec drift** ("is this spec in sync?", "what would push?") →
|
|
56
|
+
`/spec-status`.
|
|
57
|
+
|
|
58
|
+
Say which skill you're handing to and stop; don't reimplement either.
|
|
59
|
+
|
|
60
|
+
## 3. The read-only subcommands
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
pnpm exec skitterspec-linear spec-sync linked [--json]
|
|
64
|
+
pnpm exec skitterspec-linear spec-sync states [--via api|mcp] [--json]
|
|
65
|
+
pnpm exec skitterspec-linear spec-sync projects [--via api|mcp] [--json]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- **`linked`** is offline: which specs carry a `linear_identifier`, their bucket,
|
|
69
|
+
and an `N/M linked` total. Relay it, and when specs in `in-progress` have no
|
|
70
|
+
issue, say so — those are the ones a push would mint.
|
|
71
|
+
- **`states` / `projects`** reach the workspace. `states` also reports the
|
|
72
|
+
**transport** (`api` when a key is set, else `mcp`), which is the honest way to
|
|
73
|
+
answer "how is this talking to Linear?".
|
|
74
|
+
|
|
75
|
+
## 4. `verify` — check what the tracker actually stored
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
pnpm exec skitterspec-linear spec-sync verify <spec> --stored <file>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`--stored` is **what Linear currently holds**, read back over MCP:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{ "issue": "…", "subIssues": { "01-engine": "…" } }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**It is not a `linear-base/*.base.json` snapshot.** That file holds content
|
|
88
|
+
*hashes* keyed by identifier; comparing one against a description reports
|
|
89
|
+
enormous losses on a perfectly intact mirror. The engine refuses a snapshot
|
|
90
|
+
outright — if you see that refusal, you passed the wrong file, not found a bug.
|
|
91
|
+
|
|
92
|
+
Warns, never fails. The repo is the source of truth; a mangled mirror is fixed
|
|
93
|
+
by pushing again.
|
|
94
|
+
|
|
95
|
+
## 5. `stamp` — the manual escape hatch
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
pnpm exec skitterspec-linear spec-sync stamp <spec> --issue KEY-1 [--url URL] [--sub <ref>=KEY-2 …]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Writes the ids into the spec's frontmatter without touching Linear. For adopting
|
|
102
|
+
an issue created by hand, or repairing a stamp — not the normal path, which is
|
|
103
|
+
`/spec-push`. A wrong id here makes the next push see an **unlinked** spec and
|
|
104
|
+
mint a duplicate, so read the values back to the user before writing.
|
|
105
|
+
|
|
106
|
+
## 6. `apply --all <bucket>` — bulk, and the one with real blast radius
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
pnpm exec skitterspec-linear spec-sync apply --all <bucket> [--via api|mcp] [--json]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Confirm before running, and state creates and updates separately.** They are
|
|
113
|
+
not the same risk:
|
|
114
|
+
|
|
115
|
+
- an **update** refreshes a mirror that already exists;
|
|
116
|
+
- a **create** mints new issues and sub-issues in someone's shared tracker.
|
|
117
|
+
|
|
118
|
+
A repo adopting Linear reports "N to create" for every unlinked spec, so an
|
|
119
|
+
unconsidered `--all` can mint dozens of sub-issues. Get the counts first — run
|
|
120
|
+
`linked` to see what is unlinked, or `spec-sync push <spec>` per spec for exact
|
|
121
|
+
numbers — then show the user something like:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
apply --all backlog would:
|
|
125
|
+
create 12 issue(s) + 34 sub-issue(s) ← new objects in Linear
|
|
126
|
+
update 3 issue(s) + 5 sub-issue(s) ← refresh existing mirrors
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Only run it on an explicit yes. It is resumable: every id is stamped the moment
|
|
130
|
+
its object exists, so an interrupted run continues rather than duplicating.
|
|
131
|
+
|
|
132
|
+
`--all` refuses over MCP by design — bulk goes through the API path.
|
|
133
|
+
|
|
134
|
+
## 7. `retarget` — after a Linear team is renamed
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
pnpm exec skitterspec-linear spec-sync retarget [--yes]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Renaming a Linear team rewrites the key in every issue identifier, and **nothing
|
|
141
|
+
in the repo moves**: the frontmatter stamps, the config `teamKey`, and the
|
|
142
|
+
snapshot filenames and their sub-issue keys all keep the old prefix, so
|
|
143
|
+
`/spec-push` starts failing with `no Linear issue found for SKI-7`.
|
|
144
|
+
|
|
145
|
+
`retarget` detects the rename (the team id survives it; the key does not) and
|
|
146
|
+
rewrites those fields. Read-only until `--yes`.
|
|
147
|
+
|
|
148
|
+
- It **never takes the new key as an argument** — a typo would rewrite every
|
|
149
|
+
stamp to a key that does not exist.
|
|
150
|
+
- It **spot-checks one identifier by title** before reporting the plan as safe.
|
|
151
|
+
Existence is not identity: `SKS-7` existing does not make it the issue that was
|
|
152
|
+
`SKI-7`. A mismatch refuses, and writes nothing.
|
|
153
|
+
- It rewrites **machine-read fields only**. Identifiers in spec prose are the
|
|
154
|
+
historical record and are left alone — so do not report a retarget as having
|
|
155
|
+
made the repo free of the old key.
|
|
156
|
+
- `--yes` refuses on a dirty tree, so the rewrite lands as one revertable change.
|
|
157
|
+
It pushes nothing: only the repo's stamps move, and the next ordinary
|
|
158
|
+
`/spec-push` reconciles content now that its issues resolve again.
|
|
159
|
+
|
|
160
|
+
Over MCP the team key is unreadable (`get_team` does not return it), so it says
|
|
161
|
+
so and asks you to confirm the key rather than guessing.
|
|
162
|
+
|
|
163
|
+
## 8. Report
|
|
164
|
+
|
|
165
|
+
Relay the engine's output. Name the subcommand you ran, in full, so the user can
|
|
166
|
+
re-run it themselves. For anything that wrote, say what changed in Linear and
|
|
167
|
+
that the repo is unchanged — it is the source of truth either way.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.5.0",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/init.js
CHANGED
|
@@ -406,6 +406,11 @@ function installClaudeMd(dir, { mode }) {
|
|
|
406
406
|
|
|
407
407
|
// True when the repo looks already set up: any managed file present, any spec
|
|
408
408
|
// lifecycle folder, or the CLAUDE.md spec marker (Decision 1 — detect eagerly).
|
|
409
|
+
// Is skitterspec already installed here? Matched on what we ACTUALLY install —
|
|
410
|
+
// our managed files, our lifecycle folders, our CLAUDE.md marker — never on
|
|
411
|
+
// `.claude/` merely existing: someone else's skills are not evidence of ours,
|
|
412
|
+
// and reading them as ours would treat every Claude Code project as a
|
|
413
|
+
// half-finished install.
|
|
409
414
|
function isExistingSetup(dir) {
|
|
410
415
|
if (managedTargets(dir).some((t) => fs.existsSync(t.abs))) return true
|
|
411
416
|
if (SPEC_FOLDERS.some((f) => fs.existsSync(path.join(dir, 'specs', f)))) return true
|
|
@@ -562,11 +567,21 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
562
567
|
// ships none. Discovering it from what was actually installed keeps this file
|
|
563
568
|
// tracker-free — it never has to know which tracker (if any) is in the box.
|
|
564
569
|
const setupSkill = SKILLS.find((s) => /^spec-.+-setup$/.test(s))
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
+
const provider = setupSkill ? /^spec-(.+)-setup$/.exec(setupSkill)[1] : null
|
|
571
|
+
// …and the same derivation gives the provider's config filename, so this can
|
|
572
|
+
// report tracker sync the way it reports isolation above — from what is
|
|
573
|
+
// actually on disk. It used to say "opt-in: run /…-setup" even on a repo that
|
|
574
|
+
// had already configured it, telling you to set up what was already set up.
|
|
575
|
+
const trackerOn =
|
|
576
|
+
provider && fs.existsSync(path.join(dir, 'specs', '.core', `${provider}.config.json`))
|
|
577
|
+
const trackerNote = !setupSkill
|
|
578
|
+
? ''
|
|
579
|
+
: trackerOn
|
|
580
|
+
? `Tracker sync is ON: ${provider} — the repo stays the source of truth;` +
|
|
581
|
+
' /spec-push mirrors a spec up and /spec-status reports drift.\n'
|
|
582
|
+
: `Tracker sync is opt-in: run /${setupSkill} to configure it` +
|
|
583
|
+
' (it discovers your workspace and writes the config), or see' +
|
|
584
|
+
' specs/.core/SETUP.md.\n'
|
|
570
585
|
process.stdout.write(
|
|
571
586
|
'\nDone. Skills resolve as /spec, /spec-go, /spec-complete, /spec-cancel,' +
|
|
572
587
|
' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
|
package/src/vendor/linear/api.js
CHANGED
|
@@ -29,27 +29,69 @@ const ENDPOINT = 'https://api.linear.app/graphql'
|
|
|
29
29
|
const MAX_RETRIES = 5
|
|
30
30
|
const MAX_BACKOFF_MS = 60_000
|
|
31
31
|
|
|
32
|
+
const { storePath, readStore, resolveTeamKey } = require('./credentials.js')
|
|
33
|
+
|
|
32
34
|
/**
|
|
33
|
-
* Resolve the personal API key
|
|
35
|
+
* Resolve the personal API key, environment first.
|
|
36
|
+
*
|
|
37
|
+
* Order — first hit wins:
|
|
38
|
+
* 1. `process.env[auth.keyEnv]` (default LINEAR_API_KEY) — CI and every
|
|
39
|
+
* existing setup, so this path is unchanged.
|
|
40
|
+
* 2. the user-level credentials store, keyed by `linear.teamId`.
|
|
34
41
|
*
|
|
35
|
-
* Returns `{ ok: true, key, envVar }
|
|
36
|
-
*
|
|
37
|
-
*
|
|
42
|
+
* Returns `{ ok: true, key, envVar, source }` where `source` is `'env'` or
|
|
43
|
+
* `'store'`, or `{ ok: false, envVar, error }` when nothing is set — a value the
|
|
44
|
+
* caller branches on rather than an exception, because "no key" is a normal
|
|
45
|
+
* state that means "use MCP", not a failure. A store that exists but is
|
|
46
|
+
* unreadable or world-readable is reported in `error`; it never silently
|
|
47
|
+
* degrades to "no key", or the leak would be invisible.
|
|
38
48
|
*
|
|
39
49
|
* The key is never part of the returned error, and callers must keep it out of
|
|
40
50
|
* logs, plans, snapshots and stamped frontmatter.
|
|
41
51
|
*/
|
|
42
|
-
function resolveApiKey(config, env = process.env) {
|
|
52
|
+
function resolveApiKey(config, env = process.env, deps = {}) {
|
|
43
53
|
const envVar = (config && config.auth && config.auth.keyEnv) || DEFAULT_KEY_ENV
|
|
44
|
-
const
|
|
45
|
-
if (typeof
|
|
54
|
+
const fromEnv = env[envVar]
|
|
55
|
+
if (typeof fromEnv === 'string' && fromEnv.trim()) {
|
|
56
|
+
return { ok: true, key: fromEnv.trim(), envVar, source: 'env' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The store is keyed by team, so with no teamId there is nothing to look up —
|
|
60
|
+
// skip the read entirely. That also keeps callers with no Linear config (and
|
|
61
|
+
// every unit test using the defaults) off the real filesystem.
|
|
62
|
+
const teamId = config && config.linear && config.linear.teamId
|
|
63
|
+
if (!teamId) {
|
|
46
64
|
return {
|
|
47
65
|
ok: false,
|
|
48
66
|
envVar,
|
|
49
67
|
error: `no Linear API key — set ${envVar}, or apply the plan over MCP with --via mcp`,
|
|
50
68
|
}
|
|
51
69
|
}
|
|
52
|
-
|
|
70
|
+
|
|
71
|
+
const file = (deps.storePath || storePath)(env)
|
|
72
|
+
const result = (deps.readStore || readStore)(file)
|
|
73
|
+
let commandReason = ''
|
|
74
|
+
if (result.ok) {
|
|
75
|
+
const found = (deps.resolveTeamKey || resolveTeamKey)(result.store, teamId, deps)
|
|
76
|
+
if (found.key) {
|
|
77
|
+
return { ok: true, key: found.key, envVar, source: found.source, command: found.command, path: file }
|
|
78
|
+
}
|
|
79
|
+
// A keyCommand that exists but did not produce a key is a real problem —
|
|
80
|
+
// surfacing it is the difference between "fix your command" and silence.
|
|
81
|
+
if (found.reason) commandReason = `\n ${found.reason}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A store that is present but unusable is a distinct, reportable problem —
|
|
85
|
+
// don't let a permissions refusal read as "you never set a key".
|
|
86
|
+
const detail = (result.ok || result.code === 'absent' ? '' : `\n ${result.reason}`) + commandReason
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
envVar,
|
|
90
|
+
path: file,
|
|
91
|
+
error:
|
|
92
|
+
`no Linear API key — set ${envVar}, run \`skitterspec spec-sync credentials set\` ` +
|
|
93
|
+
`to store one in ${file}, or apply the plan over MCP with --via mcp${detail}`,
|
|
94
|
+
}
|
|
53
95
|
}
|
|
54
96
|
|
|
55
97
|
// Fields we read back on every write. `identifier` and `url` are what the skill
|
|
@@ -192,6 +234,18 @@ function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries
|
|
|
192
234
|
if (data && data.team) return (data.team.projects && data.team.projects.nodes) || []
|
|
193
235
|
return (data && data.projects && data.projects.nodes) || []
|
|
194
236
|
},
|
|
237
|
+
// The team's CURRENT key, which is what `retarget` compares stamped
|
|
238
|
+
// identifiers against. Read from Linear rather than `config.linear.teamKey`
|
|
239
|
+
// on purpose: the config key is itself one of the things that goes stale
|
|
240
|
+
// when a team is renamed, so trusting it would make drift invisible. The
|
|
241
|
+
// teamId is stable across a rename; the key is not.
|
|
242
|
+
//
|
|
243
|
+
// API-only, like `listIssueStates` — see the operation-contract test: the
|
|
244
|
+
// API adapter may add ops, it may only never be missing one.
|
|
245
|
+
async readTeam(teamId) {
|
|
246
|
+
const data = await call(`query($id: String!) { team(id: $id) { id key name } }`, { id: teamId })
|
|
247
|
+
return (data && data.team) || null
|
|
248
|
+
},
|
|
195
249
|
// The workspace's issue workflow states, in the shape `--workspace-states`
|
|
196
250
|
// already accepts, so the existing state check is reused rather than forked.
|
|
197
251
|
async listIssueStates(teamId) {
|