@skitterbyte/skitterspec-jira 0.0.0-stage → 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 (109) hide show
  1. package/MIGRATION.md +1089 -0
  2. package/README.md +42 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +554 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +61 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +692 -0
  23. package/assets/rules/spec-reports.md +557 -0
  24. package/assets/skills/no-spec/SKILL.md +246 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +583 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +448 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +605 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +220 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +676 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +112 -0
  40. package/assets/skills/spec-sync/SKILL.md +218 -0
  41. package/assets/skills/spec-to-main/SKILL.md +139 -0
  42. package/bin/skitterspec-jira.js +61 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5901 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +753 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/hooks.js +226 -0
  53. package/src/env/hotfix.js +385 -0
  54. package/src/env/hotfixland.js +302 -0
  55. package/src/env/integrate.js +126 -0
  56. package/src/env/live.js +504 -0
  57. package/src/env/mainguard.js +299 -0
  58. package/src/env/provision.js +466 -0
  59. package/src/env/proxy.js +192 -0
  60. package/src/env/prune.js +119 -0
  61. package/src/env/registry.js +159 -0
  62. package/src/env/render.js +17 -0
  63. package/src/env/resolve.js +716 -0
  64. package/src/env/review.js +2577 -0
  65. package/src/env/reviewers/coderabbit.js +228 -0
  66. package/src/env/reviewers.js +517 -0
  67. package/src/env/serve.js +1044 -0
  68. package/src/env/supervise.js +157 -0
  69. package/src/env/teardown.js +248 -0
  70. package/src/env/trust.js +87 -0
  71. package/src/gating.js +155 -0
  72. package/src/init.js +1150 -0
  73. package/src/lines-diff.js +114 -0
  74. package/src/prompts.js +129 -0
  75. package/src/vendor/jira/adf.js +414 -0
  76. package/src/vendor/jira/api.js +295 -0
  77. package/src/vendor/jira/cli-sync.js +1256 -0
  78. package/src/vendor/jira/commands.js +44 -0
  79. package/src/vendor/jira/config.js +296 -0
  80. package/src/vendor/jira/credentials.js +108 -0
  81. package/src/vendor/jira/dialect.js +45 -0
  82. package/src/vendor/jira/mcp.js +129 -0
  83. package/src/vendor/provider-kit/index.js +41 -0
  84. package/src/vendor/provider-kit/src/adapter.js +23 -0
  85. package/src/vendor/provider-kit/src/apply.js +197 -0
  86. package/src/vendor/provider-kit/src/args.js +68 -0
  87. package/src/vendor/provider-kit/src/config.js +198 -0
  88. package/src/vendor/provider-kit/src/credentials.js +404 -0
  89. package/src/vendor/provider-kit/src/doctor.js +35 -0
  90. package/src/vendor/provider-kit/src/http.js +44 -0
  91. package/src/vendor/provider-kit/src/identity.js +93 -0
  92. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  93. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  94. package/src/vendor/provider-kit/src/prompt.js +50 -0
  95. package/src/vendor/provider-kit/src/released.js +226 -0
  96. package/src/vendor/provider-kit/src/specs.js +166 -0
  97. package/src/vendor/provider-kit/src/transport.js +22 -0
  98. package/src/vendor/sync-core/index.js +92 -0
  99. package/src/vendor/sync-core/src/base.js +81 -0
  100. package/src/vendor/sync-core/src/compare.js +306 -0
  101. package/src/vendor/sync-core/src/dialect.js +51 -0
  102. package/src/vendor/sync-core/src/fields.js +36 -0
  103. package/src/vendor/sync-core/src/legacy.js +100 -0
  104. package/src/vendor/sync-core/src/normalize.js +989 -0
  105. package/src/vendor/sync-core/src/push.js +85 -0
  106. package/src/vendor/sync-core/src/retarget.js +278 -0
  107. package/src/vendor/sync-core/src/task-block.js +308 -0
  108. package/src/vendor/sync-core/src/verify.js +67 -0
  109. package/src/vendor/sync-core/src/write.js +191 -0
package/src/env/dev.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure planner for host dev-process supervision (`spec-env dev up|down`).
5
+ *
6
+ * Given a resolved spec and its allocated slot, `planDev` computes — for each
7
+ * configured `dev` process — its resolved port, the env var to inject, the log
8
+ * and pid file paths, the expanded health URL, and the canonical `frontPort` a
9
+ * later proxy (Phase 2) routes to it. No side effects: the CLI's `supervise.js`
10
+ * does the spawning/killing; this stays unit-testable with no processes.
11
+ *
12
+ * Port math: process `i` in the slot's block gets `portBase + slot*portsPerSpec
13
+ * + i` (reusing the same block the Docker stack draws from — see registry.js).
14
+ * So host dev servers get a reserved block even on a worktree-only spec.
15
+ *
16
+ * Log/pid files live beside the registry (default `.spec-env/`) and are keyed by
17
+ * the spec **folder** (not the bare slug) so a `feat-foo`/`bug-foo` pair can't
18
+ * collide — matching how the registry keys slots.
19
+ */
20
+
21
+ const path = require('node:path')
22
+ const { portOffset } = require('./registry.js')
23
+ const { expandTokens } = require('./resolve.js')
24
+
25
+ // The state dir (logs + pids) sits beside the registry file, e.g. `.spec-env`.
26
+ function stateDir(config) {
27
+ return path.posix.dirname(config.registry) || '.spec-env'
28
+ }
29
+
30
+ /**
31
+ * Plan the host dev processes for `spec` at `slot`.
32
+ *
33
+ * @returns {object} { slot, portOffset, procs: [{ name, command, port, portVar,
34
+ * env, frontPort, logFile, pidFile, health }] } — logFile/pidFile are paths
35
+ * relative to the primary checkout root (the CLI resolves them).
36
+ */
37
+ function planDev(spec, slot, config) {
38
+ const base = portOffset(slot, config)
39
+ const dir = stateDir(config)
40
+ const procs = (config.dev || []).map((entry, i) => {
41
+ const port = base + i
42
+ const tokens = {
43
+ [entry.portVar]: String(port),
44
+ port: String(port),
45
+ slug: spec.slug,
46
+ name: entry.name,
47
+ }
48
+ return {
49
+ name: entry.name,
50
+ command: expandTokens(entry.command, tokens),
51
+ port,
52
+ portVar: entry.portVar,
53
+ env: { [entry.portVar]: String(port) },
54
+ frontPort: typeof entry.frontPort === 'number' ? entry.frontPort : null,
55
+ logFile: `${dir}/logs/${spec.folder}-${entry.name}.log`,
56
+ pidFile: `${dir}/pids/${spec.folder}-${entry.name}.pid`,
57
+ health: entry.health ? expandTokens(entry.health, tokens) : null,
58
+ }
59
+ })
60
+ return { slot, portOffset: base, procs }
61
+ }
62
+
63
+ module.exports = { planDev, stateDir }
@@ -0,0 +1,226 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Register the review-gate hook in the project's Claude Code settings.
5
+ *
6
+ * `.claude/settings.json` rather than `settings.local.json`, and the difference
7
+ * is the point: the trusted-worktree entry is an absolute path and therefore
8
+ * one machine's business, while this is the project's policy — a phase that
9
+ * ended owes a verdict — and it should reach everyone who clones the repo. The
10
+ * command is written with `${CLAUDE_PROJECT_DIR}` so it stays true wherever the
11
+ * checkout lives, worktrees included.
12
+ *
13
+ * Conservative in the same way `trust.js` is: every existing key is preserved,
14
+ * a file it cannot parse is left exactly as it is, and re-running changes
15
+ * nothing once the entry is there. Callers own the reporting.
16
+ */
17
+
18
+ const fs = require('node:fs')
19
+ const path = require('node:path')
20
+
21
+ // `.cjs`, and the extension is load-bearing. This file is copied INTO the target
22
+ // project, where that project's `package.json` decides how node parses a `.js` —
23
+ // so the CommonJS script shipped as `review-gate.js` died on its own first
24
+ // `require` in every `"type": "module"` project, printing a stack trace over
25
+ // every Bash tool call. `.cjs` settles the parse mode at the file, independently
26
+ // of the one file skitterspec does not control.
27
+ const HOOK_STEM = '.claude/hooks/review-gate'
28
+ const HOOK_SCRIPT = `${HOOK_STEM}.cjs`
29
+ const HOOK_COMMAND = `node "\${CLAUDE_PROJECT_DIR}/${HOOK_SCRIPT}"`
30
+
31
+ // Our script under ANY extension, so a registration naming the retired `.js` is
32
+ // recognised as ours and migrated rather than duplicated. The trailing lookahead
33
+ // is the boundary: without it `review-gate-extra.js` — somebody else's hook —
34
+ // matches on the stem and gets silently rewritten.
35
+ const HOOK_SCRIPT_RE = /[.]claude[/\\]hooks[/\\]review-gate(?:[.][A-Za-z0-9]+)?(?![\w.-])/
36
+ // Seconds. The engine call behind this is one git-free read of a small JSON
37
+ // file, so anything approaching this is a wedge rather than slow work — and a
38
+ // hook that times out fails OPEN, which is the answer we want for a wedge.
39
+ const HOOK_TIMEOUT = 10
40
+ const MATCHER = 'Bash'
41
+
42
+ /**
43
+ * Every hook this package registers, as data.
44
+ *
45
+ * ONE LIST, so a second hook cannot arrive as a second copy of the merge logic
46
+ * below — which is where every property this file has earned would have to be
47
+ * re-derived and one of them forgotten. Each entry supplies only what differs:
48
+ * the script's stem, the tools it matches, and its timeout.
49
+ *
50
+ * The `re` for each is built from its stem with the same boundary lookahead the
51
+ * review gate's has, so `main-guard-extra.js` — somebody else's hook — is not
52
+ * silently rewritten as ours.
53
+ */
54
+ function hookStemRe(stem) {
55
+ const name = stem.split('/').pop()
56
+ return new RegExp(`[.]claude[/\\\\]hooks[/\\\\]${name}(?:[.][A-Za-z0-9]+)?(?![\\w.-])`)
57
+ }
58
+
59
+ const HOOKS = [
60
+ {
61
+ key: 'review-gate',
62
+ stem: HOOK_STEM,
63
+ matcher: MATCHER,
64
+ timeout: HOOK_TIMEOUT,
65
+ },
66
+ {
67
+ // Refuses an Edit/Write in the primary checkout while it is on the base
68
+ // branch. A different matcher from the review gate's, because it guards a
69
+ // different moment: the first write, before any work exists.
70
+ key: 'main-guard',
71
+ stem: '.claude/hooks/main-guard',
72
+ matcher: 'Edit|Write|NotebookEdit|MultiEdit',
73
+ timeout: HOOK_TIMEOUT,
74
+ },
75
+ ].map((h) => ({ ...h, script: `${h.stem}.cjs`, re: hookStemRe(h.stem) }))
76
+
77
+ function isObject(value) {
78
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
79
+ }
80
+
81
+ function settingsPath(dir) {
82
+ return path.join(dir, '.claude', 'settings.json')
83
+ }
84
+
85
+ function writeSettings(file, settings) {
86
+ fs.mkdirSync(path.dirname(file), { recursive: true })
87
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n')
88
+ }
89
+
90
+ function commandFor(script) {
91
+ return `node "\${CLAUDE_PROJECT_DIR}/${script}"`
92
+ }
93
+
94
+ function hookEntry(hook = HOOKS[0]) {
95
+ return {
96
+ matcher: hook.matcher,
97
+ hooks: [{ type: 'command', command: commandFor(hook.script), timeout: hook.timeout }],
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Is our hook already registered under `PreToolUse`? Pure.
103
+ *
104
+ * Matched on the SCRIPT PATH, not on the whole command string. An operator who
105
+ * added a timeout, wrapped the invocation, or changed the interpreter has
106
+ * registered our hook their way — re-adding a second copy beside theirs would
107
+ * run it twice and look like a bug in the gate.
108
+ */
109
+ function alreadyRegistered(preToolUse, hook = HOOKS[0]) {
110
+ return registeredHooks(preToolUse, hook).length > 0
111
+ }
112
+
113
+ // Every hook object under `PreToolUse` whose command names our script, whatever
114
+ // extension it names it under. Pure.
115
+ function registeredHooks(preToolUse, hook = HOOKS[0]) {
116
+ if (!Array.isArray(preToolUse)) return []
117
+ const out = []
118
+ for (const group of preToolUse) {
119
+ if (!isObject(group) || !Array.isArray(group.hooks)) continue
120
+ for (const h of group.hooks) {
121
+ if (isObject(h) && typeof h.command === 'string' && hook.re.test(h.command)) out.push(h)
122
+ }
123
+ }
124
+ return out
125
+ }
126
+
127
+ /**
128
+ * Ensure ONE hook is registered in an already-parsed settings object.
129
+ * Mutates `parsed` in place when it migrates a stale path; returns
130
+ * `{ changed, reason }` with the same vocabulary as the wrapper below.
131
+ *
132
+ * Split out so a second hook could be added without a second copy of this
133
+ * logic — which is where every property this file has earned (preserve unknown
134
+ * keys, match on the script PATH, migrate in place, stay idempotent) would have
135
+ * had to be re-derived, and one of them forgotten.
136
+ */
137
+ function ensureOneHook(parsed, hook) {
138
+ const hooks = isObject(parsed.hooks) ? parsed.hooks : {}
139
+ const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
140
+
141
+ // Registered already — but possibly under a path that no longer exists, since
142
+ // the script's extension changed. Rewrite the path IN PLACE rather than
143
+ // replacing the command: an operator who wrapped our hook, added a flag or
144
+ // changed the interpreter has registered it their way, and the thing that
145
+ // moved is the file, not their command. Adding a fresh entry beside theirs
146
+ // would run the gate twice and read as a bug in the gate; leaving the old one
147
+ // alone would aim the harness at a file this same upgrade retires.
148
+ const registered = registeredHooks(preToolUse, hook)
149
+ if (registered.length) {
150
+ const stale = registered.filter((h) => !h.command.includes(hook.script))
151
+ if (!stale.length) return { changed: false, reason: 'present' }
152
+ for (const h of stale) h.command = h.command.replace(hook.re, hook.script)
153
+ return { changed: true, reason: 'migrated' }
154
+ }
155
+
156
+ parsed.hooks = { ...hooks, PreToolUse: [...preToolUse, hookEntry(hook)] }
157
+ return { changed: true, reason: 'added' }
158
+ }
159
+
160
+ /**
161
+ * Ensure every hook this package ships is registered in `dir`'s project
162
+ * settings. Idempotent and non-destructive. Returns `{ changed, reason }`:
163
+ * - `created` — no settings file; one was written
164
+ * - `added` — merged into an existing file
165
+ * - `migrated` — registered under a retired path; the path was rewritten
166
+ * - `present` — already registered (no write)
167
+ * - `malformed` — the file exists but is not parseable JSON (left untouched)
168
+ *
169
+ * WITH SEVERAL HOOKS THE REASON IS THE STRONGEST ONE that applied, in the order
170
+ * added > migrated > present. A run that added one hook and found the other
171
+ * already there did change the file, and reporting `present` for it would tell
172
+ * an operator nothing happened when something did.
173
+ */
174
+ function ensureHooks(dir) {
175
+ const file = settingsPath(dir)
176
+
177
+ let raw
178
+ try {
179
+ raw = fs.readFileSync(file, 'utf-8')
180
+ } catch (error) {
181
+ if (error.code === 'ENOENT') {
182
+ writeSettings(file, { hooks: { PreToolUse: HOOKS.map((h) => hookEntry(h)) } })
183
+ return { changed: true, reason: 'created' }
184
+ }
185
+ throw error
186
+ }
187
+
188
+ let parsed
189
+ try {
190
+ parsed = JSON.parse(raw)
191
+ } catch {
192
+ // Never rewrite a settings file we could not read. It is the operator's
193
+ // config and everything else in it would be lost.
194
+ return { changed: false, reason: 'malformed' }
195
+ }
196
+ if (!isObject(parsed)) return { changed: false, reason: 'malformed' }
197
+
198
+ const reasons = HOOKS.map((h) => ensureOneHook(parsed, h))
199
+ const changed = reasons.some((r) => r.changed)
200
+ if (changed) writeSettings(file, parsed)
201
+
202
+ for (const rank of ['added', 'migrated', 'present']) {
203
+ if (reasons.some((r) => r.reason === rank)) return { changed, reason: rank }
204
+ }
205
+ return { changed, reason: 'present' }
206
+ }
207
+
208
+ // The name this shipped under when there was one hook. Kept as an alias rather
209
+ // than renamed away, because it is what an installed `init.js` from an older
210
+ // version calls — and a rename that breaks an upgrade path is not a rename, it
211
+ // is a removal.
212
+ const ensureReviewGateHook = ensureHooks
213
+
214
+ module.exports = {
215
+ ensureHooks,
216
+ ensureReviewGateHook,
217
+ alreadyRegistered,
218
+ registeredHooks,
219
+ hookEntry,
220
+ settingsPath,
221
+ HOOKS,
222
+ HOOK_STEM,
223
+ HOOK_SCRIPT,
224
+ HOOK_SCRIPT_RE,
225
+ HOOK_COMMAND,
226
+ }
@@ -0,0 +1,385 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure landing planner for `spec-env hotfix land`.
5
+ *
6
+ * A hotfix is built on an old release tag (its `Base version`), so it can't be
7
+ * fast-forwarded onto `main` like an ordinary spec (that's `integrate.js`).
8
+ * Instead `planHotfixLand` emits the exact side-effect-free, **never-pushing**
9
+ * commands to:
10
+ * 1. tag the hotfix branch head with the patch-bumped base tag (the prod deploy
11
+ * tag — the branch head already is baseRef + the fix);
12
+ * 2. for each extra target tag, cherry-pick the fix onto a throwaway worktree at
13
+ * that tag and re-tag it with its own patch bump (test/demo release lines);
14
+ * 3. cherry-pick the fix onto the base branch (main) for the next release.
15
+ *
16
+ * It performs no side effects — the caller (the CLI) probes git for `dirty` /
17
+ * `aheadOfBase` / `existingTags` / `mainRepoPath` and supplies them, keeping this
18
+ * deterministic and unit-testable with no live git. Conflict handling lives in the
19
+ * skill (run a cherry-pick, abort on non-zero exit, hand back), mirroring
20
+ * `integrate.js` — the planner never reasons about conflicts.
21
+ */
22
+
23
+ const path = require('node:path')
24
+ const { afterLandFor } = require('./integrate.js')
25
+
26
+ /**
27
+ * Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
28
+ * non-digit prefix (e.g. `v` → `v33.16.5`) and drops any pre-release/build suffix
29
+ * (`-rc1`, `+build`). Throws on a tag with no `MAJOR.MINOR.PATCH` core.
30
+ */
31
+ function bumpPatch(tag) {
32
+ const m = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:.*)$/.exec(String(tag || '').trim())
33
+ if (!m) {
34
+ throw new Error(
35
+ `hotfix: cannot bump a patch version from tag "${tag}" — need <prefix>MAJOR.MINOR.PATCH`,
36
+ )
37
+ }
38
+ const [, prefix, major, minor, patch] = m
39
+ return `${prefix}${major}.${minor}.${Number(patch) + 1}`
40
+ }
41
+
42
+ // A release tag's parts: `<prefix><major>.<minor>.<patch>[-<suffix>]`. A
43
+ // `hotfix.N` suffix is kept apart from every other one, because the two sort on
44
+ // opposite sides of their base (see compareTags). Null for anything else.
45
+ const TAG = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/
46
+ function parseTag(tag) {
47
+ const m = TAG.exec(String(tag || '').trim())
48
+ if (!m) return null
49
+ const [, prefix, major, minor, patch, suffix = null] = m
50
+ const hotfix = suffix && /^hotfix\.(\d+)$/.exec(suffix)
51
+ return {
52
+ prefix,
53
+ core: [Number(major), Number(minor), Number(patch)],
54
+ root: `${prefix}${major}.${minor}.${patch}`,
55
+ hotfix: hotfix ? Number(hotfix[1]) : null,
56
+ suffix: hotfix ? null : suffix,
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Order two release tags: X.Y.Z-rc.N < X.Y.Z < X.Y.Z-hotfix.N < X.Y.(Z+1).
62
+ *
63
+ * This is skittership's rule and git's `version:refname` with a hotfix suffix
64
+ * configured to sort after its base, so the two tools never disagree about
65
+ * which tag is newer. A hotfix is a later build OF X.Y.Z; every other suffix is
66
+ * a pre-release BEFORE it, as in semver. Hotfix numbers compare numerically
67
+ * (`-hotfix.10` after `-hotfix.9`); other suffixes compare as strings. An
68
+ * unparseable tag sorts first, so it can never be mistaken for the newest.
69
+ */
70
+ function compareTags(a, b) {
71
+ const x = parseTag(a)
72
+ const y = parseTag(b)
73
+ if (!x || !y) return (x ? 1 : 0) - (y ? 1 : 0)
74
+ for (let i = 0; i < 3; i++) {
75
+ if (x.core[i] !== y.core[i]) return x.core[i] - y.core[i]
76
+ }
77
+ // Rank within one core: pre-release (0) < the release itself (1) < hotfix (2).
78
+ const rank = (t) => (t.suffix ? 0 : t.hotfix === null ? 1 : 2)
79
+ if (rank(x) !== rank(y)) return rank(x) - rank(y)
80
+ if (x.hotfix !== null) return x.hotfix - y.hotfix
81
+ if (x.suffix) return x.suffix < y.suffix ? -1 : x.suffix > y.suffix ? 1 : 0
82
+ return 0
83
+ }
84
+
85
+ /**
86
+ * The next `<root>-hotfix.<N>` for a base tag: N is one above the highest
87
+ * `<root>-hotfix.*` among `existingTags`, else 1. The root is the base with any
88
+ * hotfix suffix stripped, so a hotfix on `v36.4.0-hotfix.2` continues that
89
+ * line. A later `X.Y.(Z+1)` is neither a collision nor counted — it is another
90
+ * release line entirely. Same rule as skittership's release-hotfix.
91
+ */
92
+ function nextHotfixTag(baseTag, existingTags) {
93
+ const base = parseTag(baseTag)
94
+ if (!base) {
95
+ throw new Error(
96
+ `hotfix: cannot derive a hotfix tag from "${baseTag}" — need <prefix>MAJOR.MINOR.PATCH`,
97
+ )
98
+ }
99
+ if (base.suffix) {
100
+ throw new Error(
101
+ `hotfix: base tag "${baseTag}" is a pre-release — a hotfix needs a released version`,
102
+ )
103
+ }
104
+ let n = base.hotfix || 0
105
+ for (const t of existingTags || []) {
106
+ const p = parseTag(t)
107
+ if (p && p.root === base.root && p.hotfix !== null && p.hotfix > n) n = p.hotfix
108
+ }
109
+ return `${base.root}-hotfix.${n + 1}`
110
+ }
111
+
112
+ const BUMPS = ['patch', 'hotfix-suffix']
113
+
114
+ // The tag `hotfix.bump` asks for. An unknown strategy throws rather than
115
+ // falling back to `patch`: a typo there would otherwise cut the wrong release
116
+ // line and say nothing.
117
+ function nextTag(baseTag, existingTags, bump = 'patch') {
118
+ if (bump === 'patch') return bumpPatch(baseTag)
119
+ if (bump === 'hotfix-suffix') return nextHotfixTag(baseTag, existingTags)
120
+ throw new Error(`unknown hotfix.bump "${bump}" — use one of: ${BUMPS.join(', ')}`)
121
+ }
122
+
123
+ // A filesystem/branch-safe slug derived from a tag (`v30.2.1` → `v30-2-1`).
124
+ function tagSlug(tag) {
125
+ return String(tag)
126
+ .replace(/[^a-zA-Z0-9]+/g, '-')
127
+ .replace(/^-+|-+$/g, '')
128
+ }
129
+
130
+ /**
131
+ * @param {object} spec resolved hotfix spec: { slug, branch, worktreePath, baseRef, ... }
132
+ * @param {object} config normalised env config (reads `config.hotfix`).
133
+ * @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
134
+ * base, extraTargets: string[], existingTags: string[],
135
+ * released?, push?: boolean, pushed?: boolean, remote?,
136
+ * fixCommits?: string[],
137
+ * carry?: { strategy: 'inplace'|'worktree'|'dirty', path,
138
+ * tmpPath?, commits: string[] } }
139
+ * @returns {object} { blocked, noop, reason, commands, afterLand, prodTag, targets, branch, base }
140
+ */
141
+ function planHotfixLand(spec, config, ctx) {
142
+ const c = ctx || {}
143
+ const branch = spec.branch
144
+ const baseRef = spec.baseRef
145
+ const hotfixCfg = (config && config.hotfix) || { cherryPickMain: true, targets: [] }
146
+ const result = {
147
+ blocked: false,
148
+ noop: false,
149
+ reason: null,
150
+ commands: [],
151
+ steps: [],
152
+ afterLand: afterLandFor(null),
153
+ prodTag: null,
154
+ targets: [],
155
+ branch,
156
+ base: c.base,
157
+ }
158
+ const block = (reason) => ({ ...result, blocked: true, reason })
159
+
160
+ // A hotfix must carry the tag it forked from — everything below bumps from it.
161
+ if (!baseRef) {
162
+ return block('spec has no Base version — not a hotfix, or the header is missing')
163
+ }
164
+ // The completion edits (status flip, git mv) must be committed before landing.
165
+ if (c.worktreeState && c.worktreeState.dirty) {
166
+ return block('worktree has uncommitted changes — commit the completion first')
167
+ }
168
+ // Nothing on the branch beyond the base tag → nothing to land.
169
+ if (!c.aheadOfBase) {
170
+ return { ...result, noop: true, reason: `no commits on ${branch} beyond ${baseRef} — nothing to land` }
171
+ }
172
+
173
+ // Track tags we'd create so we never plan a collision (with an existing tag, or
174
+ // between the prod tag and a target tag).
175
+ const taken = new Set(c.existingTags || [])
176
+ const fixRange = c.fixRange
177
+ // What a target cherry-picks: the fix commits by sha when the caller listed
178
+ // them (the version commit already left out), else the bare range. The
179
+ // range form is what this planner always emitted and what pure callers that
180
+ // list nothing still get.
181
+ const fixPick = Array.isArray(c.fixCommits) && c.fixCommits.length ? c.fixCommits : [fixRange]
182
+ const commands = []
183
+ const targets = []
184
+ // What the engine runs (hotfixland.js). `commands` stays beside it as the
185
+ // human-readable spelling of the same thing, for `--plan` and the tests.
186
+ const steps = []
187
+ const bump = hotfixCfg.bump || 'patch'
188
+ const releaseWith = String(hotfixCfg.releaseWith || '').trim()
189
+
190
+ // 1. Prod line: release the hotfix branch head (already baseRef + fix). No push.
191
+ // Three ways, decided here so the runner never has to: a release already
192
+ // cut on an earlier run and still at HEAD is reused, never re-cut; a
193
+ // project's `releaseWith` command cuts it and says which tag it made; or
194
+ // the built-in tagger tags HEAD with the `hotfix.bump` strategy's tag.
195
+ let prodTag = null
196
+ if (c.released && c.released.tag) {
197
+ prodTag = c.released.tag
198
+ taken.add(prodTag)
199
+ steps.push({ kind: 'release', via: 'recorded', tag: prodTag, release: c.released })
200
+ } else if (releaseWith) {
201
+ // The tag is the command's to choose and is unknown until it has run, so
202
+ // there is nothing to check a collision against here — the command refuses
203
+ // a tag that exists, and the runner refuses one a target also planned.
204
+ commands.push(`(cd ${spec.worktreePath} && ${releaseWith})`)
205
+ steps.push({ kind: 'release', via: 'command', command: releaseWith, cwd: spec.worktreePath })
206
+ } else {
207
+ prodTag = nextTag(baseRef, c.existingTags, bump)
208
+ if (taken.has(prodTag)) {
209
+ return block(`tag ${prodTag} already exists — bump the base version or delete the stale tag`)
210
+ }
211
+ taken.add(prodTag)
212
+ commands.push(`git -C ${spec.worktreePath} tag ${prodTag}`)
213
+ steps.push({
214
+ kind: 'release',
215
+ via: 'tag',
216
+ tag: prodTag,
217
+ cwd: spec.worktreePath,
218
+ baseTag: baseRef,
219
+ branch,
220
+ })
221
+ }
222
+ targets.push({ kind: 'prod', base: baseRef, tag: prodTag })
223
+
224
+ // 2. Publish the prod line: the branch first, then the tag, because the tag
225
+ // push is what starts the deploy. BEFORE anything is carried to main, so a
226
+ // declined or failed push never leaves main carrying a fix whose tag was
227
+ // never published. Three cases: the tag is already on the remote (a
228
+ // hand-run push, honoured on its positive signal); the operator consented
229
+ // (`--push`); or neither, and the run holds here with the commands printed.
230
+ const remote = c.remote || 'origin'
231
+ if (c.pushed) {
232
+ steps.push({ kind: 'push', via: 'remote', remote, branch, cwd: spec.worktreePath })
233
+ } else if (c.push) {
234
+ steps.push({ kind: 'push', via: 'push', remote, branch, cwd: spec.worktreePath })
235
+ } else {
236
+ steps.push({ kind: 'push', via: 'hold', remote, branch, cwd: spec.worktreePath })
237
+ }
238
+
239
+ // 3. Extra targets: cherry-pick the fix onto a throwaway worktree at each tag,
240
+ // re-tag with its own bump, then remove the worktree + temp branch (the
241
+ // commits survive under the new tag, so `-D` is safe for the throwaway branch).
242
+ // TAG-ONLY, deliberately: `releaseWith` is the prod line's. Running a
243
+ // project's release per target would need a hotfix/ branch and a clean
244
+ // tree for each one, and one failing half-way leaves a line half-released.
245
+ const wtRoot = path.dirname(spec.worktreePath)
246
+ for (const t of c.extraTargets || []) {
247
+ // Counted against `taken`, so two targets on one root get successive
248
+ // hotfix numbers rather than colliding on the same one.
249
+ const tag = nextTag(t, [...taken], bump)
250
+ if (taken.has(tag)) {
251
+ return block(`tag ${tag} (for target ${t}) already exists — resolve it before landing`)
252
+ }
253
+ taken.add(tag)
254
+ const slug = `${spec.slug}-onto-${tagSlug(t)}`
255
+ const tmpBranch = `hotfix/${slug}`
256
+ const tmpPath = path.join(wtRoot, slug)
257
+ commands.push(
258
+ `git worktree add ${tmpPath} -b ${tmpBranch} ${t}`,
259
+ `git -C ${tmpPath} cherry-pick ${fixPick.join(' ')}`,
260
+ `git -C ${tmpPath} tag ${tag}`,
261
+ `git worktree remove ${tmpPath}`,
262
+ `git branch -D ${tmpBranch}`,
263
+ )
264
+ const root = c.mainRepoPath
265
+ steps.push({
266
+ kind: 'target',
267
+ base: t,
268
+ tag,
269
+ worktreePath: tmpPath,
270
+ argvs: [
271
+ ['-C', root, 'worktree', 'add', tmpPath, '-b', tmpBranch, t],
272
+ ['-C', tmpPath, 'cherry-pick', ...fixPick],
273
+ ['-C', tmpPath, 'tag', tag],
274
+ ['-C', root, 'worktree', 'remove', tmpPath],
275
+ ['-C', root, 'branch', '-D', tmpBranch],
276
+ ],
277
+ })
278
+ targets.push({ kind: 'extra', base: t, tag, worktreePath: tmpPath })
279
+ }
280
+
281
+ // 4. Cherry-pick the fix onto the base branch (main) for the next release.
282
+ // Only then does the primary checkout gain anything, so only then do the
283
+ // after-land commands apply — a prod-only land leaves main untouched.
284
+ let afterLand = result.afterLand
285
+ if (hotfixCfg.cherryPickMain !== false) {
286
+ // THE CARRY IS A LIST OF COMMITS, NOT A RANGE. A releasing command leaves
287
+ // a version commit on the branch (package.json, CHANGELOG, RELEASES), and
288
+ // main's version line never moves on a hotfix — so the caller hands over
289
+ // `baseRef..branch` minus that commit and minus what `git cherry` says main
290
+ // already has. The spec's own completion commits stay in it: that is how
291
+ // the completed spec reaches main. Without a list (pure callers), the old
292
+ // range form, run in place.
293
+ const carry = c.carry || { strategy: 'inplace', path: c.mainRepoPath, commits: null }
294
+ const picks = carry.commits || [fixRange]
295
+ commands.push(
296
+ carry.strategy === 'worktree'
297
+ ? `git worktree add ${carry.tmpPath} ${c.base} && git -C ${carry.tmpPath} cherry-pick ${picks.join(' ')}`
298
+ : `git -C ${carry.path} cherry-pick ${picks.join(' ')}`,
299
+ )
300
+ steps.push({
301
+ kind: 'carry',
302
+ base: c.base,
303
+ strategy: carry.strategy,
304
+ cwd: carry.path || c.mainRepoPath,
305
+ root: c.mainRepoPath,
306
+ tmpPath: carry.tmpPath || null,
307
+ commits: picks,
308
+ })
309
+ targets.push({ kind: 'main', base: c.base })
310
+ afterLand = afterLandFor(config, c.mainRepoPath)
311
+ }
312
+
313
+ return { ...result, commands, steps, afterLand, prodTag, targets, fixRange }
314
+ }
315
+
316
+ /**
317
+ * Plan `spec-env hotfix finish`: the teardown a hotfix waits for until its
318
+ * deploy is out.
319
+ *
320
+ * A hotfix branch is needed until the release it cut is confirmed — a failed
321
+ * deploy cuts the next `-hotfix.N` from the same branch — so `/spec-complete`
322
+ * lands and keeps the worktree, and this is the later step that removes it.
323
+ *
324
+ * THREE GUARDS, every one a positive signal, and every failing one named:
325
+ * - a clean tree — anything uncommitted would go with the worktree;
326
+ * - a release tag at HEAD — the land actually released what is here;
327
+ * - the branch on the remote at HEAD — a copy exists after `branch -D`.
328
+ * A remote that cannot be asked is "cannot tell", which refuses: deleting
329
+ * the only copy of a deployed fix is not the safe way to be wrong.
330
+ *
331
+ * DELIBERATELY NO "MERGED INTO MAIN" GUARD: the version commit never reaches
332
+ * main, so the branch is never an ancestor of it and `-d` would always refuse.
333
+ * And DELIBERATELY NO `--force` — the guards are the point of the verb, and
334
+ * `spec-env down --force` already exists for anyone who means to override them.
335
+ *
336
+ * state: { dirty, tagsAtHead: string[], head, remoteHead: string|null|undefined,
337
+ * remote, mainRepoPath, hasSlot }
338
+ * remoteHead: the branch's sha on the remote; null = not there;
339
+ * undefined = the remote could not be asked.
340
+ */
341
+ function planHotfixFinish(spec, state) {
342
+ const s = state || {}
343
+ const remote = s.remote || 'origin'
344
+ const reasons = []
345
+ if (s.dirty) reasons.push('the worktree has uncommitted changes')
346
+ const releaseTag = (s.tagsAtHead || []).find((t) => parseTag(t) !== null) || null
347
+ if (!releaseTag) {
348
+ reasons.push('no release tag at HEAD — run `spec-env hotfix land` first')
349
+ }
350
+ if (s.remoteHead === undefined) {
351
+ reasons.push(`cannot tell whether ${spec.branch} is on ${remote} — the remote did not answer`)
352
+ } else if (s.remoteHead === null) {
353
+ reasons.push(`${spec.branch} is not on ${remote} — push it (\`hotfix land --push\`)`)
354
+ } else if (s.head && s.remoteHead !== s.head) {
355
+ reasons.push(`${remote}/${spec.branch} is not at HEAD — push the branch first`)
356
+ }
357
+ // A Docker stack has volumes and a slot that only `down` knows how to free.
358
+ if (s.hasSlot) {
359
+ reasons.push(`${spec.folder} holds a Docker stack — tear it down with \`spec-env down\``)
360
+ }
361
+ if (reasons.length) return { blocked: true, reasons, releaseTag, argvs: [] }
362
+ const root = s.mainRepoPath
363
+ return {
364
+ blocked: false,
365
+ reasons: [],
366
+ releaseTag,
367
+ argvs: [
368
+ ['-C', root, 'worktree', 'remove', spec.worktreePath],
369
+ // -D, not -d: the branch is never merged into main (see above), and its
370
+ // commits are on the remote and under the release tag.
371
+ ['-C', root, 'branch', '-D', spec.branch],
372
+ ],
373
+ }
374
+ }
375
+
376
+ module.exports = {
377
+ bumpPatch,
378
+ compareTags,
379
+ nextHotfixTag,
380
+ nextTag,
381
+ BUMPS,
382
+ tagSlug,
383
+ planHotfixLand,
384
+ planHotfixFinish,
385
+ }