@skitterbyte/skitterspec-linear 7.0.1 → 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.
@@ -1,13 +1,20 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * `spec-sync` CLI handler — the Linear hybrid-sync engine seam.
4
+ * `spec-sync` CLI handler — the Linear one-way sync engine seam.
5
5
  *
6
- * Extracted out of the base CLI: this ships only with the Linear provider package,
7
- * so the base (`@skitterbyte/skitterspec-common`) knows nothing about tracker sync.
8
- * It drives the provider-neutral engine (`@skitterbyte/skitterspec-sync-core`) with
9
- * the Linear config loader (`./config.js`) and a file-backed adapter; live
10
- * MCP-backed sync goes through the /spec-status · /spec-pull · /spec-push skills.
6
+ * Ships only with the Linear provider package, so the base
7
+ * (`@skitterbyte/skitterspec-common`) knows nothing about tracker sync. The repo
8
+ * is the source of truth; Linear is a generated mirror. This drives the
9
+ * provider-neutral engine (`@skitterbyte/skitterspec-sync-core`):
10
+ *
11
+ * spec-sync normalize <spec> print the local projection (JSON)
12
+ * spec-sync push <spec> print the create/update PLAN the skill applies
13
+ * spec-sync record <spec> write the last-pushed snapshot (after apply)
14
+ * spec-sync status <spec> read-only drift report (never writes)
15
+ *
16
+ * The `/spec-push` skill: `push` → apply the plan over MCP → stamp returned ids
17
+ * into the repo → `record`. There is no pull — Linear is not read for content.
11
18
  */
12
19
 
13
20
  const fs = require('node:fs')
@@ -16,26 +23,19 @@ const path = require('node:path')
16
23
  const { findSpecFolder } = require('../../env/resolve.js')
17
24
  const {
18
25
  normalizeLocal,
19
- normalizeRemote,
20
26
  readSnapshot,
21
- classify,
22
27
  readBase,
23
- pull,
24
28
  push,
29
+ recordPush,
30
+ projectionOf,
31
+ planChanges,
32
+ isEmptyPlan,
33
+ remoteWorkflowState,
34
+ validateStates,
25
35
  } = require('../sync-core')
26
36
 
27
37
  const { loadLinearConfig } = require('./config.js')
28
38
 
29
- // A compact, filesystem-safe timestamp (e.g. 20260714-030405) for backup/adapter
30
- // stamps. Inlined so this handler needs nothing from the base CLI.
31
- function compactTimestamp() {
32
- return new Date()
33
- .toISOString()
34
- .replace(/[-:]/g, '')
35
- .replace(/\.\d+Z$/, '')
36
- .replace('T', '-')
37
- }
38
-
39
39
  // Resolve a spec argument to its snapshot dir. Accepts a spec name/folder found
40
40
  // under specs/** (preferred) or a literal path to a snapshot directory.
41
41
  function resolveSnapshotDir(specArg, dir) {
@@ -46,8 +46,8 @@ function resolveSnapshotDir(specArg, dir) {
46
46
  return null
47
47
  }
48
48
 
49
- // The identifier keying the base sidecar: the spec's linear_identifier if set,
50
- // else its folder name (so the engine is usable before a spec is linked).
49
+ // The identifier keying the snapshot sidecar: the spec's linear_identifier if
50
+ // set, else its folder name (so the engine is usable before a spec is linked).
51
51
  function specIdentifier(snapshotDir, config) {
52
52
  try {
53
53
  const { frontmatter } = readSnapshot(snapshotDir, config)
@@ -58,229 +58,148 @@ function specIdentifier(snapshotDir, config) {
58
58
  return path.basename(snapshotDir)
59
59
  }
60
60
 
61
- // `spec-sync normalize <spec>` — print the normalized local field set as JSON.
62
- function specSyncNormalize(dir, config, specArg) {
63
- if (!specArg) {
64
- process.stdout.write('Usage: skitterspec spec-sync normalize <spec>\n')
65
- return
66
- }
61
+ function resolveOrExit(specArg, dir, out) {
62
+ if (!specArg) return null
67
63
  const snapshotDir = resolveSnapshotDir(specArg, dir)
68
64
  if (!snapshotDir) {
69
- process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
70
- return
65
+ out.write(`spec-sync: spec not found: ${specArg}\n`)
66
+ return null
71
67
  }
72
- const local = normalizeLocal(snapshotDir, config)
73
- process.stdout.write(JSON.stringify(local, null, 2) + '\n')
68
+ return snapshotDir
74
69
  }
75
70
 
76
- // `spec-sync status <spec> [--remote file]` read-only per-field divergence
77
- // (git status analog). With `--remote` (a Linear Project projection, supplied by
78
- // the /spec-status skill via MCP) it reports true three-way divergence; without
79
- // it, it compares local vs the committed base only (what changed locally since
80
- // the last sync).
81
- function specSyncStatus(dir, config, specArg, flags = {}) {
82
- if (!specArg) {
83
- process.stdout.write('Usage: skitterspec spec-sync status <spec> [--remote file]\n')
84
- return
85
- }
86
- const snapshotDir = resolveSnapshotDir(specArg, dir)
87
- if (!snapshotDir) {
88
- process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
89
- return
90
- }
91
- const identifier = specIdentifier(snapshotDir, config)
92
- const local = normalizeLocal(snapshotDir, config)
93
- const base = readBase(dir, identifier, config)
71
+ // `spec-sync normalize <spec>` print the local projection as JSON.
72
+ function specSyncNormalize(dir, config, specArg, out) {
73
+ const snapshotDir = resolveOrExit(specArg, dir, out)
74
+ if (!snapshotDir) return
75
+ out.write(JSON.stringify(projectionOf(snapshotDir, config), null, 2) + '\n')
76
+ }
94
77
 
95
- let remote = base // no remote compare local vs base
96
- let haveRemote = false
97
- if (flags.remote && fs.existsSync(flags.remote)) {
98
- remote = normalizeRemote(JSON.parse(fs.readFileSync(flags.remote, 'utf-8')), config)
99
- haveRemote = true
78
+ // `spec-sync push <spec> [--json]` print the create/update PLAN diffed against
79
+ // the last-pushed snapshot. Machine-readable by default; the /spec-push skill
80
+ // applies it over MCP then calls `record`.
81
+ function specSyncPush(dir, config, specArg, flags, out) {
82
+ const snapshotDir = resolveOrExit(specArg, dir, out)
83
+ if (!snapshotDir) return
84
+ const identifier = specIdentifier(snapshotDir, config)
85
+ const r = push({ dir, snapshotDir, identifier, config })
86
+ if (flags.json || !out.isTTY) {
87
+ out.write(JSON.stringify(r.plan, null, 2) + '\n')
88
+ return
100
89
  }
101
- const fields = classify(local, remote, base, config)
90
+ const p = r.plan
91
+ const lines = [`spec-sync push: ${identifier}`]
92
+ if (r.empty) lines.push(' nothing to push — mirror matches the last push')
93
+ else {
94
+ if (p.project) lines.push(' project: description/status')
95
+ if (p.milestones.create.length) lines.push(` milestones create: ${p.milestones.create.map((m) => m.name).join(', ')}`)
96
+ if (p.milestones.update.length) lines.push(` milestones update: ${p.milestones.update.map((m) => m.id).join(', ')}`)
97
+ if (p.issues.create.length) lines.push(` issues create: ${p.issues.create.length}`)
98
+ if (p.issues.update.length) lines.push(` issues update: ${p.issues.update.map((i) => i.id).join(', ')}`)
99
+ lines.push(' (run with --json for the full plan the skill applies)')
100
+ }
101
+ out.write(lines.join('\n') + '\n')
102
+ }
102
103
 
103
- const out = []
104
- out.push(`spec-sync status: ${identifier}${base ? '' : ' (no base yet never synced)'}`)
105
- if (!haveRemote) out.push(' (no --remote given — compared local vs base only)')
106
- const changed = fields.filter((f) => f.status !== 'unchanged')
107
- if (!changed.length) {
108
- out.push(haveRemote ? ' in sync local, Linear, and base agree' : ' nothing to sync — local matches base')
109
- } else {
110
- for (const f of changed) {
111
- const dir_ = f.pushable && f.pullable ? 'push+pull' : f.pushable ? 'push' : f.pullable ? 'pull' : '—'
112
- out.push(` ${f.status.padEnd(12)} ${f.field.padEnd(18)} (${f.ownership}, ${dir_})`)
113
- }
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
- }
126
- process.stdout.write(out.join('\n') + '\n')
104
+ // `spec-sync record <spec>` — write the last-pushed snapshot from the CURRENT
105
+ // files. The skill calls this AFTER applying the plan and stamping new ids.
106
+ function specSyncRecord(dir, config, specArg, out) {
107
+ const snapshotDir = resolveOrExit(specArg, dir, out)
108
+ if (!snapshotDir) return
109
+ const identifier = specIdentifier(snapshotDir, config)
110
+ const file = recordPush({ dir, snapshotDir, identifier, config })
111
+ out.write(`spec-sync record: snapshot written → ${path.relative(dir, file)}\n`)
127
112
  }
128
113
 
129
- // The linked Linear project id for a spec (frontmatter linear_project_id), else
130
- // its identifier enough for the file adapter / a single-project remote file.
131
- function specProjectId(snapshotDir, config) {
132
- try {
133
- const { frontmatter } = readSnapshot(snapshotDir, config)
134
- if (frontmatter.linear_project_id) return String(frontmatter.linear_project_id)
135
- if (frontmatter.linear_identifier) return String(frontmatter.linear_identifier)
136
- } catch {
137
- /* fall through */
114
+ // `spec-sync status <spec> [--remote file] [--workspace-states file]` read-only
115
+ // drift report. Never writes. Reports: (a) whether the spec changed since the last
116
+ // push (there is something to push), and (b) with --remote, whether Linear's
117
+ // workflow-state differs from the spec's. With --workspace-states, validates the
118
+ // configured state names and fails loudly on a typo Linear would silently no-op.
119
+ function specSyncStatus(dir, config, specArg, flags, out) {
120
+ const snapshotDir = resolveOrExit(specArg, dir, out)
121
+ if (!snapshotDir) return
122
+ const identifier = specIdentifier(snapshotDir, config)
123
+ const lines = [`spec-sync status: ${identifier}`]
124
+
125
+ if (flags.workspaceStates && fs.existsSync(flags.workspaceStates)) {
126
+ const names = JSON.parse(fs.readFileSync(flags.workspaceStates, 'utf-8'))
127
+ const missing = validateStates(config, Array.isArray(names) ? names : [])
128
+ if (missing.length) {
129
+ out.write(
130
+ `spec-sync status: ERROR — configured state name(s) not in the workspace: ${missing.join(', ')}. ` +
131
+ `Linear silently ignores an unknown project status; fix specs/.core/linear.config.json.\n`,
132
+ )
133
+ return 1
134
+ }
135
+ lines.push(' states: all configured names exist in the workspace')
138
136
  }
139
- return path.basename(snapshotDir)
140
- }
141
137
 
142
- // A file-backed MCP adapter: reads the remote Project projection from a JSON file
143
- // and (on push) writes the merged result to `outPath` (default: the same file).
144
- // This lets `spec-sync push|pull` run the engine deterministically from the CLI /
145
- // CI. Live MCP-backed sync goes through the /spec-push · /spec-pull skills, which
146
- // supply the real adapter. `stamp` bumps updatedAt on write.
147
- function fileAdapter(remotePath, outPath, stamp) {
148
- const readRemote = () => JSON.parse(fs.readFileSync(remotePath, 'utf-8'))
149
- return {
150
- async readProject() {
151
- return fs.existsSync(remotePath) ? readRemote() : null
152
- },
153
- async updateProject(id, updates) {
154
- const merged = { ...readRemote(), ...updates, updatedAt: `${stamp}-pushed` }
155
- if (outPath) fs.writeFileSync(outPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8')
156
- return merged
157
- },
138
+ const projection = projectionOf(snapshotDir, config)
139
+ const snapshot = readBase(dir, identifier, config)
140
+ const plan = planChanges(projection, snapshot)
141
+ if (!snapshot) lines.push(' push: never pushed everything is pending')
142
+ else if (isEmptyPlan(plan)) lines.push(' push: up to date nothing changed since the last push')
143
+ else {
144
+ const n = plan.milestones.create.length + plan.issues.create.length
145
+ const u = plan.milestones.update.length + plan.issues.update.length
146
+ lines.push(` push: pending — ${n} to create, ${u} to update${plan.project ? ', project changed' : ''}`)
158
147
  }
159
- }
160
148
 
161
- // Print a git-like summary of a pull/push engine result.
162
- function printSyncResult(kind, result) {
163
- const out = []
164
- if (result.ok === false && !result.blocked) {
165
- out.push(`spec-sync ${kind}: error ${result.error}`)
166
- } else if (result.blocked) {
167
- out.push(`spec-sync ${kind}: refused — ${result.message}`)
168
- } else {
169
- out.push(`spec-sync ${kind}: ok`)
170
- if (kind === 'pull') {
171
- const keyedApplied = result.keyedApplied || []
172
- const keyedCreated = result.keyedCreated || []
173
- const keyedReported = result.keyedReported || []
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)`)
178
- if (result.deferred.length) out.push(` deferred: ${result.deferred.join(', ')} (body write-back — manual)`)
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
- }
149
+ if (flags.remote && fs.existsSync(flags.remote)) {
150
+ const remote = JSON.parse(fs.readFileSync(flags.remote, 'utf-8'))
151
+ const rState = remoteWorkflowState(remote, config)
152
+ const lState = projection.status
153
+ if (rState && lState && rState !== lState) {
154
+ lines.push(` drift: Linear workflow-state is "${rState}" but the spec is "${lState}" (repo wins on next push)`)
188
155
  } else {
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)`)
196
- if (result.skipped && result.skipped.length) out.push(` skipped: ${result.skipped.join(', ')} (not pushable)`)
197
- if (result.note) out.push(` ${result.note}`)
156
+ lines.push(' drift: none — Linear workflow-state matches the spec')
198
157
  }
199
- if (result.backupPath) out.push(` backup: ${result.backupPath}`)
200
- if (result.basePath) out.push(` base: ${result.basePath}`)
201
158
  }
202
- process.stdout.write(out.join('\n') + '\n')
203
- }
204
159
 
205
- // `spec-sync push|pull <spec> [--force] [--remote file] [--out file]`.
206
- async function specSyncPushPull(kind, dir, config, specArg, flags) {
207
- if (!specArg) {
208
- process.stdout.write(`Usage: skitterspec spec-sync ${kind} <spec> [--force] [--remote file] [--out file]\n`)
209
- return
210
- }
211
- const snapshotDir = resolveSnapshotDir(specArg, dir)
212
- if (!snapshotDir) {
213
- process.stdout.write(`spec-sync: spec not found: ${specArg}\n`)
214
- return
215
- }
216
- if (!flags.remote) {
217
- process.stdout.write(
218
- `spec-sync ${kind}: live Linear sync runs through the /spec-${kind} skill, which ` +
219
- 'connects the Linear MCP server.\n' +
220
- `For a local run, pass --remote <project.json> (a Linear Project projection).\n`,
221
- )
222
- return
223
- }
224
- const identifier = specIdentifier(snapshotDir, config)
225
- const projectId = specProjectId(snapshotDir, config)
226
- const stamp = compactTimestamp()
227
- const adapter = fileAdapter(flags.remote, flags.out, stamp)
228
- const run = kind === 'pull' ? pull : push
229
- const result = await run({
230
- dir,
231
- snapshotDir,
232
- identifier,
233
- projectId,
234
- adapter,
235
- config,
236
- force: flags.force,
237
- timestamp: new Date().toISOString(),
238
- })
239
- printSyncResult(kind, result)
160
+ out.write(lines.join('\n') + '\n')
161
+ return 0
240
162
  }
241
163
 
242
- // Dispatch `skitterspec spec-sync <sub> [spec] [flags]`. No-ops with a clear
243
- // message when Linear sync isn't enabled (no specs/.core/linear.config.json).
244
- async function specSync(rest) {
164
+ async function specSync(rest, io = {}) {
165
+ const out = io.out || process.stdout
245
166
  const [sub, ...args] = rest
246
- let dir = process.cwd()
167
+ let dir = io.cwd || process.cwd()
247
168
  const positional = []
248
- const flags = { force: false, remote: null, out: null }
169
+ const flags = { json: false, remote: null, workspaceStates: null }
249
170
  for (let i = 0; i < args.length; i++) {
250
171
  if (args[i] === '--dir') dir = path.resolve(args[++i])
251
- else if (args[i] === '--force') flags.force = true
172
+ else if (args[i] === '--json') flags.json = true
252
173
  else if (args[i] === '--remote') flags.remote = path.resolve(args[++i])
253
- else if (args[i] === '--out') flags.out = path.resolve(args[++i])
174
+ else if (args[i] === '--workspace-states') flags.workspaceStates = path.resolve(args[++i])
254
175
  else positional.push(args[i])
255
176
  }
256
177
  dir = path.resolve(dir)
257
178
 
258
179
  const { config, present } = loadLinearConfig(dir)
259
180
  if (!present) {
260
- process.stdout.write(
181
+ out.write(
261
182
  'spec-sync: Linear sync not enabled (no specs/.core/linear.config.json).\n' +
262
183
  'Opt in by copying specs/.core/linear.config.json.example → linear.config.json.\n',
263
184
  )
264
- return
185
+ return 0
265
186
  }
266
187
 
267
188
  switch (sub) {
268
189
  case 'normalize':
269
- specSyncNormalize(dir, config, positional[0])
270
- break
271
- case 'status':
272
- specSyncStatus(dir, config, positional[0], flags)
273
- break
274
- case 'pull':
275
- await specSyncPushPull('pull', dir, config, positional[0], flags)
276
- break
190
+ specSyncNormalize(dir, config, positional[0], out)
191
+ return 0
277
192
  case 'push':
278
- await specSyncPushPull('push', dir, config, positional[0], flags)
279
- break
193
+ specSyncPush(dir, config, positional[0], flags, out)
194
+ return 0
195
+ case 'record':
196
+ specSyncRecord(dir, config, positional[0], out)
197
+ return 0
198
+ case 'status':
199
+ return specSyncStatus(dir, config, positional[0], flags, out) || 0
280
200
  default:
281
- process.stdout.write(
282
- 'Usage: skitterspec spec-sync <normalize|status|pull|push> <spec> [--force] [--remote file] [--out file]\n',
283
- )
201
+ out.write('Usage: skitterspec spec-sync <normalize|push|record|status> <spec> [--json] [--remote file] [--workspace-states file]\n')
202
+ return 0
284
203
  }
285
204
  }
286
205
 
@@ -39,11 +39,15 @@ const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
39
39
  const DEFAULT_CONFIG = Object.freeze({
40
40
  linear: Object.freeze({ teamKey: '', teamId: '', initiativeId: '' }),
41
41
  mapping: Object.freeze({ specFolder: 'project', phases: 'milestone', tasks: 'issue' }),
42
+ // Linear PROJECT statuses (mapping.specFolder is `project`) — NOT issue-status
43
+ // names. Linear silently no-ops save_project on an unknown status (200,
44
+ // unchanged), so these must match the workspace exactly; `validateStates`
45
+ // checks them at push/status time.
42
46
  states: Object.freeze({
43
47
  backlog: 'Backlog',
44
48
  'in-progress': 'In Progress',
45
- complete: 'Done',
46
- cancelled: 'Cancelled',
49
+ complete: 'Completed',
50
+ cancelled: 'Canceled',
47
51
  }),
48
52
  snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
49
53
  branch: Object.freeze({ pattern: '{type}/{slug}' }),
@@ -56,11 +60,15 @@ const DEFAULT_CONFIG = Object.freeze({
56
60
  // acceptance-criteria and task detail still travel *inside* `description` — a
57
61
  // separate milestone/issue round-trip is a future extension (add the fields
58
62
  // here to opt a workspace in). Any key you add joins the compared set.
63
+ // One-way (repo → Linear): every field is repo-owned and pushed. The
64
+ // `push` marker is kept for the projection field-set; there is no pull.
59
65
  fieldOwnership: Object.freeze({
60
- description: 'both',
61
- workflowState: 'pull',
62
- priority: 'pull',
63
- labels: 'pull',
66
+ description: 'push',
67
+ milestones: 'push',
68
+ tasks: 'push',
69
+ workflowState: 'push',
70
+ priority: 'push',
71
+ labels: 'push',
64
72
  }),
65
73
  localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
66
74
  // Fields that are keyed collections (arrays of objects with a stable id),
@@ -1,35 +1,41 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * Provider-neutral spec↔tracker sync engine.
4
+ * Provider-neutral one-way sync engine (repo → tracker).
5
5
  *
6
- * Every function here is parameterised by a plain `config` object and an injected
7
- * `adapter` it knows nothing about any specific tracker or provider. A
8
- * provider package supplies the config shape, the frontmatter key mapping, and the
9
- * adapter that talks to its API; this core does the three-way merge.
6
+ * Every function is parameterised by a plain `config` object it knows nothing
7
+ * about any specific tracker. The repo is the source of truth: the engine builds
8
+ * a local projection, diffs it against a committed last-pushed snapshot
9
+ * (`planChanges`), and returns a create/update plan the provider skill applies
10
+ * over its API. No remote content is read or merged.
10
11
  */
11
12
 
12
- const { normalizeLocal, normalizeRemote, readSnapshot } = require('./src/normalize.js')
13
- const { classify, hashField, stableStringify } = require('./src/compare.js')
14
- const { readBase, writeBase, backup } = require('./src/base.js')
15
- const { pull } = require('./src/pull.js')
16
- const { push } = require('./src/push.js')
17
- const { writeFrontmatter } = require('./src/write.js')
18
- const { frontmatterPatchFor, localWorkflowState } = require('./src/apply.js')
13
+ const { normalizeLocal, readSnapshot, remoteWorkflowState, titleFromText, validateStates } = require('./src/normalize.js')
14
+ const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
15
+ const { readBase, writeBase } = require('./src/base.js')
16
+ const { push, recordPush, projectionOf } = require('./src/push.js')
17
+ const { writeFrontmatter, stampMilestoneId, stampIssueId, findPhaseFileByTitle } = require('./src/write.js')
18
+ const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
19
 
20
20
  module.exports = {
21
21
  normalizeLocal,
22
- normalizeRemote,
23
22
  readSnapshot,
24
- classify,
23
+ projectionOf,
24
+ planChanges,
25
+ snapshotOf,
26
+ isEmptyPlan,
27
+ push,
28
+ recordPush,
29
+ remoteWorkflowState,
30
+ titleFromText,
31
+ validateStates,
25
32
  hashField,
26
33
  stableStringify,
27
34
  readBase,
28
35
  writeBase,
29
- backup,
30
- pull,
31
- push,
32
36
  writeFrontmatter,
33
- frontmatterPatchFor,
34
- localWorkflowState,
37
+ stampMilestoneId,
38
+ stampIssueId,
39
+ findPhaseFileByTitle,
40
+ sanitizeSpecMarkdown,
35
41
  }
@@ -1,17 +1,15 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * The committed base sidecar + the backup-before-force reflog.
4
+ * The committed **last-pushed snapshot** sidecar.
5
5
  *
6
- * The base is the last-synced snapshot per spec, stored at
7
- * `{sync.baseDir}/{identifier}.base.json` and committed so each worktree carries
8
- * its own base and the three-way divergence check stays accurate. After any
9
- * successful pull/push/force the engine rewrites it (`writeBase`).
10
- *
11
- * `backup(side, …)` lands the about-to-be-clobbered side under `{sync.backupDir}`
12
- * BEFORE a `--force` overwrites it force never destroys without first writing a
13
- * copy. The filename carries a caller-supplied timestamp (the engine takes no
14
- * Date.now(), for reproducible tests) and is made collision-safe with a counter.
6
+ * One-way sync (repo Linear) records what it last pushed per spec at
7
+ * `{sync.baseDir}/{identifier}.base.json`, committed so each worktree carries its
8
+ * own snapshot. The snapshot is a set of content hashes
9
+ * (`{ project, milestones: {id:hash}, issues: {id:hash} }`, see compare.js
10
+ * `snapshotOf`); `planChanges` diffs the current projection against it to decide
11
+ * create/update/skip no remote read. After a successful push the engine
12
+ * rewrites it (`writeBase`). Generic JSON read/write; the shape is the caller's.
15
13
  */
16
14
 
17
15
  const fs = require('node:fs')