@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.
- package/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- 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
|
+
}
|