@skitterbyte/skitterspec-linear 3.4.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
@@ -39,14 +39,15 @@ behaves exactly like the base.
39
39
  setup guide — connecting the `linear` MCP server, finding your team id, linking a
40
40
  spec, and a smoke test. Per-field docs live in `specs/.core/linear.config.md`.
41
41
 
42
- **What syncs today:** the whole spec body travels as the Linear Project
42
+ **What syncs:** by default the whole spec body travels as the Linear Project
43
43
  **`description`** (co-authored, push + pull); **status / priority / labels** are
44
44
  Linear-owned (pull only). **Field ownership** (`both` / `pull` / `push`) collapses
45
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 extensionsee `linear.config.md`. **Base sidecars**
49
- (`specs/.core/linear-base/`) are committed; **backups**
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**
50
51
  (`specs/.core/linear-backups/`) are gitignored.
51
52
 
52
53
  Branch naming that embeds the Linear id lives in the isolation config
@@ -124,11 +124,17 @@ in-repo → `/spec-push` (send content up).
124
124
  | `priority` | Linear | pull only |
125
125
  | `labels` | Linear | pull only |
126
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.
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`.
132
138
 
133
139
  Sections listed in `sync.localOnlySections` (default: **State log**, **Changelog**,
134
140
  **Open questions**) are stripped from the pushed description — they never leave
@@ -30,6 +30,7 @@
30
30
  "priority": "pull",
31
31
  "labels": "pull"
32
32
  },
33
- "localOnlySections": ["State log", "Changelog", "Open questions"]
33
+ "localOnlySections": ["State log", "Changelog", "Open questions"],
34
+ "keyedFields": {}
34
35
  }
35
36
  }
@@ -92,11 +92,48 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
92
92
 
93
93
  // Markdown sections of 00-overview.md that are local-only scaffolding and
94
94
  // are stripped from the pushed `description` (never sent to Linear).
95
- "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": {}
96
101
  }
97
102
  }
98
103
  ```
99
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
+
100
137
  ## Field ownership & conflicts
101
138
 
102
139
  The spec is a set of structured fields, most written by only one side. Marking a
@@ -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.4.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
  }
@@ -63,6 +63,11 @@ const DEFAULT_CONFIG = Object.freeze({
63
63
  labels: 'pull',
64
64
  }),
65
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({}),
66
71
  }),
67
72
  })
68
73
 
@@ -83,6 +88,7 @@ function defaults() {
83
88
  backupDir: DEFAULT_CONFIG.sync.backupDir,
84
89
  fieldOwnership: { ...DEFAULT_CONFIG.sync.fieldOwnership },
85
90
  localOnlySections: [...DEFAULT_CONFIG.sync.localOnlySections],
91
+ keyedFields: { ...DEFAULT_CONFIG.sync.keyedFields },
86
92
  },
87
93
  }
88
94
  }
@@ -115,6 +121,21 @@ function mergeFieldOwnership(base, parsed) {
115
121
  }
116
122
  }
117
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
+
118
139
  /**
119
140
  * Merge a parsed config over the defaults. Only known keys are copied (unknown
120
141
  * keys ignored for forward-compat). Nested objects are merged field-by-field.
@@ -152,6 +173,7 @@ function mergeConfig(base, parsed) {
152
173
  assign(base.sync, parsed.sync, 'baseDir', 'string')
153
174
  assign(base.sync, parsed.sync, 'backupDir', 'string')
154
175
  mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
176
+ mergeKeyedFields(base.sync.keyedFields, parsed.sync.keyedFields)
155
177
  if (Array.isArray(parsed.sync.localOnlySections)) {
156
178
  base.sync.localOnlySections = parsed.sync.localOnlySections
157
179
  .filter((s) => typeof s === 'string' && s.trim())
@@ -102,6 +102,12 @@ function makeAdapter(callTool, resolved) {
102
102
  async updateProject(id, updates) {
103
103
  return callTool(need('projectUpdate'), { id, ...updates })
104
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
+ },
105
111
  // `save_milestone` requires the owning `project`; upserts on `id`.
106
112
  async createMilestone(projectId, milestone) {
107
113
  return callTool(need('milestoneCreate'), { project: projectId, ...milestone })
@@ -109,6 +115,17 @@ function makeAdapter(callTool, resolved) {
109
115
  async updateMilestone(projectId, id, updates) {
110
116
  return callTool(need('milestoneUpdate'), { project: projectId, id, ...updates })
111
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 })
125
+ },
126
+ async updateIssue(id, updates) {
127
+ return callTool(need('issueUpdate'), { id, ...updates })
128
+ },
112
129
  }
113
130
  }
114
131
 
@@ -52,6 +52,94 @@ function rawStatus(localH, remoteH, baseH) {
52
52
  return 'conflict'
53
53
  }
54
54
 
55
+ // --- keyed collections (per-item three-way) --------------------------------
56
+
57
+ // Index an array of items by their `idKey` value (stringified). Non-arrays and
58
+ // items missing the id are skipped — they can't participate in a keyed merge.
59
+ function indexById(arr, idKey) {
60
+ const map = new Map()
61
+ if (Array.isArray(arr)) {
62
+ for (const item of arr) {
63
+ if (item && typeof item === 'object' && item[idKey] != null) {
64
+ map.set(String(item[idKey]), item)
65
+ }
66
+ }
67
+ }
68
+ return map
69
+ }
70
+
71
+ // Signature of an item under a given id: its content hash with the id stripped
72
+ // (so an id-only change / reorder is not a content edit), or 'ABSENT' when the
73
+ // id isn't present on that side. 'ABSENT' never collides with a hex hash.
74
+ function itemSignature(map, id, idKey) {
75
+ if (!map.has(id)) return 'ABSENT'
76
+ const { [idKey]: _omit, ...content } = map.get(id)
77
+ return hashField(content)
78
+ }
79
+
80
+ // Raw per-item three-way: which side moved off base, and how (added/edited/
81
+ // removed). Mirrors rawStatus but tracks presence so add vs edit vs remove is
82
+ // distinguishable.
83
+ function itemRaw(localSig, remoteSig, baseSig) {
84
+ const localChanged = localSig !== baseSig
85
+ const remoteChanged = remoteSig !== baseSig
86
+ if (!localChanged && !remoteChanged) return { raw: 'unchanged', side: null }
87
+ const kind = (sig, base) => (base === 'ABSENT' ? 'added' : sig === 'ABSENT' ? 'removed' : 'edited')
88
+ if (localChanged && !remoteChanged) return { raw: kind(localSig, baseSig), side: 'local' }
89
+ if (!localChanged && remoteChanged) return { raw: kind(remoteSig, baseSig), side: 'remote' }
90
+ if (localSig === remoteSig) return { raw: 'unchanged', side: null } // both converged
91
+ return { raw: 'conflict', side: null }
92
+ }
93
+
94
+ // Collapse a raw per-item outcome through ownership into an effective status +
95
+ // push/pull flags. Removals are report-only in v1 (surfaced, never auto-applied).
96
+ function collapseItem({ raw, side }, ownership) {
97
+ const canPush = ownership === 'both' || ownership === 'push'
98
+ const canPull = ownership === 'both' || ownership === 'pull'
99
+ if (raw === 'unchanged') return { status: 'unchanged', side: null, pushable: false, pullable: false, report: false }
100
+ if (raw === 'removed') return { status: 'removed', side, pushable: false, pullable: false, report: true }
101
+ if (raw === 'conflict') {
102
+ if (ownership === 'push') return { status: 'edited', side: 'local', pushable: true, pullable: false, report: false }
103
+ if (ownership === 'pull') return { status: 'edited', side: 'remote', pushable: false, pullable: true, report: false }
104
+ return { status: 'conflict', side: null, pushable: true, pullable: true, report: false }
105
+ }
106
+ // added / edited
107
+ if (side === 'local') return { status: raw, side, pushable: canPush, pullable: false, report: false }
108
+ return { status: raw, side, pushable: false, pullable: canPull, report: false }
109
+ }
110
+
111
+ /**
112
+ * Classify a keyed collection field item-by-item. Returns one entry per id seen
113
+ * across local/remote/base, each with its effective status and push/pull flags,
114
+ * plus the local/remote item values so a caller can apply the change.
115
+ */
116
+ function classifyItems(local, remote, base, ownership, idKey) {
117
+ const lMap = indexById(local, idKey)
118
+ const rMap = indexById(remote, idKey)
119
+ const bMap = indexById(base, idKey)
120
+ const ids = new Set([...lMap.keys(), ...rMap.keys(), ...bMap.keys()])
121
+ const items = []
122
+ for (const id of ids) {
123
+ const raw = itemRaw(
124
+ itemSignature(lMap, id, idKey),
125
+ itemSignature(rMap, id, idKey),
126
+ itemSignature(bMap, id, idKey),
127
+ )
128
+ const c = collapseItem(raw, ownership)
129
+ items.push({
130
+ id,
131
+ status: c.status,
132
+ side: c.side,
133
+ pushable: c.pushable,
134
+ pullable: c.pullable,
135
+ report: c.report,
136
+ local: lMap.get(id) || null,
137
+ remote: rMap.get(id) || null,
138
+ })
139
+ }
140
+ return items
141
+ }
142
+
55
143
  // Collapse the raw status through ownership into an effective status + flags.
56
144
  function collapse(raw, ownership) {
57
145
  const canPush = ownership === 'both' || ownership === 'push'
@@ -78,9 +166,36 @@ function collapse(raw, ownership) {
78
166
  */
79
167
  function classify(local, remote, base, config) {
80
168
  const ownership = config.sync.fieldOwnership
169
+ const keyed = (config.sync && config.sync.keyedFields) || {}
81
170
  const baseObj = base || {}
82
171
  return Object.keys(ownership).map((field) => {
83
172
  const own = ownership[field]
173
+ const idKey = keyed[field]
174
+
175
+ // Keyed collection: per-item three-way. Field-level flags aggregate the
176
+ // items so existing summary code still sees "does this field have work?".
177
+ if (idKey) {
178
+ const items = classifyItems(
179
+ local ? local[field] : null,
180
+ remote ? remote[field] : null,
181
+ field in baseObj ? baseObj[field] : null,
182
+ own,
183
+ idKey,
184
+ )
185
+ const active = items.filter((i) => i.status !== 'unchanged')
186
+ return {
187
+ field,
188
+ ownership: own,
189
+ keyed: true,
190
+ idKey,
191
+ items,
192
+ status: active.length ? 'items-changed' : 'unchanged',
193
+ pushable: items.some((i) => i.pushable),
194
+ pullable: items.some((i) => i.pullable),
195
+ }
196
+ }
197
+
198
+ // Scalar / whole-field (behaviour unchanged).
84
199
  const localH = hashField(local ? local[field] : null)
85
200
  const remoteH = hashField(remote ? remote[field] : null)
86
201
  const baseH = hashField(field in baseObj ? baseObj[field] : null)
@@ -92,6 +207,7 @@ function classify(local, remote, base, config) {
92
207
 
93
208
  module.exports = {
94
209
  classify,
210
+ classifyItems,
95
211
  hashField,
96
212
  stableStringify,
97
213
  rawStatus,
@@ -121,7 +121,37 @@ function parsePhaseIndex(phasesSection) {
121
121
  return rows
122
122
  }
123
123
 
124
- // Read the phase files (01-*.md, 02-*.md …) in execution order.
124
+ // The phase's descriptive title: the h1 with the "Phase N " prefix and any
125
+ // trailing status emoji stripped (so it matches its Linear Milestone name).
126
+ function phaseTitle(body) {
127
+ const h1 = /^#\s+(.*)$/m.exec(body)
128
+ if (!h1) return null
129
+ const t = h1[1]
130
+ .replace(/\s*[⬜🔄✅]\s*$/u, '')
131
+ .replace(/^Phase\s+\d+\s*[—–-]\s*/i, '')
132
+ .trim()
133
+ return t || null
134
+ }
135
+
136
+ // Parse a task line (already stripped of its leading "- ") into a keyed item:
137
+ // its checkbox state, its text, and the inline Linear issue identifier if present
138
+ // (`… (SKI-123)`). Returns null for a non-task line.
139
+ function parseTaskLine(line) {
140
+ const m = /^\[([ xX])\]\s*(.*)$/.exec(line)
141
+ if (!m) return null
142
+ const done = m[1].toLowerCase() === 'x'
143
+ let text = m[2].trim()
144
+ let id = null
145
+ const idm = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/.exec(text)
146
+ if (idm) {
147
+ id = idm[1]
148
+ text = text.slice(0, idm.index).trim()
149
+ }
150
+ return { id, text, done }
151
+ }
152
+
153
+ // Read the phase files (01-*.md, 02-*.md …) in execution order. Each yields its
154
+ // linked milestone id (from optional frontmatter), title, goal and tasks.
125
155
  function readPhaseFiles(snapshotDir) {
126
156
  let entries
127
157
  try {
@@ -134,11 +164,19 @@ function readPhaseFiles(snapshotDir) {
134
164
  .sort()
135
165
  .map((file) => {
136
166
  const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
137
- const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(raw) || [])[1] || ''
138
- const tasks = (raw.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
167
+ const { data, body } = parseFrontmatter(raw)
168
+ const goal = (/^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|$)/m.exec(body) || [])[1] || ''
169
+ const tasks = (body.match(/^-\s*\[[ x]\]\s*.*$/gm) || []).map((t) =>
139
170
  t.replace(/^-\s*/, '').trim(),
140
171
  )
141
- return { phase: file.replace(/\.md$/, ''), goal: goal.trim(), tasks }
172
+ return {
173
+ phase: file.replace(/\.md$/, ''),
174
+ file,
175
+ id: data.linear_milestone_id != null ? String(data.linear_milestone_id) : null,
176
+ name: phaseTitle(body),
177
+ goal: goal.trim(),
178
+ tasks,
179
+ }
142
180
  })
143
181
  }
144
182
 
@@ -170,9 +208,11 @@ function readSnapshot(snapshotDir, config) {
170
208
  }
171
209
 
172
210
  // Build the pushed description: the overview prose with local-only sections
173
- // removed. Keeps the title line for context.
174
- function buildDescription(title, sections, localOnlySections) {
175
- const skip = new Set(localOnlySections || [])
211
+ // removed. Keeps the title line for context. `extraSkip` drops additional
212
+ // sections (e.g. "Phases" once milestones sync as first-class Linear objects, so
213
+ // the phase list isn't duplicated in the description — Decision 5).
214
+ function buildDescription(title, sections, localOnlySections, extraSkip = []) {
215
+ const skip = new Set([...(localOnlySections || []), ...extraSkip])
176
216
  const parts = []
177
217
  if (title) parts.push(`# ${title}`)
178
218
  for (const [heading, content] of Object.entries(sections)) {
@@ -187,9 +227,24 @@ function buildDescription(title, sections, localOnlySections) {
187
227
  */
188
228
  function normalizeLocal(snapshotDir, config) {
189
229
  const { frontmatter, title, sections, phases } = readSnapshot(snapshotDir, config)
230
+ const milestonesKeyed = !!(config.sync.keyedFields && config.sync.keyedFields.milestones)
190
231
  const extracted = {
191
- description: buildDescription(title, sections, config.sync.localOnlySections),
192
- milestones: parsePhaseIndex(sections.Phases),
232
+ description: buildDescription(
233
+ title,
234
+ sections,
235
+ config.sync.localOnlySections,
236
+ milestonesKeyed ? ['Phases'] : [],
237
+ ),
238
+ // Keyed milestone items: a phase's linked milestone id (or null when
239
+ // unlinked), its title and goal. Unlinked phases carry id:null and are
240
+ // skipped by the keyed compare until they're linked.
241
+ milestones: phases
242
+ .filter((p) => p.name)
243
+ .map((p) => ({ id: p.id, name: p.name, goal: p.goal })),
244
+ // Keyed task items across all phases: inline issue id (or null), text, done.
245
+ // Deliberately just {id,text,done} so it hashes equal to a remote issue — the
246
+ // owning phase is recovered at push time by locating the task line.
247
+ tasks: phases.flatMap((p) => p.tasks.map(parseTaskLine).filter(Boolean)),
193
248
  phaseBodies: phases.map((p) => ({ phase: p.phase, goal: p.goal })),
194
249
  acceptanceCriteria: sections['Acceptance criteria'] || null,
195
250
  taskBreakdown: phases.map((p) => ({ phase: p.phase, tasks: p.tasks })),
@@ -251,25 +306,19 @@ function remoteLabels(labels) {
251
306
  .filter((n) => n != null)
252
307
  }
253
308
 
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'
309
+ // Whether a remote Linear issue is complete. The real MCP shape is a flat
310
+ // `statusType` ("completed") accept the legacy `state.type` and `completedAt`
311
+ // / bare `done` too (fixtures, older shapes).
312
+ function remoteIssueDone(iss) {
313
+ if (iss.done === true) return true
314
+ const type = iss.statusType != null ? iss.statusType : iss.state && iss.state.type
315
+ if (type != null) return String(type).toLowerCase() === 'completed'
316
+ return iss.completedAt != null
268
317
  }
269
318
 
270
319
  /**
271
- * Normalize a remote Project projection (from the Phase 2 MCP adapter, or a
272
- * fixture) into the same field set as `normalizeLocal`.
320
+ * Normalize a remote Project projection (from the MCP adapter, or a fixture)
321
+ * into the same field set as `normalizeLocal`.
273
322
  */
274
323
  function normalizeRemote(project, config) {
275
324
  const p = project || {}
@@ -277,15 +326,31 @@ function normalizeRemote(project, config) {
277
326
  const stateName = remoteStateName(p)
278
327
  const extracted = {
279
328
  description: p.description != null ? canonicalizeMarkdown(p.description) : null,
329
+ // Keyed milestone items mirroring normalizeLocal: id, title, goal (the Linear
330
+ // milestone's description). Progress is Linear-derived and not synced.
280
331
  milestones: milestones.map((m) => ({
332
+ id: m.id != null ? String(m.id) : null,
281
333
  name: m.name,
282
- status: remoteMilestoneStatus(m),
334
+ // A milestone's description mirrors the phase's `**Goal:**` line; strip the
335
+ // label so it hashes equal to the local goal (which readPhaseFiles already
336
+ // captures without it).
337
+ goal: (m.description != null ? m.description : '')
338
+ .replace(/^\s*\*\*Goal:\*\*\s*/, '')
339
+ .trim(),
283
340
  })),
284
341
  phaseBodies: milestones.map((m) => ({
285
342
  phase: m.name,
286
343
  goal: (m.description != null ? m.description : '').trim(),
287
344
  })),
288
345
  acceptanceCriteria: p.acceptanceCriteria != null ? p.acceptanceCriteria : null,
346
+ // Keyed task items from the project's issues: keyed by the human identifier
347
+ // (SKI-123, what the inline task-line id carries — the Linear MCP returns it as
348
+ // the issue's `id`), text ← title, done ← a completed-type workflow state.
349
+ tasks: (Array.isArray(p.issues) ? p.issues : []).map((iss) => ({
350
+ id: iss.identifier != null ? String(iss.identifier) : iss.id != null ? String(iss.id) : null,
351
+ text: iss.title != null ? iss.title : '',
352
+ done: remoteIssueDone(iss),
353
+ })),
289
354
  taskBreakdown: milestones.map((m) => ({
290
355
  phase: m.name,
291
356
  tasks: Array.isArray(m.tasks) ? m.tasks : [],
@@ -304,6 +369,7 @@ module.exports = {
304
369
  parseFrontmatter,
305
370
  parseSections,
306
371
  parsePhaseIndex,
372
+ parseTaskLine,
307
373
  canonicalRemoteStatus,
308
374
  canonicalizeMarkdown,
309
375
  bucketForState,
@@ -18,9 +18,23 @@
18
18
  const { normalizeLocal, normalizeRemote } = require('./normalize.js')
19
19
  const { classify } = require('./compare.js')
20
20
  const { readBase, writeBase, backup } = require('./base.js')
21
- const { writeFrontmatter } = require('./write.js')
21
+ const { writeFrontmatter, applyMilestonesPull, applyTasksPull } = require('./write.js')
22
22
  const { frontmatterPatchFor } = require('./apply.js')
23
23
 
24
+ // Collect the conflicting units across scalar (field-level) and keyed
25
+ // (item-level) fields, as stable labels for the refusal message.
26
+ function collectConflicts(fields) {
27
+ const out = []
28
+ for (const f of fields) {
29
+ if (f.keyed) {
30
+ for (const it of f.items) if (it.status === 'conflict') out.push(`${f.field}#${it.id}`)
31
+ } else if (f.status === 'conflict') {
32
+ out.push(f.field)
33
+ }
34
+ }
35
+ return out
36
+ }
37
+
24
38
  async function pull({ dir, snapshotDir, identifier, projectId, adapter, config, force = false, timestamp }) {
25
39
  const local = normalizeLocal(snapshotDir, config)
26
40
  const remoteRaw = await adapter.readProject(projectId)
@@ -31,40 +45,54 @@ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config,
31
45
  const base = readBase(dir, identifier, config)
32
46
  const fields = classify(local, remote, base, config)
33
47
 
34
- const conflicts = fields.filter((f) => f.status === 'conflict').map((f) => f.field)
48
+ const conflicts = collectConflicts(fields)
35
49
  if (conflicts.length && !force) {
36
50
  return {
37
51
  ok: false,
38
52
  blocked: true,
39
53
  reason: 'conflict',
40
54
  conflicts,
41
- message: `pull refused — ${conflicts.length} field(s) changed on both sides: ` +
55
+ message: `pull refused — ${conflicts.length} unit(s) changed on both sides: ` +
42
56
  `${conflicts.join(', ')}. Resolve locally or re-run with --force (remote wins).`,
43
57
  }
44
58
  }
45
59
 
46
- // Everything remote wants to write down: remote-only fields, plus (under force)
47
- // both-conflict fields where remote wins.
48
- const pullFields = fields.filter((f) => f.pullable)
49
- const fieldValues = {}
50
- for (const f of pullFields) fieldValues[f.field] = remote[f.field]
51
-
52
- const { patch, applied, deferred } = frontmatterPatchFor(fieldValues, config)
53
-
54
60
  // --force overwrites local edits — back the local side up first.
55
61
  let backupPath = null
56
62
  if (force) {
57
63
  backupPath = backup('local', dir, identifier, config, { timestamp, data: local })
58
64
  }
59
65
 
60
- // Apply frontmatter-mapped fields + stamp the sync.
66
+ // Keyed body fields (e.g. milestones) — apply per-item via the denormalizer,
67
+ // which writes/creates the matching phase files. Removals are report-only.
68
+ const keyedApplied = []
69
+ const keyedCreated = []
70
+ const keyedReported = []
71
+ for (const f of fields) {
72
+ if (!f.keyed) continue
73
+ const apply = f.field === 'tasks' ? applyTasksPull : applyMilestonesPull
74
+ const res = apply(snapshotDir, f.items)
75
+ if (res.applied.length || res.created.length) keyedApplied.push(f.field)
76
+ keyedCreated.push(...res.created)
77
+ keyedReported.push(...res.reported.map((id) => `${f.field}#${id}`))
78
+ }
79
+
80
+ // Scalar pull-owned fields → frontmatter (keyed fields handled above).
81
+ const scalarPull = fields.filter((f) => f.pullable && !f.keyed)
82
+ const fieldValues = {}
83
+ for (const f of scalarPull) fieldValues[f.field] = remote[f.field]
84
+ const { patch, applied, deferred } = frontmatterPatchFor(fieldValues, config)
85
+
61
86
  if (applied.length || timestamp) {
62
87
  writeFrontmatter(snapshotDir, config, { ...patch, last_synced_at: timestamp })
63
88
  }
64
89
 
65
- // Advance base only for reconciled fields; deferred (body) fields keep the
66
- // local value as base so the remote edit stays pending, not marked synced.
67
- const newBase = { ...local }
90
+ // Re-normalize local so the base reflects the phase-file writes we just made,
91
+ // then advance base: scalar-applied fields take the remote value; keyed fields
92
+ // take the (now-updated) local value so applied items read in-sync and any
93
+ // report-only removal stays pending.
94
+ const newLocal = normalizeLocal(snapshotDir, config)
95
+ const newBase = { ...newLocal }
68
96
  for (const field of applied) newBase[field] = remote[field]
69
97
  newBase.__meta = { updatedAt: remoteRaw.updatedAt || null, syncedAt: timestamp }
70
98
  const basePath = writeBase(dir, identifier, config, newBase)
@@ -74,10 +102,13 @@ async function pull({ dir, snapshotDir, identifier, projectId, adapter, config,
74
102
  blocked: false,
75
103
  applied,
76
104
  deferred,
105
+ keyedApplied,
106
+ keyedCreated,
107
+ keyedReported,
77
108
  conflictsForced: force ? conflicts : [],
78
109
  backupPath,
79
110
  basePath,
80
- pulled: pullFields.map((f) => f.field),
111
+ pulled: [...scalarPull.map((f) => f.field), ...keyedApplied],
81
112
  }
82
113
  }
83
114
 
@@ -32,13 +32,17 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
32
32
 
33
33
  // Remote moved past base only if a *co-authored* (`both`) field diverged on the
34
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.
35
+ // For a keyed field the equivalent is a same-item conflict (independent edits to
36
+ // different items don't collide, so they don't block). A `pull`-owned change
37
+ // (status/priority/labels) is Linear's to own and must NOT block a content push,
38
+ // and a bare `updatedAt` bump is too coarse to gate on — the pre-write re-read
39
+ // below still catches a racer that lands during the push itself.
39
40
  const remoteDivergedFields = fields
40
- .filter((f) => f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
41
+ .filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
41
42
  .map((f) => f.field)
43
+ for (const f of fields) {
44
+ if (f.keyed) for (const it of f.items) if (it.status === 'conflict') remoteDivergedFields.push(`${f.field}#${it.id}`)
45
+ }
42
46
  const moved = remoteDivergedFields.length > 0
43
47
 
44
48
  if (moved && !force) {
@@ -54,8 +58,37 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
54
58
  }
55
59
  }
56
60
 
57
- const pushFields = fields.filter((f) => f.pushable)
58
- if (!pushFields.length && !force) {
61
+ // Scalar push goes through the project adapter here. Keyed body fields
62
+ // (milestones) can't be written by the offline engine — the provider skill does
63
+ // the MCP create/update and stamps new ids — so the engine emits a *plan* the
64
+ // skill applies. The base still advances to local (below): a created milestone's
65
+ // id:null item is skipped by the keyed compare until the skill stamps it, then
66
+ // it converges on the next sync, so no special base handling is needed.
67
+ const pushFields = fields.filter((f) => f.pushable && !f.keyed)
68
+ // A per-field create/update plan for each keyed collection. The item content
69
+ // (minus its id) is exactly what the skill sends to the Linear save tool.
70
+ const keyedPush = {}
71
+ for (const f of fields) {
72
+ if (!f.keyed) continue
73
+ const strip = (obj) => {
74
+ const { [f.idKey]: _omit, ...rest } = obj
75
+ return rest
76
+ }
77
+ const plan = { create: [], update: [] }
78
+ // Edits to already-linked items (matched by id) → update.
79
+ for (const it of f.items) {
80
+ if (!it.pushable || !it.local) continue
81
+ if (it.status !== 'added') plan.update.push({ id: it.id, ...strip(it.local) })
82
+ }
83
+ // Unlinked local items (no id yet) are new content to create; the keyed
84
+ // compare skips them (nothing to key on), so collect them straight from local.
85
+ const localItems = Array.isArray(local[f.field]) ? local[f.field] : []
86
+ for (const li of localItems) if (li && li[f.idKey] == null) plan.create.push(strip(li))
87
+ if (plan.create.length || plan.update.length) keyedPush[f.field] = plan
88
+ }
89
+ const hasKeyedPush = Object.keys(keyedPush).length > 0
90
+
91
+ if (!pushFields.length && !hasKeyedPush && !force) {
59
92
  return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
60
93
  }
61
94
 
@@ -78,7 +111,9 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
78
111
 
79
112
  const updates = {}
80
113
  for (const f of pushFields) updates[f.field] = local[f.field]
81
- const updated = (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
114
+ const updated = Object.keys(updates).length
115
+ ? (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
116
+ : remoteRaw2 || remoteRaw
82
117
  const updatedRemote = normalizeRemote(updated, config)
83
118
 
84
119
  // Reconciled base: local is the source of truth for the fields we pushed (and
@@ -97,8 +132,12 @@ async function push({ dir, snapshotDir, identifier, projectId, adapter, config,
97
132
  ok: true,
98
133
  blocked: false,
99
134
  written: pushFields.map((f) => f.field),
135
+ // The skill applies these Linear writes (create → stamp the new id back into
136
+ // the phase file / task line; update → save by id). Omitted when empty.
137
+ ...(keyedPush.milestones ? { milestonesPush: keyedPush.milestones } : {}),
138
+ ...(keyedPush.tasks ? { issuesPush: keyedPush.tasks } : {}),
100
139
  skipped: fields
101
- .filter((f) => !f.pushable && f.status !== 'unchanged')
140
+ .filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
102
141
  .map((f) => f.field),
103
142
  backupPath,
104
143
  basePath,
@@ -79,8 +79,255 @@ function writeFrontmatter(snapshotDir, config, patch) {
79
79
  return Object.keys(clean)
80
80
  }
81
81
 
82
+ // --- phase-file denormalizer (keyed milestone pull) ------------------------
83
+ //
84
+ // Writes pulled milestone edits back into the *body* — the phase files — which
85
+ // the frontmatter writer above never touches. An edit updates the matching phase
86
+ // file (by its linear_milestone_id) in place, leaving everything else
87
+ // byte-untouched; a Linear-only milestone becomes a new phase file. Removals are
88
+ // never applied here (report-only, Decision 7).
89
+
90
+ // Phase files in a snapshot dir (01-*.md …), execution order.
91
+ function listPhaseFiles(snapshotDir) {
92
+ try {
93
+ return fs
94
+ .readdirSync(snapshotDir)
95
+ .filter((f) => /^\d\d-.*\.md$/.test(f) && !f.startsWith('00-'))
96
+ .sort()
97
+ } catch {
98
+ return []
99
+ }
100
+ }
101
+
102
+ // The linear_milestone_id recorded in a phase file's frontmatter, or null.
103
+ function phaseMilestoneId(raw) {
104
+ const { fmLines } = splitFrontmatter(raw)
105
+ for (const line of fmLines) {
106
+ const m = /^linear_milestone_id:\s*(.*)$/.exec(line)
107
+ if (m) return m[1].trim().replace(/^["']|["']$/g, '') || null
108
+ }
109
+ return null
110
+ }
111
+
112
+ // Find the phase file linked to a milestone id, or null.
113
+ function findPhaseFileByMilestoneId(snapshotDir, id) {
114
+ const want = String(id)
115
+ for (const file of listPhaseFiles(snapshotDir)) {
116
+ const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
117
+ if (phaseMilestoneId(raw) === want) return file
118
+ }
119
+ return null
120
+ }
121
+
122
+ // Find the phase file whose h1 title matches `name` (used to link a freshly
123
+ // created milestone back to the phase it came from, before it has an id).
124
+ function findPhaseFileByTitle(snapshotDir, name) {
125
+ const want = String(name).trim()
126
+ for (const file of listPhaseFiles(snapshotDir)) {
127
+ const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
128
+ const h1 = /^#\s+(.*)$/m.exec(splitFrontmatter(raw).body)
129
+ if (!h1) continue
130
+ const title = h1[1]
131
+ .replace(/\s*[⬜🔄✅]\s*$/u, '')
132
+ .replace(/^Phase\s+\d+\s*[—–-]\s*/i, '')
133
+ .trim()
134
+ if (title === want) return file
135
+ }
136
+ return null
137
+ }
138
+
139
+ // Update a phase file's title (h1, preserving the "Phase N — " prefix + status
140
+ // emoji) and its `**Goal:**` line, leaving everything else untouched.
141
+ function writeMilestoneFields(snapshotDir, file, { name, goal }) {
142
+ const p = path.join(snapshotDir, file)
143
+ let raw = fs.readFileSync(p, 'utf-8')
144
+ if (name != null) {
145
+ raw = raw.replace(/^(#[ \t]+)(.*)$/m, (_full, hash, rest) => {
146
+ const pm = /^(Phase\s+\d+\s*[—–-]\s*)(.*?)(\s*[⬜🔄✅])?\s*$/.exec(rest)
147
+ return pm ? `${hash}${pm[1]}${name}${pm[3] || ''}` : `${hash}${name}`
148
+ })
149
+ }
150
+ if (goal != null && /^\*\*Goal:\*\*/m.test(raw)) {
151
+ raw = raw.replace(/^(\*\*Goal:\*\*[ \t]*).*$/m, `$1${goal}`)
152
+ }
153
+ fs.writeFileSync(p, raw, 'utf-8')
154
+ }
155
+
156
+ // Add/update linear_milestone_id in a phase file's frontmatter (in place).
157
+ function stampMilestoneId(snapshotDir, file, id) {
158
+ const p = path.join(snapshotDir, file)
159
+ const raw = fs.readFileSync(p, 'utf-8')
160
+ const { fmLines, body, had } = splitFrontmatter(raw)
161
+ const patched = patchFrontmatterLines(fmLines, { linear_milestone_id: String(id) })
162
+ const fm = `---\n${patched.join('\n')}\n---\n`
163
+ fs.writeFileSync(p, had ? fm + body : fm + '\n' + raw, 'utf-8')
164
+ }
165
+
166
+ const slugify = (name) =>
167
+ String(name || 'phase')
168
+ .toLowerCase()
169
+ .replace(/[^a-z0-9]+/g, '-')
170
+ .replace(/^-+|-+$/g, '')
171
+ .slice(0, 40) || 'phase'
172
+
173
+ // Next phase number (max existing + 1).
174
+ function nextPhaseNumber(snapshotDir) {
175
+ const nums = listPhaseFiles(snapshotDir)
176
+ .map((f) => parseInt(f.slice(0, 2), 10))
177
+ .filter(Number.isFinite)
178
+ return nums.length ? Math.max(...nums) + 1 : 1
179
+ }
180
+
181
+ // Create a new phase file for a Linear-only milestone. Returns the filename.
182
+ function createPhaseFileForMilestone(snapshotDir, { id, name, goal }) {
183
+ const n = nextPhaseNumber(snapshotDir)
184
+ const file = `${String(n).padStart(2, '0')}-${slugify(name)}.md`
185
+ const content =
186
+ `---\nlinear_milestone_id: ${JSON.stringify(String(id))}\n---\n\n` +
187
+ `# Phase ${n} — ${name || 'Untitled'} ⬜\n\n` +
188
+ `> Spec: [00-overview.md](00-overview.md) · **Status:** Not started\n\n` +
189
+ `**Goal:** ${goal || ''}\n\n## Tasks\n\n- [ ] (pulled from Linear — flesh out)\n`
190
+ fs.writeFileSync(path.join(snapshotDir, file), content, 'utf-8')
191
+ return file
192
+ }
193
+
194
+ /**
195
+ * Apply a pull's keyed milestone item outcomes to the phase files.
196
+ * @param items classifyItems output for the milestones field.
197
+ * @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
198
+ */
199
+ function applyMilestonesPull(snapshotDir, items) {
200
+ const applied = []
201
+ const created = []
202
+ const reported = []
203
+ for (const it of items || []) {
204
+ if (it.report) {
205
+ reported.push(it.id)
206
+ continue
207
+ }
208
+ if (!it.pullable || !it.remote) continue
209
+ if (it.status === 'added') {
210
+ const file = createPhaseFileForMilestone(snapshotDir, it.remote)
211
+ created.push({ id: it.id, file })
212
+ } else if (it.status === 'edited' || it.status === 'conflict') {
213
+ const file = findPhaseFileByMilestoneId(snapshotDir, it.id)
214
+ if (file) {
215
+ writeMilestoneFields(snapshotDir, file, it.remote)
216
+ applied.push(it.id)
217
+ }
218
+ }
219
+ }
220
+ return { applied, created, reported }
221
+ }
222
+
223
+ // --- task-line denormalizer (keyed issue pull) -----------------------------
224
+ //
225
+ // Tasks live as checkbox lines inside phase files. A pulled issue edit rewrites
226
+ // the matching line (by its inline id) in place; a Linear-only issue appends a
227
+ // new task line; a created issue's id is stamped inline. Removals report-only.
228
+
229
+ const TASK_RE = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/
230
+ const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
231
+
232
+ // Render a task line from an item.
233
+ function taskLine(indent, { id, text, done }) {
234
+ return `${indent}- [${done ? 'x' : ' '}] ${text}${id ? ` (${id})` : ''}`
235
+ }
236
+
237
+ // Update the task line carrying inline id `id` (text + checkbox), in place.
238
+ function updateTaskLine(snapshotDir, id, { text, done }) {
239
+ const want = String(id)
240
+ for (const file of listPhaseFiles(snapshotDir)) {
241
+ const p = path.join(snapshotDir, file)
242
+ const lines = fs.readFileSync(p, 'utf-8').split('\n')
243
+ for (let i = 0; i < lines.length; i++) {
244
+ const m = TASK_RE.exec(lines[i])
245
+ if (!m) continue
246
+ const idm = INLINE_ID_RE.exec(m[3])
247
+ if (idm && idm[1] === want) {
248
+ lines[i] = taskLine(m[1], { id: want, text, done })
249
+ fs.writeFileSync(p, lines.join('\n'), 'utf-8')
250
+ return true
251
+ }
252
+ }
253
+ }
254
+ return false
255
+ }
256
+
257
+ // Append a task line for a Linear-only issue after the last existing task line
258
+ // (falls back to end of the last phase file). Returns the file it landed in.
259
+ function addTaskLine(snapshotDir, item) {
260
+ const files = listPhaseFiles(snapshotDir)
261
+ const file = files[files.length - 1]
262
+ if (!file) return null
263
+ const p = path.join(snapshotDir, file)
264
+ const lines = fs.readFileSync(p, 'utf-8').split('\n')
265
+ let lastTask = -1
266
+ for (let i = 0; i < lines.length; i++) if (TASK_RE.test(lines[i])) lastTask = i
267
+ const line = taskLine('', item)
268
+ if (lastTask >= 0) lines.splice(lastTask + 1, 0, line)
269
+ else lines.push(line)
270
+ fs.writeFileSync(p, lines.join('\n'), 'utf-8')
271
+ return file
272
+ }
273
+
274
+ // Stamp an inline id onto the (idless) task line whose text matches — used after
275
+ // the skill creates an issue for a new local task.
276
+ function stampIssueId(snapshotDir, text, id) {
277
+ const want = String(text).trim()
278
+ for (const file of listPhaseFiles(snapshotDir)) {
279
+ const p = path.join(snapshotDir, file)
280
+ const lines = fs.readFileSync(p, 'utf-8').split('\n')
281
+ for (let i = 0; i < lines.length; i++) {
282
+ const m = TASK_RE.exec(lines[i])
283
+ if (!m || INLINE_ID_RE.test(m[3])) continue
284
+ if (m[3].trim() === want) {
285
+ lines[i] = `${m[1]}- [${m[2].toLowerCase() === 'x' ? 'x' : ' '}] ${want} (${id})`
286
+ fs.writeFileSync(p, lines.join('\n'), 'utf-8')
287
+ return file
288
+ }
289
+ }
290
+ }
291
+ return null
292
+ }
293
+
294
+ /**
295
+ * Apply a pull's keyed task item outcomes to the phase files' task lines.
296
+ * @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
297
+ */
298
+ function applyTasksPull(snapshotDir, items) {
299
+ const applied = []
300
+ const created = []
301
+ const reported = []
302
+ for (const it of items || []) {
303
+ if (it.report) {
304
+ reported.push(it.id)
305
+ continue
306
+ }
307
+ if (!it.pullable || !it.remote) continue
308
+ if (it.status === 'added') {
309
+ const file = addTaskLine(snapshotDir, it.remote)
310
+ if (file) created.push({ id: it.id, file })
311
+ } else if (it.status === 'edited' || it.status === 'conflict') {
312
+ if (updateTaskLine(snapshotDir, it.id, it.remote)) applied.push(it.id)
313
+ }
314
+ }
315
+ return { applied, created, reported }
316
+ }
317
+
82
318
  module.exports = {
83
319
  writeFrontmatter,
84
320
  splitFrontmatter,
85
321
  serialize,
322
+ listPhaseFiles,
323
+ findPhaseFileByMilestoneId,
324
+ findPhaseFileByTitle,
325
+ writeMilestoneFields,
326
+ stampMilestoneId,
327
+ createPhaseFileForMilestone,
328
+ applyMilestonesPull,
329
+ updateTaskLine,
330
+ addTaskLine,
331
+ stampIssueId,
332
+ applyTasksPull,
86
333
  }