@skitterbyte/skitterspec-jira 1.0.0 → 2.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 (42) hide show
  1. package/MIGRATION.md +178 -1
  2. package/README.md +3 -1
  3. package/assets/core/SETUP.md +1 -1
  4. package/assets/core/env.config.md +54 -2
  5. package/assets/core/jira.config.md +33 -6
  6. package/assets/hooks/main-guard.cjs +1 -1
  7. package/assets/hooks/review-gate.cjs +1 -1
  8. package/assets/rules/spec-planning.md +13 -3
  9. package/assets/rules/spec-reports.md +19 -4
  10. package/assets/skills/no-spec/SKILL.md +6 -3
  11. package/assets/skills/spec-bug/SKILL.md +3 -1
  12. package/assets/skills/spec-complete/SKILL.md +6 -3
  13. package/assets/skills/spec-hotfix/SKILL.md +3 -1
  14. package/assets/skills/spec-jira-setup/SKILL.md +2 -1
  15. package/assets/skills/spec-next/SKILL.md +3 -1
  16. package/assets/skills/spec-status/SKILL.md +27 -25
  17. package/assets/skills/spec-sync/SKILL.md +6 -4
  18. package/assets/skills/spec-to-main/SKILL.md +5 -3
  19. package/bin/skitterspec-jira.js +7 -0
  20. package/package.json +1 -1
  21. package/src/cli.js +82 -16
  22. package/src/env/config.js +111 -14
  23. package/src/env/glob.js +34 -0
  24. package/src/env/hotfix.js +33 -5
  25. package/src/env/integrate.js +52 -11
  26. package/src/env/live.js +1 -26
  27. package/src/env/proxy.js +33 -0
  28. package/src/env/review.js +47 -2
  29. package/src/init.js +3 -3
  30. package/src/vendor/jira/cli-sync.js +5 -9
  31. package/src/vendor/jira/config.js +5 -15
  32. package/src/vendor/provider-kit/src/apply.js +10 -0
  33. package/src/vendor/provider-kit/src/assignment.js +277 -0
  34. package/src/vendor/provider-kit/src/config.js +101 -48
  35. package/src/vendor/provider-kit/src/http.js +30 -11
  36. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  37. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  38. package/src/vendor/provider-kit/src/released.js +80 -87
  39. package/src/vendor/provider-kit/src/specs.js +89 -7
  40. package/src/vendor/sync-core/index.js +5 -5
  41. package/src/vendor/sync-core/src/compare.js +38 -6
  42. package/src/vendor/sync-core/src/normalize.js +40 -58
@@ -7,16 +7,25 @@
7
7
  * names never reach it. A commit's `Refs:` trailer is therefore the only place
8
8
  * the ticket survives into the range a release scans — see the
9
9
  * `commit-trailers` rule. Nothing here is tracker-specific: the identifier
10
- * shape (`KEY-123`) and the team-key prefix are common to the trackers this
11
- * scans for, which is why the module lives in the kit.
10
+ * shape defaults to `KEY-123` (Linear, Jira), and a provider whose refs look
11
+ * different (GitHub's `#123`) passes its own `refPattern`.
12
12
  *
13
13
  * The parsing half is pure and takes commits as data, so the rules below are
14
14
  * testable without git. Reading the range, and enriching refs with issue titles,
15
15
  * belong to the caller.
16
16
  */
17
17
 
18
- // An issue identifier: uppercase team key, dash, number.
18
+ // An issue identifier: uppercase team key, dash, number. The default, used
19
+ // whenever a caller passes no `refPattern`.
19
20
  const IDENTIFIER_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g
21
+ // A GitHub issue ref in the same repo: `#123`.
22
+ //
23
+ // WHAT WOULD FOOL IT: `#` is also how a commit says "issue number" in prose and
24
+ // how `owner/repo#123` names ANOTHER repo's issue. Prose is excluded by the
25
+ // trailer anchor (only a `Refs:` line is scanned); the cross-repo form is
26
+ // excluded by the lookbehind, so a ref into some other repo never lands on this
27
+ // one's release.
28
+ const GITHUB_REF_RE = /(?<![\w/.-])#\d+\b/g
20
29
  // A `Refs:` trailer line. Anchored, so it is a line of its own — never prose
21
30
  // that happens to mention the word.
22
31
  const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
@@ -34,7 +43,8 @@ const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
34
43
  * which is worse than missing a ticket: the missing one is noticed when someone
35
44
  * looks for it, the invented one never is.
36
45
  */
37
- function refsInBody(body) {
46
+ function refsInBody(body, refPattern) {
47
+ const pattern = asGlobal(refPattern) || IDENTIFIER_RE
38
48
  const found = []
39
49
  let fenced = false
40
50
  for (const raw of String(body || '').split('\n')) {
@@ -48,11 +58,19 @@ function refsInBody(body) {
48
58
  if (/^ {4,}|\t/.test(line)) continue
49
59
  const m = TRAILER_RE.exec(line.trim() === line ? line : line.trimStart())
50
60
  if (!m) continue
51
- for (const id of m[1].match(IDENTIFIER_RE) || []) found.push(id)
61
+ for (const id of m[1].match(pattern) || []) found.push(id)
52
62
  }
53
63
  return found
54
64
  }
55
65
 
66
+ // `String.prototype.match` only returns every hit for a GLOBAL regex; a
67
+ // provider passing `/#\d+/` would otherwise get its first ref and lose the rest
68
+ // in silence. Anything that is not a RegExp is no pattern at all.
69
+ function asGlobal(pattern) {
70
+ if (!(pattern instanceof RegExp)) return null
71
+ return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`)
72
+ }
73
+
56
74
  /**
57
75
  * Whether a commit's changed paths are ENTIRELY bookkeeping.
58
76
  *
@@ -103,10 +121,18 @@ function onlyIgnoredPaths(paths, ignorePaths) {
103
121
  * Fold commits into the report a release needs.
104
122
  *
105
123
  * @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
106
- * @param {{ignorePaths?:string[]}} [options] `ignorePaths` marks bookkeeping —
107
- * see `onlyIgnoredPaths`. Omitted (or empty) means nothing is ignored, so the
108
- * report is what it was before the filter existed.
109
- * @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number}}
124
+ * @param {{ignorePaths?:string[], refPattern?:RegExp, foreignPatterns?:RegExp[]}} [options]
125
+ * `ignorePaths` marks bookkeeping — see `onlyIgnoredPaths`. Omitted (or
126
+ * empty) means nothing is ignored, so the report is what it was before the
127
+ * filter existed. `refPattern` is this provider's ref shape (see
128
+ * `refsInBody`). `foreignPatterns` are OTHER trackers' ref shapes this repo
129
+ * used before — a repo that moved from Linear to GitHub still has
130
+ * `Refs: SKS-29` all through its history, and those commits were referenced,
131
+ * just not to this tracker. They are counted as `historical` rather than
132
+ * `unreferenced`, which exists to surface a MISSED trailer.
133
+ * @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number, historical?:number}}
134
+ * `historical` is present only when `foreignPatterns` was given, so a
135
+ * provider that never moved trackers sees exactly the shape it always did.
110
136
  * `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
111
137
  * is listed once, where it first appears, not eight times. `ignored` is
112
138
  * reported rather than merely subtracted — a filter that removes commits in
@@ -114,9 +140,12 @@ function onlyIgnoredPaths(paths, ignorePaths) {
114
140
  */
115
141
  function ticketsInRange(commits, options = {}) {
116
142
  const ignorePaths = (options && options.ignorePaths) || []
143
+ const refPattern = options && options.refPattern
144
+ const foreign = ((options && options.foreignPatterns) || []).map(asGlobal).filter(Boolean)
117
145
  const seen = new Map()
118
146
  let unreferenced = 0
119
147
  let ignored = 0
148
+ let historical = 0
120
149
  for (const commit of commits || []) {
121
150
  if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
122
151
  // Not counted as unreferenced either: that number exists to surface a
@@ -124,103 +153,67 @@ function ticketsInRange(commits, options = {}) {
124
153
  ignored++
125
154
  continue
126
155
  }
127
- const refs = [...new Set(refsInBody(commit && commit.body))]
156
+ const refs = [...new Set(refsInBody(commit && commit.body, refPattern))]
128
157
  if (!refs.length) {
129
- unreferenced++
158
+ // A POSITIVE signal, never an absence: only a trailer that actually
159
+ // carries an older tracker's ref makes a commit historical. A commit with
160
+ // no trailer at all is still the gap `unreferenced` is there to name.
161
+ if (foreign.some((p) => refsInBody(commit && commit.body, p).length)) historical++
162
+ else unreferenced++
130
163
  continue
131
164
  }
132
165
  for (const ref of refs) seen.set(ref, (seen.get(ref) || 0) + 1)
133
166
  }
134
- return {
167
+ const report = {
135
168
  tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
136
169
  unreferenced,
137
170
  ignored,
138
171
  total: (commits || []).length,
139
172
  }
173
+ if (foreign.length) report.historical = historical
174
+ return report
140
175
  }
141
176
 
142
177
  /**
143
- * Split a release's tickets into the ones a stage move may touch and the ones it
144
- * must not, with a reason for every exclusion.
145
- *
146
- * Three reasons to leave a ticket alone, and each is a case where acting would
147
- * be worse than not acting:
178
+ * Name the spec behind each ticket a range contains, so a consumer can act on
179
+ * a release without a second lookup.
148
180
  *
149
- * - **foreign** — the ref is not this repo's team. A range can carry another
150
- * team's ref (a shared dependency, a quoted ticket), and writing to a team
151
- * this repo was never configured for is the worst failure available here.
152
- * - **unlinked** — the ref is this team's, but no spec in the repo claims it.
153
- * It may be tracker-only work, or a typo in a trailer. Either way nothing
154
- * here knows what it is, so it is reported, not moved.
155
- * - **unfinished** — a spec that has not reached the ceded bucket. Its code
156
- * really is in the release (it landed via `/spec-to-main`), but push still
157
- * owns its workflow state and would bounce it straight back. Exactly one
158
- * writer per issue at any moment beats a visible flip-flop.
181
+ * A ref no spec has stamped keeps `spec: null` and `url: null` — never a guess
182
+ * from a similar folder name. It may be tracker-only work, or a typo in a
183
+ * trailer, and a consumer acting on an invented match is worse than one told
184
+ * there was none.
159
185
  *
160
- * Pure: it takes the spec list as data, so the rules are testable without a repo.
186
+ * Pure: it takes the spec list as data (`listSpecs` rows), so it is testable
187
+ * without a repo.
161
188
  */
162
- function partitionStageMoves({ tickets, teamKey, specs, cededBucket = 'complete' }) {
163
- const bucketOf = new Map()
164
- for (const spec of specs || []) {
165
- if (spec && spec.identifier) bucketOf.set(spec.identifier, spec.bucket)
189
+ function nameTickets(tickets, specs) {
190
+ const byRef = new Map()
191
+ for (const s of specs || []) {
192
+ if (s && s.identifier && !byRef.has(s.identifier)) byRef.set(s.identifier, s)
166
193
  }
167
- const key = String(teamKey || '').trim().toUpperCase()
168
- const movable = []
169
- const foreign = []
170
- const unlinked = []
171
- const unfinished = []
172
- for (const ticket of tickets || []) {
173
- const ref = ticket && ticket.ref
174
- if (!ref) continue
175
- if (key && !ref.toUpperCase().startsWith(`${key}-`)) {
176
- foreign.push(ticket)
177
- } else if (!bucketOf.has(ref)) {
178
- unlinked.push(ticket)
179
- } else if (bucketOf.get(ref) !== cededBucket) {
180
- unfinished.push({ ...ticket, bucket: bucketOf.get(ref) })
181
- } else {
182
- movable.push(ticket)
183
- }
184
- }
185
- return { movable, foreign, unlinked, unfinished }
194
+ return (tickets || []).map((t) => {
195
+ const s = byRef.get(t.ref)
196
+ return { ...t, spec: s ? s.spec : null, url: s ? s.url || null : null }
197
+ })
186
198
  }
187
199
 
188
- /**
189
- * Whether moving from `fromState` to the rung `toKey` runs against the declared
190
- * order — and if so, how to say it.
191
- *
192
- * WARNS, never refuses. A rollback from test and a hotfix going straight to prod
193
- * are both legitimate, and a check that blocked either would be wrong on healthy
194
- * input. An issue that is on no rung yet is entering the ladder, which is the
195
- * normal case and says nothing.
196
- *
197
- * `lifecycleStates` is the bucket map's own state names, and a state among them
198
- * means the issue is at its LIFECYCLE position, not on a rung — even when the
199
- * two names coincide. They routinely do: `states.complete` and a final `prod`
200
- * rung are both naturally "Done", and without this a spec that had merely been
201
- * completed would read as already deployed to prod, so its first real deploy
202
- * would be warned about as a move backwards. Bucket wins over ladder here for
203
- * the same reason it wins in `bucketForState`.
204
- *
205
- * @returns {string|null}
206
- */
207
- function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
208
- const list = Array.isArray(stages) ? stages : []
209
- const to = list.findIndex((s) => s && s.key === toKey)
210
- if (to < 0) return null
211
- const want = String(fromState || '').toLowerCase().trim()
212
- if (!want) return null
213
- const lifecycle = new Set(
214
- (Array.isArray(lifecycleStates) ? lifecycleStates : [])
215
- .filter((n) => typeof n === 'string')
216
- .map((n) => n.toLowerCase().trim()),
200
+ // The answer a removed release verb gives (`release`, `stage`). Exit 1, not a
201
+ // quiet 0: a pipeline still calling one must learn it no longer mirrors
202
+ // anything, rather than go on believing it does.
203
+ function refuseRemovedVerb(out, program, verb) {
204
+ out.write(
205
+ `${program} ${verb}: removed — skitterspec no longer writes release or deploy state; ` +
206
+ `\`${program} released <range> --json\` lists the tickets a range contains, for your own tooling to act on\n`,
217
207
  )
218
- if (lifecycle.has(want)) return null
219
- const from = list.findIndex((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want)
220
- if (from < 0) return null
221
- if (to < from) return `moves back from "${list[from].key}"`
222
- if (to > from + 1) return `skips ${to - from - 1} rung(s) from "${list[from].key}"`
223
- return null
208
+ return 1
224
209
  }
225
210
 
226
- module.exports = { ticketsInRange, refsInBody, onlyIgnoredPaths, partitionStageMoves, stageOrderWarning }
211
+ module.exports = {
212
+ ticketsInRange,
213
+ refuseRemovedVerb,
214
+ nameTickets,
215
+ refsInBody,
216
+ onlyIgnoredPaths,
217
+ IDENTIFIER_RE,
218
+ GITHUB_REF_RE,
219
+ }
@@ -14,7 +14,16 @@
14
14
  const fs = require('node:fs')
15
15
  const path = require('node:path')
16
16
 
17
- const { BUCKETS, findSpecFolder } = require('../../../env/resolve.js')
17
+ const { execFileSync } = require('node:child_process')
18
+
19
+ const {
20
+ BUCKETS,
21
+ findSpecFolder,
22
+ resolveSpec,
23
+ liveWorktreePaths,
24
+ resolvePrimaryCheckout,
25
+ } = require('../../../env/resolve.js')
26
+ const { loadEnvConfig } = require('../../../env/config.js')
18
27
  const { readSnapshot, parseFrontmatter, lintPhases, fieldsOf, projectionOf, compareStored } = require('../../sync-core')
19
28
 
20
29
  function makeSpecHelpers({ program = 'spec-sync' } = {}) {
@@ -45,22 +54,32 @@ function makeSpecHelpers({ program = 'spec-sync' } = {}) {
45
54
  // Read a spec's stamped identifier without throwing: an unlinked spec, an
46
55
  // unreadable file and a spec with no frontmatter all read as `null`.
47
56
  function linkedIdentifier(overviewPath, config) {
48
- const field = fieldsOf(config).identifier
57
+ return linkedFields(overviewPath, config).identifier
58
+ }
59
+
60
+ // The stamped identifier AND url, each `null` when absent. The url is only
61
+ // ever what a push stamped — never built from the identifier, because a url
62
+ // constructed here would be a guess about a tracker's address scheme.
63
+ function linkedFields(overviewPath, config) {
64
+ const fields = fieldsOf(config)
49
65
  let raw
50
66
  try {
51
67
  raw = fs.readFileSync(overviewPath, 'utf-8')
52
68
  } catch {
53
- return null
69
+ return { identifier: null, url: null }
54
70
  }
55
71
  const { data } = parseFrontmatter(raw)
56
- return data[field] ? String(data[field]) : null
72
+ return {
73
+ identifier: data[fields.identifier] ? String(data[fields.identifier]) : null,
74
+ url: fields.url && data[fields.url] ? String(data[fields.url]) : null,
75
+ }
57
76
  }
58
77
 
59
78
  // Every spec under specs/<bucket>/, paired with the tracker issue it is
60
79
  // linked to (`null` when it has never been pushed). Folder specs read
61
80
  // `snapshot.overviewFile`; legacy bare `<name>.md` specs read the file
62
81
  // itself. Sorted for stable output.
63
- function listSpecs(dir, config) {
82
+ function listSpecs(dir, config, options = {}) {
64
83
  const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
65
84
  const specs = []
66
85
  for (const bucket of BUCKETS) {
@@ -83,12 +102,75 @@ function makeSpecHelpers({ program = 'spec-sync' } = {}) {
83
102
  } else {
84
103
  continue
85
104
  }
86
- specs.push({ spec: name, bucket, identifier: linkedIdentifier(overviewPath, config) })
105
+ const linked = linkedFields(overviewPath, config)
106
+ const row = { spec: name, bucket, identifier: linked.identifier }
107
+ // Opt-in, so every listing that never asked for a url keeps its shape.
108
+ if (options.urls) row.url = linked.url
109
+ specs.push(row)
87
110
  }
88
111
  }
89
112
  return specs.sort((a, b) => a.spec.localeCompare(b.spec))
90
113
  }
91
114
 
115
+ // `listSpecs`, but each spec's BUCKET read from where that spec actually
116
+ // lives. `/spec-start` moves a spec to `in-progress/` on its own branch, so
117
+ // on the base branch an in-flight spec still reads `backlog` — and a spec
118
+ // authored in its own worktree and not yet landed is not here at all. Each
119
+ // live worktree is read too, and a spec found in ITS OWN worktree (the path
120
+ // `resolveSpec` names for it) wins over every other copy. `source` says
121
+ // which: `here`, `worktree`, or `other-tree` for a spec only some other
122
+ // worktree carries.
123
+ //
124
+ // WHAT WOULD FOOL THIS: a worktree left behind by a declined teardown is
125
+ // still live, so a finished spec can read from a stale tree. And with no
126
+ // `env.config.json` there is no worktree layout to resolve, so this is
127
+ // exactly `listSpecs` — the current tree's answer, as before.
128
+ function listSpecsLive(dir, config) {
129
+ const here = listSpecs(dir, config).map((s) => ({ ...s, source: 'here' }))
130
+ const git = (args) => {
131
+ try {
132
+ return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+ let primary
138
+ let env
139
+ try {
140
+ primary = resolvePrimaryCheckout(dir, git) || dir
141
+ env = loadEnvConfig(primary)
142
+ } catch {
143
+ return here
144
+ }
145
+ if (!env.present) return here
146
+ // Compared as REAL paths: git reports a worktree resolved through
147
+ // symlinks (macOS /var → /private/var) where `resolveSpec` does not.
148
+ const real = (p) => {
149
+ try {
150
+ return fs.realpathSync(p)
151
+ } catch {
152
+ return path.resolve(p)
153
+ }
154
+ }
155
+ const self = real(dir)
156
+ const byName = new Map(here.map((s) => [s.spec, s]))
157
+ for (const listed of liveWorktreePaths(git)) {
158
+ const tree = real(listed)
159
+ if (tree === self) continue
160
+ for (const s of listSpecs(tree, config)) {
161
+ let own = null
162
+ try {
163
+ own = real(resolveSpec(s.spec, primary, env.config, { searchDirs: [tree] }).worktreePath)
164
+ } catch {
165
+ own = null
166
+ }
167
+ if (own === tree) byName.set(s.spec, { ...s, source: 'worktree' })
168
+ else if (!byName.has(s.spec)) byName.set(s.spec, { ...s, source: 'other-tree' })
169
+ }
170
+ }
171
+ return [...byName.values()].sort((a, b) => a.spec.localeCompare(b.spec))
172
+ }
173
+
92
174
  // Resolve a spec argument to its folder, or null with a message on stdout.
93
175
  // EVERY failure path here must print and EVERY caller must return a non-zero
94
176
  // code: a push skill checks $? before it applies a plan, so a resolve failure
@@ -160,7 +242,7 @@ function makeSpecHelpers({ program = 'spec-sync' } = {}) {
160
242
  return lines
161
243
  }
162
244
 
163
- return { resolveSnapshotDir, specIdentifier, linkedIdentifier, listSpecs, resolveOrExit, warningLines, warnToErr, verifyLines }
245
+ return { resolveSnapshotDir, specIdentifier, linkedIdentifier, listSpecs, listSpecsLive, resolveOrExit, warningLines, warnToErr, verifyLines }
164
246
  }
165
247
 
166
248
  module.exports = { makeSpecHelpers }
@@ -13,8 +13,8 @@
13
13
  * stored matches what was sent — it merges nothing (see `src/verify.js`).
14
14
  */
15
15
 
16
- const { ownsField, normalizeLocal, LIFECYCLE_BUCKETS, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, phaseModeFor, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
17
- const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited } = require('./src/compare.js')
16
+ const { ownsField, normalizeLocal, LIFECYCLE_BUCKETS, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, remoteStateReading, stateNamesFor, titleFromText, validateStates, stateSuggestions, phaseModeFor } = require('./src/normalize.js')
17
+ const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited, remoteSubIssueEdited } = require('./src/compare.js')
18
18
  const { readBase, writeBase } = require('./src/base.js')
19
19
  const { push, recordPush, projectionOf } = require('./src/push.js')
20
20
  const { writeFrontmatter, deleteFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
@@ -41,12 +41,11 @@ module.exports = {
41
41
  push,
42
42
  recordPush,
43
43
  remoteWorkflowState,
44
+ remoteStateReading,
45
+ stateNamesFor,
44
46
  titleFromText,
45
47
  validateStates,
46
48
  stateSuggestions,
47
- stageForState,
48
- remoteStage,
49
- LADDER_ORIGIN_BUCKET,
50
49
  hashField,
51
50
  stableStringify,
52
51
  readBase,
@@ -66,6 +65,7 @@ module.exports = {
66
65
  // Did someone else edit the tracker's description since the last push?
67
66
  // Three-valued; `null` is cannot-tell and must never be reported as drift.
68
67
  remoteDescriptionEdited,
68
+ remoteSubIssueEdited,
69
69
  // Provider seams: the frontmatter field vocabulary and the markdown dialect
70
70
  // a provider's config supplies (`config.fields` / `config.dialect`).
71
71
  fieldsOf,
@@ -58,12 +58,12 @@ function specIssueHash(p) {
58
58
 
59
59
  // Per-field hashes of the same two values.
60
60
  //
61
- // They are hashed SEPARATELY because they have different owners once a spec is
62
- // finished. The repo owns the description forever, but the workflow state is
63
- // handed off: a deploy pipeline (see `release.stages`) moves the issue past
64
- // `complete`, and welding the two meant any prose edit re-emitted the state and
65
- // dragged the issue back. Diffing them apart is what lets a push touch the
66
- // description without re-asserting a state someone else now owns.
61
+ // They are hashed SEPARATELY because skitterspec tells the tracker about its
62
+ // own transitions and nothing else. The description is resent whenever it
63
+ // changes; the state only when the spec itself moves. Welding the two meant any
64
+ // prose edit re-emitted the state and dragged back an issue someone had moved
65
+ // on the tracker — which is theirs to do. Diffing them apart is what lets a
66
+ // push touch the description without re-asserting a state.
67
67
  function specIssueFieldHashes(p, config) {
68
68
  const hashes = {
69
69
  description: hashField(p.description ?? null),
@@ -137,6 +137,30 @@ function remoteDescriptionEdited(snapshot, remote, config) {
137
137
 
138
138
  return hashField(stream(text, config)) !== recorded
139
139
  }
140
+
141
+ /**
142
+ * The same question for one phase SUB-ISSUE: has its body been edited on the
143
+ * tracker since the last push? It matters most where the tracker renders a
144
+ * phase's task list as clickable boxes — GitHub does — because ticking one
145
+ * there is an edit the next push replaces.
146
+ *
147
+ * The same three answers and the same cannot-tells as above, plus one of its
148
+ * own: no `subIssueStreams` in the snapshot, or none for this id. Every
149
+ * snapshot written before this existed lacks it, and a phase whose body was
150
+ * never sent (no goal) has nothing recorded.
151
+ *
152
+ * @param {object|null} snapshot the committed last-pushed snapshot
153
+ * @param {string} id the sub-issue's id as the snapshot keys it
154
+ * @param {object|null} remote what the tracker currently holds for it
155
+ * @returns {boolean|null}
156
+ */
157
+ function remoteSubIssueEdited(snapshot, id, remote, config) {
158
+ const streams = snapshot && snapshot.subIssueStreams
159
+ if (!streams || typeof streams !== 'object' || Array.isArray(streams)) return null
160
+ const recorded = streams[String(id)]
161
+ if (typeof recorded !== 'string' || !recorded) return null
162
+ return remoteDescriptionEdited({ issueFields: { descriptionStream: recorded } }, remote, config)
163
+ }
140
164
  // A phase SUB-ISSUE: its name, goal and state (all repo-owned).
141
165
  const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
142
166
 
@@ -160,6 +184,13 @@ function snapshotOf(projection, config) {
160
184
  issue: specIssueHash(p),
161
185
  issueFields: specIssueFieldHashes(p, config),
162
186
  subIssues: byId(p.subIssues, subIssueHash),
187
+ // Recorded and never planned on, like `descriptionStream`: `planChanges`
188
+ // reads `subIssues` by name, so this key cannot make a push pending. Only a
189
+ // phase whose body is actually sent (a string goal) gets one.
190
+ subIssueStreams: byId(
191
+ (p.subIssues || []).filter((s) => s && typeof s.goal === 'string'),
192
+ (s) => hashField(stream(s.goal, config)),
193
+ ),
163
194
  }
164
195
  }
165
196
 
@@ -295,6 +326,7 @@ function isEmptyPlan(plan) {
295
326
  module.exports = {
296
327
  planChanges,
297
328
  remoteDescriptionEdited,
329
+ remoteSubIssueEdited,
298
330
  issueChanges,
299
331
  specIssueFieldHashes,
300
332
  snapshotOf,
@@ -570,6 +570,15 @@ const UNSTARTED_BUCKETS = ['backlog', 'cancelled']
570
570
  // Buckets where a spec is FINISHED, and so has nobody working on it.
571
571
  const TERMINAL_BUCKETS = ['complete', 'cancelled']
572
572
 
573
+ // A phase's state once its SPEC's bucket has a say. Cancelling a spec abandons
574
+ // every phase not already done — left alone, those sub-issues stay open in
575
+ // Backlog under a closed parent, which reads as work still owed. A phase that
576
+ // was finished keeps `complete`: it shipped before the spec was dropped.
577
+ function phaseStateUnder(workflowState, phaseState) {
578
+ if (workflowState === 'cancelled' && phaseState !== 'complete') return 'cancelled'
579
+ return phaseState
580
+ }
581
+
573
582
  /**
574
583
  * The Linear user this spec is assigned to, AS PROJECTED — which is not simply
575
584
  * what the frontmatter records.
@@ -714,7 +723,7 @@ function normalizeLocal(snapshotDir, config) {
714
723
  ref: p.phase,
715
724
  name: p.name,
716
725
  goal: subIssueBody(p, tasksMode, config.sync.localOnlySections, dialect),
717
- state: p.state,
726
+ state: phaseStateUnder(workflowState, p.state),
718
727
  })),
719
728
  workflowState,
720
729
  // Who is building this. `toFieldSet` drops it unless the repo listed
@@ -742,39 +751,27 @@ function phasesWithheld(snapshotDir, config) {
742
751
 
743
752
  // --- remote projection ------------------------------------------------------
744
753
 
745
- // The lifecycle bucket a deployment ladder descends FROM. A spec is handed over
746
- // to the deploy pipeline once it is finished, so every rung sits downstream of
747
- // `complete` — a ticket on "On Test" is a completed spec that has been deployed,
748
- // not a spec that moved somewhere else.
749
- const LADDER_ORIGIN_BUCKET = 'complete'
750
-
751
- // The declared ladder rung with this state name, or null.
752
- function stageForState(state, config) {
753
- if (state == null) return null
754
- const want = String(state).toLowerCase().trim()
755
- const stages = (config && config.release && config.release.stages) || []
756
- if (!Array.isArray(stages)) return null
757
- return stages.find((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want) || null
754
+ // Every tracker state NAME that means this bucket: the one push writes
755
+ // (`states[bucket]`) first, then its read-only aliases (`stateAliases[bucket]`,
756
+ // from the array form of `states`). Readers use the whole list; writers only
757
+ // ever use `states[bucket]`, so an alias can never be written.
758
+ function stateNamesFor(bucket, config) {
759
+ const primary = config && config.states && config.states[bucket]
760
+ const aliases = (config && config.stateAliases && config.stateAliases[bucket]) || []
761
+ return [primary, ...(Array.isArray(aliases) ? aliases : [])].filter((n) => typeof n === 'string' && n)
758
762
  }
759
763
 
760
764
  // Map a remote workflow-state name back to the local lifecycle bucket (the
761
765
  // vocabulary `spec_status` uses) via config.states, so local and remote
762
766
  // workflowState hash equal when semantically equal. Falls back to a lowercased
763
767
  // raw value when the state isn't one of the configured names.
764
- //
765
- // A DECLARED DEPLOYMENT STAGE reads as `complete`, deliberately. Without this
766
- // the fallback lowercases it — "On Test" becomes "on test", which equals no
767
- // bucket — and every deployed spec reports as drifted forever, for the whole
768
- // time it sits in the pipeline. The project told us these states are downstream
769
- // of a finished spec, so they are not a disagreement about where the spec is.
770
768
  function bucketForState(state, config) {
771
769
  if (state == null) return null
772
770
  const states = (config && config.states) || {}
773
771
  const want = String(state).toLowerCase().trim()
774
- for (const [bucket, name] of Object.entries(states)) {
775
- if (typeof name === 'string' && name.toLowerCase().trim() === want) return bucket
772
+ for (const bucket of Object.keys(states)) {
773
+ if (stateNamesFor(bucket, config).some((name) => name.toLowerCase().trim() === want)) return bucket
776
774
  }
777
- if (stageForState(state, config)) return LADDER_ORIGIN_BUCKET
778
775
  return want
779
776
  }
780
777
 
@@ -809,11 +806,20 @@ function remoteWorkflowState(issue, config) {
809
806
  return name != null ? bucketForState(name, config) : null
810
807
  }
811
808
 
812
- // The declared ladder rung a remote issue is currently sitting on, or null.
813
- // Pairs with `remoteWorkflowState`: that says which bucket the issue maps to,
814
- // this says whether it got there by being deployed. Read-only.
815
- function remoteStage(issue, config) {
816
- return stageForState(remoteStateName(issue || {}, config), config)
809
+ // What the drift report needs to know about a remote issue's state: its name,
810
+ // the bucket it maps to, and whether it maps to one at all. Null when the
811
+ // payload carried no state — a cannot-tell, never an answer.
812
+ //
813
+ // AN UNMAPPED STATE IS NOT A DISAGREEMENT. A tracker grows states skitterspec
814
+ // never names ("Blocked", "Next Sprint"), and a ticket a person moved into one
815
+ // is not wrong about the spec — it is somewhere push does not manage, and push
816
+ // will not move it until the spec itself moves. `mapped` is what lets a report
817
+ // say that rather than accuse it.
818
+ function remoteStateReading(issue, config) {
819
+ const name = remoteStateName(issue || {}, config)
820
+ if (name == null) return null
821
+ const bucket = bucketForState(name, config)
822
+ return { name: String(name), bucket, mapped: LIFECYCLE_BUCKETS.includes(bucket) }
817
823
  }
818
824
 
819
825
  // A Linear issue title is plain text, so markdown emphasis is noise there — and
@@ -888,24 +894,16 @@ function titleFromText(text, max = 100) {
888
894
  return stripTitleMarkup(title)
889
895
  }
890
896
 
891
- // Every state NAME the config points at: the lifecycle bucket map, plus the
892
- // project's deployment ladder when it declares one. Both reach Linear the same
893
- // way and fail the same way, so both are checked by the same lookup.
897
+ // Every state NAME the config points at: the lifecycle bucket map.
894
898
  function configuredStateNames(config) {
895
- const names = Object.values((config && config.states) || {}).filter((v) => typeof v === 'string')
896
- const stages = (config && config.release && config.release.stages) || []
897
- for (const stage of Array.isArray(stages) ? stages : []) {
898
- if (stage && typeof stage.state === 'string') names.push(stage.state)
899
- }
900
- return names
899
+ // The WRITTEN names only. An alias is never written, so one the workspace
900
+ // lacks cannot strand a push; `stateSuggestions` still names it in reports.
901
+ return Object.values((config && config.states) || {}).filter((v) => typeof v === 'string')
901
902
  }
902
903
 
903
904
  // Which configured state NAMES are absent from the live workspace. The skill
904
905
  // fetches the workspace's project-status names over MCP and passes them here;
905
906
  // a non-empty result means a typo/rename that Linear would silently no-op.
906
- //
907
- // A project with no `release.stages` contributes nothing here, so the ladder
908
- // cannot make this accuse a config that was fine before it existed.
909
907
  function validateStates(config, workspaceStates) {
910
908
  const configured = configuredStateNames(config)
911
909
  const have = new Set((workspaceStates || []).map((s) => String(s).toLowerCase().trim()))
@@ -944,21 +942,6 @@ function stateSuggestions(config, workspaceStates) {
944
942
  const suggestion = names.find((n) => words.includes(n.toLowerCase().trim())) || null
945
943
  out.push({ bucket, configured, suggestion, label: `states.${bucket}` })
946
944
  }
947
- // Deployment-ladder rungs, reported the same way. No suggestion is offered:
948
- // BUCKET_WORDS describes lifecycle vocabulary, and a stage name is the
949
- // project's own — guessing "On Test" meant "In Progress" would be worse than
950
- // saying nothing.
951
- const stages = (config && config.release && config.release.stages) || []
952
- for (const stage of Array.isArray(stages) ? stages : []) {
953
- if (!stage || typeof stage.state !== 'string') continue
954
- if (have.has(stage.state.toLowerCase().trim())) continue
955
- out.push({
956
- bucket: null,
957
- configured: stage.state,
958
- suggestion: null,
959
- label: `release.stages[${stage.key}]`,
960
- })
961
- }
962
945
  return out
963
946
  }
964
947
 
@@ -982,8 +965,7 @@ module.exports = {
982
965
  canonicalRemoteStatus,
983
966
  bucketForState,
984
967
  LIFECYCLE_BUCKETS,
985
- stageForState,
986
- remoteStage,
987
968
  remoteWorkflowState,
988
- LADDER_ORIGIN_BUCKET,
969
+ remoteStateReading,
970
+ stateNamesFor,
989
971
  }