@skitterbyte/skitterspec-linear 3.1.0 → 3.4.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 +14 -8
- package/assets/core/SETUP.md +169 -0
- package/assets/core/linear.config.json.example +0 -4
- package/assets/core/linear.config.md +6 -5
- package/assets/skills/spec-bug/SKILL.md +55 -8
- package/assets/skills/spec-go/SKILL.md +6 -0
- package/package.json +1 -1
- package/src/vendor/linear/config.js +6 -4
- package/src/vendor/linear/mcp.js +21 -12
- package/src/vendor/sync-core/src/normalize.js +67 -6
- package/src/vendor/sync-core/src/push.js +8 -6
package/README.md
CHANGED
|
@@ -32,16 +32,22 @@ 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 today:** 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. A per-**Milestone** /
|
|
47
|
+
per-**Issue** round-trip (phases and tasks as first-class Linear objects) is a
|
|
48
|
+
planned extension — see `linear.config.md`. **Base sidecars**
|
|
49
|
+
(`specs/.core/linear-base/`) are committed; **backups**
|
|
50
|
+
(`specs/.core/linear-backups/`) are gitignored.
|
|
45
51
|
|
|
46
52
|
Branch naming that embeds the Linear id lives in the isolation config
|
|
47
53
|
(`env.config.json` → `branch.pattern` with `{identifier}`, `branch.identifierField:
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
The **entire spec body travels as the project `description`** — phases and
|
|
128
|
+
acceptance criteria included. A separate per-milestone / per-issue round-trip
|
|
129
|
+
(editing an individual phase in Linear and pulling just that back) is **not yet
|
|
130
|
+
implemented**; opt in later by adding `milestones`/`phaseBodies`/`taskBreakdown`
|
|
131
|
+
to `sync.fieldOwnership` once that write path exists.
|
|
132
|
+
|
|
133
|
+
Sections listed in `sync.localOnlySections` (default: **State log**, **Changelog**,
|
|
134
|
+
**Open questions**) are stripped from the pushed description — they never leave
|
|
135
|
+
the repo.
|
|
136
|
+
|
|
137
|
+
## 7. What to commit
|
|
138
|
+
|
|
139
|
+
- **Commit** `specs/.core/linear-base/` — the three-way merge's base sidecars
|
|
140
|
+
(last-synced snapshot per spec). Each worktree carries its own, so it must
|
|
141
|
+
travel with the branch.
|
|
142
|
+
- **Gitignore** `specs/.core/linear-backups/` — `--force` recovery copies, local
|
|
143
|
+
and per-machine. Add `specs/.core/linear-backups/` to `.gitignore`.
|
|
144
|
+
|
|
145
|
+
## 8. Smoke test (verify your setup)
|
|
146
|
+
|
|
147
|
+
With a linked spec, confirm the round-trip end-to-end:
|
|
148
|
+
|
|
149
|
+
1. `/spec-status` → note the current divergence.
|
|
150
|
+
2. `/spec-pull` → Linear's status/priority/labels land in the spec's frontmatter
|
|
151
|
+
(`spec_status`, `priority`, `labels`).
|
|
152
|
+
3. `/spec-status` again → **in sync**. (This also proves description idempotency:
|
|
153
|
+
Linear rewrites markdown bullets on save, and the sync canonicalizes both
|
|
154
|
+
sides so that never shows as a spurious change.)
|
|
155
|
+
4. Edit the spec body locally, `/spec-push` → the change lands on the Linear
|
|
156
|
+
project's description; `/spec-status` returns to **in sync**.
|
|
157
|
+
|
|
158
|
+
## Troubleshooting
|
|
159
|
+
|
|
160
|
+
- **"connect the `linear` MCP server"** — the server isn't connected/authed for
|
|
161
|
+
this session. Re-run step 2; remember a fresh add needs a Claude Code restart.
|
|
162
|
+
- **"missing required tools: projectUpdate"** — you're on the read-only endpoint
|
|
163
|
+
(or a restricted API key). Use `https://mcp.linear.app/mcp` for push.
|
|
164
|
+
- **A field won't stop showing as diverged** — that field genuinely differs on
|
|
165
|
+
the two sides. `pull`-owned fields (status/priority/labels) resolve to Linear;
|
|
166
|
+
`/spec-pull` reconciles them. For a co-authored `conflict`, resolve locally or
|
|
167
|
+
`--force` (which backs up the losing side under `sync.backupDir` first).
|
|
168
|
+
- **Reconnecting doesn't switch workspace** — Linear ties the OAuth session to one
|
|
169
|
+
workspace. Remove and re-add the server to authenticate against another.
|
|
@@ -26,10 +26,6 @@
|
|
|
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"
|
|
@@ -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"
|
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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",
|
|
@@ -50,12 +50,14 @@ 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',
|
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,24 @@ 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
|
+
// `save_milestone` requires the owning `project`; upserts on `id`.
|
|
97
106
|
async createMilestone(projectId, milestone) {
|
|
98
|
-
return callTool(need('milestoneCreate'), { projectId, ...milestone })
|
|
107
|
+
return callTool(need('milestoneCreate'), { project: projectId, ...milestone })
|
|
99
108
|
},
|
|
100
|
-
async updateMilestone(id, updates) {
|
|
101
|
-
return callTool(need('milestoneUpdate'), { id, ...updates })
|
|
109
|
+
async updateMilestone(projectId, id, updates) {
|
|
110
|
+
return callTool(need('milestoneUpdate'), { project: projectId, id, ...updates })
|
|
102
111
|
},
|
|
103
112
|
}
|
|
104
113
|
}
|
|
@@ -82,6 +82,25 @@ function parseSections(body) {
|
|
|
82
82
|
return { title, sections }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// Canonicalise markdown so semantically-equal content hashes equal across the
|
|
86
|
+
// boundary. Linear reserializes markdown on save (authored `-` bullets come back
|
|
87
|
+
// as `*`, trailing whitespace trimmed, blank runs collapsed), so without this a
|
|
88
|
+
// clean push→pull would report `description` as perpetually changed. Applied to
|
|
89
|
+
// the description on BOTH sides. Conservative: only unifies list markers and
|
|
90
|
+
// whitespace — the transforms actually observed from Linear.
|
|
91
|
+
function canonicalizeMarkdown(text) {
|
|
92
|
+
if (text == null) return text
|
|
93
|
+
return String(text)
|
|
94
|
+
.replace(/\r\n/g, '\n')
|
|
95
|
+
.split('\n')
|
|
96
|
+
// Unordered-list marker at line start (`*`/`+`/`-`) → `-`. Requires a space
|
|
97
|
+
// after the marker so bold/emphasis (`**Goal:**`) is untouched.
|
|
98
|
+
.map((line) => line.replace(/^(\s*)[*+-]( +)/, '$1-$2').replace(/[ \t]+$/, ''))
|
|
99
|
+
.join('\n')
|
|
100
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
101
|
+
.trim()
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
// Canonical milestone status from the phase-index emoji.
|
|
86
105
|
const EMOJI_STATUS = { '⬜': 'not-started', '🔄': 'in-progress', '✅': 'done' }
|
|
87
106
|
|
|
@@ -160,7 +179,7 @@ function buildDescription(title, sections, localOnlySections) {
|
|
|
160
179
|
if (skip.has(heading)) continue
|
|
161
180
|
parts.push(`## ${heading}\n\n${content}`.trim())
|
|
162
181
|
}
|
|
163
|
-
return parts.join('\n\n')
|
|
182
|
+
return canonicalizeMarkdown(parts.join('\n\n')) || null
|
|
164
183
|
}
|
|
165
184
|
|
|
166
185
|
/**
|
|
@@ -208,6 +227,46 @@ function canonicalRemoteStatus(state) {
|
|
|
208
227
|
return s
|
|
209
228
|
}
|
|
210
229
|
|
|
230
|
+
// The real Linear projection carries the project's workflow state in `status`
|
|
231
|
+
// (an object `{ name, type }`); accept a bare string / legacy `state` too.
|
|
232
|
+
function remoteStateName(project) {
|
|
233
|
+
const st = project.status != null ? project.status : project.state
|
|
234
|
+
if (st == null) return null
|
|
235
|
+
if (typeof st === 'object') return st.name != null ? st.name : st.type != null ? st.type : null
|
|
236
|
+
return st
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Real Linear priority is an object `{ value, name }`; accept a bare number too.
|
|
240
|
+
function remotePriority(priority) {
|
|
241
|
+
if (priority == null) return null
|
|
242
|
+
if (typeof priority === 'object') return priority.value != null ? priority.value : null
|
|
243
|
+
return priority
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Real Linear labels are `[{ id, name }]`; accept bare strings too.
|
|
247
|
+
function remoteLabels(labels) {
|
|
248
|
+
if (!Array.isArray(labels)) return []
|
|
249
|
+
return labels
|
|
250
|
+
.map((l) => (typeof l === 'string' ? l : l && l.name != null ? l.name : null))
|
|
251
|
+
.filter((n) => n != null)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A real Linear milestone has no workflow state — only `progress` ("0%".."100%").
|
|
255
|
+
// Fall back to a legacy `status`/`state` when present (fixtures / older shapes).
|
|
256
|
+
function remoteMilestoneStatus(m) {
|
|
257
|
+
if (m.status != null) return canonicalRemoteStatus(m.status)
|
|
258
|
+
if (m.state != null) return canonicalRemoteStatus(m.state)
|
|
259
|
+
if (m.progress != null) {
|
|
260
|
+
const pct = parseInt(String(m.progress), 10)
|
|
261
|
+
if (Number.isFinite(pct)) {
|
|
262
|
+
if (pct >= 100) return 'done'
|
|
263
|
+
if (pct > 0) return 'in-progress'
|
|
264
|
+
}
|
|
265
|
+
return 'not-started'
|
|
266
|
+
}
|
|
267
|
+
return 'not-started'
|
|
268
|
+
}
|
|
269
|
+
|
|
211
270
|
/**
|
|
212
271
|
* Normalize a remote Project projection (from the Phase 2 MCP adapter, or a
|
|
213
272
|
* fixture) into the same field set as `normalizeLocal`.
|
|
@@ -215,11 +274,12 @@ function canonicalRemoteStatus(state) {
|
|
|
215
274
|
function normalizeRemote(project, config) {
|
|
216
275
|
const p = project || {}
|
|
217
276
|
const milestones = Array.isArray(p.milestones) ? p.milestones : []
|
|
277
|
+
const stateName = remoteStateName(p)
|
|
218
278
|
const extracted = {
|
|
219
|
-
description: p.description != null ? p.description : null,
|
|
279
|
+
description: p.description != null ? canonicalizeMarkdown(p.description) : null,
|
|
220
280
|
milestones: milestones.map((m) => ({
|
|
221
281
|
name: m.name,
|
|
222
|
-
status:
|
|
282
|
+
status: remoteMilestoneStatus(m),
|
|
223
283
|
})),
|
|
224
284
|
phaseBodies: milestones.map((m) => ({
|
|
225
285
|
phase: m.name,
|
|
@@ -230,9 +290,9 @@ function normalizeRemote(project, config) {
|
|
|
230
290
|
phase: m.name,
|
|
231
291
|
tasks: Array.isArray(m.tasks) ? m.tasks : [],
|
|
232
292
|
})),
|
|
233
|
-
workflowState:
|
|
234
|
-
priority: p.priority
|
|
235
|
-
labels:
|
|
293
|
+
workflowState: stateName != null ? bucketForState(stateName, config) : null,
|
|
294
|
+
priority: remotePriority(p.priority),
|
|
295
|
+
labels: remoteLabels(p.labels),
|
|
236
296
|
}
|
|
237
297
|
return toFieldSet(extracted, config)
|
|
238
298
|
}
|
|
@@ -245,5 +305,6 @@ module.exports = {
|
|
|
245
305
|
parseSections,
|
|
246
306
|
parsePhaseIndex,
|
|
247
307
|
canonicalRemoteStatus,
|
|
308
|
+
canonicalizeMarkdown,
|
|
248
309
|
bucketForState,
|
|
249
310
|
}
|
|
@@ -28,16 +28,18 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
|
|
|
28
28
|
}
|
|
29
29
|
const remote = normalizeRemote(remoteRaw, config)
|
|
30
30
|
const base = readBase(dir, identifier, config)
|
|
31
|
-
const baseStamp = base && base.__meta ? base.__meta.updatedAt : null
|
|
32
31
|
const fields = classify(local, remote, base, config)
|
|
33
32
|
|
|
34
|
-
// Remote moved past base if
|
|
35
|
-
//
|
|
33
|
+
// Remote moved past base only if a *co-authored* (`both`) field diverged on the
|
|
34
|
+
// remote side — that's the case the repo can't safely overwrite without a pull.
|
|
35
|
+
// A `pull`-owned change (status/priority/labels) is Linear's to own and must
|
|
36
|
+
// NOT block a content push, and a bare `updatedAt` bump (which any Linear edit
|
|
37
|
+
// produces) is too coarse to gate on — the pre-write re-read below still catches
|
|
38
|
+
// a racer that lands during the push itself.
|
|
36
39
|
const remoteDivergedFields = fields
|
|
37
|
-
.filter((f) => f.raw === 'remote-only' || f.raw === 'conflict')
|
|
40
|
+
.filter((f) => f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
|
|
38
41
|
.map((f) => f.field)
|
|
39
|
-
const
|
|
40
|
-
const moved = remoteDivergedFields.length > 0 || stampMoved
|
|
42
|
+
const moved = remoteDivergedFields.length > 0
|
|
41
43
|
|
|
42
44
|
if (moved && !force) {
|
|
43
45
|
return {
|