@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 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 / 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 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. 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "3.1.0",
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',
@@ -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,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').trim() || null
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: canonicalRemoteStatus(m.status != null ? m.status : m.state),
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: p.state != null ? bucketForState(p.state, config) : null,
234
- priority: p.priority != null ? p.priority : null,
235
- labels: Array.isArray(p.labels) ? p.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 the classifier sees remote-side divergence OR the
35
- // recorded updatedAt no longer matches (a change we can't even see as a field).
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 stampMoved = baseStamp != null && remoteRaw.updatedAt !== baseStamp
40
- const moved = remoteDivergedFields.length > 0 || stampMoved
42
+ const moved = remoteDivergedFields.length > 0
41
43
 
42
44
  if (moved && !force) {
43
45
  return {