@skitterbyte/skitterspec 0.1.0 → 1.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.
Files changed (40) hide show
  1. package/README.md +97 -2
  2. package/assets/claude-md-section.md +11 -0
  3. package/assets/core/env.config.json.example +24 -0
  4. package/assets/core/env.config.md +83 -0
  5. package/assets/core/linear.config.json.example +39 -0
  6. package/assets/core/linear.config.md +121 -0
  7. package/assets/rules/spec-planning.md +29 -11
  8. package/assets/skills/spec/SKILL.md +61 -13
  9. package/assets/skills/spec-bug/SKILL.md +0 -4
  10. package/assets/skills/spec-cancel/SKILL.md +8 -3
  11. package/assets/skills/spec-complete/SKILL.md +10 -10
  12. package/assets/skills/spec-env/SKILL.md +57 -0
  13. package/assets/skills/spec-env-down/SKILL.md +56 -0
  14. package/assets/skills/spec-go/SKILL.md +39 -3
  15. package/assets/skills/spec-init/SKILL.md +0 -8
  16. package/assets/skills/spec-pull/SKILL.md +46 -0
  17. package/assets/skills/spec-push/SKILL.md +53 -0
  18. package/assets/skills/spec-ready/SKILL.md +0 -2
  19. package/assets/skills/spec-review/SKILL.md +2 -2
  20. package/assets/skills/spec-status/SKILL.md +46 -0
  21. package/bin/skitterspec.js +0 -0
  22. package/package.json +6 -6
  23. package/src/cli.js +497 -2
  24. package/src/env/config.js +152 -0
  25. package/src/env/provision.js +76 -0
  26. package/src/env/registry.js +95 -0
  27. package/src/env/render.js +26 -0
  28. package/src/env/resolve.js +184 -0
  29. package/src/env/teardown.js +94 -0
  30. package/src/init.js +82 -27
  31. package/src/prompts.js +23 -12
  32. package/src/sync/apply.js +66 -0
  33. package/src/sync/base.js +83 -0
  34. package/src/sync/compare.js +99 -0
  35. package/src/sync/config.js +198 -0
  36. package/src/sync/mcp.js +112 -0
  37. package/src/sync/normalize.js +249 -0
  38. package/src/sync/pull.js +84 -0
  39. package/src/sync/push.js +106 -0
  40. package/src/sync/write.js +86 -0
package/src/init.js CHANGED
@@ -25,6 +25,11 @@ const SKILLS = [
25
25
  'spec-complete',
26
26
  'spec-cancel',
27
27
  'spec-init',
28
+ 'spec-env',
29
+ 'spec-env-down',
30
+ 'spec-status',
31
+ 'spec-pull',
32
+ 'spec-push',
28
33
  'commit',
29
34
  ]
30
35
 
@@ -32,25 +37,26 @@ const RULES = ['spec-planning.md', 'commit-messages.md']
32
37
 
33
38
  const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled']
34
39
 
35
- const BACKLOG_INDEX = `<!-- Maintained by the spec skills — do not hand-edit. -->
36
- <!-- Live view of the backlog. /spec prepends a row; /spec-ready updates status; -->
37
- <!-- /spec-go and /spec-cancel remove the row when the spec leaves the backlog. -->
38
-
39
- | Added | Spec | Type | Status |
40
- |-------|------|------|--------|
41
- `
42
-
43
- const COMPLETE_INDEX = `<!-- Maintained by the spec skills — do not hand-edit. -->
44
- <!-- Append-only completion log, newest first. /spec-complete prepends a row. -->
45
-
46
- | Completed | Spec | Type |
47
- |-----------|------|------|
48
- `
40
+ // Opt-in per-spec isolation config, scaffolded into specs/.core/ as templates
41
+ // the consumer copies (env.config.json.example env.config.json to adopt).
42
+ const CORE_FILES = [
43
+ path.join('core', 'env.config.json.example'),
44
+ path.join('core', 'env.config.md'),
45
+ path.join('core', 'linear.config.json.example'),
46
+ path.join('core', 'linear.config.md'),
47
+ ]
49
48
 
50
49
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
51
50
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
52
51
 
53
- const report = { created: [], updated: [], skipped: [], warnings: [] }
52
+ const report = { created: [], updated: [], skipped: [], removed: [], warnings: [] }
53
+
54
+ // Folder index files scaffolded by earlier versions, now retired. `init`/`update`
55
+ // deletes any left behind so upgrading projects don't keep stale caches.
56
+ const RETIRED_FILES = [
57
+ path.join('specs', 'backlog', '00-index.md'),
58
+ path.join('specs', 'complete', '00-index.md'),
59
+ ]
54
60
 
55
61
  function rel(dir, p) {
56
62
  return path.relative(dir, p) || '.'
@@ -107,17 +113,14 @@ function installRule(dir, opts) {
107
113
  }
108
114
  }
109
115
 
110
- // backlog + complete are kept in git by their 00-index.md file, so they need no .gitkeep
111
- const FOLDERS_WITH_INDEX = new Set(['backlog', 'complete'])
112
-
113
116
  function installFolders(dir) {
114
117
  for (const folder of SPEC_FOLDERS) {
115
118
  const abs = path.join(dir, 'specs', folder)
116
119
  if (!fs.existsSync(abs)) {
117
120
  ensureDir(abs)
118
121
  report.created.push(rel(dir, abs) + '/')
119
- // keep otherwise-empty folders in git (those without an 00-index.md)
120
- if (!FOLDERS_WITH_INDEX.has(folder) && !fs.readdirSync(abs).length) {
122
+ // keep otherwise-empty folders in git
123
+ if (!fs.readdirSync(abs).length) {
121
124
  fs.writeFileSync(path.join(abs, '.gitkeep'), '')
122
125
  }
123
126
  } else {
@@ -126,9 +129,48 @@ function installFolders(dir) {
126
129
  }
127
130
  }
128
131
 
129
- function installIndexes(dir, opts) {
130
- writeFile(dir, path.join(dir, 'specs', 'backlog', '00-index.md'), BACKLOG_INDEX, opts)
131
- writeFile(dir, path.join(dir, 'specs', 'complete', '00-index.md'), COMPLETE_INDEX, opts)
132
+ // Delete retired folder index files left by earlier versions. If removing one
133
+ // empties its bucket, drop a `.gitkeep` so the folder stays tracked in git.
134
+ function removeRetiredFiles(dir) {
135
+ for (const relPath of RETIRED_FILES) {
136
+ const target = path.join(dir, relPath)
137
+ if (!fs.existsSync(target)) continue
138
+ fs.unlinkSync(target)
139
+ report.removed.push(rel(dir, target))
140
+ const folder = path.dirname(target)
141
+ if (fs.existsSync(folder) && !fs.readdirSync(folder).length) {
142
+ fs.writeFileSync(path.join(folder, '.gitkeep'), '')
143
+ }
144
+ }
145
+ }
146
+
147
+ // Scaffold the opt-in isolation templates into specs/.core/ (the example config
148
+ // + its field docs). Copied, not activated: the feature stays off until the
149
+ // consumer copies env.config.json.example → env.config.json.
150
+ function installCore(dir, opts) {
151
+ for (const asset of CORE_FILES) {
152
+ copyAsset(
153
+ dir,
154
+ asset,
155
+ path.join(dir, 'specs', '.core', path.basename(asset)),
156
+ opts,
157
+ )
158
+ }
159
+ }
160
+
161
+ // Activate opt-in per-spec isolation: write specs/.core/env.config.json from the
162
+ // example asset so /spec-go provisions a worktree for every in-progress spec.
163
+ // Only called when the operator opts in, and never on `update` (adopting isolation
164
+ // is a deliberate choice, not something a re-sync flips on). Idempotent: writeFile
165
+ // never clobbers an existing env.config.json without --force.
166
+ function installIsolation(dir, { enabled }, opts) {
167
+ if (!enabled) return
168
+ copyAsset(
169
+ dir,
170
+ path.join('core', 'env.config.json.example'),
171
+ path.join(dir, 'specs', '.core', 'env.config.json'),
172
+ opts,
173
+ )
132
174
  }
133
175
 
134
176
  function installClaudeMd(dir, { mode }) {
@@ -303,30 +345,43 @@ function printReport(dir, mode) {
303
345
  process.stdout.write(`\nskitterspec ${mode} → ${dir}\n`)
304
346
  line('created', report.created)
305
347
  line('updated', report.updated)
348
+ line('removed', report.removed)
306
349
  line('unchanged', report.skipped)
307
350
  if (report.warnings.length) {
308
351
  process.stdout.write('\nwarnings:\n')
309
352
  for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
310
353
  }
354
+ const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
355
+ const isolationNote = isolationOn
356
+ ? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
357
+ ' at /spec-go (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
358
+ : 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
359
+ ' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
311
360
  process.stdout.write(
312
361
  '\nDone. Skills resolve as /spec, /spec-ready, /spec-go, /spec-complete,' +
313
- ' /spec-cancel, /spec-bug, /spec-init, /commit.\n' +
362
+ ' /spec-cancel, /spec-bug, /spec-init, /spec-env, /spec-env-down,' +
363
+ ' /spec-status, /spec-pull, /spec-push, /commit.\n' +
314
364
  'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
315
- " project's stack, then run /spec.\n",
365
+ " project's stack, then run /spec.\n" +
366
+ isolationNote,
316
367
  )
317
368
  }
318
369
 
319
- async function init({ dir, force, claudeMd, mode, release }) {
370
+ async function init({ dir, force, claudeMd, mode, release, isolation }) {
320
371
  if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
321
372
  report.created.length = 0
322
373
  report.updated.length = 0
323
374
  report.skipped.length = 0
375
+ report.removed.length = 0
324
376
  report.warnings.length = 0
325
377
 
326
378
  installSkills(dir, { force })
327
379
  installRule(dir, { force })
328
380
  installFolders(dir)
329
- installIndexes(dir, { force })
381
+ removeRetiredFiles(dir)
382
+ installCore(dir, { force })
383
+ // Adopting isolation writes the live env.config.json — init only, never update.
384
+ if (mode !== 'update') installIsolation(dir, { enabled: isolation }, { force })
330
385
  if (claudeMd) installClaudeMd(dir, { mode })
331
386
 
332
387
  // Release tooling. The CLI resolves `release` from flags/prompts; when called
package/src/prompts.js CHANGED
@@ -7,10 +7,11 @@
7
7
  * test suite never imports the interactive UI.
8
8
  *
9
9
  * `seed` is the resolved release config (existing file merged with any flags),
10
- * used to pre-fill every answer. Returns a release config of the same shape.
10
+ * used to pre-fill every answer. `isolationSeed` pre-fills the per-spec isolation
11
+ * question. Returns `{ release, isolation }`.
11
12
  */
12
13
 
13
- async function promptSetup({ seed, pkgExists }) {
14
+ async function promptSetup({ seed, pkgExists, isolationSeed = false }) {
14
15
  const prompts = require('prompts')
15
16
 
16
17
  let cancelled = false
@@ -61,21 +62,31 @@ async function promptSetup({ seed, pkgExists }) {
61
62
  })
62
63
  }
63
64
 
65
+ questions.push({
66
+ type: 'confirm',
67
+ name: 'isolation',
68
+ message: 'Enable per-spec isolation — a git worktree per spec?',
69
+ initial: isolationSeed,
70
+ })
71
+
64
72
  const ans = await prompts(questions, { onCancel })
65
73
  if (cancelled) throw new Error('Setup cancelled')
66
74
 
67
75
  return {
68
- changelog: {
69
- enabled: ans.changelogEnabled,
70
- file: ans.changelogFile || seed.changelog.file,
71
- },
72
- releases: {
73
- enabled: ans.releasesEnabled,
74
- file: ans.releasesFile || seed.releases.file,
75
- productName: ans.productName || seed.releases.productName,
76
- scopeAreas: seed.releases.scopeAreas,
76
+ release: {
77
+ changelog: {
78
+ enabled: ans.changelogEnabled,
79
+ file: ans.changelogFile || seed.changelog.file,
80
+ },
81
+ releases: {
82
+ enabled: ans.releasesEnabled,
83
+ file: ans.releasesFile || seed.releases.file,
84
+ productName: ans.productName || seed.releases.productName,
85
+ scopeAreas: seed.releases.scopeAreas,
86
+ },
87
+ versionHook: pkgExists ? Boolean(ans.versionHook) : seed.versionHook,
77
88
  },
78
- versionHook: pkgExists ? Boolean(ans.versionHook) : seed.versionHook,
89
+ isolation: Boolean(ans.isolation),
79
90
  }
80
91
  }
81
92
 
@@ -0,0 +1,66 @@
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 (Linear 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: "Linear Name" }) → { "linear 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 Linear 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
+ }
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The committed base sidecar + the backup-before-force reflog.
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.
15
+ */
16
+
17
+ const fs = require('node:fs')
18
+ const path = require('node:path')
19
+
20
+ function baseFile(dir, identifier, config) {
21
+ return path.join(dir, config.sync.baseDir, `${identifier}.base.json`)
22
+ }
23
+
24
+ /**
25
+ * Read a spec's committed base. Returns the parsed object, or `null` when no base
26
+ * exists yet (never synced) — the compare treats null as "no prior state".
27
+ */
28
+ function readBase(dir, identifier, config) {
29
+ const file = baseFile(dir, identifier, config)
30
+ let raw
31
+ try {
32
+ raw = fs.readFileSync(file, 'utf-8')
33
+ } catch (error) {
34
+ if (error.code === 'ENOENT') return null
35
+ throw error
36
+ }
37
+ try {
38
+ return JSON.parse(raw)
39
+ } catch (error) {
40
+ throw new Error(`Invalid base ${path.relative(dir, file)}: ${error.message}`)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Rewrite a spec's committed base with the freshly-synced field set. Creates
46
+ * `{sync.baseDir}` if needed. Returns the absolute path written.
47
+ */
48
+ function writeBase(dir, identifier, config, data) {
49
+ const file = baseFile(dir, identifier, config)
50
+ fs.mkdirSync(path.dirname(file), { recursive: true })
51
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
52
+ return file
53
+ }
54
+
55
+ /**
56
+ * Back up the about-to-be-clobbered `side` ('local' | 'remote') into
57
+ * `{sync.backupDir}` before a --force. `timestamp` is caller-supplied (the engine
58
+ * never reads the clock); the name is made collision-safe with a `-N` counter.
59
+ * Returns the absolute path written, or null when `data` is nullish (nothing to
60
+ * back up — e.g. forcing a pull with no prior remote).
61
+ */
62
+ function backup(side, dir, identifier, config, { timestamp, data }) {
63
+ if (data == null) return null
64
+ const backupRoot = path.join(dir, config.sync.backupDir)
65
+ fs.mkdirSync(backupRoot, { recursive: true })
66
+
67
+ const stem = `${identifier}.${side}.${timestamp}`
68
+ let file = path.join(backupRoot, `${stem}.json`)
69
+ let n = 1
70
+ while (fs.existsSync(file)) {
71
+ file = path.join(backupRoot, `${stem}-${n}.json`)
72
+ n += 1
73
+ }
74
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8')
75
+ return file
76
+ }
77
+
78
+ module.exports = {
79
+ readBase,
80
+ writeBase,
81
+ backup,
82
+ baseFile,
83
+ }
@@ -0,0 +1,99 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The three-way compare at the heart of the hybrid sync.
5
+ *
6
+ * `classify(local, remote, base, config)` compares each configured field across
7
+ * the local snapshot, the remote (Linear) projection, and the committed base
8
+ * (the last-synced state). Per field it returns a raw three-way `status`
9
+ * (unchanged / local-only / remote-only / conflict), then collapses it through
10
+ * the field's ownership (`both|pull|push`) into effective `pushable` / `pullable`
11
+ * flags. Ownership is what makes most "both sides differ" cases *not* a real
12
+ * conflict:
13
+ * - a `pull` field never pushes (Linear wins) → conflict collapses to remote-only
14
+ * - a `push` field never pulls (repo wins) → conflict collapses to local-only
15
+ * - only a `both` field where both sides moved off base is a true `conflict`.
16
+ *
17
+ * Pure and deterministic: field identity is a stable content hash (sorted-key
18
+ * JSON → SHA-1), so `null`, `undefined`, and a missing base all compare equal,
19
+ * and object key order never causes a false diff. No Date.now()/Math.random().
20
+ */
21
+
22
+ const { createHash } = require('node:crypto')
23
+
24
+ // Deterministic JSON: object keys sorted recursively; array order preserved
25
+ // (order is meaningful for milestones/tasks). undefined normalises to null.
26
+ function stableStringify(value) {
27
+ if (value === undefined || value === null) return 'null'
28
+ if (Array.isArray(value)) {
29
+ return '[' + value.map(stableStringify).join(',') + ']'
30
+ }
31
+ if (typeof value === 'object') {
32
+ const keys = Object.keys(value).sort()
33
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'
34
+ }
35
+ return JSON.stringify(value)
36
+ }
37
+
38
+ // Stable content hash of a single field value.
39
+ function hashField(value) {
40
+ return createHash('sha1').update(stableStringify(value)).digest('hex')
41
+ }
42
+
43
+ // Raw three-way status from the three hashes.
44
+ function rawStatus(localH, remoteH, baseH) {
45
+ const localChanged = localH !== baseH
46
+ const remoteChanged = remoteH !== baseH
47
+ if (!localChanged && !remoteChanged) return 'unchanged'
48
+ if (localChanged && !remoteChanged) return 'local-only'
49
+ if (!localChanged && remoteChanged) return 'remote-only'
50
+ // both moved off base — but they may have converged on the same value.
51
+ if (localH === remoteH) return 'unchanged'
52
+ return 'conflict'
53
+ }
54
+
55
+ // Collapse the raw status through ownership into an effective status + flags.
56
+ function collapse(raw, ownership) {
57
+ const canPush = ownership === 'both' || ownership === 'push'
58
+ const canPull = ownership === 'both' || ownership === 'pull'
59
+
60
+ if (raw === 'unchanged') return { status: 'unchanged', pushable: false, pullable: false }
61
+ if (raw === 'local-only') return { status: 'local-only', pushable: canPush, pullable: false }
62
+ if (raw === 'remote-only') return { status: 'remote-only', pushable: false, pullable: canPull }
63
+
64
+ // conflict: both sides diverged off base.
65
+ if (ownership === 'push') return { status: 'local-only', pushable: true, pullable: false }
66
+ if (ownership === 'pull') return { status: 'remote-only', pushable: false, pullable: true }
67
+ return { status: 'conflict', pushable: true, pullable: true }
68
+ }
69
+
70
+ /**
71
+ * Classify every field in `config.sync.fieldOwnership`.
72
+ *
73
+ * @param {object} local normalized local snapshot (normalizeLocal output)
74
+ * @param {object} remote normalized remote projection (normalizeRemote output)
75
+ * @param {object|null} base the committed base (same shape) or null (never synced)
76
+ * @returns {Array<{field, ownership, raw, status, pushable, pullable}>}
77
+ * one entry per configured field, in config order.
78
+ */
79
+ function classify(local, remote, base, config) {
80
+ const ownership = config.sync.fieldOwnership
81
+ const baseObj = base || {}
82
+ return Object.keys(ownership).map((field) => {
83
+ const own = ownership[field]
84
+ const localH = hashField(local ? local[field] : null)
85
+ const remoteH = hashField(remote ? remote[field] : null)
86
+ const baseH = hashField(field in baseObj ? baseObj[field] : null)
87
+ const raw = rawStatus(localH, remoteH, baseH)
88
+ const { status, pushable, pullable } = collapse(raw, own)
89
+ return { field, ownership: own, raw, status, pushable, pullable }
90
+ })
91
+ }
92
+
93
+ module.exports = {
94
+ classify,
95
+ hashField,
96
+ stableStringify,
97
+ rawStatus,
98
+ collapse,
99
+ }
@@ -0,0 +1,198 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Config loader for the Linear hybrid-sync feature (`/spec-status`, `/spec-pull`,
5
+ * `/spec-push` and the Linear-aware paths of `/spec` and `/spec-go`).
6
+ *
7
+ * Reads `specs/.core/linear.config.json` from the project root and normalises it
8
+ * over frozen defaults. The feature is strictly opt-in: when the file is absent
9
+ * the loader never throws — it returns the defaults with `present:false`, which
10
+ * every caller treats as "Linear sync unused".
11
+ *
12
+ * Mirrors the shape/idiom of `src/env/config.js` (frozen defaults, merge known
13
+ * keys only, forward-compatible on unknown keys). Zero-dependency. The one place
14
+ * it is stricter: a `sync.fieldOwnership` value outside `both|pull|push` is a
15
+ * hard error — the engine's whole safety model rests on those enums.
16
+ *
17
+ * Shape (see assets/core/linear.config.md for field docs):
18
+ * {
19
+ * linear: { teamKey, teamId, initiativeId },
20
+ * mapping: { specFolder, phases, tasks },
21
+ * states: { backlog, "in-progress", complete, cancelled },
22
+ * snapshot: { overviewFile },
23
+ * branch: { pattern },
24
+ * sync: {
25
+ * baseDir, backupDir,
26
+ * fieldOwnership: { <field>: "both" | "pull" | "push" },
27
+ * localOnlySections: string[]
28
+ * }
29
+ * }
30
+ */
31
+
32
+ const { readFileSync } = require('node:fs')
33
+ const { join } = require('node:path')
34
+
35
+ const CONFIG_FILE = join('specs', '.core', 'linear.config.json')
36
+
37
+ const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
38
+
39
+ const DEFAULT_CONFIG = Object.freeze({
40
+ linear: Object.freeze({ teamKey: '', teamId: '', initiativeId: '' }),
41
+ mapping: Object.freeze({ specFolder: 'project', phases: 'milestone', tasks: 'issue' }),
42
+ states: Object.freeze({
43
+ backlog: 'Backlog',
44
+ 'in-progress': 'In Progress',
45
+ complete: 'Done',
46
+ cancelled: 'Cancelled',
47
+ }),
48
+ snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
49
+ branch: Object.freeze({ pattern: '{type}/{slug}' }),
50
+ sync: Object.freeze({
51
+ baseDir: 'specs/.core/linear-base',
52
+ backupDir: 'specs/.core/linear-backups',
53
+ fieldOwnership: Object.freeze({
54
+ description: 'both',
55
+ milestones: 'both',
56
+ phaseBodies: 'both',
57
+ acceptanceCriteria: 'both',
58
+ taskBreakdown: 'both',
59
+ workflowState: 'pull',
60
+ priority: 'pull',
61
+ labels: 'pull',
62
+ }),
63
+ localOnlySections: Object.freeze(['State log', 'Changelog', 'Open questions']),
64
+ }),
65
+ })
66
+
67
+ function isObject(value) {
68
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
69
+ }
70
+
71
+ // A fresh, deeply-mutable copy of the defaults to merge onto.
72
+ function defaults() {
73
+ return {
74
+ linear: { ...DEFAULT_CONFIG.linear },
75
+ mapping: { ...DEFAULT_CONFIG.mapping },
76
+ states: { ...DEFAULT_CONFIG.states },
77
+ snapshot: { ...DEFAULT_CONFIG.snapshot },
78
+ branch: { ...DEFAULT_CONFIG.branch },
79
+ sync: {
80
+ baseDir: DEFAULT_CONFIG.sync.baseDir,
81
+ backupDir: DEFAULT_CONFIG.sync.backupDir,
82
+ fieldOwnership: { ...DEFAULT_CONFIG.sync.fieldOwnership },
83
+ localOnlySections: [...DEFAULT_CONFIG.sync.localOnlySections],
84
+ },
85
+ }
86
+ }
87
+
88
+ // Copy a typed field from parsed[key] onto base[key] when it matches `type`.
89
+ // Strings are trimmed and must be non-empty to override; `string?` may be empty.
90
+ function assign(base, parsed, key, type) {
91
+ const v = parsed[key]
92
+ if (type === 'string') {
93
+ if (typeof v === 'string' && v.trim()) base[key] = v.trim()
94
+ } else if (type === 'string?') {
95
+ if (typeof v === 'string') base[key] = v
96
+ } else if (type === 'boolean') {
97
+ if (typeof v === 'boolean') base[key] = v
98
+ }
99
+ }
100
+
101
+ // Merge (and validate) sync.fieldOwnership. Any key the caller lists joins the
102
+ // compared field set; the value MUST be one of both|pull|push.
103
+ function mergeFieldOwnership(base, parsed) {
104
+ if (!isObject(parsed)) return
105
+ for (const [field, dir] of Object.entries(parsed)) {
106
+ if (!OWNERSHIP.includes(dir)) {
107
+ throw new Error(
108
+ `Invalid ${CONFIG_FILE}: sync.fieldOwnership.${field} = ${JSON.stringify(dir)} ` +
109
+ `(expected one of ${OWNERSHIP.join('|')})`,
110
+ )
111
+ }
112
+ base[field] = dir
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Merge a parsed config over the defaults. Only known keys are copied (unknown
118
+ * keys ignored for forward-compat). Nested objects are merged field-by-field.
119
+ */
120
+ function mergeConfig(base, parsed) {
121
+ if (!isObject(parsed)) return base
122
+
123
+ if (isObject(parsed.linear)) {
124
+ assign(base.linear, parsed.linear, 'teamKey', 'string?')
125
+ assign(base.linear, parsed.linear, 'teamId', 'string?')
126
+ assign(base.linear, parsed.linear, 'initiativeId', 'string?')
127
+ }
128
+
129
+ if (isObject(parsed.mapping)) {
130
+ assign(base.mapping, parsed.mapping, 'specFolder', 'string')
131
+ assign(base.mapping, parsed.mapping, 'phases', 'string')
132
+ assign(base.mapping, parsed.mapping, 'tasks', 'string')
133
+ }
134
+
135
+ if (isObject(parsed.states)) {
136
+ for (const key of Object.keys(base.states)) {
137
+ assign(base.states, parsed.states, key, 'string')
138
+ }
139
+ }
140
+
141
+ if (isObject(parsed.snapshot)) {
142
+ assign(base.snapshot, parsed.snapshot, 'overviewFile', 'string')
143
+ }
144
+
145
+ if (isObject(parsed.branch)) {
146
+ assign(base.branch, parsed.branch, 'pattern', 'string')
147
+ }
148
+
149
+ if (isObject(parsed.sync)) {
150
+ assign(base.sync, parsed.sync, 'baseDir', 'string')
151
+ assign(base.sync, parsed.sync, 'backupDir', 'string')
152
+ mergeFieldOwnership(base.sync.fieldOwnership, parsed.sync.fieldOwnership)
153
+ if (Array.isArray(parsed.sync.localOnlySections)) {
154
+ base.sync.localOnlySections = parsed.sync.localOnlySections
155
+ .filter((s) => typeof s === 'string' && s.trim())
156
+ .map((s) => s.trim())
157
+ }
158
+ }
159
+
160
+ return base
161
+ }
162
+
163
+ /**
164
+ * Load and normalise `specs/.core/linear.config.json` from `dir` (default cwd).
165
+ * Returns `{ config, present }`:
166
+ * - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
167
+ * - present → `{ config: merged, present: true }`
168
+ * Malformed JSON or a bad `fieldOwnership` enum → throws a clear Error.
169
+ */
170
+ function loadLinearConfig(dir = process.cwd()) {
171
+ const base = defaults()
172
+ const file = join(dir, CONFIG_FILE)
173
+
174
+ let raw
175
+ try {
176
+ raw = readFileSync(file, 'utf-8')
177
+ } catch (error) {
178
+ if (error.code === 'ENOENT') return { config: base, present: false }
179
+ throw error
180
+ }
181
+
182
+ let parsed
183
+ try {
184
+ parsed = JSON.parse(raw)
185
+ } catch (error) {
186
+ throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
187
+ }
188
+
189
+ return { config: mergeConfig(base, parsed), present: true }
190
+ }
191
+
192
+ module.exports = {
193
+ loadLinearConfig,
194
+ mergeConfig,
195
+ DEFAULT_CONFIG,
196
+ CONFIG_FILE,
197
+ OWNERSHIP,
198
+ }