@skitterbyte/skitterspec-linear 10.0.1 → 10.2.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.
@@ -17,7 +17,7 @@
17
17
  * Shape (see assets/core/linear.config.md for field docs):
18
18
  * {
19
19
  * linear: { teamKey, teamId, projectId },
20
- * intake: { label, bugLabels },
20
+ * intake: { label, bugLabels, hotfixLabels },
21
21
  * mapping: { specFolder, phases, tasks },
22
22
  * states: { backlog, "in-progress", complete, cancelled },
23
23
  * snapshot: { overviewFile },
@@ -44,6 +44,40 @@ const OWNERSHIP = Object.freeze(['both', 'pull', 'push'])
44
44
  // box ticked in the tracker is overwritten by the next push.
45
45
  const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
46
46
 
47
+ // When a phase becomes a sub-issue.
48
+ // subissue — always, from the spec's first push (default)
49
+ // deferred — only once the work starts: a spec sitting in `backlog` (or
50
+ // `cancelled` without ever having started) projects the issue
51
+ // alone, so adopting sync on a long backlog costs one call per
52
+ // spec instead of one per spec PLUS one per phase. A phase that
53
+ // already carries an id keeps projecting either way — one-way sync
54
+ // has no delete, so withholding a LINKED sub-issue would freeze it
55
+ // in the tracker rather than remove it.
56
+ // inline — never: each phase becomes a SECTION of the spec issue's own
57
+ // description instead, and the `## Phases` index stays as its table
58
+ // of contents. For work nobody will pick up phase by phase — 250
59
+ // finished specs are 250 issues worth reading and 669 sub-issues
60
+ // worth nobody's attention. Keeps an already-linked phase's
61
+ // sub-issue for the same reason `deferred` does.
62
+ //
63
+ // `mapping.phases` takes one of these as a scalar (one mode for the whole repo)
64
+ // OR a map keyed by lifecycle bucket — `{ "backlog": "subissue", "complete":
65
+ // "deferred" }` — because a repo can want assignable sub-issues for work in
66
+ // flight and something else entirely for work that finished long ago. A bucket
67
+ // the map omits gets DEFAULT_PHASE_MODE, so a partial map adds an exception
68
+ // rather than silently suppressing phases everywhere it is silent.
69
+ const PHASE_MAPPINGS = Object.freeze(['subissue', 'deferred', 'inline'])
70
+ const DEFAULT_PHASE_MODE = 'subissue'
71
+
72
+ // How `spec-sync apply` reaches Linear. `api` talks to the GraphQL API directly;
73
+ // `mcp` prints the plan for the skill to apply over MCP, as it always has.
74
+ const TRANSPORTS = Object.freeze(['api', 'mcp'])
75
+
76
+ // The environment variable a Linear personal API key is read from, unless
77
+ // `auth.keyEnv` names another. The config names the VARIABLE, never the key —
78
+ // nothing secret is ever written to the repo.
79
+ const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
80
+
47
81
  const DEFAULT_CONFIG = Object.freeze({
48
82
  // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
49
83
  // first `/spec-push` offer the team's projects and pre-select this one; empty
@@ -54,14 +88,18 @@ const DEFAULT_CONFIG = Object.freeze({
54
88
  linear: Object.freeze({ teamKey: '', teamId: '', projectId: '' }),
55
89
  // Issue intake (`/spec <ISSUE-REF>`, `/spec --from-issue`). `label` is the
56
90
  // inbox filter — issues carrying it are what the web app files; `bugLabels`
57
- // route an issue to `/spec-bug` instead of `/spec`. Both empty = no inbox to
58
- // browse (a bare issue ref still works) and no bug routing.
59
- intake: Object.freeze({ label: '', bugLabels: Object.freeze([]) }),
91
+ // route an issue to `/spec-bug` instead of `/spec`, and `hotfixLabels` route it
92
+ // to `/spec-hotfix` — a bug that has to be patched on the released version, not
93
+ // fixed on main. All empty = no inbox to browse (a bare issue ref still works)
94
+ // and no routing. `hotfixLabels` wins over `bugLabels` on an issue carrying
95
+ // both: production is the more specific destination, and the cost of getting it
96
+ // wrong is asymmetric — a fix that lands only on main never reaches prod.
97
+ intake: Object.freeze({ label: '', bugLabels: Object.freeze([]), hotfixLabels: Object.freeze([]) }),
60
98
  // A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
61
99
  // synced (they live only in the repo phase files).
62
100
  // A spec is an ISSUE; each phase a SUB-ISSUE of it. `tasks` selects how the
63
101
  // phase's checkboxes reach that sub-issue's description — see TASK_MAPPINGS.
64
- mapping: Object.freeze({ specFolder: 'issue', phases: 'subissue', tasks: 'checklist' }),
102
+ mapping: Object.freeze({ specFolder: 'issue', phases: DEFAULT_PHASE_MODE, tasks: 'checklist' }),
65
103
  // Linear ISSUE workflow-state names — the spec issue's state (from the folder
66
104
  // bucket) and each sub-issue's state (from the phase emoji) both map through
67
105
  // this one table. They must match the workspace's issue states exactly;
@@ -74,6 +112,12 @@ const DEFAULT_CONFIG = Object.freeze({
74
112
  }),
75
113
  snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
76
114
  branch: Object.freeze({ pattern: '{type}/{slug}' }),
115
+ // `keyEnv` names the env var holding the personal API key. It is a NAME, not a
116
+ // key: putting the secret itself here would commit it.
117
+ auth: Object.freeze({ keyEnv: DEFAULT_KEY_ENV }),
118
+ // `transport` is the default for `spec-sync apply --via`. Empty means "decide
119
+ // at run time": use the API when a key is present, MCP when it isn't.
120
+ apply: Object.freeze({ transport: '' }),
77
121
  sync: Object.freeze({
78
122
  baseDir: 'specs/.core/linear-base',
79
123
  backupDir: 'specs/.core/linear-backups',
@@ -97,6 +141,11 @@ const DEFAULT_CONFIG = Object.freeze({
97
141
  }),
98
142
  })
99
143
 
144
+ // The lifecycle buckets a per-bucket `mapping.phases` map may key on. Derived
145
+ // from `states` rather than restated: both maps key on the spec's folder bucket,
146
+ // so they cannot drift apart.
147
+ const LIFECYCLE_BUCKETS = Object.freeze(Object.keys(DEFAULT_CONFIG.states))
148
+
100
149
  function isObject(value) {
101
150
  return value !== null && typeof value === 'object' && !Array.isArray(value)
102
151
  }
@@ -105,11 +154,17 @@ function isObject(value) {
105
154
  function defaults() {
106
155
  return {
107
156
  linear: { ...DEFAULT_CONFIG.linear },
108
- intake: { label: DEFAULT_CONFIG.intake.label, bugLabels: [...DEFAULT_CONFIG.intake.bugLabels] },
157
+ intake: {
158
+ label: DEFAULT_CONFIG.intake.label,
159
+ bugLabels: [...DEFAULT_CONFIG.intake.bugLabels],
160
+ hotfixLabels: [...DEFAULT_CONFIG.intake.hotfixLabels],
161
+ },
109
162
  mapping: { ...DEFAULT_CONFIG.mapping },
110
163
  states: { ...DEFAULT_CONFIG.states },
111
164
  snapshot: { ...DEFAULT_CONFIG.snapshot },
112
165
  branch: { ...DEFAULT_CONFIG.branch },
166
+ auth: { ...DEFAULT_CONFIG.auth },
167
+ apply: { ...DEFAULT_CONFIG.apply },
113
168
  sync: {
114
169
  baseDir: DEFAULT_CONFIG.sync.baseDir,
115
170
  backupDir: DEFAULT_CONFIG.sync.backupDir,
@@ -153,6 +208,47 @@ function mergeFieldOwnership(base, parsed) {
153
208
  }
154
209
  }
155
210
 
211
+ // Merge (and validate) `mapping.phases` in either of its two forms: a scalar
212
+ // mode for the whole repo, or a map of lifecycle bucket → mode. Loud on a bad
213
+ // key or value, like fieldOwnership and mapping.tasks — a misspelt bucket would
214
+ // otherwise read as a deliberate default and go on minting the sub-issues the
215
+ // config was written to stop.
216
+ function mergePhaseMapping(base, parsed) {
217
+ const value = parsed.phases
218
+ if (typeof value === 'string') {
219
+ if (value.trim()) base.phases = value.trim()
220
+ } else if (isObject(value)) {
221
+ const byBucket = {}
222
+ for (const [bucket, mode] of Object.entries(value)) {
223
+ if (!LIFECYCLE_BUCKETS.includes(bucket)) {
224
+ throw new Error(
225
+ `Invalid ${CONFIG_FILE}: mapping.phases.${bucket} is not a lifecycle bucket ` +
226
+ `(expected one of ${LIFECYCLE_BUCKETS.join('|')})`,
227
+ )
228
+ }
229
+ if (!PHASE_MAPPINGS.includes(mode)) {
230
+ throw new Error(
231
+ `Invalid ${CONFIG_FILE}: mapping.phases.${bucket} = ${JSON.stringify(mode)} ` +
232
+ `(expected one of ${PHASE_MAPPINGS.join('|')})`,
233
+ )
234
+ }
235
+ byBucket[bucket] = mode
236
+ }
237
+ base.phases = byBucket
238
+ } else if (value !== undefined) {
239
+ throw new Error(
240
+ `Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(value)} ` +
241
+ `(expected one of ${PHASE_MAPPINGS.join('|')}, or a map of lifecycle bucket to mode)`,
242
+ )
243
+ }
244
+ if (typeof base.phases === 'string' && !PHASE_MAPPINGS.includes(base.phases)) {
245
+ throw new Error(
246
+ `Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(base.phases)} ` +
247
+ `(expected one of ${PHASE_MAPPINGS.join('|')}, or a map of lifecycle bucket to mode)`,
248
+ )
249
+ }
250
+ }
251
+
156
252
  // Merge (and validate) sync.keyedFields. Each value is the item's id property
157
253
  // name (a non-empty string); a field listed here is compared per item.
158
254
  function mergeKeyedFields(base, parsed) {
@@ -186,11 +282,13 @@ function mergeConfig(base, parsed) {
186
282
  if (Array.isArray(parsed.intake.bugLabels)) {
187
283
  base.intake.bugLabels = stringList(parsed.intake.bugLabels)
188
284
  }
285
+ if (Array.isArray(parsed.intake.hotfixLabels)) {
286
+ base.intake.hotfixLabels = stringList(parsed.intake.hotfixLabels)
287
+ }
189
288
  }
190
289
 
191
290
  if (isObject(parsed.mapping)) {
192
291
  assign(base.mapping, parsed.mapping, 'specFolder', 'string')
193
- assign(base.mapping, parsed.mapping, 'phases', 'string')
194
292
  assign(base.mapping, parsed.mapping, 'tasks', 'string')
195
293
  // Loud on a typo, like fieldOwnership above. Quietly falling back would make
196
294
  // a misspelt value look like a deliberate `none` — the same silent
@@ -201,6 +299,7 @@ function mergeConfig(base, parsed) {
201
299
  `(expected one of ${TASK_MAPPINGS.join('|')})`,
202
300
  )
203
301
  }
302
+ mergePhaseMapping(base.mapping, parsed.mapping)
204
303
  }
205
304
 
206
305
  if (isObject(parsed.states)) {
@@ -217,6 +316,22 @@ function mergeConfig(base, parsed) {
217
316
  assign(base.branch, parsed.branch, 'pattern', 'string')
218
317
  }
219
318
 
319
+ if (isObject(parsed.auth)) {
320
+ assign(base.auth, parsed.auth, 'keyEnv', 'string')
321
+ }
322
+
323
+ if (isObject(parsed.apply)) {
324
+ assign(base.apply, parsed.apply, 'transport', 'string?')
325
+ // Loud on a typo, like the mapping enums: a misspelt transport must not
326
+ // quietly fall back to MCP and look like a deliberate choice.
327
+ if (base.apply.transport && !TRANSPORTS.includes(base.apply.transport)) {
328
+ throw new Error(
329
+ `Invalid ${CONFIG_FILE}: apply.transport = ${JSON.stringify(base.apply.transport)} ` +
330
+ `(expected one of ${TRANSPORTS.join('|')})`,
331
+ )
332
+ }
333
+ }
334
+
220
335
  if (isObject(parsed.sync)) {
221
336
  assign(base.sync, parsed.sync, 'baseDir', 'string')
222
337
  assign(base.sync, parsed.sync, 'backupDir', 'string')
@@ -262,8 +377,13 @@ function loadLinearConfig(dir = process.cwd()) {
262
377
  module.exports = {
263
378
  loadLinearConfig,
264
379
  mergeConfig,
380
+ defaults,
265
381
  DEFAULT_CONFIG,
266
382
  CONFIG_FILE,
267
383
  OWNERSHIP,
268
384
  TASK_MAPPINGS,
385
+ PHASE_MAPPINGS,
386
+ LIFECYCLE_BUCKETS,
387
+ TRANSPORTS,
388
+ DEFAULT_KEY_ENV,
269
389
  }
@@ -7,7 +7,10 @@
7
7
  * about any specific tracker. The repo is the source of truth: the engine builds
8
8
  * a local projection, diffs it against a committed last-pushed snapshot
9
9
  * (`planChanges`), and returns a create/update plan the provider skill applies
10
- * over its API. No remote content is read or merged.
10
+ * over its API. No remote content is read for CONTENT: nothing the tracker holds
11
+ * ever feeds the projection, the snapshot, or a repo file. `compareStored` is the
12
+ * one function that looks at a tracker value, and it only checks that what was
13
+ * stored matches what was sent — it merges nothing (see `src/verify.js`).
11
14
  */
12
15
 
13
16
  const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
@@ -17,6 +20,8 @@ const { push, recordPush, projectionOf } = require('./src/push.js')
17
20
  const { writeFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
18
21
  const { sanitizeSpecMarkdown } = require('./src/sanitise.js')
19
22
  const { detectLegacyMirror } = require('./src/legacy.js')
23
+ const { compareStored } = require('./src/verify.js')
24
+ const { flattenNestedTables } = require('./src/tables.js')
20
25
 
21
26
  module.exports = {
22
27
  normalizeLocal,
@@ -44,4 +49,6 @@ module.exports = {
44
49
  listPhaseFiles,
45
50
  sanitizeSpecMarkdown,
46
51
  detectLegacyMirror,
52
+ compareStored,
53
+ flattenNestedTables,
47
54
  }