@skitterbyte/skitterspec-linear 3.1.0 → 4.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/README.md +15 -8
- package/assets/core/SETUP.md +175 -0
- package/assets/core/linear.config.json.example +2 -5
- package/assets/core/linear.config.md +44 -6
- package/assets/skills/spec-bug/SKILL.md +55 -8
- package/assets/skills/spec-go/SKILL.md +6 -0
- package/assets/skills/spec-pull/SKILL.md +4 -1
- package/assets/skills/spec-push/SKILL.md +21 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sync.js +32 -1
- package/src/vendor/linear/config.js +28 -4
- package/src/vendor/linear/mcp.js +38 -12
- package/src/vendor/sync-core/src/compare.js +116 -0
- package/src/vendor/sync-core/src/normalize.js +144 -17
- package/src/vendor/sync-core/src/pull.js +47 -16
- package/src/vendor/sync-core/src/push.js +51 -10
- package/src/vendor/sync-core/src/write.js +247 -0
package/README.md
CHANGED
|
@@ -32,16 +32,23 @@ and `/spec-go` pulls first so you build against the current shared state.
|
|
|
32
32
|
## Opt-in
|
|
33
33
|
|
|
34
34
|
Linear sync is inert until `specs/.core/linear.config.json` exists — copy the
|
|
35
|
-
scaffolded `linear.config.json.example` and fill in your team
|
|
36
|
-
(every field is documented in `specs/.core/linear.config.md`). Without it, this
|
|
35
|
+
scaffolded `linear.config.json.example` and fill in your team id. Without it, this
|
|
37
36
|
behaves exactly like the base.
|
|
38
37
|
|
|
39
|
-
**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
**Start here:** `specs/.core/SETUP.md` (scaffolded by `init`) is the full
|
|
39
|
+
setup guide — connecting the `linear` MCP server, finding your team id, linking a
|
|
40
|
+
spec, and a smoke test. Per-field docs live in `specs/.core/linear.config.md`.
|
|
41
|
+
|
|
42
|
+
**What syncs:** by default the whole spec body travels as the Linear Project
|
|
43
|
+
**`description`** (co-authored, push + pull); **status / priority / labels** are
|
|
44
|
+
Linear-owned (pull only). **Field ownership** (`both` / `pull` / `push`) collapses
|
|
45
|
+
conflicts — only a `both` field that moved on both sides is a real conflict, and
|
|
46
|
+
`--force` backs up the losing side before winning. **Opt into a per-Milestone /
|
|
47
|
+
per-Issue body round-trip** — phases ↔ Linear Milestones and tasks ↔ Issues,
|
|
48
|
+
compared per item — by adding `milestones`/`tasks` to `sync.keyedFields` (see the
|
|
49
|
+
"Body round-trip" section of `linear.config.md`); deletions there are report-only.
|
|
50
|
+
**Base sidecars** (`specs/.core/linear-base/`) are committed; **backups**
|
|
51
|
+
(`specs/.core/linear-backups/`) are gitignored.
|
|
45
52
|
|
|
46
53
|
Branch naming that embeds the Linear id lives in the isolation config
|
|
47
54
|
(`env.config.json` → `branch.pattern` with `{identifier}`, `branch.identifierField:
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Setting up Linear hybrid-sync
|
|
2
|
+
|
|
3
|
+
A start-to-finish guide to getting `/spec-status`, `/spec-pull`, and `/spec-push`
|
|
4
|
+
working against a real Linear workspace. Covers the **Linear side** (connecting
|
|
5
|
+
the MCP server, finding your team) that the config reference
|
|
6
|
+
(`linear.config.md`) assumes you already have.
|
|
7
|
+
|
|
8
|
+
> The whole feature is **opt-in**: until `specs/.core/linear.config.json` exists,
|
|
9
|
+
> everything below is inert and the package behaves exactly like the base
|
|
10
|
+
> `@skitterbyte/skitterspec`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Install the package
|
|
15
|
+
|
|
16
|
+
Install the Linear superset (or, if you already run the base, switch to it — it
|
|
17
|
+
contains the entire base, so install one **or** the other, never both):
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx @skitterbyte/skitterspec-linear init
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 2. Connect the Linear MCP server
|
|
24
|
+
|
|
25
|
+
The sync skills talk to Linear over MCP. Add Linear's official remote server and
|
|
26
|
+
authenticate — this is a one-time, per-machine step.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
claude mcp add --transport http linear https://mcp.linear.app/mcp
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then, **inside Claude Code**, authenticate (a browser window opens for OAuth —
|
|
33
|
+
pick the workspace you want to sync into):
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
/mcp → select "linear" → Authenticate
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A newly added server only appears in `/mcp` **after Claude Code restarts** — if
|
|
40
|
+
you don't see `linear`, relaunch with `claude --continue` and try again.
|
|
41
|
+
|
|
42
|
+
Verify it's connected:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
claude mcp list
|
|
46
|
+
# linear: https://mcp.linear.app/mcp (HTTP) - ✓ authenticated
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> Read-only trial: use `https://mcp.linear.app/mcp/readonly` to exercise
|
|
50
|
+
> `/spec-status` and `/spec-pull` without granting write access. `/spec-push`
|
|
51
|
+
> needs the full (writable) endpoint.
|
|
52
|
+
|
|
53
|
+
## 3. Find your team id
|
|
54
|
+
|
|
55
|
+
`linear.config.json` needs your Linear **team UUID**. The easiest way is to just
|
|
56
|
+
ask Claude once the MCP server is connected:
|
|
57
|
+
|
|
58
|
+
> "List my Linear teams with their ids."
|
|
59
|
+
|
|
60
|
+
It calls the Linear `list_teams` tool and returns rows like:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Skitterspec — e07c2b54-dcf6-4b6e-81bd-175a9bc79868 (key: SKI)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Copy the `id` (the UUID). The `key` (e.g. `SKI`) is the short human handle. If you
|
|
67
|
+
want an **Initiative** to group your specs, ask "list my Linear initiatives" and
|
|
68
|
+
copy that id too (optional).
|
|
69
|
+
|
|
70
|
+
## 4. Scaffold the config
|
|
71
|
+
|
|
72
|
+
`init` dropped a `specs/.core/linear.config.json.example`. Copy it and fill in the
|
|
73
|
+
ids from step 3 — the team id is the only required field:
|
|
74
|
+
|
|
75
|
+
```jsonc
|
|
76
|
+
// specs/.core/linear.config.json
|
|
77
|
+
{
|
|
78
|
+
"linear": {
|
|
79
|
+
"teamKey": "SKI",
|
|
80
|
+
"teamId": "e07c2b54-dcf6-4b6e-81bd-175a9bc79868",
|
|
81
|
+
"initiativeId": "" // optional
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Everything else (state names, field ownership) has sensible defaults — see
|
|
87
|
+
`linear.config.md` to customise. The moment this file exists, the Linear steps in
|
|
88
|
+
`/spec` and `/spec-go` and the three sync skills switch on.
|
|
89
|
+
|
|
90
|
+
## 5. Link a spec to a Linear project
|
|
91
|
+
|
|
92
|
+
A spec syncs once its `00-overview.md` frontmatter carries a `linear_project_id`.
|
|
93
|
+
Two ways to get there:
|
|
94
|
+
|
|
95
|
+
- **New spec:** run `/spec` — with Linear configured it offers to create a linked
|
|
96
|
+
Linear **Project** (one Milestone per phase) and stamps the id for you.
|
|
97
|
+
- **Existing spec / existing Linear project:** add the id by hand. Ask Claude to
|
|
98
|
+
"create a Linear project for this spec" (or find an existing one's id via
|
|
99
|
+
`list_projects`), then set the frontmatter:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
---
|
|
103
|
+
linear_project_id: "640bcb1a-28cd-46b5-b2f8-ff47ce494ed1"
|
|
104
|
+
---
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 6. Everyday sync
|
|
108
|
+
|
|
109
|
+
| Command | Direction | What it does |
|
|
110
|
+
|---------|-----------|--------------|
|
|
111
|
+
| `/spec-status` | — | Read-only. Per-field divergence: `local-only` / `remote-only` / `conflict` / `in sync`. |
|
|
112
|
+
| `/spec-pull` | Linear → repo | Applies remote-owned fields (status/priority/labels). Refuses a real conflict unless `--force`. |
|
|
113
|
+
| `/spec-push` | repo → Linear | Sends the co-authored `description` up. Refuses if a co-authored field moved on Linear since your last sync (pull first) unless `--force`. |
|
|
114
|
+
|
|
115
|
+
Typical loop: `/spec-status` → `/spec-pull` (take Linear's status) → edit the spec
|
|
116
|
+
in-repo → `/spec-push` (send content up).
|
|
117
|
+
|
|
118
|
+
### What actually syncs
|
|
119
|
+
|
|
120
|
+
| Field | Owner | Direction |
|
|
121
|
+
|-------|-------|-----------|
|
|
122
|
+
| `description` (the whole spec body: problem, solution, **phases**, acceptance criteria) | co-authored | push **and** pull |
|
|
123
|
+
| `workflowState` → `spec_status` | Linear | pull only |
|
|
124
|
+
| `priority` | Linear | pull only |
|
|
125
|
+
| `labels` | Linear | pull only |
|
|
126
|
+
|
|
127
|
+
By default the **entire spec body travels as the project `description`** — phases
|
|
128
|
+
and acceptance criteria included.
|
|
129
|
+
|
|
130
|
+
**Optional: body round-trip.** Opt in — add `milestones`/`tasks` to
|
|
131
|
+
`sync.keyedFields` (and `sync.fieldOwnership`) — and phases sync as **Milestones**
|
|
132
|
+
and tasks as **Issues**, compared per item: edit an individual phase or task in
|
|
133
|
+
Linear and pull just that back into the right phase file / task line, or push
|
|
134
|
+
local changes up. The link ids live in the phase file frontmatter
|
|
135
|
+
(`linear_milestone_id`) and inline on task lines (`- [ ] do it (SKI-123)`).
|
|
136
|
+
Deletions are report-only (surfaced by `/spec-status`, never auto-applied). Full
|
|
137
|
+
details in the "Body round-trip" section of `linear.config.md`.
|
|
138
|
+
|
|
139
|
+
Sections listed in `sync.localOnlySections` (default: **State log**, **Changelog**,
|
|
140
|
+
**Open questions**) are stripped from the pushed description — they never leave
|
|
141
|
+
the repo.
|
|
142
|
+
|
|
143
|
+
## 7. What to commit
|
|
144
|
+
|
|
145
|
+
- **Commit** `specs/.core/linear-base/` — the three-way merge's base sidecars
|
|
146
|
+
(last-synced snapshot per spec). Each worktree carries its own, so it must
|
|
147
|
+
travel with the branch.
|
|
148
|
+
- **Gitignore** `specs/.core/linear-backups/` — `--force` recovery copies, local
|
|
149
|
+
and per-machine. Add `specs/.core/linear-backups/` to `.gitignore`.
|
|
150
|
+
|
|
151
|
+
## 8. Smoke test (verify your setup)
|
|
152
|
+
|
|
153
|
+
With a linked spec, confirm the round-trip end-to-end:
|
|
154
|
+
|
|
155
|
+
1. `/spec-status` → note the current divergence.
|
|
156
|
+
2. `/spec-pull` → Linear's status/priority/labels land in the spec's frontmatter
|
|
157
|
+
(`spec_status`, `priority`, `labels`).
|
|
158
|
+
3. `/spec-status` again → **in sync**. (This also proves description idempotency:
|
|
159
|
+
Linear rewrites markdown bullets on save, and the sync canonicalizes both
|
|
160
|
+
sides so that never shows as a spurious change.)
|
|
161
|
+
4. Edit the spec body locally, `/spec-push` → the change lands on the Linear
|
|
162
|
+
project's description; `/spec-status` returns to **in sync**.
|
|
163
|
+
|
|
164
|
+
## Troubleshooting
|
|
165
|
+
|
|
166
|
+
- **"connect the `linear` MCP server"** — the server isn't connected/authed for
|
|
167
|
+
this session. Re-run step 2; remember a fresh add needs a Claude Code restart.
|
|
168
|
+
- **"missing required tools: projectUpdate"** — you're on the read-only endpoint
|
|
169
|
+
(or a restricted API key). Use `https://mcp.linear.app/mcp` for push.
|
|
170
|
+
- **A field won't stop showing as diverged** — that field genuinely differs on
|
|
171
|
+
the two sides. `pull`-owned fields (status/priority/labels) resolve to Linear;
|
|
172
|
+
`/spec-pull` reconciles them. For a co-authored `conflict`, resolve locally or
|
|
173
|
+
`--force` (which backs up the losing side under `sync.backupDir` first).
|
|
174
|
+
- **Reconnecting doesn't switch workspace** — Linear ties the OAuth session to one
|
|
175
|
+
workspace. Remove and re-add the server to authenticate against another.
|
|
@@ -26,14 +26,11 @@
|
|
|
26
26
|
"backupDir": "specs/.core/linear-backups",
|
|
27
27
|
"fieldOwnership": {
|
|
28
28
|
"description": "both",
|
|
29
|
-
"milestones": "both",
|
|
30
|
-
"phaseBodies": "both",
|
|
31
|
-
"acceptanceCriteria": "both",
|
|
32
|
-
"taskBreakdown": "both",
|
|
33
29
|
"workflowState": "pull",
|
|
34
30
|
"priority": "pull",
|
|
35
31
|
"labels": "pull"
|
|
36
32
|
},
|
|
37
|
-
"localOnlySections": ["State log", "Changelog", "Open questions"]
|
|
33
|
+
"localOnlySections": ["State log", "Changelog", "Open questions"],
|
|
34
|
+
"keyedFields": {}
|
|
38
35
|
}
|
|
39
36
|
}
|
|
@@ -77,13 +77,14 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
|
|
|
77
77
|
// "push" — local→Linear only; a remote edit never pulls and a conflict
|
|
78
78
|
// resolves to local-wins.
|
|
79
79
|
// Any field key you add here joins the compared field set; a value outside
|
|
80
|
-
// both|pull|push is rejected at load time.
|
|
80
|
+
// both|pull|push is rejected at load time. The default set is scoped to the
|
|
81
|
+
// fields that round-trip through the live skill today: the whole spec body
|
|
82
|
+
// travels as `description`; status/priority/labels are Linear-owned (pull).
|
|
83
|
+
// A phase/milestone or per-issue round-trip is a future extension — opt in by
|
|
84
|
+
// adding `milestones`/`phaseBodies`/`taskBreakdown` here once that write path
|
|
85
|
+
// exists (see the deferred body write-back note in the package README).
|
|
81
86
|
"fieldOwnership": {
|
|
82
87
|
"description": "both",
|
|
83
|
-
"milestones": "both",
|
|
84
|
-
"phaseBodies": "both",
|
|
85
|
-
"acceptanceCriteria": "both",
|
|
86
|
-
"taskBreakdown": "both",
|
|
87
88
|
"workflowState": "pull",
|
|
88
89
|
"priority": "pull",
|
|
89
90
|
"labels": "pull"
|
|
@@ -91,11 +92,48 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
|
|
|
91
92
|
|
|
92
93
|
// Markdown sections of 00-overview.md that are local-only scaffolding and
|
|
93
94
|
// are stripped from the pushed `description` (never sent to Linear).
|
|
94
|
-
"localOnlySections": ["State log", "Changelog", "Open questions"]
|
|
95
|
+
"localOnlySections": ["State log", "Changelog", "Open questions"],
|
|
96
|
+
|
|
97
|
+
// OPT-IN body round-trip. Map a keyed collection field → its item id key to
|
|
98
|
+
// sync it per item (each phase ↔ a Milestone, each task ↔ an Issue) instead of
|
|
99
|
+
// as one description blob. Empty by default. See "Body round-trip" below.
|
|
100
|
+
"keyedFields": {}
|
|
95
101
|
}
|
|
96
102
|
}
|
|
97
103
|
```
|
|
98
104
|
|
|
105
|
+
## Body round-trip (milestones & tasks) — opt-in
|
|
106
|
+
|
|
107
|
+
By default the whole spec body travels as the project **`description`**. Opt a
|
|
108
|
+
workspace into a finer-grained, bidirectional sync by adding the keyed fields:
|
|
109
|
+
|
|
110
|
+
```jsonc
|
|
111
|
+
"sync": {
|
|
112
|
+
"fieldOwnership": { "milestones": "both", "tasks": "both" },
|
|
113
|
+
"keyedFields": { "milestones": "id", "tasks": "id" }
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
With this on:
|
|
118
|
+
|
|
119
|
+
- **Phases ↔ Milestones.** Each phase file maps to a Linear Milestone. The link id
|
|
120
|
+
lives in the phase file's frontmatter (`linear_milestone_id`); its title ← the
|
|
121
|
+
phase h1, its description ← the phase `**Goal:**` line. The `Phases` index is
|
|
122
|
+
then stripped from the pushed `description` (no duplication).
|
|
123
|
+
- **Tasks ↔ Issues.** Each `- [ ]` task line maps to a Linear Issue. The link id
|
|
124
|
+
is carried **inline** on the line — `- [ ] do the thing (SKI-123)`. Text ↔ the
|
|
125
|
+
issue title; `[x]`/`[ ]` ↔ a completed / non-completed issue state.
|
|
126
|
+
- **Per-item merge.** Items are compared by id, so editing milestone A locally and
|
|
127
|
+
milestone B in Linear both apply; only the *same* item moving on both sides is a
|
|
128
|
+
conflict.
|
|
129
|
+
- **Deletions are report-only.** A phase/milestone or task/issue removed on either
|
|
130
|
+
side is surfaced by `/spec-status` (and the pull/push summaries) for you to
|
|
131
|
+
resolve by hand — it is never auto-deleted.
|
|
132
|
+
|
|
133
|
+
Unlinked local items (a new phase with no `linear_milestone_id`, a task with no
|
|
134
|
+
inline id) are created in Linear on the next `/spec-push`, which stamps the new id
|
|
135
|
+
back so they link from then on.
|
|
136
|
+
|
|
99
137
|
## Field ownership & conflicts
|
|
100
138
|
|
|
101
139
|
The spec is a set of structured fields, most written by only one side. Marking a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: spec-bug
|
|
3
|
-
description: Investigate a bug, capture it as a Bug-type spec, and drive it red→green. ALWAYS starts by reproducing the bug with a failing test, then writes the spec and works the test to green. Creates specs/in-progress/bug-<name>/00-overview.md. Use when the user reports a bug, says "/spec-bug", "investigate this bug", "this is broken — find and fix it", or pastes an error/stack trace.
|
|
3
|
+
description: Investigate a bug, capture it as a Bug-type spec, and drive it red→green. When per-spec isolation is enabled it provisions a worktree first, so the failing test and fix land on the bug's own branch, never on main. ALWAYS starts by reproducing the bug with a failing test, then writes the spec and works the test to green. Creates specs/in-progress/bug-<name>/00-overview.md. Use when the user reports a bug, says "/spec-bug", "investigate this bug", "this is broken — find and fix it", or pastes an error/stack trace.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# /spec-bug — investigate a bug, prove it with a failing test, fix it
|
|
@@ -24,7 +24,49 @@ Bugs are concrete — confirm, don't over-grill. Establish:
|
|
|
24
24
|
against the broken one (the bug usually lives in the differential). Do NOT
|
|
25
25
|
patch a symptom before you understand the cause.
|
|
26
26
|
|
|
27
|
-
## 2.
|
|
27
|
+
## 2. Isolate the fix in a worktree — when isolation is enabled
|
|
28
|
+
|
|
29
|
+
**Only when per-spec isolation is enabled** (`specs/.core/env.config.json`
|
|
30
|
+
exists). Skip this whole section otherwise — the fix happens in place, on the
|
|
31
|
+
current branch.
|
|
32
|
+
|
|
33
|
+
**Opt-out:** if the user passes `--no-worktree` (or explicitly asks to work in
|
|
34
|
+
place), skip this whole section and fix on the current branch — same as when
|
|
35
|
+
isolation is off. Warn that the fix will land wherever you currently are (usually
|
|
36
|
+
`main`); reserve it for a trivial one-liner or an explicit request.
|
|
37
|
+
|
|
38
|
+
A bug fix changes real source, so — exactly like `/spec-go` — it belongs on the
|
|
39
|
+
bug's **own branch**, never directly on `main`. Provision the worktree **now**,
|
|
40
|
+
before the failing test, so the test, the fix, and the spec all land together and
|
|
41
|
+
arrive as one reviewable PR.
|
|
42
|
+
|
|
43
|
+
The engine resolves a spec by its folder, so seed a **minimal stub** for it to
|
|
44
|
+
provision from — you'll flesh it out in §4:
|
|
45
|
+
|
|
46
|
+
- From the base branch (`main`), create
|
|
47
|
+
`specs/in-progress/bug-<name>/00-overview.md` with just the header block and the
|
|
48
|
+
`## Symptom` you established above.
|
|
49
|
+
- Run `skitterspec spec-env up bug-<name>` (the `spec-env` CLI engine). It prints
|
|
50
|
+
the `git worktree add … -b bug/<name>` command (a branch forked from `main`),
|
|
51
|
+
the worktree path, the opener, and any `in the worktree, run:` bootstrap steps.
|
|
52
|
+
- Run the printed `git worktree add`. **The worktree forks from `main`'s last
|
|
53
|
+
commit, so your uncommitted stub doesn't travel with it** — move it across so
|
|
54
|
+
`main` is left pristine:
|
|
55
|
+
`mv specs/in-progress/bug-<name> <worktreePath>/specs/in-progress/`.
|
|
56
|
+
- **Bootstrap the worktree.** A fresh worktree has no installed dependencies and
|
|
57
|
+
none of the repo's gitignored files (`.env`, local overrides). Run the printed
|
|
58
|
+
`in the worktree, run:` steps (file seeding, then `setup`) in order, before
|
|
59
|
+
anything else.
|
|
60
|
+
- **Trust the worktree for this session.** The engine wrote the printed
|
|
61
|
+
`trusted:` root into `.claude/settings.local.json`, but it won't hot-reload now
|
|
62
|
+
— run `/add-dir <trusted root>` before editing into the worktree, or the first
|
|
63
|
+
edits will prompt.
|
|
64
|
+
- **Do everything below in the worktree**, on the branch — the red test, the fix,
|
|
65
|
+
and the rest of the spec. Act on the worktree with absolute paths /
|
|
66
|
+
`git -C <worktreePath>`, or open a fresh session rooted there (the printed
|
|
67
|
+
opener). `main` changes only when the branch merges (at `/spec-complete`).
|
|
68
|
+
|
|
69
|
+
## 3. Write the failing test FIRST (RED) — mandatory
|
|
28
70
|
|
|
29
71
|
Encode the **correct** (expected) behaviour as a test, then run it and confirm it
|
|
30
72
|
**fails for the right reason**:
|
|
@@ -36,10 +78,13 @@ Encode the **correct** (expected) behaviour as a test, then run it and confirm i
|
|
|
36
78
|
passes before the fix proves nothing — keep refining the assertion until it
|
|
37
79
|
genuinely captures the bug.
|
|
38
80
|
|
|
39
|
-
##
|
|
81
|
+
## 4. Write the Bug spec
|
|
40
82
|
|
|
41
|
-
|
|
42
|
-
|
|
83
|
+
Fill in the spec's entry point `00-overview.md`. **When isolated**, you already
|
|
84
|
+
seeded this stub in §2 and moved it into the worktree — flesh it out there.
|
|
85
|
+
**When not isolated**, create the spec **folder**
|
|
86
|
+
`specs/in-progress/bug-<kebab-name>/` with its entry point `00-overview.md` now
|
|
87
|
+
(every spec is a folder — never a bare file). A bug is
|
|
43
88
|
usually a single-pass fix, so the `## Fix` block can live directly in
|
|
44
89
|
`00-overview.md`. **If the fix needs phasing** (large/uncertain root cause),
|
|
45
90
|
split it into phase files (`01-<slug>.md`, `02-…`) with a phase index in
|
|
@@ -90,7 +135,7 @@ The **State log** is the folder/status audit trail; later transitions
|
|
|
90
135
|
(`/spec-complete`, `/spec-cancel`) append a row. The **Changelog** is for the
|
|
91
136
|
fix narrative and decisions — keep them separate.
|
|
92
137
|
|
|
93
|
-
##
|
|
138
|
+
## 5. Drive to GREEN
|
|
94
139
|
|
|
95
140
|
- Implement the **minimal, root-cause** fix. Match surrounding code; honour all
|
|
96
141
|
project rules (see `.claude/rules/`).
|
|
@@ -103,8 +148,10 @@ test, split the fix into phase files (`01-<slug>.md` …) with a phase index in
|
|
|
103
148
|
`00-overview.md`, and leave the spec in `in-progress` for `/spec-go` to continue.
|
|
104
149
|
Say so explicitly — don't fake green.
|
|
105
150
|
|
|
106
|
-
##
|
|
151
|
+
## 6. Report
|
|
107
152
|
|
|
108
153
|
Summarise: root cause, the failing→passing test, the fix, and the full test
|
|
109
154
|
result. The spec stays in `in-progress`; suggest `/spec-complete` to verify and
|
|
110
|
-
archive it
|
|
155
|
+
archive it (**when isolated**, the fix lives on the bug's branch, and
|
|
156
|
+
`/spec-complete` merges it back to `main`). Do **not** `git commit` unless the
|
|
157
|
+
user asks.
|
|
@@ -26,6 +26,12 @@ Diverting your browser to the spec is a separate explicit step — `/spec-connec
|
|
|
26
26
|
the spec doesn't already have a worktree, provision it **first**, so all the
|
|
27
27
|
housekeeping below lands on the spec's branch and never on `main`:
|
|
28
28
|
|
|
29
|
+
**Opt-out:** if the user passes `--no-worktree` (or explicitly asks to work in
|
|
30
|
+
place), skip the provisioning bullets below and build on the current branch — the
|
|
31
|
+
same "in place otherwise" path used when isolation is off. Warn that the work
|
|
32
|
+
will land wherever you currently are (usually `main`); reserve it for a trivial
|
|
33
|
+
change or an explicit request.
|
|
34
|
+
|
|
29
35
|
- Run `skitterspec spec-env up <name>` (the `spec-env` CLI engine). It adds a git
|
|
30
36
|
worktree on a branch forked from `main`, and — only when the spec's
|
|
31
37
|
`> **Stack:**` header is `worktree + docker` — also brings up its Docker stack.
|
|
@@ -23,7 +23,10 @@ Use the argument, else the spec in context; ask if unclear.
|
|
|
23
23
|
spec isn't linked — stop and point at `/spec`.
|
|
24
24
|
- Discover the Linear MCP project-read tool at runtime. If Linear isn't
|
|
25
25
|
connected, relay the fix and stop — **do nothing destructive**.
|
|
26
|
-
- Call it and write the project JSON to a temp file.
|
|
26
|
+
- Call it (include milestones) and write the project JSON to a temp file. When
|
|
27
|
+
tasks are keyed, also list the project's issues and add them as an `issues`
|
|
28
|
+
array on that JSON (each `{ identifier, title, state }`) so the engine can
|
|
29
|
+
reconcile task lines.
|
|
27
30
|
|
|
28
31
|
## 3. Run the engine
|
|
29
32
|
|
|
@@ -47,6 +47,27 @@ tool with that field's local value (e.g. `description` → the project descripti
|
|
|
47
47
|
The engine has already vetted the change and moved the base — so if a Linear
|
|
48
48
|
write fails, re-run `/spec-pull` to reconcile rather than retrying blindly.
|
|
49
49
|
|
|
50
|
+
**Milestones (`milestonesPush` in the result).** When milestones are keyed, the
|
|
51
|
+
engine can't write them itself — apply the plan over MCP:
|
|
52
|
+
|
|
53
|
+
- `update`: for each `{ id, name, goal }`, call the milestone-save tool with that
|
|
54
|
+
`id` (name → milestone name, goal → its description).
|
|
55
|
+
- `create`: for each `{ name, goal }`, call the milestone-save tool with no id to
|
|
56
|
+
create it under the project, then **stamp the returned milestone id** into the
|
|
57
|
+
matching phase file's frontmatter (`linear_milestone_id`) so it links on the
|
|
58
|
+
next sync. Match the phase file by its title.
|
|
59
|
+
|
|
60
|
+
Progress is Linear-derived — never push it.
|
|
61
|
+
|
|
62
|
+
**Issues (`issuesPush` in the result).** When tasks are keyed:
|
|
63
|
+
|
|
64
|
+
- `update`: for each `{ id, text, done }`, call the issue-save tool with that `id`
|
|
65
|
+
(text → title; `done` → a completed state, else a non-completed state — leave an
|
|
66
|
+
already-non-completed issue's exact state untouched).
|
|
67
|
+
- `create`: for each `{ text, done }`, create an issue under the project (attach it
|
|
68
|
+
to the milestone of the phase the task lives in when known), then **stamp the new
|
|
69
|
+
issue identifier inline** on that task line (`… (SKI-123)`), matching by text.
|
|
70
|
+
|
|
50
71
|
## 5. Report
|
|
51
72
|
|
|
52
73
|
Relay the git-like summary (written / skipped / backup / base) plus which Linear
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with Linear hybrid-sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus git-like /spec-status · /spec-pull · /spec-push and the spec-sync CLI. Install this OR the base, not both.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -112,6 +112,17 @@ function specSyncStatus(dir, config, specArg, flags = {}) {
|
|
|
112
112
|
out.push(` ${f.status.padEnd(12)} ${f.field.padEnd(18)} (${f.ownership}, ${dir_})`)
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
+
// Deletions are never auto-applied (Decision 7) — surface them for the operator
|
|
116
|
+
// to resolve by hand: a removed keyed item on either side.
|
|
117
|
+
const removed = []
|
|
118
|
+
for (const f of fields) {
|
|
119
|
+
if (!f.keyed) continue
|
|
120
|
+
for (const it of f.items) if (it.report) removed.push(`${f.field}#${it.id} (removed in ${it.side})`)
|
|
121
|
+
}
|
|
122
|
+
if (removed.length) {
|
|
123
|
+
out.push(' needs manual resolution — removed, not auto-applied:')
|
|
124
|
+
for (const r of removed) out.push(` ${r}`)
|
|
125
|
+
}
|
|
115
126
|
process.stdout.write(out.join('\n') + '\n')
|
|
116
127
|
}
|
|
117
128
|
|
|
@@ -157,11 +168,31 @@ function printSyncResult(kind, result) {
|
|
|
157
168
|
} else {
|
|
158
169
|
out.push(`spec-sync ${kind}: ok`)
|
|
159
170
|
if (kind === 'pull') {
|
|
171
|
+
const keyedApplied = result.keyedApplied || []
|
|
172
|
+
const keyedCreated = result.keyedCreated || []
|
|
173
|
+
const keyedReported = result.keyedReported || []
|
|
160
174
|
if (result.applied.length) out.push(` applied: ${result.applied.join(', ')}`)
|
|
175
|
+
if (keyedApplied.length) out.push(` updated: ${keyedApplied.join(', ')} (phase files)`)
|
|
176
|
+
if (keyedCreated.length) out.push(` created: ${keyedCreated.map((c) => c.file).join(', ')}`)
|
|
177
|
+
if (keyedReported.length) out.push(` removed: ${keyedReported.join(', ')} (in Linear — resolve manually)`)
|
|
161
178
|
if (result.deferred.length) out.push(` deferred: ${result.deferred.join(', ')} (body write-back — manual)`)
|
|
162
|
-
if (
|
|
179
|
+
if (
|
|
180
|
+
!result.applied.length &&
|
|
181
|
+
!keyedApplied.length &&
|
|
182
|
+
!keyedCreated.length &&
|
|
183
|
+
!keyedReported.length &&
|
|
184
|
+
!result.deferred.length
|
|
185
|
+
) {
|
|
186
|
+
out.push(' nothing to pull — up to date')
|
|
187
|
+
}
|
|
163
188
|
} else {
|
|
164
189
|
if (result.written && result.written.length) out.push(` written: ${result.written.join(', ')}`)
|
|
190
|
+
const mp = result.milestonesPush
|
|
191
|
+
if (mp && mp.create.length) out.push(` milestones create: ${mp.create.map((m) => m.name).join(', ')} (skill applies via MCP)`)
|
|
192
|
+
if (mp && mp.update.length) out.push(` milestones update: ${mp.update.map((m) => m.id).join(', ')} (skill applies via MCP)`)
|
|
193
|
+
const ip = result.issuesPush
|
|
194
|
+
if (ip && ip.create.length) out.push(` issues create: ${ip.create.length} (skill applies via MCP)`)
|
|
195
|
+
if (ip && ip.update.length) out.push(` issues update: ${ip.update.map((i) => i.id).join(', ')} (skill applies via MCP)`)
|
|
165
196
|
if (result.skipped && result.skipped.length) out.push(` skipped: ${result.skipped.join(', ')} (not pushable)`)
|
|
166
197
|
if (result.note) out.push(` ${result.note}`)
|
|
167
198
|
}
|
|
@@ -50,17 +50,24 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
50
50
|
sync: Object.freeze({
|
|
51
51
|
baseDir: 'specs/.core/linear-base',
|
|
52
52
|
backupDir: 'specs/.core/linear-backups',
|
|
53
|
+
// The synced field set. Kept to the fields that genuinely round-trip through
|
|
54
|
+
// the live skill today: the project `description` (co-authored) plus the
|
|
55
|
+
// Linear-owned status/priority/labels (pull-only). A spec's phase/milestone,
|
|
56
|
+
// acceptance-criteria and task detail still travel *inside* `description` — a
|
|
57
|
+
// separate milestone/issue round-trip is a future extension (add the fields
|
|
58
|
+
// here to opt a workspace in). Any key you add joins the compared set.
|
|
53
59
|
fieldOwnership: Object.freeze({
|
|
54
60
|
description: 'both',
|
|
55
|
-
milestones: 'both',
|
|
56
|
-
phaseBodies: 'both',
|
|
57
|
-
acceptanceCriteria: 'both',
|
|
58
|
-
taskBreakdown: 'both',
|
|
59
61
|
workflowState: 'pull',
|
|
60
62
|
priority: 'pull',
|
|
61
63
|
labels: 'pull',
|
|
62
64
|
}),
|
|
63
65
|
localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
|
|
66
|
+
// Fields that are keyed collections (arrays of objects with a stable id),
|
|
67
|
+
// compared/merged per item rather than as one opaque value. Map field name →
|
|
68
|
+
// the item's id property. Empty by default — a workspace opts a field in
|
|
69
|
+
// (e.g. { milestones: "id", tasks: "id" }) once the body round-trip is wired.
|
|
70
|
+
keyedFields: Object.freeze({}),
|
|
64
71
|
}),
|
|
65
72
|
})
|
|
66
73
|
|
|
@@ -81,6 +88,7 @@ function defaults() {
|
|
|
81
88
|
backupDir: DEFAULT_CONFIG.sync.backupDir,
|
|
82
89
|
fieldOwnership: { ...DEFAULT_CONFIG.sync.fieldOwnership },
|
|
83
90
|
localOnlySections: [...DEFAULT_CONFIG.sync.localOnlySections],
|
|
91
|
+
keyedFields: { ...DEFAULT_CONFIG.sync.keyedFields },
|
|
84
92
|
},
|
|
85
93
|
}
|
|
86
94
|
}
|
|
@@ -113,6 +121,21 @@ function mergeFieldOwnership(base, parsed) {
|
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
123
|
|
|
124
|
+
// Merge (and validate) sync.keyedFields. Each value is the item's id property
|
|
125
|
+
// name (a non-empty string); a field listed here is compared per item.
|
|
126
|
+
function mergeKeyedFields(base, parsed) {
|
|
127
|
+
if (!isObject(parsed)) return
|
|
128
|
+
for (const [field, idKey] of Object.entries(parsed)) {
|
|
129
|
+
if (typeof idKey !== 'string' || !idKey.trim()) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Invalid ${CONFIG_FILE}: sync.keyedFields.${field} = ${JSON.stringify(idKey)} ` +
|
|
132
|
+
'(expected the item id property name, a non-empty string)',
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
base[field] = idKey.trim()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
/**
|
|
117
140
|
* Merge a parsed config over the defaults. Only known keys are copied (unknown
|
|
118
141
|
* keys ignored for forward-compat). Nested objects are merged field-by-field.
|
|
@@ -150,6 +173,7 @@ function mergeConfig(base, parsed) {
|
|
|
150
173
|
assign(base.sync, parsed.sync, 'baseDir', 'string')
|
|
151
174
|
assign(base.sync, parsed.sync, 'backupDir', 'string')
|
|
152
175
|
mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
|
|
176
|
+
mergeKeyedFields(base.sync.keyedFields, parsed.sync.keyedFields)
|
|
153
177
|
if (Array.isArray(parsed.sync.localOnlySections)) {
|
|
154
178
|
base.sync.localOnlySections = parsed.sync.localOnlySections
|
|
155
179
|
.filter((s) => typeof s === 'string' && s.trim())
|
package/src/vendor/linear/mcp.js
CHANGED
|
@@ -16,18 +16,20 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
// Canonical operations, and the regexes that match a Linear MCP tool name to
|
|
19
|
-
// each. Ordered patterns: first match wins.
|
|
20
|
-
// Linear MCP server
|
|
19
|
+
// each. Ordered patterns: first match wins. Matched against the real connected
|
|
20
|
+
// Linear MCP server: it exposes a single upsert `save_*` tool per object (create
|
|
21
|
+
// when no id, update when id given) rather than separate create/update verbs, so
|
|
22
|
+
// each write op accepts `save_*` as well as the legacy `create_`/`update_` names.
|
|
21
23
|
const MATCHERS = {
|
|
22
24
|
projectRead: [/get_?project\b/i, /read_?project/i, /project_?get/i],
|
|
23
|
-
projectUpdate: [/update_?project/i, /project_?update/i],
|
|
24
|
-
projectCreate: [/create_?project/i, /project_?create/i],
|
|
25
|
+
projectUpdate: [/save_?project/i, /update_?project/i, /project_?update/i],
|
|
26
|
+
projectCreate: [/save_?project/i, /create_?project/i, /project_?create/i],
|
|
25
27
|
milestoneList: [/list_?.*milestone/i, /milestones?_?list/i, /get_?.*milestones?/i],
|
|
26
|
-
milestoneCreate: [/create_?.*milestone/i, /milestone_?create/i],
|
|
27
|
-
milestoneUpdate: [/update_?.*milestone/i, /milestone_?update/i],
|
|
28
|
+
milestoneCreate: [/save_?.*milestone/i, /create_?.*milestone/i, /milestone_?create/i],
|
|
29
|
+
milestoneUpdate: [/save_?.*milestone/i, /update_?.*milestone/i, /milestone_?update/i],
|
|
28
30
|
issueList: [/list_?issues?/i, /issues?_?list/i, /get_?issues?/i],
|
|
29
|
-
issueCreate: [/create_?issue/i, /issue_?create/i],
|
|
30
|
-
issueUpdate: [/update_?issue/i, /issue_?update/i],
|
|
31
|
+
issueCreate: [/save_?issue/i, /create_?issue/i, /issue_?create/i],
|
|
32
|
+
issueUpdate: [/save_?issue/i, /update_?issue/i, /issue_?update/i],
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
// The minimum the push/pull engine can't run without. Milestone/issue ops are
|
|
@@ -88,17 +90,41 @@ function makeAdapter(callTool, resolved) {
|
|
|
88
90
|
return name
|
|
89
91
|
}
|
|
90
92
|
return {
|
|
93
|
+
// Linear's project-read tool keys on `query` (accepts a UUID, key, or slug).
|
|
91
94
|
async readProject(id) {
|
|
92
|
-
return callTool(need('projectRead'), { id })
|
|
95
|
+
return callTool(need('projectRead'), { query: id })
|
|
96
|
+
},
|
|
97
|
+
// `save_project` upserts: with `id` it updates, without it creates. Create
|
|
98
|
+
// needs a name and at least one team (`addTeams`).
|
|
99
|
+
async createProject(project) {
|
|
100
|
+
return callTool(need('projectCreate'), { ...project })
|
|
93
101
|
},
|
|
94
102
|
async updateProject(id, updates) {
|
|
95
103
|
return callTool(need('projectUpdate'), { id, ...updates })
|
|
96
104
|
},
|
|
105
|
+
// List a project's milestones (the pull read side). Most Linear reads also
|
|
106
|
+
// return milestones inline on the project via includeMilestones — this is the
|
|
107
|
+
// explicit list op for callers that need it on its own.
|
|
108
|
+
async listMilestones(projectId) {
|
|
109
|
+
return callTool(need('milestoneList'), { project: projectId })
|
|
110
|
+
},
|
|
111
|
+
// `save_milestone` requires the owning `project`; upserts on `id`.
|
|
97
112
|
async createMilestone(projectId, milestone) {
|
|
98
|
-
return callTool(need('milestoneCreate'), { projectId, ...milestone })
|
|
113
|
+
return callTool(need('milestoneCreate'), { project: projectId, ...milestone })
|
|
114
|
+
},
|
|
115
|
+
async updateMilestone(projectId, id, updates) {
|
|
116
|
+
return callTool(need('milestoneUpdate'), { project: projectId, id, ...updates })
|
|
117
|
+
},
|
|
118
|
+
// Issues (tasks). List the project's issues (pull read side); `save_issue`
|
|
119
|
+
// upserts on `id`, attached to the project (and optionally a milestone).
|
|
120
|
+
async listIssues(projectId) {
|
|
121
|
+
return callTool(need('issueList'), { project: projectId })
|
|
122
|
+
},
|
|
123
|
+
async createIssue(projectId, issue) {
|
|
124
|
+
return callTool(need('issueCreate'), { project: projectId, ...issue })
|
|
99
125
|
},
|
|
100
|
-
async
|
|
101
|
-
return callTool(need('
|
|
126
|
+
async updateIssue(id, updates) {
|
|
127
|
+
return callTool(need('issueUpdate'), { id, ...updates })
|
|
102
128
|
},
|
|
103
129
|
}
|
|
104
130
|
}
|