@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 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 / initiative IDs
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
- **Mapping** (config-driven): spec folder Linear **Project**; each phase → a
40
- **Milestone**; tasks **Issues**; an optional **Initiative** groups specs.
41
- **Field ownership** (`both` / `pull` / `push`) collapses conflicts — only a `both`
42
- field that moved on both sides is a real conflict, and `--force` backs up the
43
- losing side before winning. **Base sidecars** (`specs/.core/linear-base/`) are
44
- committed; **backups** (`specs/.core/linear-backups/`) are gitignored.
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. Write the failing test FIRST (RED)mandatory
27
+ ## 2. Isolate the fix in a worktreewhen 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
- ## 3. Write the Bug spec
81
+ ## 4. Write the Bug spec
40
82
 
41
- Create the spec **folder** `specs/in-progress/bug-<kebab-name>/` with its entry
42
- point `00-overview.md` (every spec is a folder never a bare file). A bug is
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
- ## 4. Drive to GREEN
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
- ## 5. Report
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. Do **not** `git commit` unless the user asks.
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.1.0",
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 (!result.applied.length && !result.deferred.length) out.push(' nothing to pull — up to date')
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())
@@ -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. Verified against the connected
20
- // Linear MCP server during build (resolves the overview's Open questions).
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 updateMilestone(id, updates) {
101
- return callTool(need('milestoneUpdate'), { id, ...updates })
126
+ async updateIssue(id, updates) {
127
+ return callTool(need('issueUpdate'), { id, ...updates })
102
128
  },
103
129
  }
104
130
  }