supipowers 1.2.6 → 1.5.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 (137) hide show
  1. package/README.md +118 -56
  2. package/bin/install.ts +48 -128
  3. package/package.json +11 -3
  4. package/skills/code-review/SKILL.md +137 -40
  5. package/skills/context-mode/SKILL.md +67 -56
  6. package/skills/creating-supi-agents/SKILL.md +204 -0
  7. package/skills/debugging/SKILL.md +86 -40
  8. package/skills/fix-pr/SKILL.md +96 -65
  9. package/skills/planning/SKILL.md +103 -46
  10. package/skills/qa-strategy/SKILL.md +68 -46
  11. package/skills/receiving-code-review/SKILL.md +60 -53
  12. package/skills/release/SKILL.md +111 -39
  13. package/skills/tdd/SKILL.md +118 -67
  14. package/skills/verification/SKILL.md +71 -37
  15. package/src/bootstrap.ts +27 -5
  16. package/src/commands/agents.ts +249 -0
  17. package/src/commands/ai-review.ts +1113 -0
  18. package/src/commands/config.ts +224 -95
  19. package/src/commands/doctor.ts +19 -13
  20. package/src/commands/fix-pr.ts +8 -11
  21. package/src/commands/generate.ts +200 -0
  22. package/src/commands/model-picker.ts +5 -15
  23. package/src/commands/model.ts +4 -5
  24. package/src/commands/optimize-context.ts +202 -0
  25. package/src/commands/plan.ts +148 -92
  26. package/src/commands/qa.ts +14 -23
  27. package/src/commands/release.ts +504 -275
  28. package/src/commands/review.ts +643 -86
  29. package/src/commands/status.ts +44 -17
  30. package/src/commands/supi.ts +69 -41
  31. package/src/commands/update.ts +57 -2
  32. package/src/config/defaults.ts +6 -39
  33. package/src/config/loader.ts +388 -40
  34. package/src/config/model-resolver.ts +26 -22
  35. package/src/config/schema.ts +113 -48
  36. package/src/context/analyzer.ts +61 -2
  37. package/src/context/optimizer.ts +199 -0
  38. package/src/context-mode/compressor.ts +14 -11
  39. package/src/context-mode/detector.ts +16 -54
  40. package/src/context-mode/event-extractor.ts +45 -16
  41. package/src/context-mode/event-store.ts +225 -16
  42. package/src/context-mode/hooks.ts +195 -22
  43. package/src/context-mode/knowledge/chunker.ts +235 -0
  44. package/src/context-mode/knowledge/store.ts +187 -0
  45. package/src/context-mode/routing.ts +12 -23
  46. package/src/context-mode/sandbox/executor.ts +183 -0
  47. package/src/context-mode/sandbox/runners.ts +40 -0
  48. package/src/context-mode/snapshot-builder.ts +243 -7
  49. package/src/context-mode/tools.ts +440 -0
  50. package/src/context-mode/web/fetcher.ts +117 -0
  51. package/src/context-mode/web/html-to-md.ts +293 -0
  52. package/src/debug/logger.ts +107 -0
  53. package/src/deps/registry.ts +0 -20
  54. package/src/docs/drift.ts +454 -0
  55. package/src/fix-pr/fetch-comments.ts +66 -0
  56. package/src/git/commit-msg.ts +2 -1
  57. package/src/git/commit.ts +123 -141
  58. package/src/git/conventions.ts +2 -2
  59. package/src/git/status.ts +4 -1
  60. package/src/lsp/bridge.ts +138 -12
  61. package/src/planning/approval-flow.ts +125 -19
  62. package/src/planning/plan-writer-prompt.ts +4 -11
  63. package/src/planning/planning-ask-tool.ts +81 -0
  64. package/src/planning/prompt-builder.ts +9 -169
  65. package/src/planning/system-prompt.ts +290 -0
  66. package/src/platform/omp.ts +50 -4
  67. package/src/platform/progress.ts +182 -0
  68. package/src/platform/test-utils.ts +4 -1
  69. package/src/platform/tui-colors.ts +30 -0
  70. package/src/platform/types.ts +1 -0
  71. package/src/qa/detect-app-type.ts +102 -0
  72. package/src/qa/discover-routes.ts +353 -0
  73. package/src/quality/ai-session.ts +96 -0
  74. package/src/quality/ai-setup.ts +86 -0
  75. package/src/quality/gates/ai-review.ts +129 -0
  76. package/src/quality/gates/build.ts +8 -0
  77. package/src/quality/gates/command.ts +150 -0
  78. package/src/quality/gates/format.ts +28 -0
  79. package/src/quality/gates/lint.ts +22 -0
  80. package/src/quality/gates/lsp-diagnostics.ts +84 -0
  81. package/src/quality/gates/test-suite.ts +8 -0
  82. package/src/quality/gates/typecheck.ts +22 -0
  83. package/src/quality/registry.ts +25 -0
  84. package/src/quality/review-gates.ts +33 -0
  85. package/src/quality/runner.ts +268 -0
  86. package/src/quality/schemas.ts +48 -0
  87. package/src/quality/setup.ts +227 -0
  88. package/src/release/changelog.ts +7 -3
  89. package/src/release/channels/custom.ts +43 -0
  90. package/src/release/channels/gitea.ts +35 -0
  91. package/src/release/channels/github.ts +35 -0
  92. package/src/release/channels/gitlab.ts +35 -0
  93. package/src/release/channels/registry.ts +52 -0
  94. package/src/release/channels/types.ts +27 -0
  95. package/src/release/detector.ts +10 -63
  96. package/src/release/executor.ts +61 -51
  97. package/src/release/prompt.ts +38 -38
  98. package/src/release/version.ts +129 -10
  99. package/src/review/agent-loader.ts +331 -0
  100. package/src/review/consolidator.ts +180 -0
  101. package/src/review/default-agents/correctness.md +72 -0
  102. package/src/review/default-agents/maintainability.md +64 -0
  103. package/src/review/default-agents/security.md +67 -0
  104. package/src/review/fixer.ts +219 -0
  105. package/src/review/multi-agent-runner.ts +135 -0
  106. package/src/review/output.ts +147 -0
  107. package/src/review/prompts/agent-review-wrapper.md +36 -0
  108. package/src/review/prompts/fix-findings.md +32 -0
  109. package/src/review/prompts/fix-output-schema.md +18 -0
  110. package/src/review/prompts/invalid-output-retry.md +22 -0
  111. package/src/review/prompts/output-instructions.md +14 -0
  112. package/src/review/prompts/review-output-schema.md +38 -0
  113. package/src/review/prompts/single-review.md +53 -0
  114. package/src/review/prompts/validation-review.md +30 -0
  115. package/src/review/runner.ts +128 -0
  116. package/src/review/scope.ts +353 -0
  117. package/src/review/template.ts +15 -0
  118. package/src/review/types.ts +296 -0
  119. package/src/review/validator.ts +160 -0
  120. package/src/storage/plans.ts +5 -3
  121. package/src/storage/reports.ts +50 -7
  122. package/src/storage/review-sessions.ts +117 -0
  123. package/src/text.ts +19 -0
  124. package/src/types.ts +336 -26
  125. package/src/utils/paths.ts +39 -0
  126. package/src/visual/companion.ts +5 -3
  127. package/src/visual/start-server.ts +101 -0
  128. package/src/visual/stop-server.ts +39 -0
  129. package/bin/ctx-mode-wrapper.mjs +0 -66
  130. package/src/config/profiles.ts +0 -64
  131. package/src/context-mode/installer.ts +0 -38
  132. package/src/quality/ai-review-gate.ts +0 -43
  133. package/src/quality/gate-runner.ts +0 -67
  134. package/src/quality/lsp-gate.ts +0 -24
  135. package/src/quality/test-gate.ts +0 -39
  136. package/src/visual/scripts/start-server.sh +0 -98
  137. package/src/visual/scripts/stop-server.sh +0 -21
@@ -2,16 +2,34 @@ import type { Platform } from "../platform/types.js";
2
2
  import { modelRegistry } from "../config/model-registry-instance.js";
3
3
  import { resolveModelForAction, createModelBridge, applyModelOverride } from "../config/model-resolver.js";
4
4
  import { loadModelConfig } from "../config/model-config.js";
5
- import type { ReleaseChannel, BumpType } from "../types.js";
5
+ import type { ReleaseChannel, BumpType, ReviewReport, ResolvedModel } from "../types.js";
6
6
  import { loadConfig, updateConfig } from "../config/loader.js";
7
7
  import { detectChannels } from "../release/detector.js";
8
+ import type { ChannelStatus } from "../release/channels/types.js";
8
9
  import { parseConventionalCommits, buildChangelogMarkdown, summarizeChanges } from "../release/changelog.js";
9
- import { getCurrentVersion, suggestBump, bumpVersion, isVersionReleased, isTagOnRemote } from "../release/version.js";
10
+ import {
11
+ getCurrentVersion,
12
+ suggestBump,
13
+ bumpVersion,
14
+ isVersionReleased,
15
+ isTagOnRemote,
16
+ findResumableLocalRelease,
17
+ formatTag,
18
+ } from "../release/version.js";
10
19
  import { executeRelease, type ReleaseProgressFn } from "../release/executor.js";
11
20
  import { buildPolishPrompt } from "../release/prompt.js";
12
21
  import { notifyInfo, notifySuccess, notifyError } from "../notifications/renderer.js";
13
22
  import { analyzeAndCommit } from "../git/commit.js";
14
23
  import { getWorkingTreeStatus } from "../git/status.js";
24
+ import { runStructuredAgentSession } from "../quality/ai-session.js";
25
+ import {
26
+ checkDocDrift,
27
+ buildFixPrompt,
28
+ } from "../docs/drift.js";
29
+ import { runQualityGates } from "../quality/runner.js";
30
+ import { REVIEW_GATE_REGISTRY } from "../quality/review-gates.js";
31
+ import { GATE_DISPLAY_NAMES } from "../quality/registry.js";
32
+ import { createWorkflowProgress } from "../platform/progress.js";
15
33
 
16
34
  modelRegistry.register({
17
35
  id: "release",
@@ -20,10 +38,6 @@ modelRegistry.register({
20
38
  harnessRoleHint: "slow",
21
39
  });
22
40
 
23
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
24
- const STATUS_KEY = "supi-release";
25
- const WIDGET_KEY = "supi-release";
26
-
27
41
  const BUMP_OPTIONS = [
28
42
  "patch — bug fixes only",
29
43
  "minor — new features, backwards compatible",
@@ -37,8 +51,7 @@ const BUMP_OPTIONS = [
37
51
  * This is the resume path: the version in package.json hasn't been tagged yet
38
52
  * AND all channels are already configured — the user staged the release
39
53
  * deliberately when they bumped the version, so no new decisions are needed.
40
- * Dry-run is excluded because it is exploratory by intent; the user explicitly
41
- * wants to preview and confirm before anything happens.
54
+ * Dry-run is excluded because it is exploratory by intent.
42
55
  */
43
56
  export function isInProgressRelease(opts: {
44
57
  skipBump: boolean;
@@ -48,131 +61,189 @@ export function isInProgressRelease(opts: {
48
61
  return opts.skipBump && opts.channelsWerePreConfigured && !opts.isDryRun;
49
62
  }
50
63
 
51
- // ── Release progress tracker ────────────────────────────────────
64
+ export function findInvalidReleaseChannels(
65
+ channels: ReleaseChannel[],
66
+ detected: ChannelStatus[],
67
+ ): string[] {
68
+ const detectedById = new Map(detected.map((status) => [status.channel, status]));
69
+
70
+ return channels.flatMap((channel) => {
71
+ const status = detectedById.get(channel);
72
+ if (!status) {
73
+ return [`${channel}: unknown channel`];
74
+ }
75
+ if (!status.available) {
76
+ return [`${channel}: unavailable (${status.detail})`];
77
+ }
78
+ return [];
79
+ });
80
+ }
52
81
 
53
- interface ReleaseStep {
54
- label: string;
55
- status: "pending" | "active" | "done" | "error" | "skipped";
56
- detail?: string;
82
+ export function buildSelectableReleaseChannelOptions(detected: ChannelStatus[]): string[] {
83
+ return detected
84
+ .filter((channel) => channel.available)
85
+ .map((channel) => `${channel.channel} — ${channel.detail}`);
57
86
  }
58
87
 
59
- function createReleaseProgress(ctx: any) {
60
- const steps: ReleaseStep[] = [
61
- { label: "Check working tree", status: "pending" },
62
- { label: "Detect channels", status: "pending" },
63
- { label: "Analyze commits", status: "pending" },
64
- { label: "Select version", status: "pending" },
65
- { label: "Build changelog", status: "pending" },
66
- { label: "Execute release", status: "pending" },
67
- { label: "Publish channels", status: "pending" },
68
- ];
69
-
70
- let frame = 0;
71
- let statusDetail = "";
72
- let timer: ReturnType<typeof setInterval> | null = null;
73
-
74
- function icon(step: ReleaseStep): string {
75
- switch (step.status) {
76
- case "done": return "✓";
77
- case "active": return SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
78
- case "error": return "✗";
79
- case "skipped": return "–";
80
- default: return "○";
81
- }
88
+ interface GitHubAuthAccount {
89
+ host: string;
90
+ user: string;
91
+ active: boolean;
92
+ }
93
+
94
+ export function isGitHubPermissionDeniedError(detail: string | undefined): boolean {
95
+ if (!detail) {
96
+ return false;
82
97
  }
83
98
 
84
- function renderWidget(): string[] {
85
- const lines: string[] = ["┌─ supi:release ─────────────────────┐"];
86
- for (const step of steps) {
87
- const mark = icon(step);
88
- const detail = step.detail ? ` (${step.detail})` : "";
89
- lines.push(`│ ${mark} ${step.label}${detail}`);
99
+ const normalized = detail.toLowerCase();
100
+ return normalized.includes("github.com")
101
+ && normalized.includes("403")
102
+ && (normalized.includes("permission to") || normalized.includes("denied to"));
103
+ }
104
+
105
+ export function parseGithubAuthStatusAccounts(output: string, host = "github.com"): GitHubAuthAccount[] {
106
+ const accounts: GitHubAuthAccount[] = [];
107
+ let currentAccount: GitHubAuthAccount | null = null;
108
+
109
+ for (const rawLine of output.split(/\r?\n/)) {
110
+ const line = rawLine.trim();
111
+ if (!line) {
112
+ continue;
90
113
  }
91
- lines.push("└─────────────────────────────────────┘");
92
- return lines;
93
- }
94
114
 
95
- function refresh() {
96
- frame++;
97
- ctx.ui.setWidget?.(WIDGET_KEY, renderWidget());
98
- if (statusDetail) {
99
- ctx.ui.setStatus?.(STATUS_KEY, `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${statusDetail}`);
115
+ const accountMatch = line.match(/^✓ Logged in to (\S+) account ([^(]+?) \(/);
116
+ if (accountMatch) {
117
+ currentAccount = {
118
+ host: accountMatch[1] ?? "",
119
+ user: (accountMatch[2] ?? "").trim(),
120
+ active: false,
121
+ };
122
+ if (currentAccount.host === host) {
123
+ accounts.push(currentAccount);
124
+ } else {
125
+ currentAccount = null;
126
+ }
127
+ continue;
100
128
  }
101
- }
102
129
 
103
- function startTimer() {
104
- if (!timer) {
105
- timer = setInterval(refresh, 80);
130
+ if (currentAccount && line.startsWith("- Active account:")) {
131
+ currentAccount.active = line.endsWith("true");
106
132
  }
107
133
  }
108
134
 
109
- function stopTimer() {
110
- if (timer) {
111
- clearInterval(timer);
112
- timer = null;
113
- }
135
+ return accounts;
136
+ }
137
+
138
+ export async function maybeSwitchGithubAccountForReleaseFailure(
139
+ platform: Pick<Platform, "exec">,
140
+ ctx: { cwd: string; ui: { select: (title: string, options: string[], opts?: { helpText?: string }) => Promise<string | null>; notify?: (message: string, type?: string) => void } },
141
+ detail: string | undefined,
142
+ ): Promise<string | null> {
143
+ if (!isGitHubPermissionDeniedError(detail)) {
144
+ return null;
114
145
  }
115
146
 
116
- return {
117
- activate(stepIndex: number, detail?: string) {
118
- const step = steps[stepIndex];
119
- if (step) {
120
- step.status = "active";
121
- step.detail = detail;
122
- }
123
- statusDetail = detail ?? step?.label ?? "";
124
- startTimer();
125
- refresh();
126
- },
147
+ const statusResult = await platform.exec("gh", ["auth", "status", "--hostname", "github.com"], { cwd: ctx.cwd });
148
+ if (statusResult.code !== 0) {
149
+ return null;
150
+ }
127
151
 
128
- complete(stepIndex: number, detail?: string) {
129
- const step = steps[stepIndex];
130
- if (step) {
131
- step.status = "done";
132
- if (detail !== undefined) step.detail = detail;
133
- }
134
- refresh();
135
- },
152
+ const accounts = parseGithubAuthStatusAccounts(`${statusResult.stdout}\n${statusResult.stderr}`);
153
+ if (accounts.length < 2) {
154
+ return null;
155
+ }
136
156
 
137
- fail(stepIndex: number, detail?: string) {
138
- const step = steps[stepIndex];
139
- if (step) {
140
- step.status = "error";
141
- if (detail !== undefined) step.detail = detail;
142
- }
143
- refresh();
144
- },
157
+ const deniedUser = detail?.match(/denied to\s+([A-Za-z0-9-]+)/i)?.[1] ?? null;
158
+ const options = accounts.map((account) => {
159
+ const suffix: string[] = [];
160
+ if (account.active) suffix.push("current");
161
+ if (deniedUser && account.user === deniedUser) suffix.push("denied here");
162
+ return suffix.length > 0 ? `${account.user} — ${suffix.join(", ")}` : account.user;
163
+ });
145
164
 
146
- skip(stepIndex: number, detail?: string) {
147
- const step = steps[stepIndex];
148
- if (step) {
149
- step.status = "skipped";
150
- if (detail !== undefined) step.detail = detail;
151
- }
152
- refresh();
165
+ const choice = await ctx.ui.select(
166
+ "GitHub account mismatch detected",
167
+ options,
168
+ {
169
+ helpText: deniedUser
170
+ ? `GitHub denied the current release with account ${deniedUser}. Choose another authenticated GitHub account to retry the release.`
171
+ : "GitHub denied the current release. Choose another authenticated GitHub account to retry.",
153
172
  },
173
+ );
174
+ if (!choice) {
175
+ return null;
176
+ }
177
+
178
+ const selectedUser = choice.split(" — ")[0] ?? choice;
179
+ const switchResult = await platform.exec(
180
+ "gh",
181
+ ["auth", "switch", "--hostname", "github.com", "--user", selectedUser],
182
+ { cwd: ctx.cwd },
183
+ );
184
+ if (switchResult.code !== 0) {
185
+ ctx.ui.notify?.(`GitHub account switch failed: ${switchResult.stderr || switchResult.stdout || "unknown error"}`, "error");
186
+ return null;
187
+ }
188
+
189
+ return selectedUser;
190
+ }
154
191
 
192
+
193
+ export const RELEASE_STEPS = [
194
+ { key: "checks", label: "Quality checks" },
195
+ { key: "doc-drift", label: "Check documentation drift" },
196
+ { key: "working-tree", label: "Check working tree" },
197
+ { key: "channels", label: "Detect channels" },
198
+ { key: "commits", label: "Analyze commits" },
199
+ { key: "version", label: "Select version" },
200
+ { key: "changelog", label: "Build changelog" },
201
+ { key: "polish", label: "Polish release notes" },
202
+ { key: "execute", label: "Execute release" },
203
+ { key: "publish", label: "Publish channels" },
204
+ ] as const;
205
+
206
+ type ReleaseStepKey = (typeof RELEASE_STEPS)[number]["key"];
207
+
208
+ function createReleaseProgress(ctx: any) {
209
+ const progress = createWorkflowProgress(ctx.ui, {
210
+ title: "supi:release",
211
+ statusKey: "supi-release",
212
+ statusLabel: "Releasing...",
213
+ widgetKey: "supi-release",
214
+ clearStatusKeys: ["supi-model"],
215
+ steps: [...RELEASE_STEPS],
216
+ });
217
+
218
+ return {
219
+ activate(key: ReleaseStepKey, detail?: string) {
220
+ progress.activate(key, detail);
221
+ },
222
+ complete(key: ReleaseStepKey, detail?: string) {
223
+ progress.complete(key, detail);
224
+ },
225
+ fail(key: ReleaseStepKey, detail?: string) {
226
+ progress.fail(key, detail);
227
+ },
228
+ skip(key: ReleaseStepKey, detail?: string) {
229
+ progress.skip(key, detail);
230
+ },
231
+ detail(text: string) {
232
+ progress.detail(text);
233
+ },
155
234
  /** Build an onProgress callback for executeRelease. */
156
235
  executorProgress(): ReleaseProgressFn {
157
236
  return (step, status, detail) => {
158
- // Map executor steps to sub-detail on "Execute release" (index 5)
159
- // or "Publish channels" (index 6)
160
237
  const isPublish = step.startsWith("publish-");
161
- const idx = isPublish ? 6 : 5;
162
- if (status === "active") this.activate(idx, detail);
238
+ const key: ReleaseStepKey = isPublish ? "publish" : "execute";
239
+ if (status === "active") this.activate(key, detail);
163
240
  else if (status === "done") {
164
- // Don't mark the overall step done from individual sub-steps
165
- const s = steps[idx];
166
- if (s) { s.detail = detail; }
167
- refresh();
168
- } else if (status === "error") this.fail(idx, detail);
241
+ progress.detail(detail ?? "");
242
+ } else if (status === "error") this.fail(key, detail);
169
243
  };
170
244
  },
171
-
172
245
  dispose() {
173
- stopTimer();
174
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
175
- ctx.ui.setWidget?.(WIDGET_KEY, undefined);
246
+ progress.dispose();
176
247
  },
177
248
  };
178
249
  }
@@ -199,7 +270,7 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
199
270
  const modelCfg = loadModelConfig(platform.paths, ctx.cwd);
200
271
  const bridge = createModelBridge(platform);
201
272
  const resolved = resolveModelForAction("release", modelRegistry, modelCfg, bridge);
202
- await applyModelOverride(platform, ctx, "release", resolved);
273
+ const modelCleanup = await applyModelOverride(platform, ctx, "release", resolved);
203
274
 
204
275
  if (!ctx.hasUI) {
205
276
  ctx.ui.notify("Release requires interactive mode", "warning");
@@ -210,16 +281,90 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
210
281
 
211
282
  void (async () => {
212
283
  try {
213
- const isPolish = args?.includes("--polish") ?? false;
284
+ const skipPolish = args?.includes("--raw") ?? false;
214
285
  const isDryRun = args?.includes("--dry-run") ?? false;
215
286
  const config = loadConfig(platform.paths, ctx.cwd);
216
-
217
- // 0. Check for uncommitted changes
218
- progress.activate(0, "Checking working tree");
287
+ const tagFormat = config.release.tagFormat;
219
288
  let didStash = false;
289
+
290
+ // ── 1. Quality checks (headless) ────────────────────────────────────
291
+ progress.activate("checks", "Running quality gates");
292
+ const checksReport = await runHeadlessChecks(platform, ctx, config, resolved);
293
+ if (checksReport) {
294
+ const { summary, overallStatus } = checksReport;
295
+ const detail = `${summary.passed} passed, ${summary.failed} failed, ${summary.blocked} blocked`;
296
+ if (overallStatus === "passed") {
297
+ progress.complete("checks", detail);
298
+ } else {
299
+ progress.fail("checks", detail);
300
+ const failedNames = checksReport.gates
301
+ .filter((g) => g.status === "failed" || g.status === "blocked")
302
+ .map((g) => GATE_DISPLAY_NAMES[g.gate] ?? g.gate);
303
+ const continueChoice = await ctx.ui.select(
304
+ `Quality checks ${overallStatus}: ${failedNames.join(", ")}`,
305
+ [
306
+ "Continue — release despite failures",
307
+ "Abort — fix issues first",
308
+ ],
309
+ { helpText: detail },
310
+ );
311
+ if (!continueChoice || continueChoice.startsWith("Abort")) {
312
+ progress.dispose();
313
+ return;
314
+ }
315
+ }
316
+ } else {
317
+ progress.skip("checks", "No gates configured");
318
+ }
319
+
320
+ // ── 2. Doc-drift pre-check ──────────────────────────────────────────
321
+ progress.activate("doc-drift", "Checking documentation drift");
322
+ const driftResult = await checkDocDrift(platform, ctx.cwd);
323
+ if (driftResult?.drifted) {
324
+ progress.complete("doc-drift", "Drift detected");
325
+ const driftAction = await ctx.ui.select(
326
+ "Documentation drift detected before release",
327
+ [
328
+ "Update docs — fix documentation before continuing",
329
+ "Continue — release without updating docs",
330
+ ],
331
+ { helpText: driftResult.summary },
332
+ );
333
+
334
+ if (driftAction?.startsWith("Update docs")) {
335
+ progress.activate("doc-drift", "Fixing documentation");
336
+ notifyInfo(ctx, "Updating documentation", driftResult.summary);
337
+ const fixPrompt = buildFixPrompt(driftResult.findings);
338
+ const { loadState: loadDriftState, saveState: saveDriftState, getHeadCommit } = await import("../docs/drift.js");
339
+ const driftHead = await getHeadCommit(platform, ctx.cwd);
340
+ const driftState = loadDriftState(platform.paths, ctx.cwd);
341
+ saveDriftState(platform.paths, ctx.cwd, { ...driftState, lastCommit: driftHead, lastRunAt: new Date().toISOString() });
342
+
343
+ const fixResult = await runStructuredAgentSession(
344
+ platform.createAgentSession.bind(platform),
345
+ { cwd: ctx.cwd, prompt: fixPrompt },
346
+ );
347
+ if (fixResult.status === "ok") {
348
+ progress.complete("doc-drift", "Fixed");
349
+ notifySuccess(ctx, "Documentation updated");
350
+ } else {
351
+ progress.fail("doc-drift", fixResult.error ?? "Agent session error");
352
+ notifyError(ctx, "Doc update failed", fixResult.error ?? "Agent session error");
353
+ progress.dispose();
354
+ return;
355
+ }
356
+ } else {
357
+ progress.skip("doc-drift", "Skipped by user");
358
+ }
359
+ } else {
360
+ progress.complete("doc-drift", "No drift");
361
+ }
362
+
363
+ // ── 3. Check for uncommitted changes after preflight side effects ─────
364
+ progress.activate("working-tree", "Checking working tree");
220
365
  const treeStatus = await getWorkingTreeStatus(platform.exec.bind(platform), ctx.cwd);
221
366
  if (treeStatus.dirty) {
222
- progress.complete(0, `${treeStatus.files.length} files changed`);
367
+ progress.complete("working-tree", `${treeStatus.files.length} files changed`);
223
368
  const filePreview = treeStatus.files.slice(0, 8).join(", ");
224
369
  const extra = treeStatus.files.length > 8 ? ` (+${treeStatus.files.length - 8} more)` : "";
225
370
 
@@ -227,8 +372,8 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
227
372
  `Uncommitted changes detected (${treeStatus.files.length} files)`,
228
373
  [
229
374
  "commit — commit changes with AI-generated message",
230
- "stash \u2014 stash changes and continue",
231
- "abort \u2014 cancel release",
375
+ "stash stash changes and continue",
376
+ "abort cancel release",
232
377
  ],
233
378
  { helpText: `Files: ${filePreview}${extra}` },
234
379
  );
@@ -239,72 +384,85 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
239
384
  }
240
385
 
241
386
  if (action.startsWith("commit")) {
242
- // commit.ts has its own progress widget; dispose ours temporarily
243
387
  progress.dispose();
244
388
  const commitResult = await analyzeAndCommit(platform, ctx);
245
389
  if (!commitResult) {
246
390
  notifyError(ctx, "Commit failed or cancelled", "Aborting release.");
247
391
  return;
248
392
  }
249
- // Verify tree is now clean
250
393
  const afterStatus = await getWorkingTreeStatus(platform.exec.bind(platform), ctx.cwd);
251
394
  if (afterStatus.dirty) {
252
395
  notifyError(ctx, "Still uncommitted changes", "Not all changes were committed. Aborting release.");
253
396
  return;
254
397
  }
255
- // Re-create progress after commit's widget has disposed
256
- // (reactivate shows state from where we left off)
257
- progress.complete(0, "Committed");
398
+ progress.complete("working-tree", "Committed");
258
399
  } else if (action.startsWith("stash")) {
259
- progress.activate(0, "Stashing changes");
400
+ progress.activate("working-tree", "Stashing changes");
260
401
  const stashResult = await platform.exec("git", ["stash", "push", "-m", "supi:release auto-stash"], { cwd: ctx.cwd });
261
402
  if (stashResult.code !== 0) {
262
- progress.fail(0, stashResult.stderr || "stash failed");
403
+ progress.fail("working-tree", stashResult.stderr || "stash failed");
263
404
  progress.dispose();
264
405
  notifyError(ctx, "git stash failed", stashResult.stderr || "Non-zero exit");
265
406
  return;
266
407
  }
267
408
  didStash = true;
268
- progress.complete(0, "Stashed");
409
+ progress.complete("working-tree", "Stashed");
269
410
  }
270
411
  } else {
271
- progress.complete(0, "Clean");
412
+ progress.complete("working-tree", "Clean");
272
413
  }
273
414
 
274
- // 1. Ensure channels are configured (or detect + ask)
275
- progress.activate(1, "Detecting channels");
415
+ // ── 4. Ensure channels are configured (or detect + ask) ─────────────
416
+ progress.activate("channels", "Detecting channels");
417
+ const customChannels = config.release.customChannels ?? {};
418
+ const detectedChannels = await detectChannels(platform.exec.bind(platform), ctx.cwd, customChannels);
276
419
  let channels = config.release.channels;
277
420
  // Track whether channels were already set in config before any interactive
278
421
  // setup. This distinguishes "user already decided" from "just configured now",
279
422
  // which determines whether we can auto-continue without a confirmation prompt.
280
423
  const channelsWerePreConfigured = config.release.channels.length > 0;
281
424
 
425
+ if (channelsWerePreConfigured) {
426
+ const invalidChannels = findInvalidReleaseChannels(channels, detectedChannels);
427
+ if (invalidChannels.length > 0) {
428
+ const detail = invalidChannels.join("; ");
429
+ progress.fail("channels", detail);
430
+ notifyError(ctx, "Release channels invalid", detail);
431
+ progress.dispose();
432
+ return;
433
+ }
434
+ }
435
+
282
436
  if (channels.length === 0) {
283
- progress.complete(1, "Awaiting selection");
284
- channels = await setupChannels(platform, ctx);
437
+ progress.complete("channels", "Awaiting selection");
438
+ channels = await setupChannels(platform, ctx, detectedChannels);
285
439
  if (channels.length === 0) {
286
440
  progress.dispose();
287
441
  return;
288
442
  }
289
443
  }
290
- progress.complete(1, channels.join(", "));
444
+ progress.complete("channels", channels.join(", "));
291
445
 
292
- // 2. Get last tag + current version + check if bump is needed
293
- progress.activate(2, "Parsing git history");
446
+ // ── 5. Get last tag + current version + check if bump is needed ─────
447
+ progress.activate("commits", "Parsing git history");
294
448
  const lastTag = await getLastTag(platform, ctx.cwd);
295
449
  const currentVersion = getCurrentVersion(ctx.cwd);
450
+ const resumableLocalRelease = await findResumableLocalRelease(
451
+ platform.exec.bind(platform),
452
+ ctx.cwd,
453
+ currentVersion,
454
+ tagFormat,
455
+ );
296
456
  const localTagExists = await isVersionReleased(
297
457
  platform.exec.bind(platform),
298
458
  ctx.cwd,
299
459
  currentVersion,
460
+ tagFormat,
300
461
  );
301
- // Only check remote when local tag exists — avoids a network call when
302
- // the version was never tagged at all.
303
462
  const remoteTagExists = localTagExists
304
- ? await isTagOnRemote(platform.exec.bind(platform), ctx.cwd, currentVersion)
463
+ ? await isTagOnRemote(platform.exec.bind(platform), ctx.cwd, currentVersion, tagFormat)
305
464
  : false;
306
465
 
307
- // 3. Parse commits since last tag
308
466
  const sinceArg = lastTag ? `${lastTag}..HEAD` : "HEAD~50..HEAD";
309
467
  let gitLogOutput: string;
310
468
  try {
@@ -321,103 +479,122 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
321
479
  const commits = parseConventionalCommits(gitLogOutput);
322
480
  const summary = summarizeChanges(commits);
323
481
  const commitCount = commits.features.length + commits.fixes.length + commits.breaking.length + commits.improvements.length + commits.maintenance.length + commits.other.length;
324
- progress.complete(2, `${commitCount} commits since ${lastTag ?? "start"}`);
325
-
326
- // 4. Version resolution
327
- // Three states:
328
- // a) No local tag → version is unreleased, skip bump
329
- // b) Local tag, not on remote → incomplete release, skip bump + skip tag
330
- // c) Local + remote tag → fully released, ask for bump
331
- let nextVersion: string;
332
- let skipBump: boolean;
482
+ progress.complete("commits", `${commitCount} commits since ${lastTag ?? "start"}`);
483
+
484
+ let nextVersion: string | null = null;
485
+ let skipBump: boolean | null = null;
333
486
  let skipTag = false;
334
487
 
335
- if (!localTagExists && currentVersion !== "0.0.0") {
336
- // (a) No tag at all — version in package.json is unreleased
337
- nextVersion = currentVersion;
338
- skipBump = true;
339
- progress.skip(3, `v${currentVersion} (already set, not yet released)`);
340
- notifyInfo(ctx, `Using v${currentVersion}`, "Version not yet released \u2014 skipping bump");
341
- } else if (localTagExists && !remoteTagExists) {
342
- // (b) Tag exists locally but never made it to origin — resume
343
- nextVersion = currentVersion;
344
- skipBump = true;
345
- skipTag = true;
346
- progress.skip(3, `v${currentVersion} (tag exists locally, not pushed)`);
347
- notifyInfo(ctx, `Resuming v${currentVersion}`, "Tag exists locally but not on remote \u2014 will push");
348
- } else {
349
- // (c) Fully released — ask for bump
350
- progress.activate(3, "Awaiting version selection");
351
- const suggested = suggestBump(commits);
352
- const bumpChoice = await ctx.ui.select(
353
- `Version bump (${summary})`,
354
- BUMP_OPTIONS,
355
- { helpText: `Current: ${currentVersion} (released) | Suggested: ${suggested}` },
488
+ if (resumableLocalRelease) {
489
+ progress.activate("version", "Found local release tag");
490
+ const resumeChoice = await ctx.ui.select(
491
+ `Continue failed release ${resumableLocalRelease.tag}?`,
492
+ [
493
+ `Continue — resume ${resumableLocalRelease.tag}`,
494
+ "Ignore choose version normally",
495
+ ],
496
+ {
497
+ helpText: [
498
+ `A newer local tag (${resumableLocalRelease.tag}) exists on the current HEAD but is not on origin.`,
499
+ `package.json still reports ${currentVersion}.`,
500
+ "No commits or worktree changes were detected after that local tag.",
501
+ ].join(" "),
502
+ },
356
503
  );
357
- if (!bumpChoice) {
504
+ if (!resumeChoice) {
358
505
  progress.dispose();
359
506
  return;
360
507
  }
361
508
 
362
- const bump = bumpChoice.split(" \u2014 ")[0] as BumpType;
363
- nextVersion = bumpVersion(currentVersion, bump);
364
- skipBump = false;
365
- progress.complete(3, `${currentVersion} \u2192 ${nextVersion} (${bump})`);
509
+ if (resumeChoice.startsWith("Continue")) {
510
+ nextVersion = resumableLocalRelease.version;
511
+ skipBump = true;
512
+ skipTag = true;
513
+ progress.skip("version", `${resumableLocalRelease.tag} (local tag not deployed)`);
514
+ notifyInfo(
515
+ ctx,
516
+ `Resuming failed release ${resumableLocalRelease.tag}`,
517
+ "Local tag exists only on this machine — release will continue from that tagged commit.",
518
+ );
519
+ }
366
520
  }
367
521
 
368
- // 5. Build changelog
369
- progress.activate(4, "Generating changelog");
370
- const changelog = buildChangelogMarkdown(commits, nextVersion);
371
- progress.complete(4, `${commitCount} entries`);
372
-
373
- // 6. Polish mode steer prompt to LLM, then return
374
- if (isPolish) {
375
- progress.skip(5, "Polish mode");
376
- progress.skip(6, "Polish mode");
377
- progress.dispose();
378
-
379
- const releaseCommands = buildReleaseCommands(nextVersion, channels);
380
- const prompt = buildPolishPrompt({
381
- changelog,
382
- version: nextVersion,
383
- currentVersion,
384
- channels,
385
- commands: releaseCommands,
386
- });
387
-
388
- notifyInfo(ctx, "Release polish mode", `LLM will polish notes for v${nextVersion}`);
522
+ if (nextVersion === null || skipBump === null) {
523
+ if (!localTagExists && currentVersion !== "0.0.0") {
524
+ nextVersion = currentVersion;
525
+ skipBump = true;
526
+ progress.skip("version", `${formatTag(currentVersion, tagFormat)} (already set, not yet released)`);
527
+ notifyInfo(ctx, `Using ${formatTag(currentVersion, tagFormat)}`, "Version not yet released skipping bump");
528
+ } else if (localTagExists && !remoteTagExists) {
529
+ nextVersion = currentVersion;
530
+ skipBump = true;
531
+ skipTag = true;
532
+ progress.skip("version", `${formatTag(currentVersion, tagFormat)} (tag exists locally, not pushed)`);
533
+ notifyInfo(ctx, `Resuming ${formatTag(currentVersion, tagFormat)}`, "Tag exists locally but not on remote — will push");
534
+ } else {
535
+ progress.activate("version", "Awaiting version selection");
536
+ const suggested = suggestBump(commits);
537
+ const bumpChoice = await ctx.ui.select(
538
+ `Version bump (${summary})`,
539
+ BUMP_OPTIONS,
540
+ { helpText: `Current: ${currentVersion} (released) | Suggested: ${suggested}` },
541
+ );
542
+ if (!bumpChoice) {
543
+ progress.dispose();
544
+ return;
545
+ }
389
546
 
390
- platform.sendMessage(
391
- {
392
- customType: "supi-release-polish",
393
- content: [{ type: "text", text: prompt }],
394
- display: "none",
395
- },
396
- { deliverAs: "steer", triggerTurn: true },
547
+ const bump = bumpChoice.split(" — ")[0] as BumpType;
548
+ nextVersion = bumpVersion(currentVersion, bump);
549
+ skipBump = false;
550
+ progress.complete("version", `${currentVersion} ${nextVersion} (${bump})`);
551
+ }
552
+ }
553
+ // ── 8. Build changelog ──────────────────────────────────────────────
554
+ progress.activate("changelog", "Generating changelog");
555
+ const rawChangelog = buildChangelogMarkdown(commits, nextVersion, tagFormat);
556
+ progress.complete("changelog", `${commitCount} entries`);
557
+
558
+ // ── 9. Polish release notes (default, skip with --raw) ──────────────
559
+ let changelog: string;
560
+ if (skipPolish) {
561
+ progress.skip("polish", "Skipped (--raw)");
562
+ changelog = rawChangelog;
563
+ } else {
564
+ progress.activate("polish", "Polishing release notes");
565
+ const polishPrompt = buildPolishPrompt({ changelog: rawChangelog, version: nextVersion, tagFormat });
566
+ const polishResult = await runStructuredAgentSession(
567
+ platform.createAgentSession.bind(platform),
568
+ { cwd: ctx.cwd, prompt: polishPrompt },
397
569
  );
398
- return;
570
+ if (polishResult.status === "ok") {
571
+ changelog = polishResult.finalText;
572
+ progress.complete("polish", "Polished");
573
+ } else {
574
+ // Polish failed — fall back to raw changelog, don't block the release
575
+ changelog = rawChangelog;
576
+ progress.fail("polish", polishResult.error ?? "Agent error — using raw changelog");
577
+ notifyInfo(ctx, "Polish failed — using raw changelog", polishResult.error ?? "");
578
+ }
399
579
  }
400
580
 
401
- // 7. Confirm via UI — skipped when resuming a staged unreleased release.
402
- // When skipBump is true the user already decided on the version; when
403
- // channels were pre-configured there's nothing left to decide. Asking
404
- // again is friction without benefit. Dry-run always asks because the
405
- // user explicitly opted into preview mode.
581
+ // ── 10. Confirm via UI ──────────────────────────────────────────────
582
+ // Skipped when resuming a staged unreleased release.
406
583
  const isResume = isInProgressRelease({ skipBump, channelsWerePreConfigured, isDryRun });
407
584
 
408
585
  if (isResume) {
409
586
  notifyInfo(
410
587
  ctx,
411
- `Resuming release v${nextVersion}`,
588
+ `Resuming release ${formatTag(nextVersion, tagFormat)}`,
412
589
  "Version staged and channels configured — proceeding without confirmation",
413
590
  );
414
591
  } else {
415
- const confirmLabel = isDryRun ? `[DRY RUN] Ship v${nextVersion}?` : `Ship v${nextVersion}?`;
592
+ const confirmLabel = isDryRun ? `[DRY RUN] Ship ${formatTag(nextVersion, tagFormat)}?` : `Ship ${formatTag(nextVersion, tagFormat)}?`;
416
593
  // When skipBump=true, currentVersion === nextVersion — avoid the
417
594
  // misleading "0.5.0 → 0.5.0" display.
418
595
  const versionLine = skipBump
419
- ? `v${nextVersion} (staged, not yet released)`
420
- : `${currentVersion} ${nextVersion}`;
596
+ ? `${formatTag(nextVersion, tagFormat)} (staged, not yet released)`
597
+ : `${currentVersion} \u2192 ${nextVersion}`;
421
598
  const confirmDetail = [
422
599
  versionLine,
423
600
  `Channels: ${channels.join(", ")}`,
@@ -428,7 +605,7 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
428
605
 
429
606
  const confirmed = ctx.ui.confirm
430
607
  ? await ctx.ui.confirm(confirmLabel, confirmDetail)
431
- : (await ctx.ui.select(confirmLabel, ["Yes \u2014 publish", "No \u2014 abort"])) === "Yes \u2014 publish";
608
+ : (await ctx.ui.select(confirmLabel, ["Yes publish", "No abort"])) === "Yes publish";
432
609
 
433
610
  if (!confirmed) {
434
611
  progress.dispose();
@@ -437,10 +614,10 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
437
614
  }
438
615
  }
439
616
 
440
- // 8. Execute release
441
- progress.activate(5, "Starting release execution");
617
+ // ── 11. Execute release ─────────────────────────────────────────────
618
+ progress.activate("execute", "Starting release execution");
442
619
  try {
443
- const result = await executeRelease({
620
+ let result = await executeRelease({
444
621
  exec: platform.exec.bind(platform),
445
622
  cwd: ctx.cwd,
446
623
  version: nextVersion,
@@ -449,38 +626,56 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
449
626
  dryRun: isDryRun,
450
627
  skipBump,
451
628
  skipTag,
629
+ tagFormat,
630
+ customChannels,
452
631
  onProgress: progress.executorProgress(),
453
632
  });
454
633
 
455
- // Mark execution steps based on result
634
+ if (!isDryRun && result.error && !result.pushed) {
635
+ const switchedTo = await maybeSwitchGithubAccountForReleaseFailure(platform, ctx, result.error);
636
+ if (switchedTo) {
637
+ progress.activate("execute", `Retrying with GitHub account ${switchedTo}`);
638
+ result = await executeRelease({
639
+ exec: platform.exec.bind(platform),
640
+ cwd: ctx.cwd,
641
+ version: nextVersion,
642
+ changelog,
643
+ channels,
644
+ dryRun: false,
645
+ skipBump: true,
646
+ skipTag: true,
647
+ tagFormat,
648
+ customChannels,
649
+ onProgress: progress.executorProgress(),
650
+ });
651
+ }
652
+ }
653
+
456
654
  if (result.tagCreated && result.pushed) {
457
- progress.complete(5, "Tag + push complete");
655
+ progress.complete("execute", "Tag + push complete");
458
656
  } else if (result.error) {
459
- progress.fail(5, result.error);
657
+ progress.fail("execute", result.error);
460
658
  } else {
461
- progress.fail(5, `Tag: ${result.tagCreated ? "✓" : "✗"} | Push: ${result.pushed ? "✓" : "✗"}`);
659
+ progress.fail("execute", `Tag: ${result.tagCreated ? "✓" : "✗"} | Push: ${result.pushed ? "✓" : "✗"}`);
462
660
  }
463
661
 
464
- // Mark channel publishing
465
662
  if (result.channels.length > 0) {
466
663
  const allOk = result.channels.every((c) => c.success);
467
664
  if (allOk) {
468
- progress.complete(6, result.channels.map((c) => c.channel).join(", "));
665
+ progress.complete("publish", result.channels.map((c) => c.channel).join(", "));
469
666
  } else {
470
667
  const failedChannels = result.channels.filter((c) => !c.success);
471
- progress.fail(6, failedChannels.map((c) => `${c.channel}: ${c.error ?? "failed"}`).join("; "));
668
+ progress.fail("publish", failedChannels.map((c) => `${c.channel}: ${c.error ?? "failed"}`).join("; "));
472
669
  }
473
670
  } else if (result.error) {
474
- progress.skip(6, "Skipped (release failed)");
671
+ progress.skip("publish", "Skipped (release failed)");
475
672
  } else {
476
- progress.complete(6, "No channels");
673
+ progress.complete("publish", "No channels");
477
674
  }
478
675
 
479
- // Give the user a moment to see the final widget state
480
676
  await new Promise((r) => setTimeout(r, 1500));
481
677
  progress.dispose();
482
678
 
483
- // Final notification
484
679
  const channelSummary = result.channels
485
680
  .map((c) => `${c.channel}: ${c.success ? "✓" : `✗ ${c.error ?? "failed"}`}`)
486
681
  .join(", ");
@@ -489,18 +684,17 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
489
684
  const prefix = isDryRun ? "[DRY RUN] " : "";
490
685
  notifySuccess(
491
686
  ctx,
492
- `${prefix}Released v${nextVersion}`,
687
+ `${prefix}Released ${formatTag(nextVersion, tagFormat)}`,
493
688
  `Tag: ${result.tagCreated ? "✓" : "✗"} | Push: ${result.pushed ? "✓" : "✗"} | ${channelSummary}`,
494
689
  );
495
690
  } else {
496
- // Include the error detail so the user knows WHY it failed
497
691
  const detail = result.error
498
692
  ? result.error
499
693
  : `Tag: ${result.tagCreated ? "✓" : "✗"} | Push: ${result.pushed ? "✓" : "✗"}`;
500
- notifyError(ctx, `Release v${nextVersion} failed`, detail);
694
+ notifyError(ctx, `Release ${formatTag(nextVersion, tagFormat)} failed`, detail);
501
695
  }
502
696
  } catch (err) {
503
- progress.fail(5, err instanceof Error ? err.message : "Unknown error");
697
+ progress.fail("execute", err instanceof Error ? err.message : "Unknown error");
504
698
  await new Promise((r) => setTimeout(r, 1500));
505
699
  progress.dispose();
506
700
  notifyError(
@@ -519,10 +713,41 @@ export async function handleRelease(platform: Platform, ctx: any, args?: string)
519
713
  } catch (err) {
520
714
  progress.dispose();
521
715
  notifyError(ctx, "Release error", err instanceof Error ? err.message : String(err));
716
+ } finally {
717
+ await modelCleanup();
522
718
  }
523
719
  })();
524
720
  }
525
721
 
722
+ /**
723
+ * Run quality gates headlessly — no UI widget of its own, results reported
724
+ * back to the release progress widget.
725
+ *
726
+ * Returns null when no gates are configured (caller should skip the step).
727
+ */
728
+ async function runHeadlessChecks(
729
+ platform: Platform,
730
+ ctx: any,
731
+ config: ReturnType<typeof loadConfig>,
732
+ resolved: ResolvedModel,
733
+ ): Promise<ReviewReport | null> {
734
+ const enabledGates = Object.entries(config.quality.gates)
735
+ .filter(([, gate]) => gate?.enabled === true);
736
+
737
+ if (enabledGates.length === 0) {
738
+ return null;
739
+ }
740
+
741
+ return runQualityGates({
742
+ platform,
743
+ cwd: ctx.cwd,
744
+ gates: config.quality.gates,
745
+ filters: {},
746
+ reviewModel: resolved,
747
+ gateRegistry: REVIEW_GATE_REGISTRY,
748
+ });
749
+ }
750
+
526
751
  async function getLastTag(platform: Platform, cwd: string): Promise<string | null> {
527
752
  try {
528
753
  const result = await platform.exec("git", ["describe", "--tags", "--abbrev=0"], { cwd });
@@ -532,60 +757,64 @@ async function getLastTag(platform: Platform, cwd: string): Promise<string | nul
532
757
  }
533
758
  }
534
759
 
535
- async function setupChannels(platform: Platform, ctx: any): Promise<ReleaseChannel[]> {
536
- const detected = await detectChannels(platform.exec.bind(platform), ctx.cwd);
537
- const available = detected.filter((d) => d.available);
538
-
539
- // Build options — offer "both" when both channels are available
540
- const options: string[] = [];
541
- if (available.length === 2) {
542
- options.push(`both GitHub + npm (${available.map((d) => d.detail).join(", ")})`);
760
+ async function setupChannels(
761
+ platform: Platform,
762
+ ctx: any,
763
+ detected: ChannelStatus[],
764
+ ): Promise<ReleaseChannel[]> {
765
+ const availableChannels = detected.filter((channel) => channel.available);
766
+ const unavailableChannels = detected.filter((channel) => !channel.available);
767
+ const unavailableSummary = unavailableChannels
768
+ .map((channel) => `${channel.channel}: ${channel.detail}`)
769
+ .join("; ");
770
+
771
+ if (availableChannels.length === 0) {
772
+ const detail = unavailableSummary || "No release channels are currently available.";
773
+ ctx.ui.notify(`No release channels are currently available. ${detail}`, "warning");
774
+ return [];
543
775
  }
544
- for (const d of detected) {
545
- options.push(`${d.channel} — ${d.available ? d.detail : `unavailable (${d.detail})`}`);
546
- }
547
- options.push("skip — configure later");
548
-
549
- const choice = await ctx.ui.select(
550
- "Release Setup — Select publish channels",
551
- options,
552
- { helpText: "Which channels should releases be published to?" },
553
- );
554
776
 
555
- if (!choice || choice.startsWith("skip")) return [];
777
+ const channelOptions = buildSelectableReleaseChannelOptions(detected);
778
+ const helpText = unavailableSummary
779
+ ? `Select channels one at a time. Pick Done when finished. Unavailable: ${unavailableSummary}`
780
+ : "Select channels one at a time. Pick Done when finished.";
781
+
782
+ // Loop-based multi-select: user picks channels one at a time, "Done" to finish
783
+ const selected: ReleaseChannel[] = [];
784
+
785
+ while (true) {
786
+ const remaining = channelOptions.filter(
787
+ (option) => !selected.includes(option.split(" — ")[0]),
788
+ );
789
+ const options = [
790
+ ...remaining,
791
+ ...(selected.length > 0 ? [`Done — selected: ${selected.join(", ")}`] : []),
792
+ "skip — configure later",
793
+ ];
794
+
795
+ const choice = await ctx.ui.select(
796
+ selected.length === 0
797
+ ? "Release Setup — Select publish channels"
798
+ : `Selected: ${selected.join(", ")} — add more or Done`,
799
+ options,
800
+ { helpText },
801
+ );
802
+
803
+ if (!choice || choice.startsWith("skip")) {
804
+ if (selected.length > 0) break; // keep what was selected
805
+ return [];
806
+ }
807
+ if (choice.startsWith("Done")) break;
556
808
 
557
- let channels: ReleaseChannel[];
558
- if (choice.startsWith("both")) {
559
- channels = available.map((d) => d.channel);
560
- } else {
561
- channels = [choice.split(" — ")[0] as ReleaseChannel];
809
+ const channelId = choice.split(" — ")[0];
810
+ if (!selected.includes(channelId)) {
811
+ selected.push(channelId);
812
+ }
562
813
  }
563
814
 
564
815
  // Persist to config
565
- updateConfig(platform.paths, ctx.cwd, { release: { channels } });
566
- ctx.ui.notify(`Release channels set to: ${channels.join(", ")}`, "info");
567
-
568
- return channels;
569
- }
570
-
571
- function buildReleaseCommands(
572
- version: string,
573
- channels: ReleaseChannel[],
574
- ): string[] {
575
- const commands: string[] = [
576
- `git add -A`,
577
- `git commit -m "chore(release): v${version}"`,
578
- `git tag -a v${version} -m "Release v${version}"`,
579
- `git push origin HEAD --follow-tags`,
580
- ];
581
-
582
- for (const channel of channels) {
583
- if (channel === "github") {
584
- commands.push(`gh release create v${version} --title "v${version}" --notes "...changelog..."`);
585
- } else if (channel === "npm") {
586
- commands.push(`npm publish`);
587
- }
588
- }
816
+ updateConfig(platform.paths, ctx.cwd, { release: { channels: selected } });
817
+ ctx.ui.notify(`Release channels set to: ${selected.join(", ")}`, "info");
589
818
 
590
- return commands;
819
+ return selected;
591
820
  }