@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,302 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Run a hotfix land's steps — the engine half of `spec-env hotfix land`.
5
+ *
6
+ * `planHotfixLand` (hotfix.js) decides WHAT the land is; this module does it,
7
+ * the way `bootstrap.js` does a worktree's steps. It used to be a printed plan
8
+ * the skill ran by hand, and that stopped being possible the moment the release
9
+ * could be a project's own command: the prod tag is only known once that
10
+ * command has run and said what it cut, so no plan printed beforehand can name
11
+ * it.
12
+ *
13
+ * The run stops at the first step that fails, and the release is reported to
14
+ * `io.record` the moment it succeeds — so a land that stops later (a refused
15
+ * push, a conflicted cherry-pick) can be re-run without cutting a second
16
+ * release. skittership's own command refuses a second one anyway, since HEAD is
17
+ * then already tagged; the record is what lets the re-run carry on instead of
18
+ * stopping there.
19
+ *
20
+ * Every side effect goes through `io.spawn`, so tests drive it with a fake.
21
+ */
22
+
23
+ const { spawnSync } = require('node:child_process')
24
+
25
+ // The fields skittership's `release-hotfix.cjs --json` prints, and their types.
26
+ // `versionCommit` is null only on its `--dry-run`, which skitterspec never
27
+ // passes — so a null here is accepted, and means "no version commit to leave
28
+ // behind", exactly as the built-in tagger's.
29
+ const RELEASE_FIELDS = {
30
+ tag: 'string',
31
+ version: 'string',
32
+ baseTag: 'string',
33
+ branch: 'string',
34
+ versionCommit: 'string|null',
35
+ pushed: 'boolean',
36
+ }
37
+
38
+ /**
39
+ * Parse a release command's stdout STRICTLY: the whole of it, trimmed, must be
40
+ * one JSON object carrying every field above. Anything else — a package
41
+ * manager's run banner, a stray log line, a missing field — is a refusal rather
42
+ * than a best guess, because a guessed tag is a deploy of the wrong thing.
43
+ */
44
+ function parseRelease(stdout) {
45
+ const text = String(stdout || '').trim()
46
+ if (!text) return { ok: false, reason: 'printed nothing on stdout — expected one JSON line' }
47
+ let obj
48
+ try {
49
+ obj = JSON.parse(text)
50
+ } catch {
51
+ const first = text.split('\n')[0].slice(0, 120)
52
+ return { ok: false, reason: `stdout is not one JSON object (starts: ${first})` }
53
+ }
54
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
55
+ return { ok: false, reason: 'stdout is JSON but not an object' }
56
+ }
57
+ for (const [key, type] of Object.entries(RELEASE_FIELDS)) {
58
+ const v = obj[key]
59
+ const fits = type
60
+ .split('|')
61
+ .some((t) => (t === 'null' ? v === null : typeof v === t && (t !== 'string' || v.length)))
62
+ if (!fits) return { ok: false, reason: `JSON field "${key}" is missing or not ${type}` }
63
+ }
64
+ return { ok: true, release: obj }
65
+ }
66
+
67
+ /** One line describing a step, for `--plan` and the failure report. */
68
+ function describeStep(step) {
69
+ switch (step.kind) {
70
+ case 'release':
71
+ if (step.via === 'command') return `release: ${step.command}`
72
+ if (step.via === 'recorded') return `release: ${step.tag} (cut earlier — not re-run)`
73
+ return `release: git tag ${step.tag}`
74
+ case 'push':
75
+ if (step.via === 'remote') return `push: ${step.tag || 'the tag'} is already on ${step.remote}`
76
+ if (step.via === 'hold') return `push: held — no consent to push to ${step.remote}`
77
+ return `push: ${step.branch}, then ${step.tag || 'the tag'}, to ${step.remote}`
78
+ case 'target':
79
+ return `target: ${step.base} -> ${step.tag} (cherry-pick, tag)`
80
+ case 'carry': {
81
+ const n = Array.isArray(step.commits) ? step.commits.length : 0
82
+ const what = n === 1 ? '1 commit' : `${n} commits`
83
+ if (step.strategy === 'worktree') return `carry: cherry-pick ${what} onto ${step.base} (throwaway worktree)`
84
+ return `carry: cherry-pick ${what} onto ${step.base}`
85
+ }
86
+ default:
87
+ return `unknown step ${step.kind}`
88
+ }
89
+ }
90
+
91
+ // The last non-blank line a command wrote to stderr — skittership's refusals
92
+ // are one `✖ release-hotfix: <reason>` line there, and that is what to relay.
93
+ function lastLine(text) {
94
+ const lines = String(text || '')
95
+ .split('\n')
96
+ .map((l) => l.trim())
97
+ .filter(Boolean)
98
+ return lines.length ? lines[lines.length - 1] : null
99
+ }
100
+
101
+ function why(res) {
102
+ if (res.error) return res.error.message
103
+ return res.status === null ? `killed by ${res.signal || 'a signal'}` : `exit ${res.status}`
104
+ }
105
+
106
+ function runRelease(step, ctx) {
107
+ if (step.via === 'recorded') {
108
+ return { outcome: 'skipped', line: describeStep(step), release: step.release }
109
+ }
110
+ if (step.via === 'tag') {
111
+ const res = ctx.spawn('git', ['-C', step.cwd, 'tag', step.tag], {
112
+ stdio: ['ignore', 'pipe', 'pipe'],
113
+ encoding: 'utf8',
114
+ })
115
+ if (res.error || res.status !== 0) {
116
+ return { outcome: 'failed', line: `${describeStep(step)} — ${lastLine(res.stderr) || why(res)}` }
117
+ }
118
+ const release = {
119
+ tag: step.tag,
120
+ version: step.tag.replace(/^\D*/, ''),
121
+ baseTag: step.baseTag,
122
+ branch: step.branch,
123
+ versionCommit: null,
124
+ pushed: false,
125
+ }
126
+ return { outcome: 'released', line: `tagged ${step.tag}`, release }
127
+ }
128
+ // A project's own command, through the shell. stdout is captured whole for
129
+ // the JSON; stderr is captured so a refusal can be relayed as its own line,
130
+ // and is passed on to ours so its progress output is not lost.
131
+ const res = ctx.spawn(step.command, {
132
+ cwd: step.cwd,
133
+ shell: true,
134
+ stdio: ['ignore', 'pipe', 'pipe'],
135
+ encoding: 'utf8',
136
+ })
137
+ if (res.stderr) ctx.childStderr(res.stderr)
138
+ if (res.error || res.status !== 0) {
139
+ return { outcome: 'refused', line: `release refused — ${lastLine(res.stderr) || why(res)}` }
140
+ }
141
+ const parsed = parseRelease(res.stdout)
142
+ if (!parsed.ok) return { outcome: 'refused', line: `release refused — ${parsed.reason}` }
143
+ return { outcome: 'released', line: `released ${parsed.release.tag}`, release: parsed.release }
144
+ }
145
+
146
+ // Publish the release. The tag is the release's, known only once it has run,
147
+ // so the argvs are built here rather than in the plan. `hold` is not a failure:
148
+ // it is the operator not having said yes, and it stops the run so nothing after
149
+ // it is carried before the deploy tag is public.
150
+ function runPush(step, release, ctx) {
151
+ const s = { ...step, tag: release ? release.tag : null }
152
+ if (s.via === 'remote') return { outcome: 'skipped', line: describeStep(s) }
153
+ if (s.via === 'hold') return { outcome: 'held', line: describeStep(s) }
154
+ if (!release) return { outcome: 'failed', line: `${describeStep(s)} — no release to push` }
155
+ for (const ref of [s.branch, s.tag]) {
156
+ const argv = ['-C', s.cwd, 'push', s.remote, ref]
157
+ const res = ctx.spawn('git', argv, { stdio: ['ignore', ctx.childStdout, 'pipe'], encoding: 'utf8' })
158
+ if (res.error || res.status !== 0) {
159
+ return { outcome: 'failed', line: `push ${ref} to ${s.remote} — ${lastLine(res.stderr) || why(res)}` }
160
+ }
161
+ }
162
+ return { outcome: 'pushed', line: `pushed ${s.branch}, then ${s.tag}, to ${s.remote}` }
163
+ }
164
+
165
+ /**
166
+ * Carry the fix onto the base branch, one commit at a time so a conflict can
167
+ * name the commit that caused it.
168
+ *
169
+ * Three strategies, chosen by the caller from where the base branch is:
170
+ * inplace — checked out and clean: cherry-pick there.
171
+ * worktree — not checked out anywhere: a throwaway worktree on it, removed
172
+ * afterwards, so the branch moves without anyone's checkout moving.
173
+ * dirty — checked out with uncommitted work: refuse, and print the command
174
+ * to run by hand once it is clean. Somebody's work is in that tree.
175
+ * On a conflict the cherry-pick is aborted, which leaves the base branch where
176
+ * it was before that commit, and the hotfix branch and its record untouched —
177
+ * so a re-run tries the carry again.
178
+ */
179
+ function runCarry(step, release, ctx) {
180
+ const version = release && release.versionCommit
181
+ // The version commit cannot be in a list made before the release cut it,
182
+ // but a re-run lists the branch after — so it is left out here as well.
183
+ const commits = (step.commits || []).filter((sha) => !version || sha !== version)
184
+ if (!commits.length) {
185
+ return { outcome: 'skipped', line: `carry: nothing to carry — ${step.base} already has the fix`, carried: 0 }
186
+ }
187
+ if (step.strategy === 'dirty') {
188
+ return {
189
+ outcome: 'refused',
190
+ line:
191
+ `carry: ${step.cwd} has uncommitted changes on ${step.base} — commit or stash them, ` +
192
+ `then run: git -C ${step.cwd} cherry-pick ${commits.join(' ')}`,
193
+ }
194
+ }
195
+ const git = (argv) =>
196
+ ctx.spawn('git', argv, { stdio: ['ignore', ctx.childStdout, 'pipe'], encoding: 'utf8' })
197
+ let cwd = step.cwd
198
+ if (step.strategy === 'worktree') {
199
+ cwd = step.tmpPath
200
+ const res = git(['-C', step.root, 'worktree', 'add', cwd, step.base])
201
+ if (res.error || res.status !== 0) {
202
+ return { outcome: 'failed', line: `carry: worktree add ${cwd} — ${lastLine(res.stderr) || why(res)}` }
203
+ }
204
+ }
205
+ const cleanup = () => {
206
+ if (step.strategy === 'worktree') git(['-C', step.root, 'worktree', 'remove', '--force', cwd])
207
+ }
208
+ for (const sha of commits) {
209
+ const res = git(['-C', cwd, 'cherry-pick', sha])
210
+ if (res.error || res.status !== 0) {
211
+ git(['-C', cwd, 'cherry-pick', '--abort'])
212
+ cleanup()
213
+ return {
214
+ outcome: 'failed',
215
+ line:
216
+ `carry: cherry-pick ${sha} onto ${step.base} conflicted — aborted; ` +
217
+ `${lastLine(res.stderr) || why(res)}`,
218
+ }
219
+ }
220
+ }
221
+ cleanup()
222
+ const what = commits.length === 1 ? '1 commit' : `${commits.length} commits`
223
+ return { outcome: 'ran', line: `carried ${what} onto ${step.base}`, carried: commits.length }
224
+ }
225
+
226
+ // A step that is a list of git argvs run in order — a target's worktree,
227
+ // cherry-pick, tag and cleanup, or the carry's cherry-pick.
228
+ function runArgvs(step, ctx) {
229
+ for (const argv of step.argvs) {
230
+ const res = ctx.spawn('git', argv, { stdio: ['ignore', ctx.childStdout, 'pipe'], encoding: 'utf8' })
231
+ if (res.error || res.status !== 0) {
232
+ return {
233
+ outcome: 'failed',
234
+ line: `${describeStep(step)} — git ${argv.join(' ')}: ${lastLine(res.stderr) || why(res)}`,
235
+ }
236
+ }
237
+ }
238
+ return { outcome: 'ran', line: describeStep(step) }
239
+ }
240
+
241
+ /**
242
+ * Run `steps` in order, stopping at the first that fails or refuses.
243
+ *
244
+ * io: { spawn?, write?, record?, childStdout?, childStderr? }
245
+ * - `write(line)` receives every report line as it happens
246
+ * - `record(release)` is called once, as soon as a release succeeds
247
+ *
248
+ * Returns { ok, held, results, failedAt, release } — `release` is what the
249
+ * release step cut (or found recorded), or null if it never got that far.
250
+ * `held` is a run that stopped at the push for want of consent: not a failure,
251
+ * and nothing after it ran.
252
+ */
253
+ function runHotfixLand(steps, io = {}) {
254
+ const ctx = {
255
+ spawn: io.spawn || spawnSync,
256
+ childStdout: io.childStdout || 'inherit',
257
+ childStderr: io.childStderr || ((t) => process.stderr.write(t)),
258
+ }
259
+ const write = io.write || (() => {})
260
+ const results = []
261
+ let release = null
262
+ let carried = null
263
+ for (let i = 0; i < steps.length; i++) {
264
+ const step = steps[i]
265
+ let r
266
+ try {
267
+ if (step.kind === 'release') r = runRelease(step, ctx)
268
+ else if (step.kind === 'push') r = runPush(step, release, ctx)
269
+ else if (step.kind === 'carry') r = runCarry(step, release, ctx)
270
+ else if (step.kind === 'target') {
271
+ // A project's release command can cut a tag the built-in tagger also
272
+ // planned for a target on the same root. Refuse rather than collide.
273
+ if (step.kind === 'target' && release && release.tag === step.tag) {
274
+ r = { outcome: 'refused', line: `${describeStep(step)} — the release already cut ${step.tag}` }
275
+ } else r = runArgvs(step, ctx)
276
+ } else r = { outcome: 'failed', line: describeStep(step) }
277
+ } catch (err) {
278
+ r = { outcome: 'failed', line: `${describeStep(step)} — ${err.message}` }
279
+ }
280
+ results.push({ step, outcome: r.outcome, line: r.line })
281
+ if (typeof r.carried === 'number') carried = r.carried
282
+ if (r.release) {
283
+ release = r.release
284
+ if (r.outcome === 'released' && io.record) io.record(release)
285
+ }
286
+ if (r.outcome === 'pushed' && release) {
287
+ release = { ...release, pushed: true }
288
+ if (io.record) io.record(release)
289
+ }
290
+ if (r.outcome === 'held') {
291
+ if (r.line) write(r.line)
292
+ return { ok: true, held: true, results, failedAt: null, release, carried }
293
+ }
294
+ if (r.outcome === 'failed' || r.outcome === 'refused') {
295
+ return { ok: false, results, failedAt: i + 1, release, carried }
296
+ }
297
+ if (r.line) write(r.line)
298
+ }
299
+ return { ok: true, held: false, results, failedAt: null, release, carried }
300
+ }
301
+
302
+ module.exports = { parseRelease, runHotfixLand, describeStep, RELEASE_FIELDS }
@@ -0,0 +1,167 @@
1
+ 'use strict'
2
+
3
+ const { globToRegExp } = require('./glob.js')
4
+
5
+ /**
6
+ * Pure integrate planner for `spec-env integrate`.
7
+ *
8
+ * `planIntegrate` decides whether a spec's worktree branch can be landed onto the
9
+ * base branch and, if so, emits the exact commands the `/spec-complete` skill runs
10
+ * (rebase the branch onto base in the worktree, then fast-forward base to it in the
11
+ * primary checkout). It performs no side effects: the caller (the CLI) queries git
12
+ * for `dirty`/`aheadOfBase`/`mainRepoPath` and supplies them, keeping this
13
+ * deterministic and unit-testable with no live git.
14
+ *
15
+ * Strategy is rebase + fast-forward (linear history). Conflict handling lives in
16
+ * the skill: it runs the rebase and, on a non-zero exit, `git rebase --abort` and
17
+ * hands back — so the planner never needs to reason about conflicts.
18
+ *
19
+ * @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
20
+ * @param {object} config normalised env config (reads `afterLand`).
21
+ * In CHECKOUT mode there is no worktree: the branch is already checked out in the
22
+ * primary checkout, so landing is a rebase in place, a switch to base, and the
23
+ * fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
24
+ * returns is identical so callers need no second code path.
25
+ *
26
+ * @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath,
27
+ * changedFiles } — `changedFiles` is what the land brings
28
+ * in (`base...branch`), or null when it could not be read
29
+ * @returns {object} { blocked, noop, reason, commands, afterLand, base, branch }
30
+ */
31
+ function planIntegrate(spec, config, ctx) {
32
+ const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
33
+ const branch = spec.branch
34
+ const result = {
35
+ blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
36
+ }
37
+
38
+ // The completion edits must be committed first — never rebase a dirty tree.
39
+ if (worktreeState.dirty) {
40
+ return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
41
+ }
42
+
43
+ // Nothing on the branch that isn't already on base → already landed. The
44
+ // after-land commands still apply: the land may have happened on another
45
+ // machine or in an earlier session, leaving them pending in THIS checkout.
46
+ // That earlier land's range is unknown here, so the range is null.
47
+ if (!aheadOfBase) {
48
+ return { ...result, noop: true, afterLand: afterLandFor(config, mainRepoPath, null) }
49
+ }
50
+
51
+ return {
52
+ ...result,
53
+ commands: [
54
+ `git -C ${spec.worktreePath} rebase ${base}`,
55
+ `git -C ${mainRepoPath} merge --ff-only ${branch}`,
56
+ ],
57
+ afterLand: afterLandFor(config, mainRepoPath, rangeOf(ctx)),
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Pure integrate planner for CHECKOUT mode.
63
+ *
64
+ * The branch lives in the primary checkout, so `git -C <worktree> rebase` — the
65
+ * worktree-mode plan — has nothing to address. Landing is three steps in one
66
+ * repo: rebase onto base, switch to base, fast-forward.
67
+ *
68
+ * The switch is what makes this safe to repeat. Leaving the checkout on the spec
69
+ * branch after landing would mean the next `spec-env up` refuses ("standing on
70
+ * another spec's branch") for a spec that is finished, and the operator would
71
+ * have to know to switch back by hand.
72
+ *
73
+ * ctx: { dirty, base, aheadOfBase, checkoutPath, onBranch, changedFiles }
74
+ */
75
+ function planIntegrateCheckout(spec, config, ctx) {
76
+ const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
77
+ const branch = spec.branch
78
+ const result = {
79
+ blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
80
+ }
81
+
82
+ // "Already landed" is answered FIRST, before any refusal. A landed spec needs
83
+ // no action wherever the checkout happens to be standing — and after a
84
+ // successful land it is standing on base, so asking "not on the branch" here
85
+ // would refuse the very spec this just finished landing. /spec-complete calls
86
+ // integrate again on exactly that state.
87
+ if (!aheadOfBase) {
88
+ return { ...result, noop: true, afterLand: afterLandFor(config, checkoutPath, null) }
89
+ }
90
+ if (dirty) {
91
+ return {
92
+ ...result,
93
+ blocked: true,
94
+ reason: 'the checkout has uncommitted changes — commit the completion first',
95
+ }
96
+ }
97
+ // There IS something to land, so where you are standing now matters: the
98
+ // branch is the checkout in this mode, and landing from elsewhere would
99
+ // rebase and fast-forward a branch the operator is not looking at.
100
+ if (onBranch === false) {
101
+ return {
102
+ ...result,
103
+ blocked: true,
104
+ reason: `the checkout is not on ${branch} — switch to it before landing`,
105
+ }
106
+ }
107
+
108
+ return {
109
+ ...result,
110
+ commands: [
111
+ `git -C ${checkoutPath} rebase ${base}`,
112
+ `git -C ${checkoutPath} switch ${base}`,
113
+ `git -C ${checkoutPath} merge --ff-only ${branch}`,
114
+ ],
115
+ afterLand: afterLandFor(config, checkoutPath, rangeOf(ctx)),
116
+ }
117
+ }
118
+
119
+ // The after-land half of a plan: `config.afterLand`, to run in `cwd` (the
120
+ // primary checkout, standing on base) once the land commands have succeeded.
121
+ // A blocked plan carries the empty form — nothing landed, so nothing follows.
122
+ //
123
+ // `range` is the files the land changes: a list (possibly empty) when known,
124
+ // null when it is not. Each entry gets a decision — `always` (no `when`),
125
+ // `matched` (with the first path that hit), `skipped`, or `unknown` — and
126
+ // `commands` is every entry but the skipped ones, in config order.
127
+ //
128
+ // AN UNKNOWN RANGE RUNS THE CONDITIONAL ENTRIES. A land made elsewhere, or a
129
+ // diff git would not answer, says nothing about what changed, and only one
130
+ // reading is harmless: a redundant generate costs seconds, a skipped one leaves
131
+ // the base red with nothing pointing at why (`.claude/rules/negative-checks.md`
132
+ // rule 4). An EMPTY known range is not unknown — a land whose commits touch no
133
+ // files is healthy, and it skips them.
134
+ function afterLandFor(config, cwd, range = null) {
135
+ const raw = (config && Array.isArray(config.afterLand)) ? config.afterLand : []
136
+ const entries = raw.map((e) => decide(asEntry(e), range))
137
+ const commands = entries.filter((e) => e.decision !== 'skipped').map((e) => e.run)
138
+ return { cwd: entries.length ? cwd : null, commands, entries, range }
139
+ }
140
+
141
+ function decide(entry, range) {
142
+ if (!entry.when) return { ...entry, decision: 'always', matched: null }
143
+ if (range === null) return { ...entry, decision: 'unknown', matched: null }
144
+ const res = entry.when.map(globToRegExp)
145
+ const matched = range.find((f) => res.some((r) => r.test(f)))
146
+ return matched === undefined
147
+ ? { ...entry, decision: 'skipped', matched: null }
148
+ : { ...entry, decision: 'matched', matched }
149
+ }
150
+
151
+ // Normalised config holds `{ run, when }`; a pure caller may still hand over
152
+ // the plain string an entry is written as.
153
+ function asEntry(entry) {
154
+ return typeof entry === 'string' ? { run: entry, when: null } : { run: entry.run, when: entry.when || null }
155
+ }
156
+
157
+ // `changedFiles` from a planner's ctx, with anything that is not a list read as
158
+ // unknown — a caller that did not supply it said nothing about the range.
159
+ function rangeOf(ctx) {
160
+ return Array.isArray(ctx && ctx.changedFiles) ? ctx.changedFiles : null
161
+ }
162
+
163
+ function noAfterLand() {
164
+ return { cwd: null, commands: [], entries: [], range: null }
165
+ }
166
+
167
+ module.exports = { planIntegrate, planIntegrateCheckout, afterLandFor, rangeOf }