@skitterbyte/skitterspec 21.0.0 → 22.1.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 +218 -0
- package/README.md +127 -6
- package/assets/claude-md-section.md +42 -18
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +6 -3
- package/assets/core/env.config.md +183 -25
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +1174 -101
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +195 -14
- package/assets/rules/spec-reports.md +181 -29
- package/assets/skills/no-spec/SKILL.md +219 -0
- package/assets/skills/spec/SKILL.md +248 -21
- package/assets/skills/spec-bug/SKILL.md +34 -24
- package/assets/skills/spec-cancel/SKILL.md +29 -0
- package/assets/skills/spec-complete/SKILL.md +25 -0
- package/assets/skills/spec-diff/SKILL.md +164 -37
- package/assets/skills/spec-hotfix/SKILL.md +22 -19
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +166 -58
- package/assets/skills/spec-review/SKILL.md +87 -0
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +33 -0
- package/package.json +1 -1
- package/src/cli.js +1612 -172
- package/src/env/classify.js +87 -2
- package/src/env/config.js +333 -19
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +124 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +128 -5
- package/src/env/review.js +854 -29
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +474 -50
- package/src/env/supervise.js +8 -1
- package/src/env/teardown.js +12 -1
- package/src/init.js +185 -24
package/src/env/provision.js
CHANGED
|
@@ -230,6 +230,60 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
230
230
|
return ok
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The `-f <composeFile>` argument for a `docker compose` invocation.
|
|
235
|
+
*
|
|
236
|
+
* ONLY when the configured file is known to be present, and that condition is
|
|
237
|
+
* the whole point. `docker.composeFile` was read, normalised and validated by
|
|
238
|
+
* the config loader and then never passed to docker at all, so a project naming
|
|
239
|
+
* a non-default file was silently relying on docker's own discovery instead.
|
|
240
|
+
* Passing it unconditionally would fix that project and BREAK the mirror image
|
|
241
|
+
* of it — one whose compose file is `compose.yaml` (a name docker finds by
|
|
242
|
+
* itself) while this key sits at its untouched default of `docker-compose.yml`.
|
|
243
|
+
*
|
|
244
|
+
* So the flag is added on the positive signal and omitted otherwise, which
|
|
245
|
+
* leaves docker's discovery exactly as it is for everyone it was already
|
|
246
|
+
* working for.
|
|
247
|
+
*/
|
|
248
|
+
function composeFileArg(config, present) {
|
|
249
|
+
const file = config && config.docker && config.docker.composeFile
|
|
250
|
+
return present === true && file ? ` -f ${file}` : ''
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Does this spec want a Docker stack?
|
|
255
|
+
*
|
|
256
|
+
* ONE function, called by `planUp` and by the `up` command in `cli.js`. They
|
|
257
|
+
* used to each carry their own copy and the copies disagreed: the CLI read
|
|
258
|
+
* `spec.stack === 'docker'` with no fallback, so a header-less spec allocated no
|
|
259
|
+
* registry slot, while the planner fell back to the master switch and emitted
|
|
260
|
+
* `docker compose up -d` anyway — a stack brought up on a port offset derived
|
|
261
|
+
* from a slot that was never allocated.
|
|
262
|
+
*
|
|
263
|
+
* A SPEC WITH NO `Stack:` HEADER NEEDS THE COMPOSE FILE TO BE PRESENT. The
|
|
264
|
+
* master switch alone is not evidence that there is a stack to bring up: it is
|
|
265
|
+
* a project-wide default, and `/spec` has written the header on every spec for
|
|
266
|
+
* some time, so the specs reaching this fallback are legacy or hand-written. The
|
|
267
|
+
* compose file is the positive signal that the escalation would land on
|
|
268
|
+
* something real (`.claude/rules/negative-checks.md` rule 1).
|
|
269
|
+
*
|
|
270
|
+
* WHAT WOULD FOOL THIS, deliberately unhandled: a project whose compose file is
|
|
271
|
+
* generated during `setup` rather than committed. It does not exist when the
|
|
272
|
+
* plan is built, so a header-less spec there resolves to `worktree`. The answer
|
|
273
|
+
* is an explicit `> **Stack:** worktree + docker` header, which is checked first
|
|
274
|
+
* and never consults this at all — so being wrong costs a header, not a stack.
|
|
275
|
+
*
|
|
276
|
+
* `composeFilePresent` undefined means the caller did not look, which is
|
|
277
|
+
* cannot-tell rather than false. It routes to no escalation, the harmless
|
|
278
|
+
* branch: a missing stack surfaces as a header to add, where a spurious one
|
|
279
|
+
* surfaces as `docker compose` failing mid-provision (rule 4).
|
|
280
|
+
*/
|
|
281
|
+
function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
|
|
282
|
+
const stack =
|
|
283
|
+
spec.stack || (config.docker.enabled && composeFilePresent === true ? 'docker' : 'worktree')
|
|
284
|
+
return { stack, wantsDocker: stack === 'docker' && config.docker.enabled && !docs }
|
|
285
|
+
}
|
|
286
|
+
|
|
233
287
|
/**
|
|
234
288
|
* Plan a provisioning run.
|
|
235
289
|
*
|
|
@@ -238,18 +292,34 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
238
292
|
* @param {object} alloc { slot, attached } — attached:true when the slot already
|
|
239
293
|
* existed in the registry (re-run → attach, don't clobber).
|
|
240
294
|
* @param {object} config normalised env config.
|
|
295
|
+
* @param {object} opts { docs } — documents mode (see below). Optional.
|
|
241
296
|
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
242
297
|
* envContents, commands, seedCommands,
|
|
243
|
-
* setupCommands, attached }
|
|
298
|
+
* setupCommands, docs, attached }
|
|
244
299
|
*/
|
|
245
|
-
function planUp(spec, alloc, config, ctx) {
|
|
300
|
+
function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
246
301
|
const { slot, attached } = alloc
|
|
247
302
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
303
|
+
// DOCUMENTS MODE — a worktree to write a spec in, not to run code in.
|
|
304
|
+
//
|
|
305
|
+
// `/spec` provisions before it writes the spec folder, so that authoring never
|
|
306
|
+
// touches the base branch. What it needs is a checkout on a branch; what it
|
|
307
|
+
// does not need is `pnpm install --frozen-lockfile` and a database, to write
|
|
308
|
+
// markdown. So this mode keeps the fork and drops everything whose only
|
|
309
|
+
// purpose is making the tree runnable.
|
|
310
|
+
//
|
|
311
|
+
// It is not a lesser provision that has to be undone: `/spec-start` re-runs
|
|
312
|
+
// `up` over the same worktree WITHOUT this flag, and the setup commands run
|
|
313
|
+
// then — at the first moment the tree is used for code.
|
|
314
|
+
const docs = opts.docs === true
|
|
315
|
+
|
|
316
|
+
// Per-spec escalation, decided in ONE place (`resolveStack`) so the planner and
|
|
317
|
+
// the CLI cannot answer it differently — which they did, for exactly the
|
|
318
|
+
// header-less spec this gate is about.
|
|
319
|
+
const { wantsDocker } = resolveStack(spec, config, {
|
|
320
|
+
docs,
|
|
321
|
+
composeFilePresent: ctx ? ctx.composeFilePresent : undefined,
|
|
322
|
+
})
|
|
253
323
|
|
|
254
324
|
// Slot, port block and `.env` are Docker-only. A worktree-only spec takes none
|
|
255
325
|
// of them: no registry slot, no PORT_OFFSET, no `.env`.
|
|
@@ -298,7 +368,10 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
298
368
|
: `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
|
|
299
369
|
)
|
|
300
370
|
if (wantsDocker) {
|
|
301
|
-
commands.push(
|
|
371
|
+
commands.push(
|
|
372
|
+
`docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +
|
|
373
|
+
` --project-name ${spec.projectName} up -d`,
|
|
374
|
+
)
|
|
302
375
|
}
|
|
303
376
|
|
|
304
377
|
// The tree gate. A worktree forks from the base branch's tree, so an
|
|
@@ -325,7 +398,16 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
325
398
|
envContents,
|
|
326
399
|
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
327
400
|
seedCommands: gate.blocked ? [] : seedCommands,
|
|
328
|
-
|
|
401
|
+
// Documents mode drops these, and keeps the seed commands above. The two are
|
|
402
|
+
// not the same kind of thing: seeding links the gitignored files a worktree
|
|
403
|
+
// has none of — cheap, and a spec author may well want `.env` present —
|
|
404
|
+
// while `setup` is the expensive half that exists to make the tree run.
|
|
405
|
+
setupCommands: gate.blocked || docs ? [] : setupCommands,
|
|
406
|
+
// Reported so a caller reading `--json` can tell a documents worktree from a
|
|
407
|
+
// full one POSITIVELY, rather than inferring it from what is missing. An
|
|
408
|
+
// empty `setupCommands` also describes a project that configured no setup
|
|
409
|
+
// at all, and those two must not read alike.
|
|
410
|
+
docs,
|
|
329
411
|
attached,
|
|
330
412
|
}
|
|
331
413
|
}
|
|
@@ -347,6 +429,12 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
347
429
|
* refusal — it is the re-run, and the answer is "already attached".
|
|
348
430
|
*
|
|
349
431
|
* There is no bootstrap: the primary checkout already has its dependencies.
|
|
432
|
+
*
|
|
433
|
+
* AND THAT IS WHY `--docs` IS INERT HERE, rather than unimplemented. Documents
|
|
434
|
+
* mode exists to skip making a fresh tree runnable; this mode makes no fresh
|
|
435
|
+
* tree, so there is nothing for it to skip. The flag is accepted and changes
|
|
436
|
+
* nothing, because a caller passing it is asking for a cheap checkout and gets
|
|
437
|
+
* one — refusing would make `/spec` mode-aware for no behavioural difference.
|
|
350
438
|
*/
|
|
351
439
|
function planCheckoutUp(spec, ctx, config) {
|
|
352
440
|
const base = ctx.base || (config && config.baseBranch) || 'main'
|
|
@@ -388,4 +476,12 @@ function planCheckoutUp(spec, ctx, config) {
|
|
|
388
476
|
return result
|
|
389
477
|
}
|
|
390
478
|
|
|
391
|
-
module.exports = {
|
|
479
|
+
module.exports = {
|
|
480
|
+
planUp,
|
|
481
|
+
planCheckoutUp,
|
|
482
|
+
planSpecCommit,
|
|
483
|
+
seedCommandFor,
|
|
484
|
+
worktreeCd,
|
|
485
|
+
resolveStack,
|
|
486
|
+
composeFileArg,
|
|
487
|
+
}
|
package/src/env/registry.js
CHANGED
|
@@ -34,7 +34,7 @@ function readRegistry(rootDir, config) {
|
|
|
34
34
|
try {
|
|
35
35
|
raw = fs.readFileSync(file, 'utf-8')
|
|
36
36
|
} catch (error) {
|
|
37
|
-
if (error.code === 'ENOENT') return { slots: {} }
|
|
37
|
+
if (error.code === 'ENOENT') return { slots: {}, specless: {} }
|
|
38
38
|
throw error
|
|
39
39
|
}
|
|
40
40
|
let parsed
|
|
@@ -43,14 +43,24 @@ function readRegistry(rootDir, config) {
|
|
|
43
43
|
} catch (error) {
|
|
44
44
|
throw new Error(`Invalid registry ${config.registry}: ${error.message}`)
|
|
45
45
|
}
|
|
46
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
slots: isObject(parsed.slots) ? { ...parsed.slots } : {},
|
|
48
|
+
specless: isObject(parsed.specless) ? { ...parsed.specless } : {},
|
|
49
|
+
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
// Persist the registry, creating its parent dir as needed.
|
|
50
53
|
function writeRegistry(rootDir, config, registry) {
|
|
51
54
|
const file = registryPath(rootDir, config)
|
|
52
55
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
53
|
-
|
|
56
|
+
// `specless` is written only when it holds something, so a project that never
|
|
57
|
+
// ran `/no-spec` keeps a registry file byte-identical to the one it had
|
|
58
|
+
// before this key existed.
|
|
59
|
+
const payload = { slots: registry.slots }
|
|
60
|
+
if (registry.specless && Object.keys(registry.specless).length) {
|
|
61
|
+
payload.specless = registry.specless
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2) + '\n')
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/**
|
|
@@ -60,14 +70,19 @@ function writeRegistry(rootDir, config, registry) {
|
|
|
60
70
|
*/
|
|
61
71
|
function allocateSlot(registry, name) {
|
|
62
72
|
const slots = { ...registry.slots }
|
|
73
|
+
// CARRIED THROUGH, NOT REBUILT. Every one of these helpers returns a whole
|
|
74
|
+
// registry, so a helper that forgets a key silently deletes it on the next
|
|
75
|
+
// write — which is how a `/no-spec` branch would have become unresolvable the
|
|
76
|
+
// moment any Docker spec was provisioned beside it.
|
|
77
|
+
const specless = { ...(registry.specless || {}) }
|
|
63
78
|
if (Object.prototype.hasOwnProperty.call(slots, name)) {
|
|
64
|
-
return { registry: { slots }, slot: slots[name] }
|
|
79
|
+
return { registry: { slots, specless }, slot: slots[name] }
|
|
65
80
|
}
|
|
66
81
|
const used = new Set(Object.values(slots))
|
|
67
82
|
let slot = 0
|
|
68
83
|
while (used.has(slot)) slot++
|
|
69
84
|
slots[name] = slot
|
|
70
|
-
return { registry: { slots }, slot }
|
|
85
|
+
return { registry: { slots, specless }, slot }
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/**
|
|
@@ -77,7 +92,7 @@ function allocateSlot(registry, name) {
|
|
|
77
92
|
function freeSlot(registry, name) {
|
|
78
93
|
const slots = { ...registry.slots }
|
|
79
94
|
delete slots[name]
|
|
80
|
-
return { slots }
|
|
95
|
+
return { slots, specless: { ...(registry.specless || {}) } }
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
// Port block base for a slot.
|
|
@@ -85,6 +100,51 @@ function portOffset(slot, config) {
|
|
|
85
100
|
return config.docker.portBase + slot * config.docker.portsPerSpec
|
|
86
101
|
}
|
|
87
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Record a SPECLESS branch — `/no-spec` work, which has a worktree and a branch
|
|
105
|
+
* and no spec document anywhere.
|
|
106
|
+
*
|
|
107
|
+
* A SEPARATE KEY RATHER THAN A SLOT, and that is the whole design of it. A slot
|
|
108
|
+
* means a reserved port block, which mechanical work does not want; and every
|
|
109
|
+
* consumer of `slots` reads an entry there as "this spec runs a Docker stack".
|
|
110
|
+
* Sharing the map would have made `/no-spec` allocate ports it never binds and
|
|
111
|
+
* appear as a stateful spec in `status`.
|
|
112
|
+
*
|
|
113
|
+
* It is what makes a specless name RESOLVABLE. Without a record, "no spec folder
|
|
114
|
+
* for this name" is indistinguishable from a typo — and `resolve.js` would have
|
|
115
|
+
* to treat every unknown name as possibly-specless, which is exactly the
|
|
116
|
+
* absence-as-evidence this codebase refuses (`.claude/rules/negative-checks.md`
|
|
117
|
+
* rule 1). This is the positive signal: the engine wrote this name down.
|
|
118
|
+
*
|
|
119
|
+
* Idempotent, and pure — returns a new registry object.
|
|
120
|
+
*/
|
|
121
|
+
function recordSpecless(registry, name, entry = {}) {
|
|
122
|
+
const specless = { ...(registry.specless || {}) }
|
|
123
|
+
specless[name] = { ...(specless[name] || {}), ...entry }
|
|
124
|
+
return { slots: { ...registry.slots }, specless }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Forget a specless branch. Idempotent: forgetting an absent one is a clean
|
|
129
|
+
* no-op, which is what lets teardown call it without looking first.
|
|
130
|
+
*/
|
|
131
|
+
function forgetSpecless(registry, name) {
|
|
132
|
+
const specless = { ...(registry.specless || {}) }
|
|
133
|
+
delete specless[name]
|
|
134
|
+
return { slots: { ...registry.slots }, specless }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Is this name a recorded specless branch? Pure, and the positive signal
|
|
138
|
+
// `resolve.js` asks for before it will resolve a name with no spec folder.
|
|
139
|
+
function isSpecless(registry, name) {
|
|
140
|
+
return Object.prototype.hasOwnProperty.call((registry && registry.specless) || {}, name)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Every recorded specless name, sorted so output is stable. Pure.
|
|
144
|
+
function speclessNames(registry) {
|
|
145
|
+
return Object.keys((registry && registry.specless) || {}).sort()
|
|
146
|
+
}
|
|
147
|
+
|
|
88
148
|
module.exports = {
|
|
89
149
|
registryPath,
|
|
90
150
|
readRegistry,
|
|
@@ -92,4 +152,8 @@ module.exports = {
|
|
|
92
152
|
allocateSlot,
|
|
93
153
|
freeSlot,
|
|
94
154
|
portOffset,
|
|
155
|
+
recordSpecless,
|
|
156
|
+
forgetSpecless,
|
|
157
|
+
isSpecless,
|
|
158
|
+
speclessNames,
|
|
95
159
|
}
|
package/src/env/resolve.js
CHANGED
|
@@ -167,7 +167,37 @@ function parsePhase(text, name) {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
|
-
* The spec
|
|
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 this knows nothing about Linear beyond the
|
|
174
|
+
* two key names a provider writes — an unlinked spec, or a project with no
|
|
175
|
+
* provider installed, simply has neither and gets `null`.
|
|
176
|
+
*
|
|
177
|
+
* ONLY http(s) URLS SURVIVE. The value comes out of a file someone edits, and
|
|
178
|
+
* the page turns it into an `href`; `javascript:` in that position is script
|
|
179
|
+
* execution on a page served over the network. An id with an unusable url is
|
|
180
|
+
* still worth having, so the id is kept and the link dropped rather than the
|
|
181
|
+
* whole ticket.
|
|
182
|
+
*/
|
|
183
|
+
function readTicket(text) {
|
|
184
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(text)
|
|
185
|
+
if (!fm) return null
|
|
186
|
+
const field = (name) => {
|
|
187
|
+
const m = new RegExp(`^${name}:\\s*(.*)$`, 'm').exec(fm[1])
|
|
188
|
+
if (!m) return null
|
|
189
|
+
const v = m[1].trim().replace(/^["']|["']$/g, '').trim()
|
|
190
|
+
return v || null
|
|
191
|
+
}
|
|
192
|
+
const id = field('linear_identifier')
|
|
193
|
+
if (!id) return null
|
|
194
|
+
const url = field('linear_url')
|
|
195
|
+
return { id, url: url && /^https?:\/\//i.test(url) ? url : null }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The spec's own `## Problem` and `## Impact`, plus its tracker ticket, for the
|
|
200
|
+
* page's header.
|
|
171
201
|
*
|
|
172
202
|
* WHAT WOULD FOOL THIS: a spec that renames those headings, or a legacy bare
|
|
173
203
|
* `<name>.md` with no overview at all. Both yield `null`, which the caller
|
|
@@ -183,10 +213,14 @@ function readOverview(specDir, { overviewFile = '00-overview.md' } = {}) {
|
|
|
183
213
|
}
|
|
184
214
|
const problem = sectionOf(text, 'Problem') || sectionOf(text, 'Symptom')
|
|
185
215
|
const impact = impactRows(sectionOf(text, 'Impact'))
|
|
186
|
-
|
|
216
|
+
const ticket = readTicket(text)
|
|
217
|
+
// The ticket counts towards "is there anything to say": a spec linked to a
|
|
218
|
+
// tracker but carrying neither section still has a header worth drawing.
|
|
219
|
+
if (!problem && !impact && !ticket) return null
|
|
187
220
|
return {
|
|
188
221
|
...(problem ? { problem } : {}),
|
|
189
222
|
...(impact ? { impact } : {}),
|
|
223
|
+
...(ticket ? { ticket } : {}),
|
|
190
224
|
}
|
|
191
225
|
}
|
|
192
226
|
|
|
@@ -434,11 +468,68 @@ function assertPrimaryOnMain(config, git) {
|
|
|
434
468
|
* That is `bug-spec-env-cwd-anchor`'s fix and it is deliberately untouched here;
|
|
435
469
|
* only which *file* is read moves.
|
|
436
470
|
*/
|
|
471
|
+
/**
|
|
472
|
+
* Resolve a SPECLESS branch — `/no-spec` work — to the same shape `resolveSpec`
|
|
473
|
+
* returns, minus every field that comes from a spec document.
|
|
474
|
+
*
|
|
475
|
+
* `bucket`, `path`, `stack` and `baseRef` are null rather than invented: there
|
|
476
|
+
* is no document to read them from, and a plausible default would make a
|
|
477
|
+
* consumer believe it had read one. `specless: true` is the flag every caller
|
|
478
|
+
* branches on, so "is there a spec here?" is a field rather than an inference
|
|
479
|
+
* from a missing `path`.
|
|
480
|
+
*
|
|
481
|
+
* The branch is `chore/{slug}` by default — `branch.pattern` already carries a
|
|
482
|
+
* `{type}`, so nothing about branch naming is special-cased here. A recorded
|
|
483
|
+
* entry may name its own `branch`, which wins: the record is what the worktree
|
|
484
|
+
* was actually created against, and re-deriving it would go wrong the moment a
|
|
485
|
+
* project changed its pattern under a standing branch.
|
|
486
|
+
*
|
|
487
|
+
* Pure but for `repoInfo`, like `resolveSpec`.
|
|
488
|
+
*/
|
|
489
|
+
function resolveSpecless(name, dir, config, entry = {}) {
|
|
490
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
491
|
+
const slug = name
|
|
492
|
+
const tokens = { repo, repoSlug, slug }
|
|
493
|
+
const spec = {
|
|
494
|
+
folder: name,
|
|
495
|
+
bucket: null,
|
|
496
|
+
path: null,
|
|
497
|
+
type: (entry && entry.type) || 'chore',
|
|
498
|
+
slug,
|
|
499
|
+
stack: 'worktree',
|
|
500
|
+
baseRef: null,
|
|
501
|
+
specless: true,
|
|
502
|
+
}
|
|
503
|
+
const branch = (entry && entry.branch) || branchFor(spec, config)
|
|
504
|
+
const worktreeRoot = expandTokens(config.worktree.root, tokens)
|
|
505
|
+
const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
|
|
506
|
+
return {
|
|
507
|
+
...spec,
|
|
508
|
+
repo,
|
|
509
|
+
repoSlug,
|
|
510
|
+
branch,
|
|
511
|
+
worktreeRoot,
|
|
512
|
+
worktreeFolder,
|
|
513
|
+
worktreePath: path.resolve(dir, worktreeRoot, worktreeFolder),
|
|
514
|
+
projectName: expandTokens(config.docker.projectNamePattern, tokens),
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
437
518
|
function resolveSpec(specArg, dir, config, opts = {}) {
|
|
438
519
|
const searchDirs = opts.searchDirs || []
|
|
439
520
|
const preferDirs = opts.preferDirs || []
|
|
440
521
|
const found = findSpecFolder(specArg, dir, searchDirs, preferDirs)
|
|
441
522
|
if (!found) {
|
|
523
|
+
// A SPECLESS BRANCH, but only on the registry's word. `/no-spec` work has a
|
|
524
|
+
// worktree and a branch and no spec document anywhere, so it can never be
|
|
525
|
+
// found by looking under `specs/**` — and "not found there" is not evidence
|
|
526
|
+
// of it, because a typo looks identical (`.claude/rules/negative-checks.md`
|
|
527
|
+
// rule 1). The caller passes the registry's specless map, and being named in
|
|
528
|
+
// it is the positive signal; without one, nothing changes and the error
|
|
529
|
+
// below is thrown exactly as before.
|
|
530
|
+
if (opts.specless && Object.prototype.hasOwnProperty.call(opts.specless, specArg)) {
|
|
531
|
+
return resolveSpecless(specArg, dir, config, opts.specless[specArg])
|
|
532
|
+
}
|
|
442
533
|
// Name the roots we looked under: the usual cause is a spec that only exists
|
|
443
534
|
// on its own branch, and the message should say where we didn't find it.
|
|
444
535
|
throw new Error(
|
|
@@ -455,7 +546,19 @@ function resolveSpec(specArg, dir, config, opts = {}) {
|
|
|
455
546
|
// A hotfix forks its worktree from a release tag (its `Base version`) instead
|
|
456
547
|
// of base HEAD; every other type resolves to baseRef:null (fork from HEAD).
|
|
457
548
|
const baseRef = type === 'hotfix' ? readBaseVersionField(found.path) : null
|
|
458
|
-
|
|
549
|
+
// `specless: false` is written out rather than left off, so every consumer
|
|
550
|
+
// reads the same field on every resolution instead of treating `undefined` as
|
|
551
|
+
// an answer.
|
|
552
|
+
const spec = {
|
|
553
|
+
folder: found.folder,
|
|
554
|
+
bucket: found.bucket,
|
|
555
|
+
path: found.path,
|
|
556
|
+
type,
|
|
557
|
+
slug,
|
|
558
|
+
stack,
|
|
559
|
+
baseRef,
|
|
560
|
+
specless: false,
|
|
561
|
+
}
|
|
459
562
|
const branch = branchFor(spec, config)
|
|
460
563
|
|
|
461
564
|
const worktreeRoot = expandTokens(config.worktree.root, tokens)
|
|
@@ -519,23 +622,43 @@ function collectSpecFolders(roots) {
|
|
|
519
622
|
* spec that was authored on its branch and never committed to the primary
|
|
520
623
|
* checkout.
|
|
521
624
|
*/
|
|
522
|
-
function allSpecs(dir, config, worktreePaths) {
|
|
625
|
+
function allSpecs(dir, config, worktreePaths, specless = {}) {
|
|
523
626
|
const searchDirs = [...worktreePaths]
|
|
524
627
|
const specs = []
|
|
525
628
|
for (const folder of collectSpecFolders([dir, ...searchDirs])) {
|
|
526
629
|
try {
|
|
527
630
|
const spec = resolveSpec(folder, dir, config, { searchDirs })
|
|
528
|
-
specs.push({
|
|
631
|
+
specs.push({
|
|
632
|
+
folder: spec.folder,
|
|
633
|
+
slug: spec.slug,
|
|
634
|
+
worktreePath: spec.worktreePath,
|
|
635
|
+
specless: false,
|
|
636
|
+
})
|
|
529
637
|
} catch {
|
|
530
638
|
// Unresolvable folder (not a real spec) — skip.
|
|
531
639
|
}
|
|
532
640
|
}
|
|
641
|
+
// SPECLESS BRANCHES ARE LISTED TOO, and they have to be added here rather than
|
|
642
|
+
// found: `collectSpecFolders` scans `specs/**`, where by definition they are
|
|
643
|
+
// not. Leaving them out is what would make a `/no-spec` worktree invisible to
|
|
644
|
+
// `status` and to every bare resolution — the outcome decision 7 rejected for
|
|
645
|
+
// a plain git branch, arrived at by omission instead.
|
|
646
|
+
for (const name of Object.keys(specless).sort()) {
|
|
647
|
+
const spec = resolveSpecless(name, dir, config, specless[name])
|
|
648
|
+
specs.push({
|
|
649
|
+
folder: spec.folder,
|
|
650
|
+
slug: spec.slug,
|
|
651
|
+
worktreePath: spec.worktreePath,
|
|
652
|
+
specless: true,
|
|
653
|
+
})
|
|
654
|
+
}
|
|
533
655
|
return specs
|
|
534
656
|
}
|
|
535
657
|
|
|
536
658
|
module.exports = {
|
|
537
659
|
BUCKETS,
|
|
538
660
|
resolveSpec,
|
|
661
|
+
resolveSpecless,
|
|
539
662
|
liveWorktreePaths,
|
|
540
663
|
collectSpecFolders,
|
|
541
664
|
allSpecs,
|