@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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 (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -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 +606 -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 +88 -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 +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -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 +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -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 +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,479 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Live-overlay receipt — the advisory record of which spec currently holds the
5
+ * primary checkout for live testing.
6
+ *
7
+ * The *authority* on "who's live" is the branch checked out in the primary
8
+ * checkout (see `assertPrimaryOnMain` in resolve.js): on the base branch → free;
9
+ * on a feature branch → that spec is in control. This receipt is only metadata —
10
+ * it powers `live status` and crash recovery (`live abort` reads `baseMainCommit`
11
+ * from here to restore the primary checkout). It lives beside the slot registry
12
+ * at the primary checkout root (`.spec-env/live.json`, gitignored).
13
+ *
14
+ * `receiptPath`/`renderReceipt`/`summarizeReceipt` are pure; `read`/`write`/`clear`
15
+ * are the thin IO seam the CLI drives, mirroring env/registry.js. No `Date.now()`
16
+ * — the caller passes `heldSince`.
17
+ */
18
+
19
+ const fs = require('node:fs')
20
+ const path = require('node:path')
21
+ const { globToRegExp } = require('./glob.js')
22
+
23
+ const REQUIRED = ['spec', 'branch', 'holder', 'heldSince', 'baseMainCommit']
24
+
25
+ // Absolute path to the receipt — a sibling of the configured registry file, so it
26
+ // follows wherever `.spec-env` is configured (default `.spec-env/live.json`).
27
+ function receiptPath(rootDir, config) {
28
+ return path.resolve(rootDir, path.dirname(config.registry), 'live.json')
29
+ }
30
+
31
+ // Normalize receipt fields into the persisted shape. Pure; throws on a missing
32
+ // field so a half-formed receipt is never written.
33
+ function renderReceipt(fields) {
34
+ for (const key of REQUIRED) {
35
+ if (!fields || !fields[key]) throw new Error(`live receipt: missing ${key}`)
36
+ }
37
+ const receipt = {}
38
+ for (const key of REQUIRED) receipt[key] = String(fields[key])
39
+ return receipt
40
+ }
41
+
42
+ // Read the receipt. Missing file → null (no one is live). Malformed JSON → Error.
43
+ function readReceipt(rootDir, config) {
44
+ const file = receiptPath(rootDir, config)
45
+ let raw
46
+ try {
47
+ raw = fs.readFileSync(file, 'utf-8')
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') return null
50
+ throw error
51
+ }
52
+ try {
53
+ return JSON.parse(raw)
54
+ } catch (error) {
55
+ throw new Error(
56
+ `Invalid live receipt ${path.dirname(config.registry)}/live.json: ${error.message}`,
57
+ )
58
+ }
59
+ }
60
+
61
+ // Persist the receipt, creating its parent dir as needed. Returns the written shape.
62
+ function writeReceipt(rootDir, config, fields) {
63
+ const receipt = renderReceipt(fields)
64
+ const file = receiptPath(rootDir, config)
65
+ fs.mkdirSync(path.dirname(file), { recursive: true })
66
+ fs.writeFileSync(file, JSON.stringify(receipt, null, 2) + '\n')
67
+ return receipt
68
+ }
69
+
70
+ // Remove the receipt. Idempotent: clearing an absent receipt is a clean no-op.
71
+ function clearReceipt(rootDir, config) {
72
+ const file = receiptPath(rootDir, config)
73
+ try {
74
+ fs.unlinkSync(file)
75
+ } catch (error) {
76
+ if (error.code !== 'ENOENT') throw error
77
+ }
78
+ }
79
+
80
+ // A one-line human summary of a receipt (or the "free" state when null). Pure.
81
+ function summarizeReceipt(receipt) {
82
+ if (!receipt) return 'free — no spec is live'
83
+ return (
84
+ `${receipt.spec} (branch ${receipt.branch}) — ` +
85
+ `held by ${receipt.holder} since ${receipt.heldSince}`
86
+ )
87
+ }
88
+
89
+ // --- is this spec live? ---------------------------------------------------
90
+
91
+ /**
92
+ * Whether a spec is currently live, in a form a page can carry. Pure.
93
+ *
94
+ * FOUR STATES, NOT TWO, and the fourth is what keeps this quiet in a healthy
95
+ * repo. `on` and `off` are the two a reader acts on; `held` is another spec
96
+ * holding the workbench, which is a refusal with a named way out; and
97
+ * `unavailable` is **cannot tell** — routed to silence rather than to a warning
98
+ * (`.claude/rules/negative-checks.md` rule 4). A project with no isolation and a
99
+ * spec with no worktree both land there, and both are perfectly healthy.
100
+ *
101
+ * THE BRANCH IS THE AUTHORITY, NOT THE RECEIPT, exactly as this file's header
102
+ * says: the receipt is metadata for `live status` and crash recovery. So an
103
+ * unreadable or absent receipt costs the *holder's name* and nothing else — the
104
+ * state still comes from which branch the primary checkout is on. Deriving the
105
+ * state from the receipt instead would report `off` for a checkout plainly
106
+ * sitting on a feature branch, which is the one answer that would let something
107
+ * act.
108
+ *
109
+ * WHAT WOULD FOOL THIS: `onBase` and `primaryBranch` are read from the primary
110
+ * checkout by the caller. A caller that passes a *worktree's* git reader would
111
+ * describe the worktree's HEAD and call it the workbench — so `ctx` is built by
112
+ * `assertPrimaryOnMain` against the repo root and nowhere else.
113
+ *
114
+ * ctx: { isolated, onBase, primaryBranch, receipt, worktreeExists, url }
115
+ * @returns {{state:'on'|'off'|'held'|'unavailable', holder:string|null, url:string|null, reason:string|null}}
116
+ */
117
+ function liveStateFor(spec, ctx) {
118
+ const c = ctx || {}
119
+ const unavailable = (reason) => ({ state: 'unavailable', holder: null, url: null, reason })
120
+
121
+ if (!spec || !spec.branch) return unavailable('no spec to ask about')
122
+ if (c.isolated === false) return unavailable('isolation is not configured')
123
+
124
+ // Not on base — something holds the workbench. Which something is the only
125
+ // question left, and the branch answers it without the receipt.
126
+ if (c.onBase === false) {
127
+ const branch = c.primaryBranch || null
128
+ if (branch && branch === spec.branch) {
129
+ return { state: 'on', holder: null, url: c.url || null, reason: null }
130
+ }
131
+ const holder = c.receipt && c.receipt.spec ? String(c.receipt.spec) : null
132
+ const on = branch || '(detached)'
133
+ return {
134
+ state: 'held',
135
+ holder,
136
+ url: null,
137
+ // Every way out, because this is the only explanation the reader gets —
138
+ // the same reasoning `planTake`'s guard 1 records for its own refusal.
139
+ reason: holder
140
+ ? `${holder} holds the workbench (branch ${on})`
141
+ : `the workbench is on ${on} — no receipt; switched by hand?`,
142
+ }
143
+ }
144
+
145
+ // `onBase` is deliberately three-valued: anything but a definite `true` here
146
+ // is a checkout we could not read, and that is not evidence the spec is off.
147
+ if (c.onBase !== true) return unavailable('could not read the primary checkout')
148
+ if (!c.worktreeExists) return unavailable('no worktree to put live')
149
+
150
+ return { state: 'off', holder: null, url: null, reason: null }
151
+ }
152
+
153
+ /**
154
+ * The one line the render prints for a live state, or null for `unavailable`.
155
+ * Pure.
156
+ *
157
+ * NULL IS THE POINT. `unavailable` prints nothing at all — not a dash, not an
158
+ * explanation — because every input that produces it is a healthy repo that
159
+ * simply has no live surface, and a line about one is an accusation.
160
+ */
161
+ function liveStateLine(live) {
162
+ if (!live || live.state === 'unavailable') return null
163
+ const label = ' live:'.padEnd(11)
164
+ // IT NAMES THE COMMAND, not the capability. This line read
165
+ // `off — the page can put it live`, which told the reader a page somewhere
166
+ // could do it and left them to find the verb — and the verb is the thing they
167
+ // actually need when they are in a terminal rather than on the page. It is
168
+ // `/spec-live` rather than `spec-env live take` because that command is what a
169
+ // person types: it is user-only by its own marking, and the model cannot run
170
+ // it either way.
171
+ if (live.state === 'on') {
172
+ return (
173
+ `${label}on${live.url ? ` — running at ${live.url}` : ''}` +
174
+ '; /spec-live main to restore main'
175
+ )
176
+ }
177
+ if (live.state === 'held') return `${label}held — ${live.reason}`
178
+ return `${label}off — /spec-live to put it live`
179
+ }
180
+
181
+ // --- stateful detection (glob matching) -----------------------------------
182
+
183
+ // True when any file path matches any of the migration globs. Empty patterns or
184
+ // no files → false (nothing configured / nothing changed).
185
+ function migrationsHit(files, patterns) {
186
+ if (!Array.isArray(files) || !files.length) return false
187
+ if (!Array.isArray(patterns) || !patterns.length) return false
188
+ const res = patterns.map(globToRegExp)
189
+ return files.some((f) => res.some((r) => r.test(f)))
190
+ }
191
+
192
+ // --- take planner ---------------------------------------------------------
193
+
194
+ /**
195
+ * Pure planner for `spec-env live take`. Validates the preconditions for putting
196
+ * a spec live on the running instance by branch-switch, and returns either a
197
+ * structured refusal or the ordered git steps + the receipt to write. All git /
198
+ * port state is probed by the CLI and passed in `ctx`, keeping this deterministic
199
+ * and unit-testable with no live git.
200
+ *
201
+ * ctx:
202
+ * primary { onBase, branch, baseBranch } — the guard result for the primary checkout
203
+ * primaryPath absolute path of the primary checkout (the checkout target)
204
+ * inFlight string|null — folder of the spec holding the primary, from
205
+ * its receipt; null when nothing is recorded (a hand-switched
206
+ * branch), which degrades the message to the branch name
207
+ * clean boolean — primary checkout working tree is clean
208
+ * worktreeClean boolean — the SPEC WORKTREE's tree is clean (the rebase runs there)
209
+ * worktreeExists boolean — the spec's worktree is on disk
210
+ * base resolved base branch name (rebase target)
211
+ * baseMainCommit primary HEAD before the switch (receipt / crash recovery)
212
+ * serverUp true | false | null — null = no canonical ports declared → no gate
213
+ * canonicalPorts number[] — declared canonical (frontPort) ports, for messages
214
+ * migrationsHit boolean — the branch diff touches configured migration globs
215
+ * depsChanged boolean — the branch diff touches a lockfile/manifest (→ warn)
216
+ * holder,heldSince receipt provenance (injected by the CLI; no Date.now here)
217
+ *
218
+ * @returns {object} { blocked, reason, commands, warnings, receipt, base, branch, worktreePath }
219
+ */
220
+ function planTake(spec, config, ctx) {
221
+ const c = ctx || {}
222
+ const branch = spec.branch
223
+ const base = c.base
224
+ const result = {
225
+ blocked: false,
226
+ reason: null,
227
+ commands: [],
228
+ warnings: [],
229
+ receipt: null,
230
+ base,
231
+ branch,
232
+ worktreePath: spec.worktreePath,
233
+ }
234
+ /**
235
+ * A refusal, and — only where one exists — the way out of it.
236
+ *
237
+ * AN OFFER IS DECLARED HERE OR NOWHERE. A caller that raises a picker from a
238
+ * refusal is reading this field, never re-deciding from the reason text: the
239
+ * one boundary that matters is that work which is not the operator's must
240
+ * never be offered up (a workbench another spec holds is theirs to free), and
241
+ * a rule in prose is not what should be holding that line.
242
+ *
243
+ * ABSENT STAYS ABSENT. A refusal with no way out carries no `offer` key at
244
+ * all, so every existing consumer sees exactly the shape it saw before.
245
+ *
246
+ * @param {string} reason what is wrong, unchanged
247
+ * @param {object} [offer] { kind: 'satisfy'|'bypass', label, command }
248
+ */
249
+ const block = (reason, offer) => ({
250
+ ...result,
251
+ blocked: true,
252
+ reason,
253
+ ...(offer ? { offer } : {}),
254
+ })
255
+
256
+ // 1. The lock: the primary checkout must be on base (free). Off-base → a spec
257
+ // (or you) already holds the live instance.
258
+ if (!c.primary || !c.primary.onBase) {
259
+ const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
260
+ // Name the SPEC, not just the branch, and name every way out. `/spec-start`
261
+ // relays this refusal verbatim as its gate, so whatever is missing here is
262
+ // missing from the operator's only explanation of why they are stuck —
263
+ // "release it with /spec-live main" alone reads as the sole option when
264
+ // completing or cancelling the held spec are usually the ones they want.
265
+ const held = c.inFlight ? `${c.inFlight} (branch ${on})` : on
266
+ return block(
267
+ `primary checkout is on ${on}, not ${base} — ${held} already holds it. ` +
268
+ 'Free the workbench first: `/spec-complete` if it is finished, ' +
269
+ '`/spec-cancel` if it is not wanted, or `/spec-live main` to park it',
270
+ )
271
+ }
272
+ // 2. Never switch a dirty tree — the checkout is reset back to base on release.
273
+ if (!c.clean) {
274
+ return block('primary checkout has uncommitted changes — commit or stash them first')
275
+ }
276
+ // 3. Need a worktree holding the branch to detach and hand over.
277
+ if (!c.worktreeExists) {
278
+ return block(`${spec.folder} has no worktree — run \`/spec-start ${spec.folder}\` first`)
279
+ }
280
+ // 3b. The WORKTREE's own tree, not the primary checkout's. Check 2 above reads
281
+ // the checkout we switch INTO; the rebase runs in the worktree, and git
282
+ // refuses to rebase over uncommitted changes. Checking only the primary
283
+ // let a dirty worktree through to fail at the rebase, where the failure
284
+ // was then reported as a merge conflict — a wrong cause for a real
285
+ // problem, which is worse than no message. Validate the tree the
286
+ // operation actually touches.
287
+ if (c.worktreeClean === false) {
288
+ return block(
289
+ `${spec.folder}'s worktree has uncommitted changes — commit or stash them in ` +
290
+ `${spec.worktreePath} first (the rebase cannot run over them)`,
291
+ // SATISFYING THE GUARD, not stepping past it. Committing the phase is the
292
+ // precondition `live take` is asking for, so doing it weakens nothing and
293
+ // needs no record — which is what separates this from a `bypass`.
294
+ //
295
+ // Offered for the WORKTREE's tree and never for the primary checkout's
296
+ // (check 2 above): that one holds whatever the operator happens to have
297
+ // open, not this spec's phase work, and committing it under this spec's
298
+ // ticket is the mis-stamping `commit-trailers.md` exists to prevent.
299
+ { kind: 'satisfy', label: 'Commit first, then go live', command: '/commit' },
300
+ )
301
+ }
302
+ // 4. A hotfix is built on an old release tag; checking its branch out under the
303
+ // running dev server risks schema/DB drift breaking the shared instance.
304
+ // Always refuse, regardless of Stack — test it in isolation via /spec-connect.
305
+ if (spec.type === 'hotfix') {
306
+ return block(
307
+ `${spec.folder} is a hotfix (built on an old release tag) — live overlay ` +
308
+ 'could break the running instance; use `/spec-connect` to test it in isolation',
309
+ )
310
+ }
311
+ // 5. v1 is code-only: refuse a stateful spec (Stack: worktree + docker)…
312
+ if (spec.stack === 'docker') {
313
+ return block(
314
+ `${spec.folder} is stateful (Stack: worktree + docker) — live overlay is ` +
315
+ 'code-only; use `/spec-connect` for a Docker-backed spec',
316
+ )
317
+ }
318
+ // 6. …and refuse a branch that changes migrations (would mutate the shared DB).
319
+ if (c.migrationsHit) {
320
+ return block(
321
+ `${spec.folder}'s branch changes migrations — live overlay is code-only; ` +
322
+ 'use `/spec-connect`',
323
+ )
324
+ }
325
+ // 7. Verify-only: a dev server must be listening to hot-reload the switch.
326
+ if (c.serverUp === false) {
327
+ return block(
328
+ `no dev server listening on canonical port(s) ${(c.canonicalPorts || []).join(', ')} — ` +
329
+ 'start your dev server (or `skitterspec spec-env dev up`) first',
330
+ )
331
+ }
332
+
333
+ const warnings = []
334
+ if (c.depsChanged) {
335
+ warnings.push('dependencies changed on this branch — restart your dev server after the switch')
336
+ }
337
+ if (c.serverUp === null) {
338
+ warnings.push('no canonical dev ports configured — nothing to hot-reload; switching anyway')
339
+ }
340
+
341
+ return {
342
+ ...result,
343
+ commands: [
344
+ `git -C ${spec.worktreePath} rebase ${base}`,
345
+ `git -C ${spec.worktreePath} switch --detach`,
346
+ `git -C ${c.primaryPath} checkout ${branch}`,
347
+ ],
348
+ warnings,
349
+ receipt: {
350
+ spec: spec.folder,
351
+ branch,
352
+ holder: c.holder,
353
+ heldSince: c.heldSince,
354
+ baseMainCommit: c.baseMainCommit,
355
+ },
356
+ }
357
+ }
358
+
359
+ // --- release planner ------------------------------------------------------
360
+
361
+ /**
362
+ * Pure planner for `spec-env live release` — hand the running instance back to
363
+ * base and re-isolate the spec's branch into its worktree (the graceful exit of
364
+ * an unfinished session). All git state is probed by the CLI and passed in.
365
+ *
366
+ * In the branch-switch model `take` never moves the base ref, so release is just
367
+ * `checkout base` (frees the branch) then re-attach it to the worktree — no reset.
368
+ *
369
+ * ctx: { primary:{onBase,branch}, primaryPath, base, clean, worktreeExists }
370
+ * @returns {object} { blocked, noop, reason, commands, clears, base, branch, worktreePath }
371
+ */
372
+ function planRelease(spec, config, ctx) {
373
+ const c = ctx || {}
374
+ const branch = spec.branch
375
+ const base = c.base
376
+ const result = {
377
+ blocked: false,
378
+ noop: false,
379
+ reason: null,
380
+ commands: [],
381
+ clears: false,
382
+ base,
383
+ branch,
384
+ worktreePath: spec.worktreePath,
385
+ }
386
+
387
+ // Nothing live — the primary checkout is already on base.
388
+ if (c.primary && c.primary.onBase) {
389
+ return { ...result, noop: true, reason: `nothing is live — the primary checkout is on ${base}` }
390
+ }
391
+ // A different spec holds the instance.
392
+ if (c.primary && c.primary.branch !== branch) {
393
+ return {
394
+ ...result,
395
+ blocked: true,
396
+ reason: `the live spec is ${c.primary.branch}, not ${branch} — release that one`,
397
+ }
398
+ }
399
+ // Fixes made while live must be committed to the branch first (never discarded).
400
+ if (!c.clean) {
401
+ return {
402
+ ...result,
403
+ blocked: true,
404
+ reason: `primary checkout has uncommitted changes — commit your fixes to ${branch} first`,
405
+ }
406
+ }
407
+
408
+ const commands = [`git -C ${c.primaryPath} checkout ${base}`]
409
+ if (c.worktreeExists) commands.push(`git -C ${spec.worktreePath} switch ${branch}`)
410
+ return { ...result, commands, clears: true }
411
+ }
412
+
413
+ // --- abort planner --------------------------------------------------------
414
+
415
+ /**
416
+ * Pure planner for `spec-env live abort` — crash recovery. Works from the receipt
417
+ * (not a resolved spec), so it recovers even when the spec folder can't be found.
418
+ * Conservative: it refuses to discard uncommitted work, and won't force anything
419
+ * without a receipt to tell it what "live" was.
420
+ *
421
+ * Like release, recovery is `checkout base` + re-isolate — no `reset --hard`:
422
+ * branch-switch never moved base, and resetting to the receipt's recorded commit
423
+ * would discard any legitimate advance of base. `baseMainCommit` stays a record.
424
+ *
425
+ * ctx: { receipt, primary:{onBase,branch}, primaryPath, base, clean, worktreeExists, worktreePath }
426
+ * @returns {object} { blocked, noop, reason, commands, clears, base, branch }
427
+ */
428
+ function planAbort(config, ctx) {
429
+ const c = ctx || {}
430
+ const base = c.base
431
+ const result = { blocked: false, noop: false, reason: null, commands: [], clears: false, base, branch: null }
432
+
433
+ if (!c.receipt) {
434
+ if (c.primary && c.primary.onBase) {
435
+ return { ...result, noop: true, reason: `nothing is live — the primary checkout is on ${base}` }
436
+ }
437
+ const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
438
+ return {
439
+ ...result,
440
+ blocked: true,
441
+ reason:
442
+ `no live receipt, but the primary checkout is on ${on} — no recorded base ` +
443
+ `to restore; check out ${base} manually once you're sure it's safe`,
444
+ }
445
+ }
446
+
447
+ // Receipt present. Never discard uncommitted work — surface it instead.
448
+ if (!c.clean) {
449
+ return {
450
+ ...result,
451
+ blocked: true,
452
+ reason:
453
+ 'the primary checkout has uncommitted changes that abort would discard — ' +
454
+ 'commit or stash them (or resolve manually) first',
455
+ }
456
+ }
457
+
458
+ const branch = c.receipt.branch
459
+ const commands = []
460
+ if (!(c.primary && c.primary.onBase)) commands.push(`git -C ${c.primaryPath} checkout ${base}`)
461
+ if (c.worktreeExists) commands.push(`git -C ${c.worktreePath} switch ${branch}`)
462
+ return { ...result, commands, clears: true, branch }
463
+ }
464
+
465
+ module.exports = {
466
+ receiptPath,
467
+ renderReceipt,
468
+ readReceipt,
469
+ writeReceipt,
470
+ clearReceipt,
471
+ summarizeReceipt,
472
+ globToRegExp,
473
+ migrationsHit,
474
+ planTake,
475
+ planRelease,
476
+ planAbort,
477
+ liveStateFor,
478
+ liveStateLine,
479
+ }