@skitterbyte/skitterspec-linear 10.6.0 → 10.8.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 (34) hide show
  1. package/assets/claude-md-section.md +19 -9
  2. package/assets/commands/spec-connect.md +13 -0
  3. package/assets/commands/spec-live.md +14 -0
  4. package/assets/core/ci-stages.md +110 -0
  5. package/assets/core/env.config.md +18 -0
  6. package/assets/core/linear.config.json.example +4 -0
  7. package/assets/core/linear.config.md +88 -0
  8. package/assets/rules/commit-trailers.md +37 -5
  9. package/assets/rules/spec-planning.md +19 -4
  10. package/assets/skills/spec/SKILL.md +20 -0
  11. package/assets/skills/spec-bug/SKILL.md +48 -2
  12. package/assets/skills/spec-cancel/SKILL.md +9 -0
  13. package/assets/skills/spec-complete/SKILL.md +12 -1
  14. package/assets/skills/spec-go/SKILL.md +10 -8
  15. package/assets/skills/spec-hotfix/SKILL.md +49 -3
  16. package/assets/skills/spec-linear-setup/SKILL.md +38 -2
  17. package/assets/skills/spec-status/SKILL.md +1 -0
  18. package/assets/skills/spec-sync/SKILL.md +9 -0
  19. package/assets/skills/spec-to-main/SKILL.md +2 -1
  20. package/bin/skitterspec-linear.js +10 -0
  21. package/package.json +1 -1
  22. package/src/cli.js +211 -44
  23. package/src/env/config.js +19 -0
  24. package/src/env/teardown.js +62 -4
  25. package/src/init.js +120 -2
  26. package/src/vendor/linear/cli-sync.js +454 -34
  27. package/src/vendor/linear/config.js +158 -1
  28. package/src/vendor/linear/doctor.js +67 -1
  29. package/src/vendor/linear/released.js +149 -5
  30. package/src/vendor/sync-core/index.js +4 -1
  31. package/src/vendor/sync-core/src/compare.js +59 -3
  32. package/src/vendor/sync-core/src/normalize.js +65 -2
  33. package/assets/skills/spec-connect/SKILL.md +0 -59
  34. package/assets/skills/spec-live/SKILL.md +0 -73
@@ -20,6 +20,7 @@
20
20
  * intake: { label, bugLabels, hotfixLabels },
21
21
  * mapping: { specFolder, phases, tasks },
22
22
  * states: { backlog, "in-progress", complete, cancelled },
23
+ * release: { stages: [{ key, state }] },
23
24
  * snapshot: { overviewFile },
24
25
  * branch: { pattern },
25
26
  * sync: {
@@ -78,6 +79,36 @@ const TRANSPORTS = Object.freeze(['api', 'mcp'])
78
79
  // nothing secret is ever written to the repo.
79
80
  const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
80
81
 
82
+ // The project's OWN deployment ladder: where a ticket goes AFTER its spec is
83
+ // complete — deployed to test, approved for demo, live in prod. Deliberately an
84
+ // open, ORDERED list in the project's vocabulary rather than a fixed set: one
85
+ // team's `On Test`/`Ready for Demo` is another's `staging`, and most projects
86
+ // have none at all. Empty (the default) means the project declared no ladder,
87
+ // and every stage-aware path is simply unused.
88
+ //
89
+ // It is NOT keyed by lifecycle bucket, unlike `states` and `mapping.phases`.
90
+ // A deployment stage is a fact about an ENVIRONMENT, and no folder under
91
+ // `specs/` can ever derive one — which is why it must not join
92
+ // LIFECYCLE_BUCKETS.
93
+ const DEFAULT_RELEASE_STAGES = Object.freeze([])
94
+
95
+ // Repo-relative path prefixes whose commits are BOOKKEEPING, not shipped work.
96
+ //
97
+ // A spec's `chore(spec): complete <name>` commit carries the same `Refs:`
98
+ // trailer as the code it describes, but lands AFTER the tag that shipped that
99
+ // code — so without this the ticket appears in two consecutive release ranges:
100
+ // once for its code, once for its paperwork. Measured on one consumer, 14 of the
101
+ // 22 ref-carrying commits in 300 were spec bookkeeping, so this is the dominant
102
+ // case rather than an edge one.
103
+ //
104
+ // PATHS, not commit subjects. `chore(spec):` is a convention a mislabelled
105
+ // commit escapes; what a commit changed is a fact. A commit touching an ignored
106
+ // path AND a source file still counts — it shipped code.
107
+ //
108
+ // An explicit `[]` is the opt-out, and a project that keeps its paperwork
109
+ // elsewhere names its own directories here.
110
+ const DEFAULT_RELEASE_IGNORE_PATHS = Object.freeze(['specs/'])
111
+
81
112
  const DEFAULT_CONFIG = Object.freeze({
82
113
  // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
83
114
  // first `/spec-push` offer the team's projects and pre-select this one; empty
@@ -111,6 +142,12 @@ const DEFAULT_CONFIG = Object.freeze({
111
142
  cancelled: 'Canceled',
112
143
  }),
113
144
  snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
145
+ // See DEFAULT_RELEASE_STAGES above. `stages` is ordered: the order is recorded
146
+ // for reporting and doctor's ladder check, and deliberately NOT enforced — a
147
+ // rollback from test and a hotfix going straight to prod are both legitimate.
148
+ // `ignorePaths` (see DEFAULT_RELEASE_IGNORE_PATHS) is what `released`/`stage`
149
+ // treat as bookkeeping rather than shipped work.
150
+ release: Object.freeze({ stages: DEFAULT_RELEASE_STAGES, ignorePaths: DEFAULT_RELEASE_IGNORE_PATHS }),
114
151
  branch: Object.freeze({ pattern: '{type}/{slug}' }),
115
152
  // `keyEnv` names the env var holding the personal API key. It is a NAME, not a
116
153
  // key: putting the secret itself here would commit it.
@@ -162,6 +199,10 @@ function defaults() {
162
199
  mapping: { ...DEFAULT_CONFIG.mapping },
163
200
  states: { ...DEFAULT_CONFIG.states },
164
201
  snapshot: { ...DEFAULT_CONFIG.snapshot },
202
+ release: {
203
+ stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })),
204
+ ignorePaths: [...DEFAULT_CONFIG.release.ignorePaths],
205
+ },
165
206
  branch: { ...DEFAULT_CONFIG.branch },
166
207
  auth: { ...DEFAULT_CONFIG.auth },
167
208
  apply: { ...DEFAULT_CONFIG.apply },
@@ -249,6 +290,84 @@ function mergePhaseMapping(base, parsed) {
249
290
  }
250
291
  }
251
292
 
293
+ // Merge (and validate) `release.stages` — the project's deployment ladder.
294
+ //
295
+ // Loud on a malformed entry, like fieldOwnership and mapping.phases above: this
296
+ // list names Linear states, and Linear SILENTLY IGNORES an unknown state. A
297
+ // half-typed ladder would push clean and move nothing, which is exactly the
298
+ // failure `validateStates` exists to stop — so a bad shape fails here, at load,
299
+ // rather than at the first deploy nobody is watching.
300
+ //
301
+ // An ABSENT `release` block is not an error: it is the opt-out, and the whole
302
+ // feature is unused without it.
303
+ function mergeReleaseStages(base, parsed) {
304
+ const value = parsed.stages
305
+ if (value === undefined) return
306
+ if (!Array.isArray(value)) {
307
+ throw new Error(
308
+ `Invalid ${CONFIG_FILE}: release.stages = ${JSON.stringify(value)} ` +
309
+ '(expected an array of { key, state })',
310
+ )
311
+ }
312
+ const stages = []
313
+ const seen = new Set()
314
+ value.forEach((entry, i) => {
315
+ if (!isObject(entry)) {
316
+ throw new Error(
317
+ `Invalid ${CONFIG_FILE}: release.stages[${i}] = ${JSON.stringify(entry)} ` +
318
+ '(expected { key, state })',
319
+ )
320
+ }
321
+ for (const field of ['key', 'state']) {
322
+ if (typeof entry[field] !== 'string' || !entry[field].trim()) {
323
+ throw new Error(
324
+ `Invalid ${CONFIG_FILE}: release.stages[${i}].${field} = ${JSON.stringify(entry[field])} ` +
325
+ '(expected a non-empty string)',
326
+ )
327
+ }
328
+ }
329
+ const key = entry.key.trim()
330
+ if (seen.has(key)) {
331
+ throw new Error(
332
+ `Invalid ${CONFIG_FILE}: release.stages[${i}].key = ${JSON.stringify(key)} is a duplicate ` +
333
+ '(a stage key is how CI names the rung, so it must be unique)',
334
+ )
335
+ }
336
+ seen.add(key)
337
+ stages.push({ key, state: entry.state.trim() })
338
+ })
339
+ base.stages = stages
340
+ }
341
+
342
+ // Merge (and validate) release.ignorePaths — the path prefixes whose commits are
343
+ // bookkeeping. Loud on anything but an array of non-empty strings, like
344
+ // release.stages above: a bad value that quietly fell back to the default would
345
+ // let a project believe it had opted out and go on double-counting tickets.
346
+ //
347
+ // A BLANK entry is rejected rather than dropped. `""` is a prefix of every path,
348
+ // so a stray empty string would silently ignore every commit in the range and
349
+ // report a release as containing nothing — the loudest possible wrong answer,
350
+ // arriving as silence.
351
+ function mergeReleaseIgnorePaths(base, parsed) {
352
+ const value = parsed.ignorePaths
353
+ if (value === undefined) return
354
+ if (!Array.isArray(value)) {
355
+ throw new Error(
356
+ `Invalid ${CONFIG_FILE}: release.ignorePaths = ${JSON.stringify(value)} ` +
357
+ '(expected an array of repo-relative path prefixes)',
358
+ )
359
+ }
360
+ value.forEach((entry, i) => {
361
+ if (typeof entry !== 'string' || !entry.trim()) {
362
+ throw new Error(
363
+ `Invalid ${CONFIG_FILE}: release.ignorePaths[${i}] = ${JSON.stringify(entry)} ` +
364
+ '(expected a non-empty repo-relative path prefix, e.g. "specs/")',
365
+ )
366
+ }
367
+ })
368
+ base.ignorePaths = stringList(value)
369
+ }
370
+
252
371
  // Merge (and validate) sync.keyedFields. Each value is the item's id property
253
372
  // name (a non-empty string); a field listed here is compared per item.
254
373
  function mergeKeyedFields(base, parsed) {
@@ -312,6 +431,11 @@ function mergeConfig(base, parsed) {
312
431
  assign(base.snapshot, parsed.snapshot, 'overviewFile', 'string')
313
432
  }
314
433
 
434
+ if (isObject(parsed.release)) {
435
+ mergeReleaseStages(base.release, parsed.release)
436
+ mergeReleaseIgnorePaths(base.release, parsed.release)
437
+ }
438
+
315
439
  if (isObject(parsed.branch)) {
316
440
  assign(base.branch, parsed.branch, 'pattern', 'string')
317
441
  }
@@ -350,7 +474,8 @@ function mergeConfig(base, parsed) {
350
474
  * Returns `{ config, present }`:
351
475
  * - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
352
476
  * - present → `{ config: merged, present: true }`
353
- * Malformed JSON or a bad `fieldOwnership` enum throws a clear Error.
477
+ * Malformed JSON, a bad `fieldOwnership` enum, or a malformed `release.stages`
478
+ * entry → throws a clear Error.
354
479
  */
355
480
  function loadLinearConfig(dir = process.cwd()) {
356
481
  const base = defaults()
@@ -374,8 +499,39 @@ function loadLinearConfig(dir = process.cwd()) {
374
499
  return { config: mergeConfig(base, parsed), present: true }
375
500
  }
376
501
 
502
+ /**
503
+ * The project's declared deployment ladder, always an array (empty = none
504
+ * declared). Callers read this rather than reaching into the config, so an
505
+ * older config object without a `release` block cannot throw.
506
+ */
507
+ function releaseStages(config) {
508
+ const stages = config && config.release && config.release.stages
509
+ return Array.isArray(stages) ? stages : []
510
+ }
511
+
512
+ /** The ladder rung with this key, or null. */
513
+ function stageFor(config, key) {
514
+ return releaseStages(config).find((s) => s.key === key) || null
515
+ }
516
+
517
+ /**
518
+ * The path prefixes whose commits are bookkeeping, always an array.
519
+ *
520
+ * A config object that predates the field gets the DEFAULT — the list
521
+ * `loadLinearConfig` would have produced — rather than an empty one, so an older
522
+ * object cannot quietly turn the filter off. An explicit `[]` survives
523
+ * `Array.isArray` and is honoured as the opt-out.
524
+ */
525
+ function releaseIgnorePaths(config) {
526
+ const paths = config && config.release && config.release.ignorePaths
527
+ return Array.isArray(paths) ? paths : [...DEFAULT_RELEASE_IGNORE_PATHS]
528
+ }
529
+
377
530
  module.exports = {
378
531
  loadLinearConfig,
532
+ releaseStages,
533
+ releaseIgnorePaths,
534
+ stageFor,
379
535
  mergeConfig,
380
536
  defaults,
381
537
  DEFAULT_CONFIG,
@@ -386,4 +542,5 @@ module.exports = {
386
542
  LIFECYCLE_BUCKETS,
387
543
  TRANSPORTS,
388
544
  DEFAULT_KEY_ENV,
545
+ DEFAULT_RELEASE_IGNORE_PATHS,
389
546
  }
@@ -29,7 +29,11 @@
29
29
  * by construction rather than by convention.
30
30
  */
31
31
 
32
- const STATES = ['ok', 'missing', 'broken', 'skipped']
32
+ // `warn` sits between `ok` and `broken`: worth a human's attention, but not a
33
+ // failure. A ladder whose last rung never closes an issue is a real problem for
34
+ // most projects and a deliberate choice for some, and only the project knows
35
+ // which — so it is said, and the exit code is left alone.
36
+ const STATES = ['ok', 'missing', 'broken', 'skipped', 'warn']
33
37
 
34
38
  // A row is a check. `fix` is the exact command to run, or null when there is
35
39
  // nothing to fix.
@@ -57,6 +61,7 @@ function runChecks(state = {}) {
57
61
  projectCheck(state.project, state.tracker, state.remote),
58
62
  keyCheck(state.key, state.tracker),
59
63
  remoteCheck(state.remote),
64
+ ladderCheck(state.ladder, state.tracker),
60
65
  mcpCheck(state.mcp, state.tracker, state.project, state.remote),
61
66
  ]
62
67
  // `missing` is a declined opt-in, so it must not fail the run. Only a
@@ -64,6 +69,67 @@ function runChecks(state = {}) {
64
69
  return { ok: !checks.some((c) => c.state === 'broken'), checks }
65
70
  }
66
71
 
72
+ /**
73
+ * The deployment ladder's shape, when one is declared.
74
+ *
75
+ * WHAT COULD FOOL THIS CHECK, and why it is a `warn` rather than a refusal: a
76
+ * workspace can close an issue by automation (a merged PR, a linked release)
77
+ * rather than by the last rung's own type, and a ladder that stops at a
78
+ * `started` state is then perfectly healthy. Only the project knows which it is.
79
+ *
80
+ * It needs the workspace's state TYPES, which only the API transport returns —
81
+ * `--workspace-states` and the MCP path carry names alone. Without them the row
82
+ * is `skipped`: not knowing a type is not evidence of a bad one.
83
+ */
84
+ function ladderCheck(s = {}, tracker = {}) {
85
+ if (!tracker.present) return row('ladder', 'ladder', 'skipped', 'no tracker configured')
86
+ const stages = Array.isArray(s.stages) ? s.stages : []
87
+ if (!stages.length) {
88
+ return row('ladder', 'ladder', 'skipped', 'no deployment ladder declared')
89
+ }
90
+
91
+ const summary = stages.map((st) => `${st.key} -> ${st.state}`).join(', ')
92
+ const states = Array.isArray(s.workspaceStates) ? s.workspaceStates : null
93
+ if (!states) {
94
+ return row('ladder', 'ladder', 'skipped', `${stages.length} rung(s), unchecked: ${summary}`)
95
+ }
96
+
97
+ const byName = new Map(
98
+ states.filter((st) => st && typeof st.name === 'string').map((st) => [st.name.toLowerCase().trim(), st]),
99
+ )
100
+ // A list that arrived EMPTY, or held nothing with a name, cannot prove a rung
101
+ // is absent — it is a lookup that saw nothing, not a workspace without these
102
+ // states. Accusing every rung off that would be the exact absence-as-evidence
103
+ // mistake the negative-checks rule exists for.
104
+ if (!byName.size) {
105
+ return row('ladder', 'ladder', 'skipped', `${stages.length} rung(s), unchecked: ${summary}`)
106
+ }
107
+ const unknown = stages.filter((st) => !byName.has(String(st.state).toLowerCase().trim()))
108
+ if (unknown.length) {
109
+ return row(
110
+ 'ladder',
111
+ 'ladder',
112
+ 'broken',
113
+ `rung state(s) not in this workspace: ${unknown.map((st) => `${st.key} -> "${st.state}"`).join(', ')}` +
114
+ ' — Linear silently ignores an unknown state, so these would move nothing',
115
+ '/spec-linear-setup',
116
+ )
117
+ }
118
+
119
+ const last = stages[stages.length - 1]
120
+ const type = (byName.get(String(last.state).toLowerCase().trim()) || {}).type
121
+ if (type !== 'completed') {
122
+ return row(
123
+ 'ladder',
124
+ 'ladder',
125
+ 'warn',
126
+ `ends at "${last.state}" (type: ${type || 'unknown'}) — an issue finishing the ladder never reaches a ` +
127
+ 'completed state. Add a final rung, or ignore this if Linear automation closes them.',
128
+ )
129
+ }
130
+ return row('ladder', 'ladder', 'ok', `${stages.length} rung(s): ${summary}`)
131
+ }
132
+
67
133
  function scaffoldCheck(s = {}) {
68
134
  if (!s.specsDir) {
69
135
  return row('scaffold', 'scaffold', 'missing', 'no specs/ folder', 'skitterspec init')
@@ -51,18 +51,77 @@ function refsInBody(body) {
51
51
  return found
52
52
  }
53
53
 
54
+ /**
55
+ * Whether a commit's changed paths are ENTIRELY bookkeeping.
56
+ *
57
+ * The problem it solves: a spec's `chore(spec): complete <name>` commit carries
58
+ * the same `Refs:` trailer as the code it describes, but lands after the tag
59
+ * that shipped that code — so the ticket turns up in two consecutive release
60
+ * ranges, once for its code and once for its paperwork.
61
+ *
62
+ * Deliberately a POSITIVE signal: it says yes only when it actually saw paths
63
+ * and every one of them sits under an ignored prefix. It never reasons from an
64
+ * absence.
65
+ *
66
+ * WHAT WOULD FOOL THE OTHER PHRASING ("no unignored path was found"): git lists
67
+ * no files at all for a MERGE commit (without `-m`) — verified, not assumed —
68
+ * nor for a genuinely empty one, and a `git log` that failed outright yields no
69
+ * paths for anything. Under that phrasing every one of those becomes a release
70
+ * silently losing its tickets. Under this one they mean "nothing was seen, so
71
+ * nothing is known", which keeps the pre-filter behaviour: an over-claimed
72
+ * ticket is noticed when someone looks for it, whereas a ticket that quietly
73
+ * belongs to no release never is.
74
+ *
75
+ * Matching is path-prefix, not glob: `specs` and `specs/` both mean the
76
+ * directory, and a prefix only matches on a path SEGMENT boundary, so `specs/`
77
+ * never swallows `specs-archive/`.
78
+ *
79
+ * @param {string[]|null|undefined} paths repo-relative paths the commit changed
80
+ * @param {string[]} ignorePaths repo-relative prefixes that are bookkeeping
81
+ */
82
+ function onlyIgnoredPaths(paths, ignorePaths) {
83
+ if (!Array.isArray(paths) || !paths.length) return false
84
+ const prefixes = (Array.isArray(ignorePaths) ? ignorePaths : [])
85
+ .filter((p) => typeof p === 'string' && p.trim())
86
+ .map((p) => p.trim().replace(/^\.\//, '').replace(/\/+$/, ''))
87
+ .filter(Boolean)
88
+ if (!prefixes.length) return false
89
+ return paths.every((raw) => {
90
+ const file = String(raw == null ? '' : raw)
91
+ .trim()
92
+ .replace(/^\.\//, '')
93
+ // An unreadable entry is an unknown, not an ignored one — it makes the whole
94
+ // commit count, per the bias above.
95
+ if (!file) return false
96
+ return prefixes.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))
97
+ })
98
+ }
99
+
54
100
  /**
55
101
  * Fold commits into the report a release needs.
56
102
  *
57
- * @param {Array<{sha?:string, subject?:string, body?:string}>} commits
58
- * @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, total:number}}
103
+ * @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
104
+ * @param {{ignorePaths?:string[]}} [options] `ignorePaths` marks bookkeeping —
105
+ * see `onlyIgnoredPaths`. Omitted (or empty) means nothing is ignored, so the
106
+ * report is what it was before the filter existed.
107
+ * @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number}}
59
108
  * `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
60
- * is listed once, where it first appears, not eight times.
109
+ * is listed once, where it first appears, not eight times. `ignored` is
110
+ * reported rather than merely subtracted — a filter that removes commits in
111
+ * silence reads as "there was nothing there".
61
112
  */
62
- function ticketsInRange(commits) {
113
+ function ticketsInRange(commits, options = {}) {
114
+ const ignorePaths = (options && options.ignorePaths) || []
63
115
  const seen = new Map()
64
116
  let unreferenced = 0
117
+ let ignored = 0
65
118
  for (const commit of commits || []) {
119
+ if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
120
+ // Not counted as unreferenced either: that number exists to surface a
121
+ // MISSED trailer, and a paperwork commit is not a gap someone should hunt.
122
+ ignored++
123
+ continue
124
+ }
66
125
  const refs = [...new Set(refsInBody(commit && commit.body))]
67
126
  if (!refs.length) {
68
127
  unreferenced++
@@ -73,8 +132,93 @@ function ticketsInRange(commits) {
73
132
  return {
74
133
  tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
75
134
  unreferenced,
135
+ ignored,
76
136
  total: (commits || []).length,
77
137
  }
78
138
  }
79
139
 
80
- module.exports = { ticketsInRange, refsInBody }
140
+ /**
141
+ * Split a release's tickets into the ones a stage move may touch and the ones it
142
+ * must not, with a reason for every exclusion.
143
+ *
144
+ * Three reasons to leave a ticket alone, and each is a case where acting would
145
+ * be worse than not acting:
146
+ *
147
+ * - **foreign** — the ref is not this repo's team. A range can carry another
148
+ * team's ref (a shared dependency, a quoted ticket), and writing to a team
149
+ * this repo was never configured for is the worst failure available here.
150
+ * - **unlinked** — the ref is this team's, but no spec in the repo claims it.
151
+ * It may be tracker-only work, or a typo in a trailer. Either way nothing
152
+ * here knows what it is, so it is reported, not moved.
153
+ * - **unfinished** — a spec that has not reached the ceded bucket. Its code
154
+ * really is in the release (it landed via `/spec-to-main`), but push still
155
+ * owns its workflow state and would bounce it straight back. Exactly one
156
+ * writer per issue at any moment beats a visible flip-flop.
157
+ *
158
+ * Pure: it takes the spec list as data, so the rules are testable without a repo.
159
+ */
160
+ function partitionStageMoves({ tickets, teamKey, specs, cededBucket = 'complete' }) {
161
+ const bucketOf = new Map()
162
+ for (const spec of specs || []) {
163
+ if (spec && spec.identifier) bucketOf.set(spec.identifier, spec.bucket)
164
+ }
165
+ const key = String(teamKey || '').trim().toUpperCase()
166
+ const movable = []
167
+ const foreign = []
168
+ const unlinked = []
169
+ const unfinished = []
170
+ for (const ticket of tickets || []) {
171
+ const ref = ticket && ticket.ref
172
+ if (!ref) continue
173
+ if (key && !ref.toUpperCase().startsWith(`${key}-`)) {
174
+ foreign.push(ticket)
175
+ } else if (!bucketOf.has(ref)) {
176
+ unlinked.push(ticket)
177
+ } else if (bucketOf.get(ref) !== cededBucket) {
178
+ unfinished.push({ ...ticket, bucket: bucketOf.get(ref) })
179
+ } else {
180
+ movable.push(ticket)
181
+ }
182
+ }
183
+ return { movable, foreign, unlinked, unfinished }
184
+ }
185
+
186
+ /**
187
+ * Whether moving from `fromState` to the rung `toKey` runs against the declared
188
+ * order — and if so, how to say it.
189
+ *
190
+ * WARNS, never refuses. A rollback from test and a hotfix going straight to prod
191
+ * are both legitimate, and a check that blocked either would be wrong on healthy
192
+ * input. An issue that is on no rung yet is entering the ladder, which is the
193
+ * normal case and says nothing.
194
+ *
195
+ * `lifecycleStates` is the bucket map's own state names, and a state among them
196
+ * means the issue is at its LIFECYCLE position, not on a rung — even when the
197
+ * two names coincide. They routinely do: `states.complete` and a final `prod`
198
+ * rung are both naturally "Done", and without this a spec that had merely been
199
+ * completed would read as already deployed to prod, so its first real deploy
200
+ * would be warned about as a move backwards. Bucket wins over ladder here for
201
+ * the same reason it wins in `bucketForState`.
202
+ *
203
+ * @returns {string|null}
204
+ */
205
+ function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
206
+ const list = Array.isArray(stages) ? stages : []
207
+ const to = list.findIndex((s) => s && s.key === toKey)
208
+ if (to < 0) return null
209
+ const want = String(fromState || '').toLowerCase().trim()
210
+ if (!want) return null
211
+ const lifecycle = new Set(
212
+ (Array.isArray(lifecycleStates) ? lifecycleStates : [])
213
+ .filter((n) => typeof n === 'string')
214
+ .map((n) => n.toLowerCase().trim()),
215
+ )
216
+ if (lifecycle.has(want)) return null
217
+ const from = list.findIndex((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want)
218
+ if (from < 0) return null
219
+ if (to < from) return `moves back from "${list[from].key}"`
220
+ if (to > from + 1) return `skips ${to - from - 1} rung(s) from "${list[from].key}"`
221
+ return null
222
+ }
223
+
224
+ module.exports = { ticketsInRange, refsInBody, onlyIgnoredPaths, partitionStageMoves, stageOrderWarning }
@@ -13,7 +13,7 @@
13
13
  * stored matches what was sent — it merges nothing (see `src/verify.js`).
14
14
  */
15
15
 
16
- const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
16
+ const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
17
17
  const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
18
18
  const { readBase, writeBase } = require('./src/base.js')
19
19
  const { push, recordPush, projectionOf } = require('./src/push.js')
@@ -39,6 +39,9 @@ module.exports = {
39
39
  titleFromText,
40
40
  validateStates,
41
41
  stateSuggestions,
42
+ stageForState,
43
+ remoteStage,
44
+ LADDER_ORIGIN_BUCKET,
42
45
  hashField,
43
46
  stableStringify,
44
47
  readBase,
@@ -43,9 +43,28 @@ function hashField(value) {
43
43
  // The spec ISSUE fields the repo owns and pushes: prose + workflow state.
44
44
  // Priority, labels, cycles and comments are Linear-native triage — one-way sync
45
45
  // neither pushes nor reads them, so a PM's triage is never clobbered.
46
+ //
47
+ // The COMBINED hash, retained for snapshots written before the fields were split
48
+ // (and read by any older CLI still pointed at this repo). New pushes diff
49
+ // `issueFields` below; this stays so neither direction breaks on the other.
46
50
  function specIssueHash(p) {
47
51
  return hashField({ description: p.description ?? null, state: p.status ?? null })
48
52
  }
53
+
54
+ // Per-field hashes of the same two values.
55
+ //
56
+ // They are hashed SEPARATELY because they have different owners once a spec is
57
+ // finished. The repo owns the description forever, but the workflow state is
58
+ // handed off: a deploy pipeline (see `release.stages`) moves the issue past
59
+ // `complete`, and welding the two meant any prose edit re-emitted the state and
60
+ // dragged the issue back. Diffing them apart is what lets a push touch the
61
+ // description without re-asserting a state someone else now owns.
62
+ function specIssueFieldHashes(p) {
63
+ return {
64
+ description: hashField(p.description ?? null),
65
+ state: hashField(p.status ?? null),
66
+ }
67
+ }
49
68
  // A phase SUB-ISSUE: its name, goal and state (all repo-owned).
50
69
  const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
51
70
 
@@ -63,7 +82,11 @@ function snapshotOf(projection) {
63
82
  return out
64
83
  }
65
84
  return {
85
+ // Both shapes are written: `issueFields` is what a current push diffs, and
86
+ // `issue` keeps a snapshot readable by anything still expecting the combined
87
+ // hash. Cheap insurance — two SHA-1s of text already in hand.
66
88
  issue: specIssueHash(p),
89
+ issueFields: specIssueFieldHashes(p),
67
90
  subIssues: byId(p.subIssues, subIssueHash),
68
91
  }
69
92
  }
@@ -92,12 +115,43 @@ function planChanges(projection, snapshot) {
92
115
  }
93
116
 
94
117
  const plan = { subIssues }
95
- if (snap.issue !== specIssueHash(p)) {
96
- plan.issue = { description: p.description ?? null, state: p.status ?? null }
97
- }
118
+ const issue = issueChanges(p, snap)
119
+ if (issue) plan.issue = issue
98
120
  return plan
99
121
  }
100
122
 
123
+ /**
124
+ * What changed about the spec issue itself — `{description}`, `{state}`, or
125
+ * both, or null when neither did.
126
+ *
127
+ * Three states, not two: a snapshot may carry the split hashes, only the old
128
+ * combined one, or nothing at all. Only the first can say which field moved.
129
+ *
130
+ * The other two are UNKNOWN, and route to the harmless branch — today's welded
131
+ * behaviour, sending both fields. Sending a state that did not change is
132
+ * redundant; withholding one that did would leave the mirror silently stale, and
133
+ * that is the failure worth avoiding. The push rewrites the snapshot in the new
134
+ * shape, so a spec passes through `unknown` exactly once.
135
+ */
136
+ function issueChanges(projection, snapshot) {
137
+ const p = projection || {}
138
+ const snap = snapshot || {}
139
+ const both = () => ({ description: p.description ?? null, state: p.status ?? null })
140
+
141
+ const fields = snap.issueFields
142
+ if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) {
143
+ // No split hashes recorded: an old snapshot, or no snapshot at all (a
144
+ // create, which needs both fields anyway).
145
+ return snap.issue === specIssueHash(p) ? null : both()
146
+ }
147
+
148
+ const want = specIssueFieldHashes(p)
149
+ const changed = {}
150
+ if (fields.description !== want.description) changed.description = p.description ?? null
151
+ if (fields.state !== want.state) changed.state = p.status ?? null
152
+ return Object.keys(changed).length ? changed : null
153
+ }
154
+
101
155
  // True when a plan would push nothing.
102
156
  function isEmptyPlan(plan) {
103
157
  return !plan.issue && !plan.subIssues.create.length && !plan.subIssues.update.length
@@ -105,6 +159,8 @@ function isEmptyPlan(plan) {
105
159
 
106
160
  module.exports = {
107
161
  planChanges,
162
+ issueChanges,
163
+ specIssueFieldHashes,
108
164
  snapshotOf,
109
165
  isEmptyPlan,
110
166
  hashField,