@skitterbyte/skitterspec-jira 0.0.0-stage → 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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,716 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure spec/branch resolution for per-spec isolation.
5
+ *
6
+ * Given a spec argument (a folder name or path) it locates the spec folder under
7
+ * `specs/**`, splits the `feat-`/`bug-` prefix into `{ type, slug }`, derives the
8
+ * git branch from the config's `branch.pattern` (provider-neutral; `{identifier}`
9
+ * is filled from a tracker id when one is configured, else it falls back to
10
+ * `{type}/{slug}`), and expands the config's path/name tokens (`{repo}`,
11
+ * `{repoSlug}`, `{slug}`). Reads files to locate the spec and read frontmatter,
12
+ * but makes no git/docker side effects — deterministic and safe to unit-test with
13
+ * fixtures.
14
+ */
15
+
16
+ const fs = require('node:fs')
17
+ const path = require('node:path')
18
+
19
+ const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
20
+
21
+ // Find the spec folder under specs/<bucket>/<name>. `specArg` may be a bare
22
+ // folder name or a path — only its basename is matched against the buckets.
23
+ //
24
+ // Search order is `preferDirs`, then `dir`, then `extraDirs`:
25
+ // - `preferDirs` is the checkout the CALLER is standing in. A spec's bucket
26
+ // and its `Stack:` / `Base version:` headers are properties of the branch,
27
+ // not of the repo — `/spec-start` moves a spec to `in-progress` on the
28
+ // spec's own branch, so the primary checkout keeps showing `backlog`.
29
+ // - `dir` is the primary checkout, and stays the fallback (and the sole
30
+ // source of repo identity — see `resolveSpec`).
31
+ // - `extraDirs` lets a caller (e.g. `spec-env integrate`) reach a spec that
32
+ // was authored on a branch and never committed to the primary checkout.
33
+ function findSpecFolder(specArg, dir, extraDirs = [], preferDirs = []) {
34
+ const name = path.basename(specArg)
35
+ for (const root of [...preferDirs, dir, ...extraDirs]) {
36
+ for (const bucket of BUCKETS) {
37
+ const abs = path.join(root, 'specs', bucket, name)
38
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
39
+ return { folder: name, bucket, path: abs }
40
+ }
41
+ }
42
+ }
43
+ return null
44
+ }
45
+
46
+ /**
47
+ * A spec's phases, as the review page needs them. Never throws.
48
+ *
49
+ * WHAT IT ANSWERS: is there a phase left for `/spec-next` to build? That is the
50
+ * question a `Commit & Continue` button needs, and it is simply `done < total` —
51
+ * `/spec-next` acts on the FIRST UNFINISHED phase, so a spec sitting mid-way
52
+ * through its last phase still has one to build, while a spec whose phases are
53
+ * all done has none whether it was finished a minute ago or a month ago.
54
+ *
55
+ * THREE OUTCOMES. `null` means cannot tell — a legacy bare `<name>.md`, an
56
+ * overview carrying its phases inline, a folder this cannot see. The caller
57
+ * must route that to leaving things as they are: an absence is not evidence
58
+ * that a spec has no phases (`.claude/rules/negative-checks.md` rules 1 and 4).
59
+ *
60
+ * WHAT WOULD FOOL THIS: a spec whose phase files exist but whose statuses were
61
+ * never updated reads as unfinished, so the button stays offered — the harmless
62
+ * direction. The opposite error, reading a live spec as finished, would take a
63
+ * file claiming Done that is not, which is a lie in the repo rather than a gap
64
+ * in this reader.
65
+ */
66
+ function readPhases(specDir, { overviewFile = '00-overview.md' } = {}) {
67
+ let entries
68
+ try {
69
+ entries = fs.readdirSync(specDir, { withFileTypes: true })
70
+ } catch {
71
+ return null
72
+ }
73
+ const files = entries
74
+ .filter((e) => e.isFile() && /^\d\d-.+\.md$/.test(e.name) && e.name !== overviewFile)
75
+ .map((e) => e.name)
76
+ .sort()
77
+ // No phase files is not "no phases" — it is a legacy layout whose phases live
78
+ // inline in the overview, and this reader cannot see them.
79
+ if (!files.length) return null
80
+
81
+ let done = 0
82
+ for (const name of files) {
83
+ let text
84
+ try {
85
+ text = fs.readFileSync(path.join(specDir, name), 'utf8')
86
+ } catch {
87
+ return null
88
+ }
89
+ if (phaseIsDone(text)) done++
90
+ }
91
+ return { total: files.length, done, hasNextPhase: done < files.length, live: livePhase(specDir, files) }
92
+ }
93
+
94
+ /**
95
+ * The phase this diff is about — its number, title, goal and tasks.
96
+ *
97
+ * WHICH PHASE. The one in progress, else the last one done. A goal shown beside
98
+ * a diff that predates it is worse than no goal at all, and those two are the
99
+ * only phases a diff can plausibly be about: work in flight, or work just
100
+ * finished and not yet committed.
101
+ *
102
+ * `null` throughout rather than a half-filled object — a phase file this cannot
103
+ * parse is a phase with nothing to say, and the page omits the section rather
104
+ * than rendering an empty one.
105
+ */
106
+ function livePhase(specDir, files) {
107
+ let inProgress = null
108
+ let lastDone = null
109
+ for (const name of files) {
110
+ let text
111
+ try {
112
+ text = fs.readFileSync(path.join(specDir, name), 'utf8')
113
+ } catch {
114
+ continue
115
+ }
116
+ const parsed = parsePhase(text, name)
117
+ if (!parsed) continue
118
+ if (phaseIsDone(text)) lastDone = parsed
119
+ else if (phaseIsStarted(text)) inProgress = inProgress || parsed
120
+ }
121
+ return inProgress || lastDone
122
+ }
123
+
124
+ /** Is this phase under way? The status line wins, for `phaseIsDone`'s reason. */
125
+ function phaseIsStarted(text) {
126
+ const status = /^>.*\*\*Status:\*\*\s*(.+)$/m.exec(text)
127
+ if (status) return /^in progress\b/i.test(status[1].trim())
128
+ return /^#\s.*🔄\s*$/m.test(text)
129
+ }
130
+
131
+ /**
132
+ * One phase file's readable parts. Pure, and tolerant: every field but `n` is
133
+ * optional, because a phase file someone wrote by hand is still a phase file.
134
+ */
135
+ function parsePhase(text, name) {
136
+ const num = /^(\d\d)-/.exec(name)
137
+ if (!num) return null
138
+ const heading = /^#\s+(.+?)\s*$/m.exec(text)
139
+ // `# Phase 1 — The engine reads the phase index ✅` → the title between the
140
+ // dash and the status emoji, which is the half a reader wants.
141
+ let title = heading ? heading[1] : ''
142
+ title = title.replace(/^Phase\s+\d+\s*[—–-]\s*/i, '').replace(/\s*[⬜🔄✅]\s*$/u, '').trim()
143
+ const goal = /^\*\*Goal:\*\*\s*([\s\S]*?)(?:\n\n|\n##)/m.exec(text)
144
+ // LINE BY LINE, not one regex. A task wraps across lines with the
145
+ // continuation indented, and an `$` under `/m` matches at every line end — so
146
+ // the obvious regex silently truncates every wrapped task at its first line.
147
+ // It looked right on the short ones, which is why this is done the long way.
148
+ const tasks = []
149
+ for (const line of text.split('\n')) {
150
+ const start = /^- \[([ xX])\]\s*(.*)$/.exec(line)
151
+ if (start) {
152
+ tasks.push({ done: start[1].toLowerCase() === 'x', text: start[2].trim() })
153
+ continue
154
+ }
155
+ // An indented non-empty line continues the task above it. Anything else —
156
+ // a blank line, a heading, an unindented paragraph — ends the list.
157
+ if (!tasks.length) continue
158
+ if (/^\s+\S/.test(line)) tasks[tasks.length - 1].text += ' ' + line.trim()
159
+ else if (line.trim()) break
160
+ }
161
+ return {
162
+ n: Number(num[1]),
163
+ title,
164
+ goal: goal ? goal[1].replace(/\s+/g, ' ').trim() : null,
165
+ tasks,
166
+ }
167
+ }
168
+
169
+ /**
170
+ * The tracker ticket a spec is linked to, as `{ id, url }`, or null.
171
+ *
172
+ * Read straight out of the overview's frontmatter, where every provider stamps
173
+ * it. The base is tracker-free, so it hardcodes no provider's key names:
174
+ * every provider stamps `<prefix>_identifier` (`linear_identifier`,
175
+ * `jira_identifier`, …) with `<prefix>_url` beside it, so the id is whichever
176
+ * key matches that shape. A caller that knows the exact field (from
177
+ * `branch.identifierField`) may pass it; an unlinked spec, or a project with no
178
+ * provider installed, has no matching key and gets `null`.
179
+ *
180
+ * ONLY http(s) URLS SURVIVE. The value comes out of a file someone edits, and
181
+ * the page turns it into an `href`; `javascript:` in that position is script
182
+ * execution on a page served over the network. An id with an unusable url is
183
+ * still worth having, so the id is kept and the link dropped rather than the
184
+ * whole ticket.
185
+ */
186
+ function readTicket(text, identifierField) {
187
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text)
188
+ if (!fm) return null
189
+ const field = (name) => {
190
+ const m = new RegExp(`^${name}:\\s*(.*)$`, 'm').exec(fm[1])
191
+ if (!m) return null
192
+ const v = m[1].trim().replace(/^["']|["']$/g, '').trim()
193
+ return v || null
194
+ }
195
+ let idField = identifierField || null
196
+ if (!idField) {
197
+ // First stamped `<prefix>_identifier` key wins. Two providers stamping one
198
+ // spec does not happen — the distributions are install-one-of.
199
+ const m = /^([A-Za-z][A-Za-z0-9]*)_identifier:/m.exec(fm[1])
200
+ idField = m ? `${m[1]}_identifier` : null
201
+ }
202
+ if (!idField) return null
203
+ const id = field(idField)
204
+ if (!id) return null
205
+ const urlField = idField.endsWith('_identifier')
206
+ ? idField.replace(/_identifier$/, '_url')
207
+ : `${idField}_url`
208
+ const url = field(urlField)
209
+ return { id, url: url && /^https?:\/\//i.test(url) ? url : null }
210
+ }
211
+
212
+ /**
213
+ * The spec's own `## Problem` and `## Impact`, plus its tracker ticket, for the
214
+ * page's header.
215
+ *
216
+ * WHAT WOULD FOOL THIS: a spec that renames those headings, or a legacy bare
217
+ * `<name>.md` with no overview at all. Both yield `null`, which the caller
218
+ * routes to omitting the header — the page rendered without one yesterday and
219
+ * still does (`.claude/rules/negative-checks.md` rule 4).
220
+ */
221
+ function readOverview(specDir, { overviewFile = '00-overview.md', identifierField = null } = {}) {
222
+ let text
223
+ try {
224
+ text = fs.readFileSync(path.join(specDir, overviewFile), 'utf8')
225
+ } catch {
226
+ return null
227
+ }
228
+ const problem = sectionOf(text, 'Problem') || sectionOf(text, 'Symptom')
229
+ const impact = impactRows(sectionOf(text, 'Impact'))
230
+ const ticket = readTicket(text, identifierField)
231
+ // The ticket counts towards "is there anything to say": a spec linked to a
232
+ // tracker but carrying neither section still has a header worth drawing.
233
+ if (!problem && !impact && !ticket) return null
234
+ return {
235
+ ...(problem ? { problem } : {}),
236
+ ...(impact ? { impact } : {}),
237
+ ...(ticket ? { ticket } : {}),
238
+ }
239
+ }
240
+
241
+ /**
242
+ * The body under one `## Heading`, up to the next one. Trimmed, or null.
243
+ *
244
+ * SLICED, not lookahead-matched. The obvious regex ends with
245
+ * `(?=^##\s|\Z)` — and JS has no `\Z`, so that alternative is a literal
246
+ * `Z` and every section at the END of a file returns nothing. It passed on the
247
+ * spec used to write it, whose Problem happened to be followed by another
248
+ * heading, and failed on a bug spec's Symptom and on any Impact table written
249
+ * last.
250
+ */
251
+ function sectionOf(text, heading) {
252
+ const open = new RegExp(`^##\\s+${heading}\\s*$`, 'm').exec(text)
253
+ if (!open) return null
254
+ const from = open.index + open[0].length
255
+ const next = /^##\s/m.exec(text.slice(from))
256
+ const body = (next ? text.slice(from, from + next.index) : text.slice(from)).trim()
257
+ return body || null
258
+ }
259
+
260
+ /**
261
+ * The Impact table's rows. The heading is always present in a spec, and the
262
+ * table is not — a spec touching no external surface writes a one-line sentence
263
+ * instead, which is a real answer and is returned as prose.
264
+ */
265
+ function impactRows(section) {
266
+ if (!section) return null
267
+ const rows = []
268
+ for (const line of section.split('\n')) {
269
+ if (!line.trim().startsWith('|')) continue
270
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim())
271
+ if (cells.length < 3) continue
272
+ // The header and its `|---|` separator, dropped by shape rather than by
273
+ // position — a spec that omits either still yields its rows.
274
+ if (/^-{2,}$/.test(cells[0].replace(/:/g, ''))) continue
275
+ if (/^surface$/i.test(cells[0]) && /^change$/i.test(cells[1])) continue
276
+ rows.push({ surface: cells[0], change: cells[1], detail: cells[2] })
277
+ }
278
+ if (rows.length) return { rows }
279
+ const prose = section.split('\n').map((l) => l.trim()).filter(Boolean).join(' ').replace(/^_|_$/g, '')
280
+ return prose ? { prose } : null
281
+ }
282
+
283
+ /**
284
+ * Is one phase file finished? Pure.
285
+ *
286
+ * THE STATUS LINE WINS over the heading's emoji. Both are written by the
287
+ * lifecycle skills and both are kept in step, but a hand edit that fixes one
288
+ * and forgets the other far more often leaves a stale emoji than stale prose —
289
+ * and the emoji is the half a reader's eye skips.
290
+ */
291
+ function phaseIsDone(text) {
292
+ const status = /^>.*\*\*Status:\*\*\s*(.+)$/m.exec(text)
293
+ if (status) return /^done\b/i.test(status[1].trim())
294
+ return /^#\s.*✅\s*$/m.test(text)
295
+ }
296
+
297
+ // Split a `feat-`/`bug-`/`hotfix-` prefix. Unknown prefix → type defaults to
298
+ // `feat` and the whole folder name is the slug.
299
+ function splitPrefix(folder) {
300
+ const m = /^(feat|bug|hotfix)-(.+)$/.exec(folder)
301
+ if (m) return { type: m[1], slug: m[2] }
302
+ return { type: 'feat', slug: folder }
303
+ }
304
+
305
+ // Repo identity used for token expansion.
306
+ function repoInfo(dir) {
307
+ const repo = path.basename(dir)
308
+ const repoSlug = repo
309
+ .toLowerCase()
310
+ .replace(/[^a-z0-9]+/g, '-')
311
+ .replace(/^-+|-+$/g, '')
312
+ return { repo, repoSlug }
313
+ }
314
+
315
+ // Replace {token} occurrences from `tokens`; unknown tokens are left intact.
316
+ function expandTokens(str, tokens) {
317
+ return String(str).replace(/\{(\w+)\}/g, (m, key) =>
318
+ Object.prototype.hasOwnProperty.call(tokens, key) ? tokens[key] : m,
319
+ )
320
+ }
321
+
322
+ // Read a named field from a spec's 00-overview.md YAML frontmatter, if present.
323
+ // `field` is provider-neutral (e.g. a tracker's ticket-id field, configured via
324
+ // `branch.identifierField`). Returns null when there's no frontmatter / field /
325
+ // file, or no field name was given.
326
+ function readFrontmatterField(specPath, field) {
327
+ if (!field) return null
328
+ const overview = path.join(specPath, '00-overview.md')
329
+ let raw
330
+ try {
331
+ raw = fs.readFileSync(overview, 'utf-8')
332
+ } catch {
333
+ return null
334
+ }
335
+ const fm = /^---\n([\s\S]*?)\n---/.exec(raw)
336
+ if (!fm) return null
337
+ const m = new RegExp(`^${field}:\\s*(.+)$`, 'm').exec(fm[1])
338
+ if (!m) return null
339
+ return m[1].trim().replace(/^["']|["']$/g, '') || null
340
+ }
341
+
342
+ /**
343
+ * Read a spec's `> **Stack:** …` blockquote field from 00-overview.md and map it
344
+ * to the isolation stack: any value containing `docker` → `'docker'`, otherwise
345
+ * `'worktree'`. A spec with no field falls back to the project default — which
346
+ * preserves pre-`Stack` behaviour: with Docker available (`docker.enabled`) a
347
+ * legacy spec still gets Docker, else it's worktree-only. The planner ANDs this
348
+ * with the master switch, so an explicit `worktree` always suppresses Docker.
349
+ */
350
+ function readStackField(specPath, config) {
351
+ const overview = path.join(specPath, '00-overview.md')
352
+ let raw
353
+ try {
354
+ raw = fs.readFileSync(overview, 'utf-8')
355
+ } catch {
356
+ raw = null
357
+ }
358
+ const m = raw && /^>\s*\*\*Stack:\*\*\s*(.+)$/m.exec(raw)
359
+ if (m) {
360
+ return /docker/i.test(m[1]) ? 'docker' : 'worktree'
361
+ }
362
+ return config.docker && config.docker.enabled ? 'docker' : 'worktree'
363
+ }
364
+
365
+ /**
366
+ * Read a spec's `> **Base version:** <tag>` blockquote field from 00-overview.md.
367
+ * This is the release tag a hotfix forks its worktree from (e.g. `v33.16.4`),
368
+ * authored by the `/spec-hotfix` skill. Returns the trimmed tag, or null when the
369
+ * field / file is absent — so a non-hotfix spec resolves to `baseRef: null` and
370
+ * provisioning forks from base HEAD as before.
371
+ */
372
+ function readBaseVersionField(specPath) {
373
+ const overview = path.join(specPath, '00-overview.md')
374
+ let raw
375
+ try {
376
+ raw = fs.readFileSync(overview, 'utf-8')
377
+ } catch {
378
+ return null
379
+ }
380
+ const m = /^>\s*\*\*Base version:\*\*\s*(.+)$/m.exec(raw)
381
+ if (!m) return null
382
+ return m[1].trim().replace(/^["'`]|["'`]$/g, '') || null
383
+ }
384
+
385
+ /**
386
+ * Derive the git branch for a spec from the provider-neutral `branch.pattern`
387
+ * (`{type}`, `{slug}`, and optionally `{identifier}`). When the pattern uses
388
+ * `{identifier}`, the id is read from the frontmatter field named by
389
+ * `branch.identifierField` (a tracker provider writes it); if that field is unset
390
+ * or absent on the spec, the branch falls back to `{type}/{slug}` so we never
391
+ * emit a half-expanded name. No knowledge of any specific tracker lives here.
392
+ */
393
+ function branchFor(spec, config) {
394
+ const branch = (config.branch && config.branch.pattern) || '{type}/{slug}'
395
+ const tokens = { type: spec.type, slug: spec.slug }
396
+ if (/\{identifier\}/.test(branch)) {
397
+ const field = config.branch && config.branch.identifierField
398
+ const identifier = readFrontmatterField(spec.path, field)
399
+ if (!identifier) return `${spec.type}/${spec.slug}`
400
+ tokens.identifier = identifier
401
+ }
402
+ return expandTokens(branch, tokens)
403
+ }
404
+
405
+ /**
406
+ * Resolve the integration base branch (the branch specs fork from and land back
407
+ * onto). Precedence:
408
+ * 1. `config.baseBranch` — explicit override
409
+ * 2. `origin/HEAD` — the remote's default branch
410
+ * 3. `main` if it exists locally
411
+ * 4. `master` if it exists locally
412
+ * 5. `main` — last-resort default
413
+ *
414
+ * `git(args)` runs a read-only git command and returns trimmed stdout, or `null`
415
+ * on a non-zero exit / failure. It's injected so this stays pure and unit-testable
416
+ * with no live git; the CLI supplies a real reader. (Note: `show-ref --quiet`
417
+ * emits no stdout on success, so a non-null `''` still means "exists".)
418
+ */
419
+ function resolveBaseBranch(config, git) {
420
+ const explicit = config && typeof config.baseBranch === 'string' && config.baseBranch.trim()
421
+ if (explicit) return explicit
422
+
423
+ const originHead = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
424
+ if (originHead) return originHead.replace(/^origin\//, '')
425
+
426
+ for (const name of ['main', 'master']) {
427
+ if (git(['show-ref', '--verify', '--quiet', `refs/heads/${name}`]) !== null) return name
428
+ }
429
+ return 'main'
430
+ }
431
+
432
+ /**
433
+ * Resolve `dir` to the primary checkout root — the parent of the shared git dir.
434
+ * From the primary checkout `git rev-parse --git-common-dir` is `.git` (relative),
435
+ * so the parent is `dir`; from a linked worktree it's the absolute `<main>/.git`,
436
+ * so the parent is `<main>`. Anchoring every `spec-env` command here means they
437
+ * resolve `{repo}` / worktree paths / the registry identically whether run from
438
+ * `main` or a worktree. `git(args)` returns trimmed stdout or `null` (not a repo)
439
+ * — injected for testability; a `null` degrades to `dir` (today's behaviour).
440
+ */
441
+ function resolvePrimaryCheckout(dir, git) {
442
+ const common = git(['rev-parse', '--git-common-dir'])
443
+ return common ? path.dirname(path.resolve(dir, common)) : dir
444
+ }
445
+
446
+ /**
447
+ * The current branch of a checkout, or `null` when detached (or not a repo).
448
+ * `git` is a reader bound to the target checkout — for the live-overlay guard the
449
+ * CLI binds it to the primary checkout. (`symbolic-ref --short HEAD` exits
450
+ * non-zero on a detached HEAD, which the reader maps to `null`.)
451
+ */
452
+ function currentBranch(git) {
453
+ return git(['symbolic-ref', '--short', 'HEAD'])
454
+ }
455
+
456
+ /**
457
+ * The live-overlay guard: is the primary checkout on the integration base branch
458
+ * (free) or on a feature branch (a spec is in control)? Returns
459
+ * `{ onBase, branch, baseBranch }` — a structured result, never a throw, so each
460
+ * caller phrases its own refusal. `git` must be bound to the primary checkout.
461
+ * `onBase` is false on a detached HEAD (`branch === null`), which is not the base.
462
+ */
463
+ function assertPrimaryOnMain(config, git) {
464
+ const baseBranch = resolveBaseBranch(config, git)
465
+ const branch = currentBranch(git)
466
+ return { onBase: branch === baseBranch, branch, baseBranch }
467
+ }
468
+
469
+ /**
470
+ * Resolve a spec argument to its identity + isolation coordinates.
471
+ * Throws a clear Error when the spec folder can't be found.
472
+ *
473
+ * `opts.preferDirs` are checkout roots searched BEFORE `dir` — the checkout the
474
+ * caller is standing in, so the spec's bucket and headers come from the branch
475
+ * they are on rather than from the base branch's stale copy.
476
+ * `opts.searchDirs` adds fallback checkout roots to look under (after `dir`) when
477
+ * locating the spec folder.
478
+ *
479
+ * **Identity/coordinate tokens still expand against `dir`** (the primary
480
+ * checkout) in every case — `{repo}`, the worktree path, the docker project name
481
+ * and the registry are repo-level facts that must be identical from anywhere.
482
+ * That is `bug-spec-env-cwd-anchor`'s fix and it is deliberately untouched here;
483
+ * only which *file* is read moves.
484
+ */
485
+ /**
486
+ * Resolve a SPECLESS branch — `/no-spec` work — to the same shape `resolveSpec`
487
+ * returns, minus every field that comes from a spec document.
488
+ *
489
+ * `bucket`, `path`, `stack` and `baseRef` are null rather than invented: there
490
+ * is no document to read them from, and a plausible default would make a
491
+ * consumer believe it had read one. `specless: true` is the flag every caller
492
+ * branches on, so "is there a spec here?" is a field rather than an inference
493
+ * from a missing `path`.
494
+ *
495
+ * The branch is `chore/{slug}` by default — `branch.pattern` already carries a
496
+ * `{type}`, so nothing about branch naming is special-cased here. A recorded
497
+ * entry may name its own `branch`, which wins: the record is what the worktree
498
+ * was actually created against, and re-deriving it would go wrong the moment a
499
+ * project changed its pattern under a standing branch.
500
+ *
501
+ * Pure but for `repoInfo`, like `resolveSpec`.
502
+ */
503
+ function resolveSpecless(name, dir, config, entry = {}) {
504
+ const { repo, repoSlug } = repoInfo(dir)
505
+ // AN AUTHORING RECORD RESOLVES AS THE SPEC IT WILL BE. `spec: true` marks a
506
+ // record written by `up --docs` for a spec not written yet (the `/spec`
507
+ // authoring lane), so type and slug come from the name's own prefix — the
508
+ // branch and worktree it answers with MUST equal what the written spec will
509
+ // later resolve to, or the confirm step lands in one tree and the documents
510
+ // in another. A plain `/no-spec` record keeps the whole name as its slug,
511
+ // byte-identical to before.
512
+ const authored = entry && entry.spec === true
513
+ const slug = authored ? splitPrefix(name).slug : name
514
+ const tokens = { repo, repoSlug, slug }
515
+ const spec = {
516
+ folder: name,
517
+ bucket: null,
518
+ path: null,
519
+ type: authored ? splitPrefix(name).type : (entry && entry.type) || 'chore',
520
+ slug,
521
+ stack: 'worktree',
522
+ baseRef: null,
523
+ specless: true,
524
+ unwritten: authored,
525
+ }
526
+ const branch = (entry && entry.branch) || branchFor(spec, config)
527
+ const worktreeRoot = expandTokens(config.worktree.root, tokens)
528
+ const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
529
+ return {
530
+ ...spec,
531
+ repo,
532
+ repoSlug,
533
+ branch,
534
+ worktreeRoot,
535
+ worktreeFolder,
536
+ worktreePath: path.resolve(dir, worktreeRoot, worktreeFolder),
537
+ projectName: expandTokens(config.docker.projectNamePattern, tokens),
538
+ }
539
+ }
540
+
541
+ function resolveSpec(specArg, dir, config, opts = {}) {
542
+ const searchDirs = opts.searchDirs || []
543
+ const preferDirs = opts.preferDirs || []
544
+ const found = findSpecFolder(specArg, dir, searchDirs, preferDirs)
545
+ if (!found) {
546
+ // A SPECLESS BRANCH, but only on the registry's word. `/no-spec` work has a
547
+ // worktree and a branch and no spec document anywhere, so it can never be
548
+ // found by looking under `specs/**` — and "not found there" is not evidence
549
+ // of it, because a typo looks identical (`.claude/rules/negative-checks.md`
550
+ // rule 1). The caller passes the registry's specless map, and being named in
551
+ // it is the positive signal; without one, nothing changes and the error
552
+ // below is thrown exactly as before.
553
+ if (opts.specless && Object.prototype.hasOwnProperty.call(opts.specless, specArg)) {
554
+ return resolveSpecless(specArg, dir, config, opts.specless[specArg])
555
+ }
556
+ // Name the roots we looked under: the usual cause is a spec that only exists
557
+ // on its own branch, and the message should say where we didn't find it.
558
+ // The code is what lets `up --docs` catch exactly this refusal and open the
559
+ // authoring lane — matching on the message would make its wording API.
560
+ const err = new Error(
561
+ `spec not found under specs/**: ${specArg} ` +
562
+ `(searched: ${[...preferDirs, dir, ...searchDirs].join(', ')})`,
563
+ )
564
+ err.code = 'SPEC_NOT_FOUND'
565
+ throw err
566
+ }
567
+
568
+ const { type, slug } = splitPrefix(found.folder)
569
+ const { repo, repoSlug } = repoInfo(dir)
570
+ const tokens = { repo, repoSlug, slug }
571
+
572
+ const stack = readStackField(found.path, config)
573
+ // A hotfix forks its worktree from a release tag (its `Base version`) instead
574
+ // of base HEAD; every other type resolves to baseRef:null (fork from HEAD).
575
+ const baseRef = type === 'hotfix' ? readBaseVersionField(found.path) : null
576
+ // `specless: false` is written out rather than left off, so every consumer
577
+ // reads the same field on every resolution instead of treating `undefined` as
578
+ // an answer.
579
+ const spec = {
580
+ folder: found.folder,
581
+ bucket: found.bucket,
582
+ path: found.path,
583
+ type,
584
+ slug,
585
+ stack,
586
+ baseRef,
587
+ specless: false,
588
+ }
589
+ const branch = branchFor(spec, config)
590
+
591
+ const worktreeRoot = expandTokens(config.worktree.root, tokens)
592
+ const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
593
+ const worktreePath = path.resolve(dir, worktreeRoot, worktreeFolder)
594
+ const projectName = expandTokens(config.docker.projectNamePattern, tokens)
595
+
596
+ return {
597
+ ...spec,
598
+ repo,
599
+ repoSlug,
600
+ branch,
601
+ worktreeRoot,
602
+ worktreeFolder,
603
+ worktreePath,
604
+ projectName,
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Every worktree git knows about, as absolute paths — the primary checkout
610
+ * included, since `git worktree list` reports it as one.
611
+ *
612
+ * `git` is an injected reader, as everywhere else in this file: the caller owns
613
+ * the child-process boundary, which is what keeps this module testable without
614
+ * one.
615
+ */
616
+ function liveWorktreePaths(git) {
617
+ const out = git(['worktree', 'list', '--porcelain'])
618
+ const paths = new Set()
619
+ if (out == null) return paths
620
+ for (const line of out.split('\n')) {
621
+ if (line.startsWith('worktree ')) {
622
+ paths.add(path.resolve(line.slice('worktree '.length).trim()))
623
+ }
624
+ }
625
+ return paths
626
+ }
627
+
628
+ // An in-progress spec lives on its *worktree branch*, not the primary checkout,
629
+ // so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
630
+ function collectSpecFolders(roots) {
631
+ const folders = new Set()
632
+ for (const root of roots) {
633
+ for (const bucket of BUCKETS) {
634
+ let entries
635
+ try {
636
+ entries = fs.readdirSync(path.join(root, 'specs', bucket), { withFileTypes: true })
637
+ } catch {
638
+ continue
639
+ }
640
+ for (const entry of entries) if (entry.isDirectory()) folders.add(entry.name)
641
+ }
642
+ }
643
+ return folders
644
+ }
645
+
646
+ /**
647
+ * Resolve every spec folder (found in the primary checkout OR any worktree) to
648
+ * `{ folder, slug, worktreePath }`. `searchDirs` lets `resolveSpec` locate a
649
+ * spec that was authored on its branch and never committed to the primary
650
+ * checkout.
651
+ */
652
+ function allSpecs(dir, config, worktreePaths, specless = {}) {
653
+ const searchDirs = [...worktreePaths]
654
+ const specs = []
655
+ for (const folder of collectSpecFolders([dir, ...searchDirs])) {
656
+ try {
657
+ const spec = resolveSpec(folder, dir, config, { searchDirs })
658
+ specs.push({
659
+ folder: spec.folder,
660
+ slug: spec.slug,
661
+ worktreePath: spec.worktreePath,
662
+ specless: false,
663
+ })
664
+ } catch {
665
+ // Unresolvable folder (not a real spec) — skip.
666
+ }
667
+ }
668
+ // SPECLESS BRANCHES ARE LISTED TOO, and they have to be added here rather than
669
+ // found: `collectSpecFolders` scans `specs/**`, where by definition they are
670
+ // not. Leaving them out is what would make a `/no-spec` worktree invisible to
671
+ // `status` and to every bare resolution — the outcome decision 7 rejected for
672
+ // a plain git branch, arrived at by omission instead.
673
+ //
674
+ // A FOLDER SHADOWS ITS OWN RECORD. An authoring record (`up --docs` on an
675
+ // unwritten spec) shares its name with the folder `/spec` then writes, and
676
+ // once that folder exists it is the answer — listing the record beside it
677
+ // would show one spec twice.
678
+ const collected = new Set(specs.map((s) => s.folder))
679
+ for (const name of Object.keys(specless).sort()) {
680
+ if (collected.has(name)) continue
681
+ const spec = resolveSpecless(name, dir, config, specless[name])
682
+ specs.push({
683
+ folder: spec.folder,
684
+ slug: spec.slug,
685
+ worktreePath: spec.worktreePath,
686
+ specless: true,
687
+ })
688
+ }
689
+ return specs
690
+ }
691
+
692
+ module.exports = {
693
+ BUCKETS,
694
+ resolveSpec,
695
+ resolveSpecless,
696
+ liveWorktreePaths,
697
+ collectSpecFolders,
698
+ allSpecs,
699
+ resolveBaseBranch,
700
+ resolvePrimaryCheckout,
701
+ currentBranch,
702
+ assertPrimaryOnMain,
703
+ branchFor,
704
+ splitPrefix,
705
+ repoInfo,
706
+ expandTokens,
707
+ findSpecFolder,
708
+ readPhases,
709
+ phaseIsDone,
710
+ phaseIsStarted,
711
+ parsePhase,
712
+ readOverview,
713
+ readFrontmatterField,
714
+ readStackField,
715
+ readBaseVersionField,
716
+ }