@skitterbyte/skitterspec-linear 7.0.2 → 8.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/assets/core/SETUP.md +46 -51
- package/assets/core/linear.config.json.example +2 -2
- package/assets/core/linear.config.md +9 -7
- package/assets/rules/spec-planning.md +9 -7
- package/assets/skills/spec-go/SKILL.md +12 -13
- package/assets/skills/spec-push/SKILL.md +53 -47
- package/assets/skills/spec-status/SKILL.md +31 -26
- package/package.json +1 -1
- package/src/vendor/linear/cli-sanitise.js +0 -0
- package/src/vendor/linear/cli-sync.js +123 -204
- package/src/vendor/linear/config.js +14 -6
- package/src/vendor/sync-core/index.js +23 -19
- package/src/vendor/sync-core/src/base.js +8 -10
- package/src/vendor/sync-core/src/compare.js +83 -174
- package/src/vendor/sync-core/src/normalize.js +79 -78
- package/src/vendor/sync-core/src/push.js +39 -133
- package/src/vendor/sync-core/src/sanitise.js +4 -8
- package/src/vendor/sync-core/src/task-block.js +13 -1
- package/src/vendor/sync-core/src/write.js +15 -205
- package/assets/skills/spec-pull/SKILL.md +0 -49
- package/src/vendor/sync-core/src/apply.js +0 -66
- package/src/vendor/sync-core/src/pull.js +0 -115
|
@@ -1,147 +1,53 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `push` — repo →
|
|
4
|
+
* `push` — repo → Linear, one-way. The repo is the source of truth; Linear is a
|
|
5
|
+
* generated mirror. We never read remote content: `push` builds the local
|
|
6
|
+
* projection, diffs it against the committed **last-pushed snapshot**, and
|
|
7
|
+
* returns a **create/update plan** the provider skill applies over MCP.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `--force`. It also **re-reads the remote immediately before writing** to catch a
|
|
11
|
-
* writer that raced in during the compare. `--force` makes local win after backing
|
|
12
|
-
* up the remote side. On success it rewrites the base and stamps `last_synced_at`.
|
|
9
|
+
* The skill: applies the plan → stamps each newly-created id back into the phase
|
|
10
|
+
* frontmatter / task line → calls `recordPush`, which re-reads the (now
|
|
11
|
+
* id-stamped) projection and writes the snapshot. That closes the loop: the next
|
|
12
|
+
* `push` sees an unchanged snapshot and produces an empty plan.
|
|
13
13
|
*
|
|
14
|
-
* Pure
|
|
15
|
-
*
|
|
14
|
+
* Pure aside from reading the snapshot; no adapter, no remote read, no
|
|
15
|
+
* Date.now(). `recordPush` writes the snapshot sidecar.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const { normalizeLocal
|
|
19
|
-
const {
|
|
20
|
-
const { readBase, writeBase
|
|
21
|
-
const { writeFrontmatter } = require('./write.js')
|
|
18
|
+
const { normalizeLocal } = require('./normalize.js')
|
|
19
|
+
const { planChanges, snapshotOf, isEmptyPlan } = require('./compare.js')
|
|
20
|
+
const { readBase, writeBase } = require('./base.js')
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
// Build the one-way projection from a local snapshot: the project prose + status,
|
|
23
|
+
// and the milestone/issue items. `status` is the local lifecycle bucket; the
|
|
24
|
+
// skill maps it to the Linear project-state NAME via config.states at apply time.
|
|
25
|
+
function projectionOf(snapshotDir, config) {
|
|
24
26
|
const local = normalizeLocal(snapshotDir, config)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Remote moved past base only if a *co-authored* (`both`) field diverged on the
|
|
34
|
-
// remote side — that's the case the repo can't safely overwrite without a pull.
|
|
35
|
-
// 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.
|
|
40
|
-
const remoteDivergedFields = fields
|
|
41
|
-
.filter((f) => !f.keyed && f.ownership === 'both' && (f.raw === 'remote-only' || f.raw === 'conflict'))
|
|
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
|
-
}
|
|
46
|
-
const moved = remoteDivergedFields.length > 0
|
|
47
|
-
|
|
48
|
-
if (moved && !force) {
|
|
49
|
-
return {
|
|
50
|
-
ok: false,
|
|
51
|
-
blocked: true,
|
|
52
|
-
reason: 'remote-moved',
|
|
53
|
-
movedFields: remoteDivergedFields,
|
|
54
|
-
message:
|
|
55
|
-
'push refused — remote moved since the last sync' +
|
|
56
|
-
(remoteDivergedFields.length ? ` (${remoteDivergedFields.join(', ')})` : '') +
|
|
57
|
-
'. Pull first, or re-run with --force (local wins).',
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
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) {
|
|
92
|
-
return { ok: true, blocked: false, written: [], skipped: [], note: 'nothing to push' }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Optimistic concurrency: re-read immediately before writing to catch a racer.
|
|
96
|
-
const remoteRaw2 = await adapter.readProject(projectId)
|
|
97
|
-
if (remoteRaw2 && remoteRaw2.updatedAt !== remoteRaw.updatedAt && !force) {
|
|
98
|
-
return {
|
|
99
|
-
ok: false,
|
|
100
|
-
blocked: true,
|
|
101
|
-
reason: 'concurrent-write',
|
|
102
|
-
message: 'push refused — remote changed during the push. Pull first, or --force.',
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// --force clobbers the remote side — back it up first.
|
|
107
|
-
let backupPath = null
|
|
108
|
-
if (force) {
|
|
109
|
-
backupPath = backup('remote', dir, identifier, config, { timestamp, data: remoteRaw2 || remoteRaw })
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const updates = {}
|
|
113
|
-
for (const f of pushFields) updates[f.field] = local[f.field]
|
|
114
|
-
const updated = Object.keys(updates).length
|
|
115
|
-
? (await adapter.updateProject(projectId, updates)) || remoteRaw2 || remoteRaw
|
|
116
|
-
: remoteRaw2 || remoteRaw
|
|
117
|
-
const updatedRemote = normalizeRemote(updated, config)
|
|
118
|
-
|
|
119
|
-
// Reconciled base: local is the source of truth for the fields we pushed (and
|
|
120
|
-
// for unchanged/local-only fields); pull-owned fields keep remote's value so
|
|
121
|
-
// they don't read as pending next time.
|
|
122
|
-
const newBase = { ...local }
|
|
123
|
-
for (const [field, own] of Object.entries(config.sync.fieldOwnership)) {
|
|
124
|
-
if (own === 'pull') newBase[field] = updatedRemote[field]
|
|
27
|
+
return {
|
|
28
|
+
description: local.description ?? null,
|
|
29
|
+
status: local.workflowState ?? null,
|
|
30
|
+
priority: local.priority ?? null,
|
|
31
|
+
labels: Array.isArray(local.labels) ? local.labels : [],
|
|
32
|
+
milestones: Array.isArray(local.milestones) ? local.milestones : [],
|
|
33
|
+
issues: Array.isArray(local.tasks) ? local.tasks : [],
|
|
125
34
|
}
|
|
126
|
-
|
|
127
|
-
const basePath = writeBase(dir, identifier, config, newBase)
|
|
35
|
+
}
|
|
128
36
|
|
|
129
|
-
|
|
37
|
+
function push({ dir, snapshotDir, identifier, config }) {
|
|
38
|
+
const projection = projectionOf(snapshotDir, config)
|
|
39
|
+
const snapshot = readBase(dir, identifier, config)
|
|
40
|
+
const plan = planChanges(projection, snapshot)
|
|
41
|
+
return { ok: true, empty: isEmptyPlan(plan), plan, projection }
|
|
42
|
+
}
|
|
130
43
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
...(keyedPush.tasks ? { issuesPush: keyedPush.tasks } : {}),
|
|
139
|
-
skipped: fields
|
|
140
|
-
.filter((f) => !f.pushable && !f.keyed && f.status !== 'unchanged')
|
|
141
|
-
.map((f) => f.field),
|
|
142
|
-
backupPath,
|
|
143
|
-
basePath,
|
|
144
|
-
}
|
|
44
|
+
/**
|
|
45
|
+
* Record the last-pushed snapshot from the CURRENT files — call after the skill
|
|
46
|
+
* has applied the plan and stamped new ids. Returns the snapshot path.
|
|
47
|
+
*/
|
|
48
|
+
function recordPush({ dir, snapshotDir, identifier, config }) {
|
|
49
|
+
const projection = projectionOf(snapshotDir, config)
|
|
50
|
+
return writeBase(dir, identifier, config, snapshotOf(projection))
|
|
145
51
|
}
|
|
146
52
|
|
|
147
|
-
module.exports = { push }
|
|
53
|
+
module.exports = { push, recordPush, projectionOf }
|
|
@@ -15,12 +15,7 @@
|
|
|
15
15
|
* Pure string→string (`sanitizeSpecMarkdown`); the CLI wraps it with fs walking.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const {
|
|
19
|
-
wrapEmphasisAware,
|
|
20
|
-
collapse,
|
|
21
|
-
inferWidth,
|
|
22
|
-
DEFAULT_WIDTH,
|
|
23
|
-
} = require('./task-block.js')
|
|
18
|
+
const { wrapEmphasisAware, collapseHyphenAware, inferWidth, DEFAULT_WIDTH } = require('./task-block.js')
|
|
24
19
|
const { joinEmphasisAcrossBreaks } = require('./normalize.js')
|
|
25
20
|
|
|
26
21
|
// A run of non-blank lines contains an emphasis straddle / mangle iff joining
|
|
@@ -30,9 +25,10 @@ function hasStraddle(text) {
|
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
// Turn a multi-line region into its clean single logical line: repair mangles and
|
|
33
|
-
// join straddles first (needs the newlines), then collapse whitespace
|
|
28
|
+
// join straddles first (needs the newlines), then collapse whitespace — hyphen-
|
|
29
|
+
// aware, so a compound wrapped at the hyphen isn't spaced out.
|
|
34
30
|
function cleanLogicalLine(text) {
|
|
35
|
-
return
|
|
31
|
+
return collapseHyphenAware(joinEmphasisAcrossBreaks(text))
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
// A bullet line: indent, marker (`- `, `* `, `+ `, `1. `, optionally a `[ ]`/`[x]`
|
|
@@ -26,6 +26,17 @@ function collapse(text) {
|
|
|
26
26
|
return String(text).replace(/\s+/g, ' ').trim()
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Like `collapse`, but a word wrapped at a hyphen (`state-entry-with-`⏎
|
|
30
|
+
// `assignment`) rejoins TIGHT — one compound — instead of gaining a space. Every
|
|
31
|
+
// other break collapses to a single space. Use this wherever hand-wrapped prose
|
|
32
|
+
// is flattened to one logical line, so a hard wrap at a hyphen isn't data loss.
|
|
33
|
+
function collapseHyphenAware(text) {
|
|
34
|
+
return String(text)
|
|
35
|
+
.replace(/(\w-)[ \t]*\n[ \t]*(?=\w)/g, '$1')
|
|
36
|
+
.replace(/\s+/g, ' ')
|
|
37
|
+
.trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
// Mark every character of `body` that lies inside an inline emphasis or link
|
|
30
41
|
// span — `**bold**`, `*italic*`, `[text](url)` — so wrapping can avoid breaking
|
|
31
42
|
// one across a line (Linear mangles a straddling `**`/`*`/link on save). Code
|
|
@@ -84,7 +95,7 @@ function findTaskBlocks(lines) {
|
|
|
84
95
|
end: j,
|
|
85
96
|
indent: m[1],
|
|
86
97
|
mark: m[2].toLowerCase() === 'x' ? 'x' : ' ',
|
|
87
|
-
text:
|
|
98
|
+
text: collapseHyphenAware(parts.join('\n')),
|
|
88
99
|
})
|
|
89
100
|
i = j - 1
|
|
90
101
|
}
|
|
@@ -171,6 +182,7 @@ module.exports = {
|
|
|
171
182
|
wrapEmphasisAware,
|
|
172
183
|
spanMask,
|
|
173
184
|
collapse,
|
|
185
|
+
collapseHyphenAware,
|
|
174
186
|
inferWidth,
|
|
175
187
|
DEFAULT_WIDTH,
|
|
176
188
|
}
|
|
@@ -1,32 +1,25 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Push-side writeback: after `push` creates milestones/issues in Linear, the
|
|
5
|
+
* skill stamps each returned id back into the repo so the next push updates
|
|
6
|
+
* rather than recreates. Everything here edits the repo in place:
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* - `stampMilestoneId(dir, file, id)` — add/update `linear_milestone_id` in a
|
|
9
|
+
* phase file's frontmatter (locate the file with `findPhaseFileByTitle`).
|
|
10
|
+
* - `stampIssueId(dir, text, id)` — append `(ID)` to the matching task line,
|
|
11
|
+
* re-wrapped in the file's own style.
|
|
12
|
+
* - `writeFrontmatter(dir, config, patch)` — patch `00-overview.md` frontmatter
|
|
13
|
+
* (e.g. `last_synced_at`).
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
* here — that denormalizer is a tracked follow-up (see the spec). Callers advance
|
|
15
|
-
* the base only for fields they actually applied, so an un-applied remote edit
|
|
16
|
-
* stays pending rather than being silently marked synced.
|
|
15
|
+
* No remote read, no pull writeback — the repo is the source of truth.
|
|
17
16
|
*/
|
|
18
17
|
|
|
19
18
|
const fs = require('node:fs')
|
|
20
19
|
const path = require('node:path')
|
|
21
|
-
const {
|
|
22
|
-
findTaskBlocks,
|
|
23
|
-
renderTaskBlock,
|
|
24
|
-
collapse,
|
|
25
|
-
inferWidth,
|
|
26
|
-
} = require('./task-block.js')
|
|
20
|
+
const { findTaskBlocks, renderTaskBlock, collapse, inferWidth } = require('./task-block.js')
|
|
27
21
|
|
|
28
|
-
// Serialize a JS value as a YAML-ish frontmatter scalar.
|
|
29
|
-
// key is dropped (caller shouldn't pass those).
|
|
22
|
+
// Serialize a JS value as a YAML-ish frontmatter scalar.
|
|
30
23
|
function serialize(value) {
|
|
31
24
|
if (Array.isArray(value)) return JSON.stringify(value)
|
|
32
25
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
@@ -54,7 +47,6 @@ function patchFrontmatterLines(lines, patch) {
|
|
|
54
47
|
out.push(line)
|
|
55
48
|
}
|
|
56
49
|
}
|
|
57
|
-
// Append any new keys not already present.
|
|
58
50
|
for (const key of Object.keys(patch)) {
|
|
59
51
|
if (!seen.has(key)) out.push(`${key}: ${serialize(patch[key])}`)
|
|
60
52
|
}
|
|
@@ -85,14 +77,6 @@ function writeFrontmatter(snapshotDir, config, patch) {
|
|
|
85
77
|
return Object.keys(clean)
|
|
86
78
|
}
|
|
87
79
|
|
|
88
|
-
// --- phase-file denormalizer (keyed milestone pull) ------------------------
|
|
89
|
-
//
|
|
90
|
-
// Writes pulled milestone edits back into the *body* — the phase files — which
|
|
91
|
-
// the frontmatter writer above never touches. An edit updates the matching phase
|
|
92
|
-
// file (by its linear_milestone_id) in place, leaving everything else
|
|
93
|
-
// byte-untouched; a Linear-only milestone becomes a new phase file. Removals are
|
|
94
|
-
// never applied here (report-only, Decision 7).
|
|
95
|
-
|
|
96
80
|
// Phase files in a snapshot dir (01-*.md …), execution order.
|
|
97
81
|
function listPhaseFiles(snapshotDir) {
|
|
98
82
|
try {
|
|
@@ -105,28 +89,8 @@ function listPhaseFiles(snapshotDir) {
|
|
|
105
89
|
}
|
|
106
90
|
}
|
|
107
91
|
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
const { fmLines } = splitFrontmatter(raw)
|
|
111
|
-
for (const line of fmLines) {
|
|
112
|
-
const m = /^linear_milestone_id:\s*(.*)$/.exec(line)
|
|
113
|
-
if (m) return m[1].trim().replace(/^["']|["']$/g, '') || null
|
|
114
|
-
}
|
|
115
|
-
return null
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Find the phase file linked to a milestone id, or null.
|
|
119
|
-
function findPhaseFileByMilestoneId(snapshotDir, id) {
|
|
120
|
-
const want = String(id)
|
|
121
|
-
for (const file of listPhaseFiles(snapshotDir)) {
|
|
122
|
-
const raw = fs.readFileSync(path.join(snapshotDir, file), 'utf-8')
|
|
123
|
-
if (phaseMilestoneId(raw) === want) return file
|
|
124
|
-
}
|
|
125
|
-
return null
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Find the phase file whose h1 title matches `name` (used to link a freshly
|
|
129
|
-
// created milestone back to the phase it came from, before it has an id).
|
|
92
|
+
// Find the phase file whose h1 title matches `name` (used to stamp a freshly
|
|
93
|
+
// created milestone's id back into the phase it came from).
|
|
130
94
|
function findPhaseFileByTitle(snapshotDir, name) {
|
|
131
95
|
const want = String(name).trim()
|
|
132
96
|
for (const file of listPhaseFiles(snapshotDir)) {
|
|
@@ -142,23 +106,6 @@ function findPhaseFileByTitle(snapshotDir, name) {
|
|
|
142
106
|
return null
|
|
143
107
|
}
|
|
144
108
|
|
|
145
|
-
// Update a phase file's title (h1, preserving the "Phase N — " prefix + status
|
|
146
|
-
// emoji) and its `**Goal:**` line, leaving everything else untouched.
|
|
147
|
-
function writeMilestoneFields(snapshotDir, file, { name, goal }) {
|
|
148
|
-
const p = path.join(snapshotDir, file)
|
|
149
|
-
let raw = fs.readFileSync(p, 'utf-8')
|
|
150
|
-
if (name != null) {
|
|
151
|
-
raw = raw.replace(/^(#[ \t]+)(.*)$/m, (_full, hash, rest) => {
|
|
152
|
-
const pm = /^(Phase\s+\d+\s*[—–-]\s*)(.*?)(\s*[⬜🔄✅])?\s*$/.exec(rest)
|
|
153
|
-
return pm ? `${hash}${pm[1]}${name}${pm[3] || ''}` : `${hash}${name}`
|
|
154
|
-
})
|
|
155
|
-
}
|
|
156
|
-
if (goal != null && /^\*\*Goal:\*\*/m.test(raw)) {
|
|
157
|
-
raw = raw.replace(/^(\*\*Goal:\*\*[ \t]*).*$/m, `$1${goal}`)
|
|
158
|
-
}
|
|
159
|
-
fs.writeFileSync(p, raw, 'utf-8')
|
|
160
|
-
}
|
|
161
|
-
|
|
162
109
|
// Add/update linear_milestone_id in a phase file's frontmatter (in place).
|
|
163
110
|
function stampMilestoneId(snapshotDir, file, id) {
|
|
164
111
|
const p = path.join(snapshotDir, file)
|
|
@@ -169,111 +116,8 @@ function stampMilestoneId(snapshotDir, file, id) {
|
|
|
169
116
|
fs.writeFileSync(p, had ? fm + body : fm + '\n' + raw, 'utf-8')
|
|
170
117
|
}
|
|
171
118
|
|
|
172
|
-
const slugify = (name) =>
|
|
173
|
-
String(name || 'phase')
|
|
174
|
-
.toLowerCase()
|
|
175
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
176
|
-
.replace(/^-+|-+$/g, '')
|
|
177
|
-
.slice(0, 40) || 'phase'
|
|
178
|
-
|
|
179
|
-
// Next phase number (max existing + 1).
|
|
180
|
-
function nextPhaseNumber(snapshotDir) {
|
|
181
|
-
const nums = listPhaseFiles(snapshotDir)
|
|
182
|
-
.map((f) => parseInt(f.slice(0, 2), 10))
|
|
183
|
-
.filter(Number.isFinite)
|
|
184
|
-
return nums.length ? Math.max(...nums) + 1 : 1
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Create a new phase file for a Linear-only milestone. Returns the filename.
|
|
188
|
-
function createPhaseFileForMilestone(snapshotDir, { id, name, goal }) {
|
|
189
|
-
const n = nextPhaseNumber(snapshotDir)
|
|
190
|
-
const file = `${String(n).padStart(2, '0')}-${slugify(name)}.md`
|
|
191
|
-
const content =
|
|
192
|
-
`---\nlinear_milestone_id: ${JSON.stringify(String(id))}\n---\n\n` +
|
|
193
|
-
`# Phase ${n} — ${name || 'Untitled'} ⬜\n\n` +
|
|
194
|
-
`> Spec: [00-overview.md](00-overview.md) · **Status:** Not started\n\n` +
|
|
195
|
-
`**Goal:** ${goal || ''}\n\n## Tasks\n\n- [ ] (pulled from Linear — flesh out)\n`
|
|
196
|
-
fs.writeFileSync(path.join(snapshotDir, file), content, 'utf-8')
|
|
197
|
-
return file
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Apply a pull's keyed milestone item outcomes to the phase files.
|
|
202
|
-
* @param items classifyItems output for the milestones field.
|
|
203
|
-
* @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
|
|
204
|
-
*/
|
|
205
|
-
function applyMilestonesPull(snapshotDir, items) {
|
|
206
|
-
const applied = []
|
|
207
|
-
const created = []
|
|
208
|
-
const reported = []
|
|
209
|
-
for (const it of items || []) {
|
|
210
|
-
if (it.report) {
|
|
211
|
-
reported.push(it.id)
|
|
212
|
-
continue
|
|
213
|
-
}
|
|
214
|
-
if (!it.pullable || !it.remote) continue
|
|
215
|
-
if (it.status === 'added') {
|
|
216
|
-
const file = createPhaseFileForMilestone(snapshotDir, it.remote)
|
|
217
|
-
created.push({ id: it.id, file })
|
|
218
|
-
} else if (it.status === 'edited' || it.status === 'conflict') {
|
|
219
|
-
const file = findPhaseFileByMilestoneId(snapshotDir, it.id)
|
|
220
|
-
if (file) {
|
|
221
|
-
writeMilestoneFields(snapshotDir, file, it.remote)
|
|
222
|
-
applied.push(it.id)
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return { applied, created, reported }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// --- task-line denormalizer (keyed issue pull) -----------------------------
|
|
230
|
-
//
|
|
231
|
-
// Tasks live as (hand-wrapped) checkbox bullets inside phase files. A pulled
|
|
232
|
-
// issue edit rewrites the matching bullet — whole block, re-wrapped — by its
|
|
233
|
-
// inline id; a Linear-only issue appends a new bullet; a created issue's id is
|
|
234
|
-
// stamped inline. Removals report-only. See task-block.js for the wrapping.
|
|
235
|
-
|
|
236
119
|
const INLINE_ID_RE = /\s*\(([A-Za-z][A-Za-z0-9]*-\d+)\)\s*$/
|
|
237
120
|
|
|
238
|
-
// Update the task carrying inline id `id` (text + checkbox), in place.
|
|
239
|
-
//
|
|
240
|
-
// Block-aware: a task bullet is hand-wrapped across several lines, so the whole
|
|
241
|
-
// block is replaced and the new text re-wrapped in the file's own style. Editing
|
|
242
|
-
// only the first line would strand its continuation lines as orphaned prose.
|
|
243
|
-
function updateTaskLine(snapshotDir, id, { text, done }) {
|
|
244
|
-
const want = String(id)
|
|
245
|
-
for (const file of listPhaseFiles(snapshotDir)) {
|
|
246
|
-
const p = path.join(snapshotDir, file)
|
|
247
|
-
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
248
|
-
const width = inferWidth(lines)
|
|
249
|
-
for (const b of findTaskBlocks(lines)) {
|
|
250
|
-
const idm = INLINE_ID_RE.exec(b.text)
|
|
251
|
-
if (!idm || idm[1] !== want) continue
|
|
252
|
-
const rendered = renderTaskBlock({ indent: b.indent, done, text, id: want }, width)
|
|
253
|
-
lines.splice(b.start, b.end - b.start, ...rendered)
|
|
254
|
-
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
255
|
-
return true
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return false
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Append a task line for a Linear-only issue after the last existing task line
|
|
262
|
-
// (falls back to end of the last phase file). Returns the file it landed in.
|
|
263
|
-
function addTaskLine(snapshotDir, item) {
|
|
264
|
-
const files = listPhaseFiles(snapshotDir)
|
|
265
|
-
const file = files[files.length - 1]
|
|
266
|
-
if (!file) return null
|
|
267
|
-
const p = path.join(snapshotDir, file)
|
|
268
|
-
const lines = fs.readFileSync(p, 'utf-8').split('\n')
|
|
269
|
-
const blocks = findTaskBlocks(lines)
|
|
270
|
-
const rendered = renderTaskBlock({ indent: '', ...item }, inferWidth(lines))
|
|
271
|
-
if (blocks.length) lines.splice(blocks[blocks.length - 1].end, 0, ...rendered)
|
|
272
|
-
else lines.push(...rendered)
|
|
273
|
-
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
274
|
-
return file
|
|
275
|
-
}
|
|
276
|
-
|
|
277
121
|
// Stamp an inline id onto the (idless) task line whose text matches — used after
|
|
278
122
|
// the skill creates an issue for a new local task.
|
|
279
123
|
function stampIssueId(snapshotDir, text, id) {
|
|
@@ -285,10 +129,7 @@ function stampIssueId(snapshotDir, text, id) {
|
|
|
285
129
|
for (const b of findTaskBlocks(lines)) {
|
|
286
130
|
if (INLINE_ID_RE.test(b.text)) continue
|
|
287
131
|
if (b.text !== want) continue
|
|
288
|
-
const rendered = renderTaskBlock(
|
|
289
|
-
{ indent: b.indent, done: b.mark === 'x', text: want, id },
|
|
290
|
-
width,
|
|
291
|
-
)
|
|
132
|
+
const rendered = renderTaskBlock({ indent: b.indent, done: b.mark === 'x', text: want, id }, width)
|
|
292
133
|
lines.splice(b.start, b.end - b.start, ...rendered)
|
|
293
134
|
fs.writeFileSync(p, lines.join('\n'), 'utf-8')
|
|
294
135
|
return file
|
|
@@ -297,43 +138,12 @@ function stampIssueId(snapshotDir, text, id) {
|
|
|
297
138
|
return null
|
|
298
139
|
}
|
|
299
140
|
|
|
300
|
-
/**
|
|
301
|
-
* Apply a pull's keyed task item outcomes to the phase files' task lines.
|
|
302
|
-
* @returns { applied:string[], created:Array<{id,file}>, reported:string[] }
|
|
303
|
-
*/
|
|
304
|
-
function applyTasksPull(snapshotDir, items) {
|
|
305
|
-
const applied = []
|
|
306
|
-
const created = []
|
|
307
|
-
const reported = []
|
|
308
|
-
for (const it of items || []) {
|
|
309
|
-
if (it.report) {
|
|
310
|
-
reported.push(it.id)
|
|
311
|
-
continue
|
|
312
|
-
}
|
|
313
|
-
if (!it.pullable || !it.remote) continue
|
|
314
|
-
if (it.status === 'added') {
|
|
315
|
-
const file = addTaskLine(snapshotDir, it.remote)
|
|
316
|
-
if (file) created.push({ id: it.id, file })
|
|
317
|
-
} else if (it.status === 'edited' || it.status === 'conflict') {
|
|
318
|
-
if (updateTaskLine(snapshotDir, it.id, it.remote)) applied.push(it.id)
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
return { applied, created, reported }
|
|
322
|
-
}
|
|
323
|
-
|
|
324
141
|
module.exports = {
|
|
325
142
|
writeFrontmatter,
|
|
326
143
|
splitFrontmatter,
|
|
327
144
|
serialize,
|
|
328
145
|
listPhaseFiles,
|
|
329
|
-
findPhaseFileByMilestoneId,
|
|
330
146
|
findPhaseFileByTitle,
|
|
331
|
-
writeMilestoneFields,
|
|
332
147
|
stampMilestoneId,
|
|
333
|
-
createPhaseFileForMilestone,
|
|
334
|
-
applyMilestonesPull,
|
|
335
|
-
updateTaskLine,
|
|
336
|
-
addTaskLine,
|
|
337
148
|
stampIssueId,
|
|
338
|
-
applyTasksPull,
|
|
339
149
|
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: spec-pull
|
|
3
|
-
description: Pull a spec's linked Linear project into the local spec (Linear → repo), three-way aware. Applies remote-only fields; refuses to clobber local edits on a conflict unless --force (which backs up the local side first). Fetches Linear over MCP and runs `skitterspec spec-sync pull`. Opt-in — needs specs/.core/linear.config.json. Use when the user says "/spec-pull", "pull from Linear", "sync Linear changes down", or "update this spec from Linear".
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# /spec-pull — bring Linear changes into the spec
|
|
7
|
-
|
|
8
|
-
Linear → repo. Applies fields Linear changed since the last sync (status,
|
|
9
|
-
priority, labels, and co-authored fields), rewrites the committed base, and
|
|
10
|
-
stamps `last_synced_at`. It **refuses** to overwrite a local edit that conflicts
|
|
11
|
-
with a Linear edit unless you pass `--force`.
|
|
12
|
-
|
|
13
|
-
**Opt-in**: only runs when `specs/.core/linear.config.json` exists. If absent,
|
|
14
|
-
tell the user how to enable Linear sync and stop.
|
|
15
|
-
|
|
16
|
-
## 1. Identify the target spec
|
|
17
|
-
|
|
18
|
-
Use the argument, else the spec in context; ask if unclear.
|
|
19
|
-
|
|
20
|
-
## 2. Fetch the Linear project
|
|
21
|
-
|
|
22
|
-
- Read `linear_project_id` from `00-overview.md` frontmatter; if missing, the
|
|
23
|
-
spec isn't linked — stop and point at `/spec`.
|
|
24
|
-
- Discover the Linear MCP project-read tool at runtime. If Linear isn't
|
|
25
|
-
connected, relay the fix and stop — **do nothing destructive**.
|
|
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.
|
|
30
|
-
|
|
31
|
-
## 3. Run the engine
|
|
32
|
-
|
|
33
|
-
```
|
|
34
|
-
skitterspec spec-sync pull <spec> --remote <tempfile> [--force]
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
- **No conflict** — the engine applies remote-only fields to the local snapshot,
|
|
38
|
-
rewrites the base, and stamps the sync. Body fields with no local home yet are
|
|
39
|
-
reported as `deferred` (apply them by hand from Linear if needed).
|
|
40
|
-
- **Conflict** (a co-authored field changed on both sides) — the engine
|
|
41
|
-
**refuses** and lists the fields. Relay that; do not force on the user's behalf.
|
|
42
|
-
- **`--force`** — only when the user explicitly asks. Remote wins after the engine
|
|
43
|
-
backs up the local side under `sync.backupDir` (the reflog). Relay the backup
|
|
44
|
-
path.
|
|
45
|
-
|
|
46
|
-
## 4. Report
|
|
47
|
-
|
|
48
|
-
Relay the git-like summary (applied / deferred / conflicts / backup / base). If
|
|
49
|
-
fields were applied, remind the user to review and commit the refreshed snapshot.
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Translate normalized field values into a local frontmatter patch (pull side).
|
|
5
|
-
*
|
|
6
|
-
* Only the `pull`-owned, frontmatter-backed fields have a local home in Phase 2:
|
|
7
|
-
* workflowState → spec_status (remote state name mapped back to the bucket),
|
|
8
|
-
* priority → priority,
|
|
9
|
-
* labels → labels.
|
|
10
|
-
* Any other field handed in (a body field like `description`/`milestones`) has no
|
|
11
|
-
* frontmatter mapping yet, so it's returned in `deferred` — the caller must NOT
|
|
12
|
-
* advance its base, keeping the remote edit pending instead of falsely synced.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// field name → frontmatter key.
|
|
16
|
-
const FRONTMATTER_FIELD = {
|
|
17
|
-
workflowState: 'spec_status',
|
|
18
|
-
priority: 'priority',
|
|
19
|
-
labels: 'labels',
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Invert config.states ({ bucket: "remote Name" }) → { "remote name": bucket }.
|
|
23
|
-
function invertStates(config) {
|
|
24
|
-
const out = {}
|
|
25
|
-
const states = (config && config.states) || {}
|
|
26
|
-
for (const [bucket, name] of Object.entries(states)) {
|
|
27
|
-
if (typeof name === 'string') out[name.toLowerCase()] = bucket
|
|
28
|
-
}
|
|
29
|
-
return out
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Map a remote workflowState (a remote state name) back to a local bucket. Falls
|
|
33
|
-
// back to the raw value when it isn't one of the configured states.
|
|
34
|
-
function localWorkflowState(value, config) {
|
|
35
|
-
if (value == null) return null
|
|
36
|
-
const bucket = invertStates(config)[String(value).toLowerCase()]
|
|
37
|
-
return bucket || String(value)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Build the frontmatter patch for a set of applied field values.
|
|
42
|
-
* @param {object} fieldValues { fieldName: value } to write locally
|
|
43
|
-
* @returns {{ patch:object, applied:string[], deferred:string[] }}
|
|
44
|
-
*/
|
|
45
|
-
function frontmatterPatchFor(fieldValues, config) {
|
|
46
|
-
const patch = {}
|
|
47
|
-
const applied = []
|
|
48
|
-
const deferred = []
|
|
49
|
-
for (const [field, value] of Object.entries(fieldValues)) {
|
|
50
|
-
const key = FRONTMATTER_FIELD[field]
|
|
51
|
-
if (!key) {
|
|
52
|
-
deferred.push(field)
|
|
53
|
-
continue
|
|
54
|
-
}
|
|
55
|
-
patch[key] = field === 'workflowState' ? localWorkflowState(value, config) : value
|
|
56
|
-
applied.push(field)
|
|
57
|
-
}
|
|
58
|
-
return { patch, applied, deferred }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
module.exports = {
|
|
62
|
-
frontmatterPatchFor,
|
|
63
|
-
localWorkflowState,
|
|
64
|
-
invertStates,
|
|
65
|
-
FRONTMATTER_FIELD,
|
|
66
|
-
}
|