@skitterbyte/skitterspec-linear 11.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/MIGRATION.md +260 -10
  2. package/README.md +32 -2
  3. package/assets/claude-md-section.md +48 -2
  4. package/assets/commands/spec-connect.md +2 -2
  5. package/assets/commands/spec-live.md +2 -2
  6. package/assets/core/SETUP.md +21 -3
  7. package/assets/core/env.config.json.example +9 -3
  8. package/assets/core/env.config.md +102 -30
  9. package/assets/core/gating.config.json.example +4 -0
  10. package/assets/core/gating.config.md +81 -0
  11. package/assets/core/linear.config.md +67 -8
  12. package/assets/review/page.html +1501 -0
  13. package/assets/rules/spec-planning.md +224 -15
  14. package/assets/rules/spec-reports.md +269 -0
  15. package/assets/skills/spec/SKILL.md +64 -13
  16. package/assets/skills/spec-bug/SKILL.md +193 -27
  17. package/assets/skills/spec-cancel/SKILL.md +99 -8
  18. package/assets/skills/spec-claim/SKILL.md +114 -0
  19. package/assets/skills/spec-complete/SKILL.md +123 -22
  20. package/assets/skills/spec-diff/SKILL.md +564 -0
  21. package/assets/skills/spec-hotfix/SKILL.md +202 -22
  22. package/assets/skills/spec-init/SKILL.md +49 -9
  23. package/assets/skills/spec-linear-setup/SKILL.md +86 -7
  24. package/assets/skills/spec-list/SKILL.md +218 -0
  25. package/assets/skills/spec-next/SKILL.md +300 -7
  26. package/assets/skills/spec-push/SKILL.md +45 -22
  27. package/assets/skills/spec-review/SKILL.md +59 -11
  28. package/assets/skills/spec-reviewed/SKILL.md +241 -0
  29. package/assets/skills/spec-start/SKILL.md +426 -66
  30. package/assets/skills/spec-status/SKILL.md +24 -2
  31. package/assets/skills/spec-sync/SKILL.md +47 -11
  32. package/assets/skills/spec-to-main/SKILL.md +42 -20
  33. package/package.json +11 -7
  34. package/src/cli.js +1710 -80
  35. package/src/env/building.js +143 -0
  36. package/src/env/classify.js +91 -0
  37. package/src/env/config.js +57 -9
  38. package/src/env/provision.js +192 -19
  39. package/src/env/proxy.js +34 -1
  40. package/src/env/render.js +3 -12
  41. package/src/env/resolve.js +296 -9
  42. package/src/env/review.js +1329 -0
  43. package/src/env/serve.js +549 -0
  44. package/src/env/teardown.js +13 -6
  45. package/src/gating.js +155 -0
  46. package/src/init.js +124 -2
  47. package/src/prompts.js +10 -1
  48. package/src/vendor/linear/api.js +104 -1
  49. package/src/vendor/linear/cli-sync.js +874 -17
  50. package/src/vendor/linear/config.js +8 -0
  51. package/src/vendor/linear/credentials.js +94 -0
  52. package/src/vendor/linear/doctor.js +35 -0
  53. package/src/vendor/linear/identity.js +105 -0
  54. package/src/vendor/linear/mcp.js +26 -0
  55. package/src/vendor/sync-core/index.js +6 -2
  56. package/src/vendor/sync-core/src/compare.js +74 -5
  57. package/src/vendor/sync-core/src/normalize.js +30 -0
  58. package/src/vendor/sync-core/src/push.js +11 -1
  59. package/src/vendor/sync-core/src/write.js +38 -0
  60. package/LICENSE +0 -21
package/src/env/render.js CHANGED
@@ -3,24 +3,15 @@
3
3
  /**
4
4
  * Pure renderers for per-spec isolation artifacts.
5
5
  *
6
- * `renderEnvFile` produces the worktree's `.env` body (the only file the engine
7
- * writes). `expandOpenCommand` expands the optional, editor/terminal-agnostic
8
- * `open.command` template. No side effects — unit-testable in isolation.
6
+ * `renderEnvFile` produces the worktree's `.env` body the only file the engine
7
+ * writes. No side effects unit-testable in isolation.
9
8
  */
10
9
 
11
- const { expandTokens } = require('./resolve.js')
12
-
13
10
  // The worktree `.env`: COMPOSE_PROJECT_NAME namespaces the Docker stack and its
14
11
  // named volumes; PORT_OFFSET shifts the spec's reserved port block.
15
12
  function renderEnvFile({ projectName, portOffset }) {
16
13
  return `COMPOSE_PROJECT_NAME=${projectName}\nPORT_OFFSET=${portOffset}\n`
17
14
  }
18
15
 
19
- // Expand the opener template with the provided tokens. An empty/whitespace-only
20
- // template means "no auto-open" → returns null.
21
- function expandOpenCommand(template, tokens) {
22
- if (typeof template !== 'string' || !template.trim()) return null
23
- return expandTokens(template, tokens)
24
- }
25
16
 
26
- module.exports = { renderEnvFile, expandOpenCommand }
17
+ module.exports = { renderEnvFile }
@@ -20,12 +20,19 @@ const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
20
20
 
21
21
  // Find the spec folder under specs/<bucket>/<name>. `specArg` may be a bare
22
22
  // folder name or a path — only its basename is matched against the buckets.
23
- // Searches `dir` first, then any `extraDirs` in order — so a caller (e.g.
24
- // `spec-env integrate`) can fall back to a worktree checkout for a spec that
25
- // was authored on its branch and never committed to the primary checkout.
26
- function findSpecFolder(specArg, dir, extraDirs = []) {
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 = []) {
27
34
  const name = path.basename(specArg)
28
- for (const root of [dir, ...extraDirs]) {
35
+ for (const root of [...preferDirs, dir, ...extraDirs]) {
29
36
  for (const bucket of BUCKETS) {
30
37
  const abs = path.join(root, 'specs', bucket, name)
31
38
  if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
@@ -36,6 +43,209 @@ function findSpecFolder(specArg, dir, extraDirs = []) {
36
43
  return null
37
44
  }
38
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 spec's own `## Problem` and `## Impact`, for the page's header.
171
+ *
172
+ * WHAT WOULD FOOL THIS: a spec that renames those headings, or a legacy bare
173
+ * `<name>.md` with no overview at all. Both yield `null`, which the caller
174
+ * routes to omitting the header — the page rendered without one yesterday and
175
+ * still does (`.claude/rules/negative-checks.md` rule 4).
176
+ */
177
+ function readOverview(specDir, { overviewFile = '00-overview.md' } = {}) {
178
+ let text
179
+ try {
180
+ text = fs.readFileSync(path.join(specDir, overviewFile), 'utf8')
181
+ } catch {
182
+ return null
183
+ }
184
+ const problem = sectionOf(text, 'Problem') || sectionOf(text, 'Symptom')
185
+ const impact = impactRows(sectionOf(text, 'Impact'))
186
+ if (!problem && !impact) return null
187
+ return {
188
+ ...(problem ? { problem } : {}),
189
+ ...(impact ? { impact } : {}),
190
+ }
191
+ }
192
+
193
+ /**
194
+ * The body under one `## Heading`, up to the next one. Trimmed, or null.
195
+ *
196
+ * SLICED, not lookahead-matched. The obvious regex ends with
197
+ * `(?=^##\s|\Z)` — and JS has no `\Z`, so that alternative is a literal
198
+ * `Z` and every section at the END of a file returns nothing. It passed on the
199
+ * spec used to write it, whose Problem happened to be followed by another
200
+ * heading, and failed on a bug spec's Symptom and on any Impact table written
201
+ * last.
202
+ */
203
+ function sectionOf(text, heading) {
204
+ const open = new RegExp(`^##\\s+${heading}\\s*$`, 'm').exec(text)
205
+ if (!open) return null
206
+ const from = open.index + open[0].length
207
+ const next = /^##\s/m.exec(text.slice(from))
208
+ const body = (next ? text.slice(from, from + next.index) : text.slice(from)).trim()
209
+ return body || null
210
+ }
211
+
212
+ /**
213
+ * The Impact table's rows. The heading is always present in a spec, and the
214
+ * table is not — a spec touching no external surface writes a one-line sentence
215
+ * instead, which is a real answer and is returned as prose.
216
+ */
217
+ function impactRows(section) {
218
+ if (!section) return null
219
+ const rows = []
220
+ for (const line of section.split('\n')) {
221
+ if (!line.trim().startsWith('|')) continue
222
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim())
223
+ if (cells.length < 3) continue
224
+ // The header and its `|---|` separator, dropped by shape rather than by
225
+ // position — a spec that omits either still yields its rows.
226
+ if (/^-{2,}$/.test(cells[0].replace(/:/g, ''))) continue
227
+ if (/^surface$/i.test(cells[0]) && /^change$/i.test(cells[1])) continue
228
+ rows.push({ surface: cells[0], change: cells[1], detail: cells[2] })
229
+ }
230
+ if (rows.length) return { rows }
231
+ const prose = section.split('\n').map((l) => l.trim()).filter(Boolean).join(' ').replace(/^_|_$/g, '')
232
+ return prose ? { prose } : null
233
+ }
234
+
235
+ /**
236
+ * Is one phase file finished? Pure.
237
+ *
238
+ * THE STATUS LINE WINS over the heading's emoji. Both are written by the
239
+ * lifecycle skills and both are kept in step, but a hand edit that fixes one
240
+ * and forgets the other far more often leaves a stale emoji than stale prose —
241
+ * and the emoji is the half a reader's eye skips.
242
+ */
243
+ function phaseIsDone(text) {
244
+ const status = /^>.*\*\*Status:\*\*\s*(.+)$/m.exec(text)
245
+ if (status) return /^done\b/i.test(status[1].trim())
246
+ return /^#\s.*✅\s*$/m.test(text)
247
+ }
248
+
39
249
  // Split a `feat-`/`bug-`/`hotfix-` prefix. Unknown prefix → type defaults to
40
250
  // `feat` and the whole folder name is the slug.
41
251
  function splitPrefix(folder) {
@@ -212,18 +422,28 @@ function assertPrimaryOnMain(config, git) {
212
422
  * Resolve a spec argument to its identity + isolation coordinates.
213
423
  * Throws a clear Error when the spec folder can't be found.
214
424
  *
425
+ * `opts.preferDirs` are checkout roots searched BEFORE `dir` — the checkout the
426
+ * caller is standing in, so the spec's bucket and headers come from the branch
427
+ * they are on rather than from the base branch's stale copy.
215
428
  * `opts.searchDirs` adds fallback checkout roots to look under (after `dir`) when
216
- * locating the spec folder; identity/coordinate tokens still expand against `dir`
217
- * (the primary checkout), so a worktree-only spec resolves to the right base.
429
+ * locating the spec folder.
430
+ *
431
+ * **Identity/coordinate tokens still expand against `dir`** (the primary
432
+ * checkout) in every case — `{repo}`, the worktree path, the docker project name
433
+ * and the registry are repo-level facts that must be identical from anywhere.
434
+ * That is `bug-spec-env-cwd-anchor`'s fix and it is deliberately untouched here;
435
+ * only which *file* is read moves.
218
436
  */
219
437
  function resolveSpec(specArg, dir, config, opts = {}) {
220
438
  const searchDirs = opts.searchDirs || []
221
- const found = findSpecFolder(specArg, dir, searchDirs)
439
+ const preferDirs = opts.preferDirs || []
440
+ const found = findSpecFolder(specArg, dir, searchDirs, preferDirs)
222
441
  if (!found) {
223
442
  // Name the roots we looked under: the usual cause is a spec that only exists
224
443
  // on its own branch, and the message should say where we didn't find it.
225
444
  throw new Error(
226
- `spec not found under specs/**: ${specArg} (searched: ${[dir, ...searchDirs].join(', ')})`,
445
+ `spec not found under specs/**: ${specArg} ` +
446
+ `(searched: ${[...preferDirs, dir, ...searchDirs].join(', ')})`,
227
447
  )
228
448
  }
229
449
 
@@ -255,9 +475,70 @@ function resolveSpec(specArg, dir, config, opts = {}) {
255
475
  }
256
476
  }
257
477
 
478
+ /**
479
+ * Every worktree git knows about, as absolute paths — the primary checkout
480
+ * included, since `git worktree list` reports it as one.
481
+ *
482
+ * `git` is an injected reader, as everywhere else in this file: the caller owns
483
+ * the child-process boundary, which is what keeps this module testable without
484
+ * one.
485
+ */
486
+ function liveWorktreePaths(git) {
487
+ const out = git(['worktree', 'list', '--porcelain'])
488
+ const paths = new Set()
489
+ if (out == null) return paths
490
+ for (const line of out.split('\n')) {
491
+ if (line.startsWith('worktree ')) {
492
+ paths.add(path.resolve(line.slice('worktree '.length).trim()))
493
+ }
494
+ }
495
+ return paths
496
+ }
497
+
498
+ // An in-progress spec lives on its *worktree branch*, not the primary checkout,
499
+ // so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
500
+ function collectSpecFolders(roots) {
501
+ const folders = new Set()
502
+ for (const root of roots) {
503
+ for (const bucket of BUCKETS) {
504
+ let entries
505
+ try {
506
+ entries = fs.readdirSync(path.join(root, 'specs', bucket), { withFileTypes: true })
507
+ } catch {
508
+ continue
509
+ }
510
+ for (const entry of entries) if (entry.isDirectory()) folders.add(entry.name)
511
+ }
512
+ }
513
+ return folders
514
+ }
515
+
516
+ /**
517
+ * Resolve every spec folder (found in the primary checkout OR any worktree) to
518
+ * `{ folder, slug, worktreePath }`. `searchDirs` lets `resolveSpec` locate a
519
+ * spec that was authored on its branch and never committed to the primary
520
+ * checkout.
521
+ */
522
+ function allSpecs(dir, config, worktreePaths) {
523
+ const searchDirs = [...worktreePaths]
524
+ const specs = []
525
+ for (const folder of collectSpecFolders([dir, ...searchDirs])) {
526
+ try {
527
+ const spec = resolveSpec(folder, dir, config, { searchDirs })
528
+ specs.push({ folder: spec.folder, slug: spec.slug, worktreePath: spec.worktreePath })
529
+ } catch {
530
+ // Unresolvable folder (not a real spec) — skip.
531
+ }
532
+ }
533
+ return specs
534
+ }
535
+
258
536
  module.exports = {
259
537
  BUCKETS,
260
538
  resolveSpec,
539
+ liveWorktreePaths,
540
+ collectSpecFolders,
541
+ allSpecs,
261
542
  resolveBaseBranch,
262
543
  resolvePrimaryCheckout,
263
544
  currentBranch,
@@ -267,6 +548,12 @@ module.exports = {
267
548
  repoInfo,
268
549
  expandTokens,
269
550
  findSpecFolder,
551
+ readPhases,
552
+ phaseIsDone,
553
+ phaseIsStarted,
554
+ parsePhase,
555
+ readOverview,
556
+ readFrontmatterField,
270
557
  readStackField,
271
558
  readBaseVersionField,
272
559
  }