@skitterbyte/skitterspec-linear 1.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 (45) hide show
  1. package/README.md +56 -0
  2. package/assets/claude-md-section.md +39 -0
  3. package/assets/core/env.config.json.example +28 -0
  4. package/assets/core/env.config.md +99 -0
  5. package/assets/core/linear.config.json.example +39 -0
  6. package/assets/core/linear.config.md +121 -0
  7. package/assets/rules/spec-planning.md +152 -0
  8. package/assets/skills/spec/SKILL.md +232 -0
  9. package/assets/skills/spec-bug/SKILL.md +110 -0
  10. package/assets/skills/spec-cancel/SKILL.md +61 -0
  11. package/assets/skills/spec-complete/SKILL.md +87 -0
  12. package/assets/skills/spec-env/SKILL.md +63 -0
  13. package/assets/skills/spec-env-down/SKILL.md +64 -0
  14. package/assets/skills/spec-go/SKILL.md +134 -0
  15. package/assets/skills/spec-init/SKILL.md +84 -0
  16. package/assets/skills/spec-pull/SKILL.md +46 -0
  17. package/assets/skills/spec-push/SKILL.md +53 -0
  18. package/assets/skills/spec-ready/SKILL.md +50 -0
  19. package/assets/skills/spec-review/SKILL.md +69 -0
  20. package/assets/skills/spec-status/SKILL.md +46 -0
  21. package/bin/skitterspec-linear.js +26 -0
  22. package/package.json +38 -0
  23. package/src/cli.js +495 -0
  24. package/src/deprecate.js +138 -0
  25. package/src/env/config.js +165 -0
  26. package/src/env/integrate.js +46 -0
  27. package/src/env/provision.js +76 -0
  28. package/src/env/registry.js +95 -0
  29. package/src/env/render.js +26 -0
  30. package/src/env/resolve.js +202 -0
  31. package/src/env/teardown.js +109 -0
  32. package/src/env/trust.js +87 -0
  33. package/src/init.js +311 -0
  34. package/src/prompts.js +56 -0
  35. package/src/vendor/linear/cli-sync.js +256 -0
  36. package/src/vendor/linear/config.js +198 -0
  37. package/src/vendor/linear/mcp.js +112 -0
  38. package/src/vendor/sync-core/index.js +35 -0
  39. package/src/vendor/sync-core/src/apply.js +66 -0
  40. package/src/vendor/sync-core/src/base.js +83 -0
  41. package/src/vendor/sync-core/src/compare.js +99 -0
  42. package/src/vendor/sync-core/src/normalize.js +249 -0
  43. package/src/vendor/sync-core/src/pull.js +84 -0
  44. package/src/vendor/sync-core/src/push.js +106 -0
  45. package/src/vendor/sync-core/src/write.js +86 -0
@@ -0,0 +1,165 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Config loader for the per-spec isolation feature (`/spec-env`).
5
+ *
6
+ * Reads `specs/.core/env.config.json` from the project root and normalises it
7
+ * over frozen defaults. The feature is strictly opt-in: when the file is absent
8
+ * the loader never throws — it returns the defaults with `present:false`, which
9
+ * every caller treats as "feature unused".
10
+ *
11
+ * Mirrors the shape/idiom of `assets/scripts/lib/config.js` (frozen defaults,
12
+ * merge known keys only, forward-compatible on unknown keys). Zero-dependency.
13
+ *
14
+ * Shape (see specs/.core/env.config.md for field docs):
15
+ * {
16
+ * worktree: { root, folderPattern },
17
+ * docker: { enabled, composeFile, projectNamePattern, portBase,
18
+ * portsPerSpec, envFile, backupCommand },
19
+ * open: { command }, // optional, editor/terminal-agnostic opener
20
+ * registry: ".spec-env/registry.json",
21
+ * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
22
+ * baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
23
+ * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed }
24
+ * }
25
+ */
26
+
27
+ const { readFileSync } = require('node:fs')
28
+ const { join } = require('node:path')
29
+
30
+ const CONFIG_FILE = join('specs', '.core', 'env.config.json')
31
+
32
+ const DEFAULT_CONFIG = Object.freeze({
33
+ worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
34
+ docker: Object.freeze({
35
+ enabled: true,
36
+ composeFile: 'docker-compose.yml',
37
+ projectNamePattern: '{repoSlug}_{slug}',
38
+ portBase: 3000,
39
+ portsPerSpec: 10,
40
+ envFile: '.env',
41
+ backupCommand: '',
42
+ }),
43
+ open: Object.freeze({ command: '' }),
44
+ registry: '.spec-env/registry.json',
45
+ // Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
46
+ // when a tracker provider is linked, {identifier}; `identifierField` names the
47
+ // 00-overview.md frontmatter field a provider writes the ticket id into (empty
48
+ // = no identifier, so patterns referencing {identifier} fall back to type/slug).
49
+ branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
50
+ // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
51
+ baseBranch: '',
52
+ guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
53
+ })
54
+
55
+ function isObject(value) {
56
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
57
+ }
58
+
59
+ // A fresh, deeply-mutable copy of the defaults to merge onto.
60
+ function defaults() {
61
+ return {
62
+ worktree: { ...DEFAULT_CONFIG.worktree },
63
+ docker: { ...DEFAULT_CONFIG.docker },
64
+ open: { ...DEFAULT_CONFIG.open },
65
+ registry: DEFAULT_CONFIG.registry,
66
+ branch: { ...DEFAULT_CONFIG.branch },
67
+ baseBranch: DEFAULT_CONFIG.baseBranch,
68
+ guards: { ...DEFAULT_CONFIG.guards },
69
+ }
70
+ }
71
+
72
+ // Copy a typed field from parsed[key] onto base[key] when it matches `type`.
73
+ // Strings are trimmed and must be non-empty to override.
74
+ function assign(base, parsed, key, type) {
75
+ const v = parsed[key]
76
+ if (type === 'string') {
77
+ if (typeof v === 'string' && v.trim()) base[key] = v.trim()
78
+ } else if (type === 'string?') {
79
+ // string that may be intentionally empty (e.g. backupCommand)
80
+ if (typeof v === 'string') base[key] = v
81
+ } else if (type === 'boolean') {
82
+ if (typeof v === 'boolean') base[key] = v
83
+ } else if (type === 'number') {
84
+ if (typeof v === 'number' && Number.isFinite(v)) base[key] = v
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Merge a parsed config over the defaults. Only known keys are copied (unknown
90
+ * keys ignored for forward-compat). Nested objects are merged field-by-field.
91
+ */
92
+ function mergeConfig(base, parsed) {
93
+ if (!isObject(parsed)) return base
94
+
95
+ if (isObject(parsed.worktree)) {
96
+ assign(base.worktree, parsed.worktree, 'root', 'string')
97
+ assign(base.worktree, parsed.worktree, 'folderPattern', 'string')
98
+ }
99
+
100
+ if (isObject(parsed.docker)) {
101
+ assign(base.docker, parsed.docker, 'enabled', 'boolean')
102
+ assign(base.docker, parsed.docker, 'composeFile', 'string')
103
+ assign(base.docker, parsed.docker, 'projectNamePattern', 'string')
104
+ assign(base.docker, parsed.docker, 'portBase', 'number')
105
+ assign(base.docker, parsed.docker, 'portsPerSpec', 'number')
106
+ assign(base.docker, parsed.docker, 'envFile', 'string')
107
+ assign(base.docker, parsed.docker, 'backupCommand', 'string?')
108
+ }
109
+
110
+ if (isObject(parsed.open)) {
111
+ // command may be intentionally empty (no auto-open)
112
+ assign(base.open, parsed.open, 'command', 'string?')
113
+ }
114
+
115
+ if (isObject(parsed.branch)) {
116
+ assign(base.branch, parsed.branch, 'pattern', 'string')
117
+ assign(base.branch, parsed.branch, 'identifierField', 'string')
118
+ }
119
+
120
+ assign(base, parsed, 'registry', 'string')
121
+ assign(base, parsed, 'baseBranch', 'string')
122
+
123
+ if (isObject(parsed.guards)) {
124
+ assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
125
+ assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
126
+ }
127
+
128
+ return base
129
+ }
130
+
131
+ /**
132
+ * Load and normalise `specs/.core/env.config.json` from `dir` (default cwd).
133
+ * Returns `{ config, present }`:
134
+ * - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
135
+ * - present → `{ config: merged, present: true }`
136
+ * Malformed JSON → throws a clear Error (callers exit non-zero).
137
+ */
138
+ function loadEnvConfig(dir = process.cwd()) {
139
+ const base = defaults()
140
+ const file = join(dir, CONFIG_FILE)
141
+
142
+ let raw
143
+ try {
144
+ raw = readFileSync(file, 'utf-8')
145
+ } catch (error) {
146
+ if (error.code === 'ENOENT') return { config: base, present: false }
147
+ throw error
148
+ }
149
+
150
+ let parsed
151
+ try {
152
+ parsed = JSON.parse(raw)
153
+ } catch (error) {
154
+ throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
155
+ }
156
+
157
+ return { config: mergeConfig(base, parsed), present: true }
158
+ }
159
+
160
+ module.exports = {
161
+ loadEnvConfig,
162
+ mergeConfig,
163
+ DEFAULT_CONFIG,
164
+ CONFIG_FILE,
165
+ }
@@ -0,0 +1,46 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure integrate planner for `spec-env integrate`.
5
+ *
6
+ * `planIntegrate` decides whether a spec's worktree branch can be landed onto the
7
+ * base branch and, if so, emits the exact commands the `/spec-complete` skill runs
8
+ * (rebase the branch onto base in the worktree, then fast-forward base to it in the
9
+ * primary checkout). It performs no side effects: the caller (the CLI) queries git
10
+ * for `dirty`/`aheadOfBase`/`mainRepoPath` and supplies them, keeping this
11
+ * deterministic and unit-testable with no live git.
12
+ *
13
+ * Strategy is rebase + fast-forward (linear history). Conflict handling lives in
14
+ * the skill: it runs the rebase and, on a non-zero exit, `git rebase --abort` and
15
+ * hands back — so the planner never needs to reason about conflicts.
16
+ *
17
+ * @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
18
+ * @param {object} config normalised env config (unused today; kept for symmetry).
19
+ * @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
20
+ * @returns {object} { blocked, noop, reason, commands, base, branch }
21
+ */
22
+ function planIntegrate(spec, config, ctx) {
23
+ const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
24
+ const branch = spec.branch
25
+ const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
26
+
27
+ // The completion edits must be committed first — never rebase a dirty tree.
28
+ if (worktreeState.dirty) {
29
+ return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
30
+ }
31
+
32
+ // Nothing on the branch that isn't already on base → already landed.
33
+ if (!aheadOfBase) {
34
+ return { ...result, noop: true }
35
+ }
36
+
37
+ return {
38
+ ...result,
39
+ commands: [
40
+ `git -C ${spec.worktreePath} rebase ${base}`,
41
+ `git -C ${mainRepoPath} merge --ff-only ${branch}`,
42
+ ],
43
+ }
44
+ }
45
+
46
+ module.exports = { planIntegrate }
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure provisioning planner for `spec-env up`.
5
+ *
6
+ * Given a resolved spec and its allocated slot, `planUp` returns the exact
7
+ * side-effecting commands the `/spec-env` skill runs (`git worktree add`,
8
+ * `docker compose up`), the rendered `.env` contents, and the expanded opener —
9
+ * but performs no side effects itself. The caller (the CLI) reads/allocates the
10
+ * registry and passes the slot; this stays deterministic and unit-testable with
11
+ * no live git/docker.
12
+ */
13
+
14
+ const { portOffset } = require('./registry.js')
15
+ const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
+
17
+ /**
18
+ * Plan a provisioning run.
19
+ *
20
+ * @param {object} spec resolved spec (from resolveSpec): { slug, type, branch,
21
+ * worktreePath, projectName, ... }
22
+ * @param {object} alloc { slot, attached } — attached:true when the slot already
23
+ * existed in the registry (re-run → attach, don't clobber).
24
+ * @param {object} config normalised env config.
25
+ * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
26
+ * envContents, openCommand, commands, attached }
27
+ */
28
+ function planUp(spec, alloc, config) {
29
+ const { slot, attached } = alloc
30
+
31
+ // Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
32
+ // gated by the project master switch. A spec resolved without an explicit stack
33
+ // (legacy/tests) follows the master switch — preserving pre-`Stack` behaviour.
34
+ const stack = spec.stack || (config.docker.enabled ? 'docker' : 'worktree')
35
+ const wantsDocker = stack === 'docker' && config.docker.enabled
36
+
37
+ // Slot, port block and `.env` are Docker-only. A worktree-only spec takes none
38
+ // of them: no registry slot, no PORT_OFFSET, no `.env`.
39
+ const offset = wantsDocker ? portOffset(slot, config) : null
40
+ const envContents = wantsDocker
41
+ ? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
42
+ : null
43
+
44
+ const openCommand = expandOpenCommand(config.open.command, {
45
+ worktreePath: spec.worktreePath,
46
+ slug: spec.slug,
47
+ branch: spec.branch,
48
+ projectName: spec.projectName,
49
+ portOffset: offset === null ? '' : String(offset),
50
+ })
51
+
52
+ const commands = []
53
+ // Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
54
+ commands.push(
55
+ attached
56
+ ? `git worktree add ${spec.worktreePath} ${spec.branch}`
57
+ : `git worktree add ${spec.worktreePath} -b ${spec.branch}`,
58
+ )
59
+ if (wantsDocker) {
60
+ commands.push(`docker compose --project-name ${spec.projectName} up -d`)
61
+ }
62
+
63
+ return {
64
+ worktreePath: spec.worktreePath,
65
+ branch: spec.branch,
66
+ projectName: spec.projectName,
67
+ slot: wantsDocker ? slot : null,
68
+ portOffset: offset,
69
+ envContents,
70
+ openCommand,
71
+ commands,
72
+ attached,
73
+ }
74
+ }
75
+
76
+ module.exports = { planUp }
@@ -0,0 +1,95 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Slot registry for per-spec isolation — the single source of truth for which
5
+ * spec owns which slot index. It lives at the **primary checkout root** (shared
6
+ * by all worktrees, machine-local, gitignored) at the config-driven `registry`
7
+ * path (default `.spec-env/registry.json`).
8
+ *
9
+ * Slot `n` → a reserved port block: `portOffset = portBase + n * portsPerSpec`.
10
+ *
11
+ * The allocation helpers are pure transforms on a registry object so they can be
12
+ * unit-tested with no filesystem; `readRegistry`/`writeRegistry` are the only IO
13
+ * and are the seam the CLI drives. No `Date.now()`/`Math.random()` — determinism
14
+ * matters (callers pass timestamps when needed).
15
+ */
16
+
17
+ const fs = require('node:fs')
18
+ const path = require('node:path')
19
+
20
+ function isObject(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
22
+ }
23
+
24
+ // Absolute path to the registry file, resolved against the primary checkout root.
25
+ function registryPath(rootDir, config) {
26
+ return path.resolve(rootDir, config.registry)
27
+ }
28
+
29
+ // Read the registry from disk. Missing file → an empty registry (never throws
30
+ // on absence). Malformed JSON → a clear Error.
31
+ function readRegistry(rootDir, config) {
32
+ const file = registryPath(rootDir, config)
33
+ let raw
34
+ try {
35
+ raw = fs.readFileSync(file, 'utf-8')
36
+ } catch (error) {
37
+ if (error.code === 'ENOENT') return { slots: {} }
38
+ throw error
39
+ }
40
+ let parsed
41
+ try {
42
+ parsed = JSON.parse(raw)
43
+ } catch (error) {
44
+ throw new Error(`Invalid registry ${config.registry}: ${error.message}`)
45
+ }
46
+ return { slots: isObject(parsed.slots) ? { ...parsed.slots } : {} }
47
+ }
48
+
49
+ // Persist the registry, creating its parent dir as needed.
50
+ function writeRegistry(rootDir, config, registry) {
51
+ const file = registryPath(rootDir, config)
52
+ fs.mkdirSync(path.dirname(file), { recursive: true })
53
+ fs.writeFileSync(file, JSON.stringify({ slots: registry.slots }, null, 2) + '\n')
54
+ }
55
+
56
+ /**
57
+ * Allocate the lowest free slot index to `name`. Idempotent: if `name` already
58
+ * holds a slot, that slot is returned and the registry is unchanged. Returns a
59
+ * new registry object (does not mutate the input).
60
+ */
61
+ function allocateSlot(registry, name) {
62
+ const slots = { ...registry.slots }
63
+ if (Object.prototype.hasOwnProperty.call(slots, name)) {
64
+ return { registry: { slots }, slot: slots[name] }
65
+ }
66
+ const used = new Set(Object.values(slots))
67
+ let slot = 0
68
+ while (used.has(slot)) slot++
69
+ slots[name] = slot
70
+ return { registry: { slots }, slot }
71
+ }
72
+
73
+ /**
74
+ * Free `name`'s slot. Idempotent: freeing an absent spec is a clean no-op.
75
+ * Returns a new registry object (does not mutate the input).
76
+ */
77
+ function freeSlot(registry, name) {
78
+ const slots = { ...registry.slots }
79
+ delete slots[name]
80
+ return { slots }
81
+ }
82
+
83
+ // Port block base for a slot.
84
+ function portOffset(slot, config) {
85
+ return config.docker.portBase + slot * config.docker.portsPerSpec
86
+ }
87
+
88
+ module.exports = {
89
+ registryPath,
90
+ readRegistry,
91
+ writeRegistry,
92
+ allocateSlot,
93
+ freeSlot,
94
+ portOffset,
95
+ }
@@ -0,0 +1,26 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure renderers for per-spec isolation artifacts.
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.
9
+ */
10
+
11
+ const { expandTokens } = require('./resolve.js')
12
+
13
+ // The worktree `.env`: COMPOSE_PROJECT_NAME namespaces the Docker stack and its
14
+ // named volumes; PORT_OFFSET shifts the spec's reserved port block.
15
+ function renderEnvFile({ projectName, portOffset }) {
16
+ return `COMPOSE_PROJECT_NAME=${projectName}\nPORT_OFFSET=${portOffset}\n`
17
+ }
18
+
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
+
26
+ module.exports = { renderEnvFile, expandOpenCommand }
@@ -0,0 +1,202 @@
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
+ // 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 = []) {
27
+ const name = path.basename(specArg)
28
+ for (const root of [dir, ...extraDirs]) {
29
+ for (const bucket of BUCKETS) {
30
+ const abs = path.join(root, 'specs', bucket, name)
31
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
32
+ return { folder: name, bucket, path: abs }
33
+ }
34
+ }
35
+ }
36
+ return null
37
+ }
38
+
39
+ // Split a `feat-`/`bug-` prefix. Unknown prefix → type defaults to `feat` and
40
+ // the whole folder name is the slug.
41
+ function splitPrefix(folder) {
42
+ const m = /^(feat|bug)-(.+)$/.exec(folder)
43
+ if (m) return { type: m[1], slug: m[2] }
44
+ return { type: 'feat', slug: folder }
45
+ }
46
+
47
+ // Repo identity used for token expansion.
48
+ function repoInfo(dir) {
49
+ const repo = path.basename(dir)
50
+ const repoSlug = repo
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9]+/g, '-')
53
+ .replace(/^-+|-+$/g, '')
54
+ return { repo, repoSlug }
55
+ }
56
+
57
+ // Replace {token} occurrences from `tokens`; unknown tokens are left intact.
58
+ function expandTokens(str, tokens) {
59
+ return String(str).replace(/\{(\w+)\}/g, (m, key) =>
60
+ Object.prototype.hasOwnProperty.call(tokens, key) ? tokens[key] : m,
61
+ )
62
+ }
63
+
64
+ // Read a named field from a spec's 00-overview.md YAML frontmatter, if present.
65
+ // `field` is provider-neutral (e.g. a tracker's ticket-id field, configured via
66
+ // `branch.identifierField`). Returns null when there's no frontmatter / field /
67
+ // file, or no field name was given.
68
+ function readFrontmatterField(specPath, field) {
69
+ if (!field) return null
70
+ const overview = path.join(specPath, '00-overview.md')
71
+ let raw
72
+ try {
73
+ raw = fs.readFileSync(overview, 'utf-8')
74
+ } catch {
75
+ return null
76
+ }
77
+ const fm = /^---\n([\s\S]*?)\n---/.exec(raw)
78
+ if (!fm) return null
79
+ const m = new RegExp(`^${field}:\\s*(.+)$`, 'm').exec(fm[1])
80
+ if (!m) return null
81
+ return m[1].trim().replace(/^["']|["']$/g, '') || null
82
+ }
83
+
84
+ /**
85
+ * Read a spec's `> **Stack:** …` blockquote field from 00-overview.md and map it
86
+ * to the isolation stack: any value containing `docker` → `'docker'`, otherwise
87
+ * `'worktree'`. A spec with no field falls back to the project default — which
88
+ * preserves pre-`Stack` behaviour: with Docker available (`docker.enabled`) a
89
+ * legacy spec still gets Docker, else it's worktree-only. The planner ANDs this
90
+ * with the master switch, so an explicit `worktree` always suppresses Docker.
91
+ */
92
+ function readStackField(specPath, config) {
93
+ const overview = path.join(specPath, '00-overview.md')
94
+ let raw
95
+ try {
96
+ raw = fs.readFileSync(overview, 'utf-8')
97
+ } catch {
98
+ raw = null
99
+ }
100
+ const m = raw && /^>\s*\*\*Stack:\*\*\s*(.+)$/m.exec(raw)
101
+ if (m) {
102
+ return /docker/i.test(m[1]) ? 'docker' : 'worktree'
103
+ }
104
+ return config.docker && config.docker.enabled ? 'docker' : 'worktree'
105
+ }
106
+
107
+ /**
108
+ * Derive the git branch for a spec from the provider-neutral `branch.pattern`
109
+ * (`{type}`, `{slug}`, and optionally `{identifier}`). When the pattern uses
110
+ * `{identifier}`, the id is read from the frontmatter field named by
111
+ * `branch.identifierField` (a tracker provider writes it); if that field is unset
112
+ * or absent on the spec, the branch falls back to `{type}/{slug}` so we never
113
+ * emit a half-expanded name. No knowledge of any specific tracker lives here.
114
+ */
115
+ function branchFor(spec, config) {
116
+ const branch = (config.branch && config.branch.pattern) || '{type}/{slug}'
117
+ const tokens = { type: spec.type, slug: spec.slug }
118
+ if (/\{identifier\}/.test(branch)) {
119
+ const field = config.branch && config.branch.identifierField
120
+ const identifier = readFrontmatterField(spec.path, field)
121
+ if (!identifier) return `${spec.type}/${spec.slug}`
122
+ tokens.identifier = identifier
123
+ }
124
+ return expandTokens(branch, tokens)
125
+ }
126
+
127
+ /**
128
+ * Resolve the integration base branch (the branch specs fork from and land back
129
+ * onto). Precedence:
130
+ * 1. `config.baseBranch` — explicit override
131
+ * 2. `origin/HEAD` — the remote's default branch
132
+ * 3. `main` if it exists locally
133
+ * 4. `master` if it exists locally
134
+ * 5. `main` — last-resort default
135
+ *
136
+ * `git(args)` runs a read-only git command and returns trimmed stdout, or `null`
137
+ * on a non-zero exit / failure. It's injected so this stays pure and unit-testable
138
+ * with no live git; the CLI supplies a real reader. (Note: `show-ref --quiet`
139
+ * emits no stdout on success, so a non-null `''` still means "exists".)
140
+ */
141
+ function resolveBaseBranch(config, git) {
142
+ const explicit = config && typeof config.baseBranch === 'string' && config.baseBranch.trim()
143
+ if (explicit) return explicit
144
+
145
+ const originHead = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
146
+ if (originHead) return originHead.replace(/^origin\//, '')
147
+
148
+ for (const name of ['main', 'master']) {
149
+ if (git(['show-ref', '--verify', '--quiet', `refs/heads/${name}`]) !== null) return name
150
+ }
151
+ return 'main'
152
+ }
153
+
154
+ /**
155
+ * Resolve a spec argument to its identity + isolation coordinates.
156
+ * Throws a clear Error when the spec folder can't be found.
157
+ *
158
+ * `opts.searchDirs` adds fallback checkout roots to look under (after `dir`) when
159
+ * locating the spec folder; identity/coordinate tokens still expand against `dir`
160
+ * (the primary checkout), so a worktree-only spec resolves to the right base.
161
+ */
162
+ function resolveSpec(specArg, dir, config, opts = {}) {
163
+ const found = findSpecFolder(specArg, dir, opts.searchDirs || [])
164
+ if (!found) {
165
+ throw new Error(`spec not found under specs/**: ${specArg}`)
166
+ }
167
+
168
+ const { type, slug } = splitPrefix(found.folder)
169
+ const { repo, repoSlug } = repoInfo(dir)
170
+ const tokens = { repo, repoSlug, slug }
171
+
172
+ const stack = readStackField(found.path, config)
173
+ const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack }
174
+ const branch = branchFor(spec, config)
175
+
176
+ const worktreeRoot = expandTokens(config.worktree.root, tokens)
177
+ const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
178
+ const worktreePath = path.resolve(dir, worktreeRoot, worktreeFolder)
179
+ const projectName = expandTokens(config.docker.projectNamePattern, tokens)
180
+
181
+ return {
182
+ ...spec,
183
+ repo,
184
+ repoSlug,
185
+ branch,
186
+ worktreeRoot,
187
+ worktreeFolder,
188
+ worktreePath,
189
+ projectName,
190
+ }
191
+ }
192
+
193
+ module.exports = {
194
+ resolveSpec,
195
+ resolveBaseBranch,
196
+ branchFor,
197
+ splitPrefix,
198
+ repoInfo,
199
+ expandTokens,
200
+ findSpecFolder,
201
+ readStackField,
202
+ }