@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.
Files changed (43) hide show
  1. package/MIGRATION.md +218 -0
  2. package/README.md +127 -6
  3. package/assets/claude-md-section.md +42 -18
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-remote-review.md +22 -0
  6. package/assets/commands/spec-skip.md +24 -0
  7. package/assets/core/env.config.json.example +6 -3
  8. package/assets/core/env.config.md +183 -25
  9. package/assets/hooks/main-guard.cjs +145 -0
  10. package/assets/hooks/review-gate.cjs +8 -3
  11. package/assets/review/page.html +1174 -101
  12. package/assets/rules/offered-unblocks.md +100 -0
  13. package/assets/rules/spec-planning.md +195 -14
  14. package/assets/rules/spec-reports.md +181 -29
  15. package/assets/skills/no-spec/SKILL.md +219 -0
  16. package/assets/skills/spec/SKILL.md +248 -21
  17. package/assets/skills/spec-bug/SKILL.md +34 -24
  18. package/assets/skills/spec-cancel/SKILL.md +29 -0
  19. package/assets/skills/spec-complete/SKILL.md +25 -0
  20. package/assets/skills/spec-diff/SKILL.md +164 -37
  21. package/assets/skills/spec-hotfix/SKILL.md +22 -19
  22. package/assets/skills/spec-init/SKILL.md +4 -2
  23. package/assets/skills/spec-next/SKILL.md +166 -58
  24. package/assets/skills/spec-review/SKILL.md +87 -0
  25. package/assets/skills/spec-reviewed/SKILL.md +31 -5
  26. package/assets/skills/spec-start/SKILL.md +33 -0
  27. package/package.json +1 -1
  28. package/src/cli.js +1612 -172
  29. package/src/env/classify.js +87 -2
  30. package/src/env/config.js +333 -19
  31. package/src/env/hooks.js +102 -33
  32. package/src/env/live.js +124 -1
  33. package/src/env/mainguard.js +240 -0
  34. package/src/env/provision.js +106 -10
  35. package/src/env/registry.js +70 -6
  36. package/src/env/resolve.js +128 -5
  37. package/src/env/review.js +854 -29
  38. package/src/env/reviewers/coderabbit.js +228 -0
  39. package/src/env/reviewers.js +517 -0
  40. package/src/env/serve.js +474 -50
  41. package/src/env/supervise.js +8 -1
  42. package/src/env/teardown.js +12 -1
  43. package/src/init.js +185 -24
@@ -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
- // Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
249
- // gated by the project master switch. A spec resolved without an explicit stack
250
- // (legacy/tests) follows the master switch — preserving pre-`Stack` behaviour.
251
- const stack = spec.stack || (config.docker.enabled ? 'docker' : 'worktree')
252
- const wantsDocker = stack === 'docker' && config.docker.enabled
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(`docker compose --project-name ${spec.projectName} up -d`)
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
- setupCommands: gate.blocked ? [] : setupCommands,
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 = { planUp, planCheckoutUp, planSpecCommit, seedCommandFor, worktreeCd }
479
+ module.exports = {
480
+ planUp,
481
+ planCheckoutUp,
482
+ planSpecCommit,
483
+ seedCommandFor,
484
+ worktreeCd,
485
+ resolveStack,
486
+ composeFileArg,
487
+ }
@@ -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 { slots: isObject(parsed.slots) ? { ...parsed.slots } : {} }
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
- fs.writeFileSync(file, JSON.stringify({ slots: registry.slots }, null, 2) + '\n')
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
  }
@@ -167,7 +167,37 @@ function parsePhase(text, name) {
167
167
  }
168
168
 
169
169
  /**
170
- * The spec's own `## Problem` and `## Impact`, for the page's header.
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
- if (!problem && !impact) return null
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
- const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack, baseRef }
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({ folder: spec.folder, slug: spec.slug, worktreePath: spec.worktreePath })
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,