@skitterbyte/skitterspec-linear 9.2.0 → 10.0.1

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/MIGRATION.md ADDED
@@ -0,0 +1,292 @@
1
+ # Migration guide
2
+
3
+ ## `@skitterbyte/skitterspec-linear` v9 → v10 (`push` validates your issue states)
4
+
5
+ **`spec-sync push` now refuses to run until the configured `states` names have
6
+ been checked against your Linear workspace.** The check itself is not new — it
7
+ already existed on `spec-sync status --workspace-states` — but it was advisory,
8
+ and skipping it sent a state name Linear **silently ignores**: the description
9
+ lands, the issue never moves, and nothing errors. The base
10
+ `@skitterbyte/skitterspec` is unaffected.
11
+
12
+ ### Breaking change
13
+
14
+ | | v9 | v10 |
15
+ |---|-----|-----|
16
+ | `spec-sync push <spec>` | runs | **exits 1** unless `--workspace-states <file>` or `--skip-state-check` is passed |
17
+ | A configured state absent from the workspace | pushed, silently no-op | **exits 1**, naming the workspace's real states |
18
+
19
+ `/spec-push` handles this for you — it fetches the workspace's issue
20
+ workflow-state names over MCP and passes them on. **Nothing changes if you drive
21
+ sync through the skill.** Only a direct CLI caller needs updating.
22
+
23
+ ### What to do
24
+
25
+ 1. **Using `/spec-push`?** Nothing. Run `update` and carry on.
26
+ 2. **Calling `spec-sync push` from CI or a script?** Supply the workspace's issue
27
+ workflow-state names as a JSON array and pass the file:
28
+
29
+ ```sh
30
+ # names come from your Linear workspace, e.g. via the MCP server or the API
31
+ echo '["Backlog","Todo","In Progress","Done","Canceled"]' > states.json
32
+ skitterspec spec-sync push my-spec --workspace-states states.json --json
33
+ ```
34
+
35
+ Or opt out deliberately with `--skip-state-check`. Don't reach for it to get
36
+ past a *failing* check — that check is the only thing standing between you and
37
+ a push that moves nothing.
38
+ 3. **If the check refuses,** it tells you what your workspace actually has and,
39
+ where the lifecycle bucket makes it unambiguous, which name to use:
40
+
41
+ ```
42
+ spec-sync push: refusing — configured state name(s) not in the workspace
43
+
44
+ states.complete: "Done" is not an issue state in this workspace
45
+ use "Completed" instead
46
+
47
+ available: Backlog, Todo, In Progress, Completed, Canceled
48
+ ```
49
+
50
+ Fix `specs/.core/linear.config.json` → `states`. `/spec-push` will offer to
51
+ apply the fix for you.
52
+
53
+ ### Also in v10 (not breaking)
54
+
55
+ - **A pre-9.0 mirror is detected before it can be orphaned.** A spec still
56
+ carrying `linear_project_id` / `linear_milestone_id` reads as unlinked to v9+,
57
+ so `push` would emit an all-creates plan and abandon the live mirror. The plan
58
+ now carries a `legacy` field naming how many objects that would strand, and
59
+ `/spec-push` stops. **If you skipped the v8 → v9 migration below, read it now** —
60
+ this is the guard that catches you, not a substitute for it.
61
+ - **`update` says what it skipped** — each `customized (kept)` file now reports
62
+ `+added −removed`, with `--diff` to see the upstream changes you declined.
63
+ - **This guide now ships inside the package** (it wasn't in the published tarball
64
+ before v10 — `files` listed only `bin`/`src`/`assets`).
65
+
66
+ ## `@skitterbyte/skitterspec-linear` v8 → v9 (a spec is an Issue, phases are sub-issues)
67
+
68
+ **v9 remaps the Linear mirror.** A spec is now a Linear **issue** (not a Project),
69
+ each phase a **sub-issue** (not a Milestone), and **tasks are no longer synced**
70
+ (they stay in the repo phase files). This collapses a large spec from ~1 project +
71
+ N milestones + dozens of task-issues down to **one issue + one sub-issue per
72
+ phase**. The base `@skitterbyte/skitterspec` is unaffected (still v15).
73
+
74
+ ### Breaking changes
75
+
76
+ | Area | v8 | v9 |
77
+ |------|-----|-----|
78
+ | `linear.config.json` → `mapping` | `{specFolder:"project", phases:"milestone", tasks:"issue"}` | `{specFolder:"issue", phases:"subissue", tasks:"none"}` |
79
+ | `linear.config.json` → `linear` | `initiativeId` | `projectId` (the project picker's default) |
80
+ | `linear.config.json` → `states` | Linear **Project** statuses (e.g. `Completed`) | Linear **issue** workflow states (e.g. `Done`) |
81
+ | `linear.config.json` → `sync.fieldOwnership` | `{description, milestones, tasks, workflowState}` | `{description, subIssues, workflowState}` |
82
+ | Phase frontmatter | `linear_milestone_id` | `linear_issue_id` (the sub-issue id) |
83
+ | Overview frontmatter | `linear_project_id` + `linear_identifier` | `linear_identifier` (the spec issue) |
84
+ | Last-pushed snapshot | `{project, milestones, issues}` | `{issue, subIssues}` |
85
+
86
+ ### What to do
87
+
88
+ 1. **Upgrade and re-run `update`:** `npx @skitterbyte/skitterspec-linear update`.
89
+ It refreshes the skills, the `linear.config.md` / `SETUP.md` docs, and the
90
+ config example.
91
+ 2. **Edit `specs/.core/linear.config.json`** to the new keys above (or delete it
92
+ and re-copy `linear.config.json.example`). Point `states` at your workspace's
93
+ **issue** states; set `linear.projectId` if most specs belong to one Project —
94
+ it pre-selects the picker's default rather than fixing every spec there.
95
+ 3. **Optionally add `intake`** to start specs from issues someone else filed:
96
+
97
+ ```jsonc
98
+ "intake": {
99
+ "label": "web-app", // the inbox `/spec --from-issue` browses
100
+ "bugLabels": ["bug"] // issues with these route to /spec-bug
101
+ }
102
+ ```
103
+
104
+ Without it, `/spec SKI-123` still adopts an issue by id; only the browsable
105
+ inbox and the bug routing need the labels.
106
+ 4. **Existing pushed specs:** the snapshot format changed, so the first
107
+ `/spec-push` after upgrading **re-creates** the mirror (a fresh issue +
108
+ sub-issues). Delete any stale `specs/.core/linear-base/*.base.json` and the old
109
+ `linear_project_id` / `linear_milestone_id` frontmatter first. If you were
110
+ pre-first-push, there's nothing to reconcile.
111
+ 5. **Task-level issues** created under v8 are no longer managed by the sync —
112
+ close or repurpose them in Linear by hand.
113
+
114
+ ## `@skitterbyte/skitterspec-linear` v7 → v8 (sync goes one-way; `/spec-pull` removed)
115
+
116
+ **v8 made sync one-way.** The repo became the sole source of truth and Linear a
117
+ **generated mirror**: content is pushed up, never read back or merged. The
118
+ three-way merge engine and everything that fed it were retired.
119
+
120
+ ### Breaking changes
121
+
122
+ | Area | v7 | v8 |
123
+ |------|-----|-----|
124
+ | Skills | `/spec-status`, `/spec-push`, **`/spec-pull`** | `/spec-status`, `/spec-push` |
125
+ | `spec-sync` subcommands | `normalize`, `push`, `status`, **`pull`** | `normalize`, `push`, `status`, **`record`** |
126
+ | Sidecar | a three-way merge base | the **last-pushed snapshot** (`record` writes it) |
127
+ | `sync.fieldOwnership` | `pull` / `both` values were load-bearing | still parsed; nothing is pulled |
128
+
129
+ ### What to do
130
+
131
+ 1. **Drop `/spec-pull` from any workflow that calls it.** There is no
132
+ replacement: editing the mirror in Linear is no longer an input. A person
133
+ editing the issue will see it overwritten by the next push.
134
+ 2. **Replace `spec-sync pull` in scripts with `spec-sync record`** — it writes the
135
+ snapshot from the current repo files after a push is applied.
136
+ 3. **Delete stale merge-base sidecars** under `sync.baseDir`; the first push after
137
+ upgrading writes the new snapshot format.
138
+
139
+ ## `@skitterbyte/skitterspec-linear` v1 → v7 (no breaking changes)
140
+
141
+ Every major in this range was a **routine version bump**, not a breaking
142
+ contract. The skills (`/spec-status`, `/spec-push`, `/spec-pull`) and the
143
+ `spec-sync` subcommands (`normalize`, `push`, `pull`, `status`) were identical at
144
+ v1 and at v7. Upgrading anywhere inside this range needs **no action** beyond
145
+ re-running `update`.
146
+
147
+ One thing did change quietly, at **v4**: `sync.fieldOwnership` lost its
148
+ `milestones`, `phaseBodies`, `acceptanceCriteria` and `taskBreakdown` entries when
149
+ that detail moved inside `description`. A config still listing them does not
150
+ error — unknown keys merge in and *join the compared set* — so remove them if
151
+ you have them, or they will be compared against fields that no longer exist.
152
+
153
+ ## `@skitterbyte/skitterspec` v3 → v16 (no breaking changes)
154
+
155
+ **Nothing in this range requires action.** Thirteen majors sounds like thirteen
156
+ migrations; it was one habit. Every release in this period was cut as a major
157
+ bump regardless of size (see `RELEASING.md`), and the base package's contract
158
+ never broke: **no skill was ever removed and no CLI flag was ever removed** — the
159
+ surface only grew. The spec folder layout
160
+ (`.core`/`backlog`/`in-progress`/`complete`/`cancelled`) is unchanged throughout.
161
+
162
+ The `feat(sync)!` commits that appear in this window changed
163
+ `@skitterbyte/skitterspec-linear`, which ships separately; the base was bumped
164
+ alongside it in lockstep. If you are on the **superset**, read the provider
165
+ entries above — those are the ones with work in them.
166
+
167
+ What each major actually added, so you can see what you gain by upgrading:
168
+
169
+ | Major | What landed |
170
+ |-------|-------------|
171
+ | v4 | `setup` commands bootstrap a fresh worktree's dependencies |
172
+ | v5, v6 | version bumps only |
173
+ | v7 | release docs refreshed; stale scripts dropped |
174
+ | v8 | version bump only |
175
+ | v9 | released alongside the provider's Linear body round-trip |
176
+ | v10, v11 | version bumps only |
177
+ | v12 | install manifest + `update --resync` / `--reset`; **`/spec-live`** overlay |
178
+ | v13 | **`/spec-hotfix`**, **`/spec-to-main`**, `spec-env prune` for orphaned test DBs |
179
+ | v14 | **Impact map** in the spec templates; live-aware `/spec-go`; the docs site |
180
+ | v15 | released alongside the provider's one-way sync switch |
181
+ | v16 | spec `Name:` handle; `/spec-complete` · `/spec-cancel` commit their own edits |
182
+
183
+ **Spec files written under an older version still read.** The template grew
184
+ (the Impact map at v14, the `Name:` header at v16) but the lifecycle skills treat
185
+ both as optional — `/spec-review` adds them if you want them.
186
+
187
+ ## `@skitterbyte/skitterspec` v2 → v3 (slimmer surface + local traffic diversion)
188
+
189
+ **v3 shrinks the everyday command surface to five verbs — `spec → go → connect →
190
+ commit → complete` — by folding provisioning, teardown, and grooming into the
191
+ lifecycle skills, and adds `/spec-connect` for testing a worktree at your normal
192
+ `localhost` URL.** (`@skitterbyte/skitterspec-linear` moves to v2.0.0 in lockstep.)
193
+
194
+ ### Removed skills (breaking) → where they went
195
+
196
+ | Removed skill | Replaced by |
197
+ |---------------|-------------|
198
+ | `/spec-env` | **Automatic in `/spec-go`** — it provisions the worktree and (with your OK) starts the spec's dev servers. Escalate Docker later with the CLI: `skitterspec spec-env up <name>`. |
199
+ | `/spec-env-down` | **Folded into `/spec-complete` and `/spec-cancel`** — they tear the environment down (dev servers, worktree, stack, slot) as part of finishing/abandoning a spec. |
200
+ | `/spec-ready` | **Folded into `/spec`** — grilling now writes a `Ready` spec directly (or `Draft` if you deliberately leave open questions). Go straight to `/spec-go`. |
201
+
202
+ The **`skitterspec spec-env` CLI engine stays** (`up`, `down`, `dev`, `connect`,
203
+ `integrate`, `status`, `resolve`) — only the three *skills* were removed. Anything
204
+ that scripted those CLI verbs keeps working.
205
+
206
+ ### New — `/spec-connect` and two config blocks
207
+
208
+ - **`/spec-connect <name>`** points your canonical `localhost` ports at a spec's
209
+ running dev servers (so you can test a worktree's UI/API at the normal URL);
210
+ `/spec-connect main` hands the ports back. It's a small bundled Node reverse
211
+ proxy — no external install. Exclusive: one spec exposed at a time.
212
+ - **`env.config.json` gains `dev` and `proxy` blocks.** `dev` lists the host dev
213
+ servers `/spec-go` starts (`{ name, command, portVar, health?, frontPort? }`);
214
+ `proxy` configures the front-door proxy (`{ enabled, host }`). Both default to
215
+ off/empty, so existing projects are unaffected until you fill `dev` in.
216
+
217
+ ### What to do
218
+
219
+ 1. **Upgrade and re-run `init`** (or `update`): `npx @skitterbyte/skitterspec
220
+ update`. It stops installing the three removed skills, installs `/spec-connect`,
221
+ and refreshes the CLAUDE.md section + `spec-planning` rule. Your specs and
222
+ `env.config.json` are untouched.
223
+ 2. **Remove muscle memory for the old commands** — use `/spec-go` to bring a spec
224
+ up, `/spec-complete`/`/spec-cancel` to tear it down, and `/spec` (no separate
225
+ `/spec-ready`) to reach a Ready spec.
226
+ 3. **To test UI/API worktrees:** add a `dev` block to `env.config.json` (see
227
+ `specs/.core/env.config.md`), then `/spec-go` → `/spec-connect <name>`.
228
+
229
+ ## `@skitterbyte/skitterspec` v1 → v2 (tracker-free base)
230
+
231
+ **v2 of the base package is tracker-free.** The Linear sync feature — the
232
+ `/spec-status`, `/spec-push` skills, the `spec-sync` CLI, the
233
+ Linear-aware steps of `/spec` and `/spec-go`, and the `linear.config.*`
234
+ templates — moved out of `@skitterbyte/skitterspec` into a separate **superset**
235
+ distribution, `@skitterbyte/skitterspec-linear`. You now install exactly one:
236
+
237
+ | If you… | Install |
238
+ |---------|---------|
239
+ | don't sync specs to a tracker | `@skitterbyte/skitterspec` (v2) |
240
+ | use (or want) Linear sync | `@skitterbyte/skitterspec-linear` |
241
+
242
+ Everything else — the spec lifecycle and per-spec isolation — is unchanged and
243
+ present in **both**.
244
+
245
+ ### If you did NOT use Linear sync
246
+
247
+ Nothing to do. Upgrade to v2 and re-run `init` (or `update`) as usual. The base
248
+ never installed the Linear skills for you, so there's nothing to remove.
249
+
250
+ ### If you DID use Linear sync
251
+
252
+ Switching is one install plus a re-`init`:
253
+
254
+ 1. **Install the superset** (in place of the base):
255
+
256
+ ```sh
257
+ npm rm @skitterbyte/skitterspec # if it was a dependency
258
+ npx @skitterbyte/skitterspec-linear init
259
+ ```
260
+
261
+ 2. **Re-run `init`.** It re-installs the shared skills (now composed with the
262
+ Linear steps) and the three sync skills, and re-scaffolds the config
263
+ templates. Your existing files are preserved — `init` never overwrites without
264
+ `--force`.
265
+
266
+ 3. **Your config is unchanged.** The live config path is still
267
+ `specs/.core/linear.config.json`, and the committed base sidecars under
268
+ `specs/.core/linear-base/` are read as-is. No re-linking, no re-sync.
269
+
270
+ That's it — `/spec-status`, `/spec-push`, and `skitterspec-linear
271
+ spec-sync …` work exactly as before.
272
+
273
+ ### One config note — branch naming
274
+
275
+ Embedding the Linear identifier in a worktree branch name is now configured in the
276
+ **isolation** config, not the Linear config. In `specs/.core/env.config.json` set:
277
+
278
+ ```jsonc
279
+ "branch": { "pattern": "{identifier}-{slug}", "identifierField": "linear_identifier" }
280
+ ```
281
+
282
+ If you don't need the id in branch names, leave the default `{type}/{slug}` — the
283
+ old implicit Linear-branch behaviour is off unless you opt in this way. (This is
284
+ the only behavioural change beyond the package split.)
285
+
286
+ ## Why the split
287
+
288
+ The base couldn't ship without a specific tracker's fingerprints baked into shared
289
+ skills and a `src/sync/` engine. Extracting the provider makes the base a clean,
290
+ tracker-free workflow and lets a new provider (e.g. Jira) ship as another superset
291
+ over the same base — without re-patching the base. See
292
+ `specs/complete/feat-extract-ticketing-provider/` for the full rationale.
package/README.md CHANGED
@@ -51,6 +51,25 @@ fuller guide):
51
51
  `/spec-status` reports what would push. Sync is **one-way**: the repo is the
52
52
  source of truth and Linear is a generated mirror.
53
53
 
54
+ ## Upgrading
55
+
56
+ ```sh
57
+ npx @skitterbyte/skitterspec-linear update
58
+ ```
59
+
60
+ `update` refreshes the files it manages (skills, rules, `specs/.core` docs) and
61
+ **keeps anything you edited**. A file it kept is listed under
62
+ `customized (kept)` with the change it declined summarised as `+added −removed`:
63
+
64
+ ```
65
+ customized (kept):
66
+ .claude/rules/spec-planning.md +34 −13
67
+ ```
68
+
69
+ Add `--diff` to see those changes as a unified diff before deciding whether to
70
+ re-apply your edits on top, or `--force` to take the package version and lose
71
+ them. Your `specs/` content and live `.core` config are never touched.
72
+
54
73
  ## What the superset adds
55
74
 
56
75
  On top of the base skills (`/spec`, `/spec-go`, isolation, …):
@@ -14,6 +14,26 @@ team) that the config reference (`linear.config.md`) assumes you already have.
14
14
 
15
15
  ---
16
16
 
17
+ ## Upgrading an existing install
18
+
19
+ Fresh installs can skip this section. **`MIGRATION.md` ships with the package** —
20
+ read the entry for the version you are coming from before upgrading a repo with a
21
+ live mirror.
22
+
23
+ **From 9.x — `push` now validates your issue states.** `spec-sync push` refuses
24
+ to run until the configured `states` names have been checked against the
25
+ workspace. `/spec-push` does that for you, so nothing changes if you drive sync
26
+ through the skill; a script calling the CLI directly must pass
27
+ `--workspace-states <file>` (or `--skip-state-check`). See `MIGRATION.md`
28
+ → "v9 → v10".
29
+
30
+ **From 8.x — the mirror was remapped.** A spec is now an **issue** (was a
31
+ Project), a phase a **sub-issue** (was a Milestone), and tasks are no longer
32
+ objects. The frontmatter keys moved with it, so a spec linked under 8.x reads as
33
+ **unlinked** — the next `/spec-push` would mint a fresh mirror and abandon the old
34
+ one. `push` detects this and refuses to let the plan be applied blind, but read
35
+ `MIGRATION.md` → "v8 → v9" first.
36
+
17
37
  ## 1. Install the package
18
38
 
19
39
  Install the Linear superset (or, if you already run the base, switch to it — it
@@ -173,10 +193,15 @@ With a linked spec, confirm push end-to-end:
173
193
  this session. Re-run step 2; remember a fresh add needs a Claude Code restart.
174
194
  - **"missing required tools: issueCreate"** — you're on the read-only endpoint
175
195
  (or a restricted API key). Use `https://mcp.linear.app/mcp` for push.
176
- - **A configured state name silently does nothing** — Linear ignores an unknown
177
- issue state. Run `/spec-status` (it validates the `states` names against the
178
- workspace) and fix `linear.config.json` to the real issue-state names
179
- (`Backlog / Todo / In Progress / Done / Canceled`).
196
+ - **"refusing — the configured issue states have not been validated"** — `push`
197
+ requires the workspace's issue-state names (`--workspace-states <file>`), which
198
+ `/spec-push` fetches for you. Run the skill rather than the CLI directly, or
199
+ pass the file yourself.
200
+ - **"refusing — configured state name(s) not in the workspace"** — Linear ignores
201
+ an unknown issue state, so this is caught before the push rather than after.
202
+ Fix `linear.config.json` to the real issue-state names
203
+ (`Backlog / Todo / In Progress / Done / Canceled`). Upgrading from 8.x, the
204
+ value inverts: project status `Completed` → issue state `Done`.
180
205
  - **Reconnecting doesn't switch workspace** — Linear ties the OAuth session to one
181
206
  workspace. Remove and re-add the server to authenticate against another.
182
207
  - **Bold around an inline code span renders oddly in Linear** — Linear moves the
@@ -46,6 +46,13 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
46
46
  // that sub-issue: "checklist" mirrors them into its description (default),
47
47
  // "none" leaves the description as the phase's Goal line alone. Either way no
48
48
  // issue is created per task. These are the defaults.
49
+ //
50
+ // Under "checklist" the mirror keeps the phase file's OWN section headings: a
51
+ // phase with `## Tasks` and `## Acceptance` arrives as two headed sections, in
52
+ // source order, each heading reproduced as written. Checkboxes written before
53
+ // any heading appear under `## Tasks`. A heading with no checkboxes under it
54
+ // is not mirrored. Nesting, sub-bullets and inline formatting are preserved;
55
+ // a legacy inline `(KEY-123)` on a task line is stripped.
49
56
  "mapping": {
50
57
  "specFolder": "issue",
51
58
  "phases": "subissue",
@@ -54,7 +61,12 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
54
61
 
55
62
  // Map the spec's lifecycle bucket → the Linear ISSUE workflow-state name. Used
56
63
  // for the spec issue's state (from its folder) AND each sub-issue's state (from
57
- // the phase emoji). Names must match the workspace's issue states exactly.
64
+ // the phase emoji). Names must match the workspace's issue states exactly
65
+ // Linear silently IGNORES an unknown state, so a typo pushes clean and the
66
+ // issue never moves. `/spec-push` fetches the workspace's names and `push`
67
+ // refuses to run without them, so a wrong name here fails loudly rather than
68
+ // quietly. (Upgrading from 8.x? The right value inverts: the project status
69
+ // `Completed` becomes the issue state `Done`.)
58
70
  "states": {
59
71
  "backlog": "Backlog",
60
72
  "in-progress": "In Progress",
@@ -20,10 +20,36 @@ tell the user how to enable Linear sync and stop.
20
20
 
21
21
  Use the argument, else the spec in context; ask if unclear.
22
22
 
23
- ## 2. Get the plan from the engine
23
+ ## 2. Connect, and validate the issue states
24
+
25
+ Discover the issue **read + create/update** tools at runtime (`get_issue`,
26
+ `save_issue` — a single upsert covers create and update), plus the **project
27
+ list** tool if this push will mint the spec issue (see the picker below — it is
28
+ optional; without it the picker is skipped, not failed). If Linear isn't
29
+ connected or a needed tool is missing, relay the fix and stop, **writing
30
+ nothing**.
31
+
32
+ Then fetch the workspace's issue workflow-state **names** and write them to a
33
+ file as a JSON array (e.g. `["Backlog","In Progress","Done","Canceled"]`). Step 3
34
+ requires that file: `push` **refuses to run** without it, because Linear silently
35
+ ignores an unknown issue state — the description lands, the issue never moves,
36
+ and nothing errors. If the check reports a name that isn't in the workspace, stop
37
+ and fix `specs/.core/linear.config.json`.
38
+
39
+ **If the check refuses, offer to fix it.** The refusal lists every configured
40
+ name the workspace lacks, the workspace's real state names, and — where the
41
+ bucket makes it unambiguous — which one to use instead. Relay that, then offer to
42
+ apply it to `specs/.core/linear.config.json` → `states`, and do so on the user's
43
+ confirmation. Never edit their config without asking, and never guess a bucket
44
+ the refusal made no suggestion for — ask which state they want.
45
+
46
+ `--skip-state-check` exists for the deliberate exception; do not reach for it to
47
+ get past a failing check.
48
+
49
+ ## 3. Get the plan from the engine
24
50
 
25
51
  ```
26
- skitterspec spec-sync push <spec> --json
52
+ skitterspec spec-sync push <spec> --workspace-states <file> --json
27
53
  ```
28
54
 
29
55
  The engine prints a JSON **plan** (no network, no remote read):
@@ -43,19 +69,15 @@ date — say so and stop. `state` values are local buckets
43
69
  (`backlog`/`in-progress`/`complete`/`cancelled`); map each to the Linear
44
70
  issue-state NAME via `config.states` at apply time.
45
71
 
46
- ## 3. Discover the Linear MCP tools
47
-
48
- Discover the issue **read + create/update** tools at runtime (`get_issue`,
49
- `save_issue` — a single upsert covers create and update), plus the **project
50
- list** tool if this push will mint the spec issue (see the picker below — it is
51
- optional; without it the picker is skipped, not failed). If Linear isn't
52
- connected or a needed tool is missing, relay the fix and stop, **writing
53
- nothing**.
72
+ ### Stop if the plan reports a pre-9.0 mirror
54
73
 
55
- **Validate the issue states first.** Fetch the workspace's issue workflow-state
56
- names and run `skitterspec spec-sync status <spec> --workspace-states <file>`; if
57
- it errors (a configured `states` name isn't in the workspace), stop and fix the
58
- config Linear silently ignores an unknown issue state.
74
+ If the plan carries a **`legacy`** field, this spec was linked under the pre-9.0
75
+ model (`linear_project_id` / `linear_milestone_id`). v9 reads the new keys, finds
76
+ none, and the plan above is therefore **all-creates** applying it mints a fresh
77
+ mirror and **abandons** the existing one. **Stop.** Relay `legacy.keys`,
78
+ `legacy.files` and `legacy.orphanCount` ("this would orphan N live objects"),
79
+ point at `MIGRATION.md` → "v8 → v9", and apply nothing until the user has
80
+ migrated or explicitly confirms they want a new mirror.
59
81
 
60
82
  ## 4. Apply the plan (order matters)
61
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "9.2.0",
3
+ "version": "10.0.1",
4
4
  "description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
@@ -21,7 +21,8 @@
21
21
  "files": [
22
22
  "bin",
23
23
  "src",
24
- "assets"
24
+ "assets",
25
+ "MIGRATION.md"
25
26
  ],
26
27
  "engines": {
27
28
  "node": ">=18"
package/src/cli.js CHANGED
@@ -79,6 +79,8 @@ Options (init / update):
79
79
  --reset (init) Start again: reset managed scaffolding fresh
80
80
  (needs --yes; never touches your specs or config)
81
81
  --force Overwrite skill/rule/script files that already exist
82
+ --diff (update) Show the upstream changes each customized
83
+ file declined, as a unified diff
82
84
  --dir <path> Target project dir (default: positional arg or cwd)
83
85
  --no-claude-md Skip creating/patching CLAUDE.md
84
86
  --yes, -y Accept defaults; skip the interactive setup prompts
@@ -104,6 +106,7 @@ function parse(argv) {
104
106
  removeReleaseTooling: false,
105
107
  resync: false,
106
108
  reset: false,
109
+ diff: false,
107
110
  }
108
111
  const positional = []
109
112
  for (let i = 0; i < argv.length; i++) {
@@ -116,6 +119,7 @@ function parse(argv) {
116
119
  else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
117
120
  else if (a === '--resync') opts.resync = true
118
121
  else if (a === '--reset') opts.reset = true
122
+ else if (a === '--diff') opts.diff = true
119
123
  else if (a === '--dir') opts.dir = argv[++i]
120
124
  else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
121
125
  else positional.push(a)
@@ -1346,7 +1350,7 @@ async function run(argv) {
1346
1350
  break
1347
1351
  }
1348
1352
  if (action === 'resync') {
1349
- resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
1353
+ resync(dir, { claudeMd: opts.claudeMd, force: opts.force, diff: opts.diff })
1350
1354
  break
1351
1355
  }
1352
1356
  // action === 'create-missing' → fall through to a normal (skip-existing) init.
@@ -1365,7 +1369,7 @@ async function run(argv) {
1365
1369
  case 'update':
1366
1370
  // `update` is a resync — refresh managed files, keep customized ones
1367
1371
  // (--force to overwrite). Leaves specs/ and live .core config alone.
1368
- resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
1372
+ resync(dir, { claudeMd: opts.claudeMd, force: opts.force, diff: opts.diff })
1369
1373
  await cleanupReleaseTooling(dir, opts)
1370
1374
  break
1371
1375
  default:
package/src/init.js CHANGED
@@ -53,7 +53,7 @@ const CORE_FILES = listCoreTemplates()
53
53
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
54
54
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
55
55
 
56
- const report = { created: [], updated: [], skipped: [], removed: [], customized: [], warnings: [] }
56
+ const report = { created: [], updated: [], skipped: [], removed: [], customized: [], healed: [], warnings: [] }
57
57
 
58
58
  function resetReport() {
59
59
  for (const k of Object.keys(report)) report[k].length = 0
@@ -82,6 +82,8 @@ function ensureDir(p) {
82
82
  // old version we own" (safe to update) from "a file the user edited" (keep). It
83
83
  // lists only managed FILES (skills, rules, .core templates) — never user content.
84
84
 
85
+ const { linesDiff } = require('./lines-diff.js')
86
+
85
87
  const MANIFEST_FILE = path.join('specs', '.core', '.skitterspec-manifest.json')
86
88
  const MANIFEST_VERSION = 1
87
89
 
@@ -132,13 +134,24 @@ function writeManifest(dir, files) {
132
134
 
133
135
  // Classify a managed file against the manifest baseline.
134
136
  // missing — not on disk
135
- // pristine — on disk and matches the hash we recorded (ours to update)
136
- // customized — on disk but differs (or unknown) — a user edit; keep it
137
- function managedState(dir, relPath, manifest) {
137
+ // pristine — ours to update: it matches the package asset, or the hash we recorded
138
+ // customized — on disk but differs from both — a user edit; keep it
139
+ //
140
+ // `bundled` (the current package asset) is optional but decisive: a file whose
141
+ // CONTENT equals what we ship is not customized, whatever the manifest says.
142
+ // Without that check a stale hash pinned the file out of updates permanently —
143
+ // anything that changed it out-of-band (an errant tool, a partial restore, a
144
+ // manifest lost and re-seeded at the wrong version) froze it for good, silently.
145
+ // Comparing content first makes the tool self-healing after any restore.
146
+ // `pruneRetiredManaged` passes no `bundled` on purpose: the package no longer
147
+ // ships that file, so there is nothing to compare it against.
148
+ function managedState(dir, relPath, manifest, bundled) {
138
149
  const abs = path.join(dir, relPath)
139
150
  if (!fs.existsSync(abs)) return 'missing'
151
+ const onDisk = fs.readFileSync(abs, 'utf8')
152
+ if (bundled !== undefined && onDisk === bundled) return 'pristine'
140
153
  const known = manifest.files[relPath]
141
- return known && sha1(fs.readFileSync(abs, 'utf8')) === known ? 'pristine' : 'customized'
154
+ return known && sha1(onDisk) === known ? 'pristine' : 'customized'
142
155
  }
143
156
 
144
157
  // Reconcile and persist the manifest after an install/resync run: keep prior
@@ -405,7 +418,7 @@ function isExistingSetup(dir) {
405
418
  // update; customized (edited) → keep + report, unless `force`.
406
419
  function resyncManagedFile(dir, target, manifest, force) {
407
420
  const { relPath, abs, bundled } = target
408
- const state = managedState(dir, relPath, manifest)
421
+ const state = managedState(dir, relPath, manifest, bundled)
409
422
  const write = (bucket) => {
410
423
  ensureDir(path.dirname(abs))
411
424
  fs.writeFileSync(abs, bundled)
@@ -416,17 +429,25 @@ function resyncManagedFile(dir, target, manifest, force) {
416
429
  if (state === 'customized') {
417
430
  if (force) return write('updated')
418
431
  writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
419
- return report.customized.push(relPath)
432
+ // Carry the change the user just DECLINED. A bare filename tells them a
433
+ // decision was made on their behalf but not what it was, which leaves
434
+ // "clobber and re-apply my edits by hand" as the only safe way to upgrade.
435
+ const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
436
+ return report.customized.push({ relPath, added, removed, hunks })
420
437
  }
421
438
  // pristine — update only if the bundled content actually changed
422
439
  if (fs.readFileSync(abs, 'utf8') === bundled) {
440
+ // The file is ours and current, but the manifest disagreed — record the
441
+ // repair rather than healing in silence: a file that quietly starts
442
+ // updating again is as opaque as one that quietly stopped.
443
+ if (manifest.files[relPath] !== sha1(bundled)) report.healed.push(relPath)
423
444
  writtenHashes[relPath] = sha1(bundled)
424
445
  return report.skipped.push(relPath)
425
446
  }
426
447
  write('updated')
427
448
  }
428
449
 
429
- function resync(dir, { force = false, claudeMd = true } = {}) {
450
+ function resync(dir, { force = false, claudeMd = true, diff = false } = {}) {
430
451
  if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
431
452
  resetReport()
432
453
  const manifest = readManifest(dir)
@@ -436,7 +457,7 @@ function resync(dir, { force = false, claudeMd = true } = {}) {
436
457
  pruneRetiredManaged(dir, manifest)
437
458
  if (claudeMd) installClaudeMd(dir, { mode: 'update' })
438
459
  flushManifest(dir)
439
- printReport(dir, 'resync')
460
+ printReport(dir, 'resync', { diff })
440
461
  }
441
462
 
442
463
  // The never-touch set: START AGAIN may only delete a known managed file, and may
@@ -502,7 +523,7 @@ function reset(dir, { claudeMd = true } = {}) {
502
523
  printReport(dir, 'reset')
503
524
  }
504
525
 
505
- function printReport(dir, mode) {
526
+ function printReport(dir, mode, { diff = false } = {}) {
506
527
  const line = (label, items) => {
507
528
  if (!items.length) return
508
529
  process.stdout.write(`\n${label}:\n`)
@@ -512,12 +533,25 @@ function printReport(dir, mode) {
512
533
  line('created', report.created)
513
534
  line('updated', report.updated)
514
535
  line('removed', report.removed)
515
- line('customized (kept)', report.customized)
536
+ line(
537
+ 'customized (kept)',
538
+ report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
539
+ )
540
+ line('manifest repaired', report.healed)
516
541
  line('unchanged', report.skipped)
517
542
  if (report.warnings.length) {
518
543
  process.stdout.write('\nwarnings:\n')
519
544
  for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
520
545
  }
546
+ if (diff) {
547
+ for (const c of report.customized) {
548
+ if (!c.hunks.length) continue
549
+ process.stdout.write(`\n--- ${c.relPath} (kept — this is what you declined)\n`)
550
+ for (const h of c.hunks) process.stdout.write(`${h}\n`)
551
+ }
552
+ } else if (report.customized.length) {
553
+ process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
554
+ }
521
555
  const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
522
556
  const isolationNote = isolationOn
523
557
  ? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +