@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 +6 -5
- package/assets/core/SETUP.md +11 -5
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +38 -1
- package/assets/skills/spec-pull/SKILL.md +4 -1
- package/assets/skills/spec-push/SKILL.md +21 -0
- package/package.json +1 -1
- package/src/vendor/linear/cli-sync.js +32 -1
- package/src/vendor/linear/config.js +22 -0
- package/src/vendor/linear/mcp.js +17 -0
- package/src/vendor/sync-core/src/compare.js +116 -0
- package/src/vendor/sync-core/src/normalize.js +92 -26
- package/src/vendor/sync-core/src/pull.js +47 -16
- package/src/vendor/sync-core/src/push.js +48 -9
- package/src/vendor/sync-core/src/write.js +247 -0
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
|
|
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.
|
|
47
|
-
per
|
|
48
|
-
|
|
49
|
-
|
|
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
|
package/assets/core/SETUP.md
CHANGED
|
@@ -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
|
-
|
|
128
|
-
acceptance criteria included.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
@@ -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
|
+
"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 (
|
|
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())
|
package/src/vendor/linear/mcp.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
138
|
-
const
|
|
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 {
|
|
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
|
-
|
|
175
|
-
|
|
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(
|
|
192
|
-
|
|
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
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
272
|
-
*
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
//
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
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:
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// a
|
|
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
|
-
|
|
58
|
-
|
|
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 =
|
|
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
|
}
|