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
@@ -0,0 +1,268 @@
1
+ // src/quality/runner.ts
2
+ import type { ExecOptions, ExecResult, Platform } from "../platform/types.js";
3
+ import type {
4
+ GateDefinition,
5
+ GateExecutionContext,
6
+ GateFilters,
7
+ GateId,
8
+ GateResult,
9
+ GateSummary,
10
+ QualityGatesConfig,
11
+ ResolvedModel,
12
+ ReviewReport,
13
+ } from "../types.js";
14
+ import { CANONICAL_GATE_ORDER, type GateRegistry } from "./registry.js";
15
+ import { collectLspDiagnostics } from "../lsp/bridge.js";
16
+
17
+ interface ReviewScope {
18
+ changedFiles: string[];
19
+ scopeFiles: string[];
20
+ fileScope: GateExecutionContext["fileScope"];
21
+ }
22
+
23
+ export type ReviewRunEvent =
24
+ | {
25
+ type: "scope-discovered";
26
+ changedFiles: number;
27
+ scopeFiles: number;
28
+ fileScope: GateExecutionContext["fileScope"];
29
+ }
30
+ | { type: "gate-started"; gateId: GateId }
31
+ | { type: "gate-skipped"; gateId: GateId; reason: string }
32
+ | {
33
+ type: "gate-completed";
34
+ gateId: GateId;
35
+ status: GateResult["status"];
36
+ summary: string;
37
+ };
38
+
39
+ export interface RunQualityGatesInput {
40
+ platform: Pick<Platform, "exec" | "getActiveTools" | "createAgentSession">;
41
+ cwd: string;
42
+ gates: QualityGatesConfig;
43
+ filters: GateFilters;
44
+ reviewModel: ResolvedModel;
45
+ gateRegistry?: GateRegistry;
46
+ getLspDiagnostics?: GateExecutionContext["getLspDiagnostics"];
47
+ now?: () => Date;
48
+ onEvent?: (event: ReviewRunEvent) => void;
49
+ }
50
+
51
+ function isGateEnabled(config: QualityGatesConfig[GateId] | undefined): boolean {
52
+ if (!config) {
53
+ return false;
54
+ }
55
+
56
+ return config.enabled === true;
57
+ }
58
+
59
+ function normalizeFileList(...chunks: string[]): string[] {
60
+ const seen = new Set<string>();
61
+
62
+ for (const chunk of chunks) {
63
+ for (const line of chunk.split("\n")) {
64
+ const file = line.trim();
65
+ if (file.length === 0) {
66
+ continue;
67
+ }
68
+ seen.add(file);
69
+ }
70
+ }
71
+
72
+ return [...seen];
73
+ }
74
+
75
+ async function safeExec(
76
+ exec: Platform["exec"],
77
+ cmd: string,
78
+ args: string[],
79
+ opts?: ExecOptions,
80
+ ): Promise<ExecResult> {
81
+ try {
82
+ return await exec(cmd, args, opts);
83
+ } catch (error) {
84
+ return {
85
+ stdout: "",
86
+ stderr: (error as Error).message,
87
+ code: 1,
88
+ };
89
+ }
90
+ }
91
+
92
+ export async function discoverReviewScope(
93
+ exec: Platform["exec"],
94
+ cwd: string,
95
+ ): Promise<ReviewScope> {
96
+ const head = await safeExec(exec, "git", ["diff", "--name-only", "HEAD"], { cwd });
97
+ const cached = await safeExec(exec, "git", ["diff", "--name-only", "--cached"], { cwd });
98
+ const untracked = await safeExec(exec, "git", ["ls-files", "--others", "--exclude-standard"], { cwd });
99
+ const changedFiles = normalizeFileList(head.stdout, cached.stdout, untracked.stdout);
100
+
101
+ if (changedFiles.length > 0) {
102
+ return {
103
+ changedFiles,
104
+ scopeFiles: changedFiles,
105
+ fileScope: "changed-files",
106
+ };
107
+ }
108
+
109
+ const tracked = await safeExec(exec, "git", ["ls-files"], { cwd });
110
+ const scopeFiles = normalizeFileList(tracked.stdout);
111
+
112
+ return {
113
+ changedFiles: [],
114
+ scopeFiles,
115
+ fileScope: "all-files",
116
+ };
117
+ }
118
+
119
+ function selectConfiguredGates(gates: QualityGatesConfig, filters: GateFilters): GateId[] {
120
+ const enabledGates = CANONICAL_GATE_ORDER.filter((gateId) =>
121
+ isGateEnabled(gates[gateId]),
122
+ );
123
+
124
+ if (filters.only && filters.only.length > 0) {
125
+ const only = new Set(filters.only);
126
+ return enabledGates.filter((gateId) => only.has(gateId));
127
+ }
128
+
129
+ return enabledGates;
130
+ }
131
+
132
+ function createExecShell(exec: Platform["exec"]): GateExecutionContext["execShell"] {
133
+ return async (command: string, opts?: ExecOptions): Promise<ExecResult> => {
134
+ if (process.platform === "win32") {
135
+ return exec("cmd", ["/d", "/s", "/c", command], opts);
136
+ }
137
+
138
+ return exec("sh", ["-lc", command], opts);
139
+ };
140
+ }
141
+
142
+ function createGateExecutionContext(
143
+ input: RunQualityGatesInput,
144
+ scope: ReviewScope,
145
+ ): GateExecutionContext {
146
+ const exec = input.platform.exec.bind(input.platform);
147
+ const createAgentSession = input.platform.createAgentSession.bind(input.platform);
148
+ const activeTools = input.platform.getActiveTools();
149
+ const reviewModel = {
150
+ model: input.reviewModel.model,
151
+ thinkingLevel: input.reviewModel.thinkingLevel,
152
+ };
153
+
154
+ return {
155
+ cwd: input.cwd,
156
+ changedFiles: scope.changedFiles,
157
+ scopeFiles: scope.scopeFiles,
158
+ fileScope: scope.fileScope,
159
+ exec,
160
+ execShell: createExecShell(exec),
161
+ getLspDiagnostics:
162
+ input.getLspDiagnostics ??
163
+ ((scopeFiles, fileScope) =>
164
+ collectLspDiagnostics({
165
+ cwd: input.cwd,
166
+ scopeFiles,
167
+ fileScope,
168
+ createAgentSession,
169
+ reviewModel,
170
+ })),
171
+ createAgentSession,
172
+ activeTools,
173
+ reviewModel,
174
+ };
175
+ }
176
+
177
+ function createSkippedGateResult(gate: GateId, reason = "Skipped by filter"): GateResult {
178
+ return {
179
+ gate,
180
+ status: "skipped",
181
+ summary: reason,
182
+ issues: [],
183
+ };
184
+ }
185
+
186
+ async function runConfiguredGate(
187
+ gateId: GateId,
188
+ registry: GateRegistry,
189
+ context: GateExecutionContext,
190
+ gates: QualityGatesConfig,
191
+ ): Promise<GateResult> {
192
+ const definition = registry[gateId] as GateDefinition<NonNullable<QualityGatesConfig[typeof gateId]>> | undefined;
193
+ if (!definition) {
194
+ throw new Error(`Gate definition not registered: ${gateId}`);
195
+ }
196
+
197
+ const config = gates[gateId];
198
+ if (!config || config.enabled !== true) {
199
+ throw new Error(`Gate ${gateId} is not enabled`);
200
+ }
201
+
202
+ return definition.run(context, config as NonNullable<QualityGatesConfig[typeof gateId]>);
203
+ }
204
+
205
+ export function summarizeGateStatuses(gates: GateResult[]): GateSummary {
206
+ return gates.reduce<GateSummary>(
207
+ (summary, gate) => {
208
+ summary[gate.status] += 1;
209
+ return summary;
210
+ },
211
+ { passed: 0, failed: 0, skipped: 0, blocked: 0 },
212
+ );
213
+ }
214
+
215
+ export function computeOverallStatus(summary: GateSummary): ReviewReport["overallStatus"] {
216
+ if (summary.blocked > 0) {
217
+ return "blocked";
218
+ }
219
+ if (summary.failed > 0) {
220
+ return "failed";
221
+ }
222
+ return "passed";
223
+ }
224
+
225
+ export async function runQualityGates(input: RunQualityGatesInput): Promise<ReviewReport> {
226
+ const selectedGates = selectConfiguredGates(input.gates, input.filters);
227
+ const scope = await discoverReviewScope(input.platform.exec.bind(input.platform), input.cwd);
228
+ input.onEvent?.({
229
+ type: "scope-discovered",
230
+ changedFiles: scope.changedFiles.length,
231
+ scopeFiles: scope.scopeFiles.length,
232
+ fileScope: scope.fileScope,
233
+ });
234
+ const context = createGateExecutionContext(input, scope);
235
+ const skipped = new Set(input.filters.skip ?? []);
236
+ const registry = input.gateRegistry ?? {};
237
+ const gates: GateResult[] = [];
238
+
239
+ // Run all gates concurrently — each gate is independent (no shared mutable state)
240
+ const promises = selectedGates.map(async (gateId): Promise<GateResult> => {
241
+ if (skipped.has(gateId)) {
242
+ input.onEvent?.({ type: "gate-skipped", gateId, reason: "Skipped by filter" });
243
+ return createSkippedGateResult(gateId);
244
+ }
245
+
246
+ input.onEvent?.({ type: "gate-started", gateId });
247
+ const result = await runConfiguredGate(gateId, registry, context, input.gates);
248
+ input.onEvent?.({
249
+ type: "gate-completed",
250
+ gateId,
251
+ status: result.status,
252
+ summary: result.summary,
253
+ });
254
+ return result;
255
+ });
256
+
257
+ gates.push(...await Promise.all(promises));
258
+
259
+ const summary = summarizeGateStatuses(gates);
260
+
261
+ return {
262
+ timestamp: (input.now ?? (() => new Date()))().toISOString(),
263
+ selectedGates,
264
+ gates,
265
+ summary,
266
+ overallStatus: computeOverallStatus(summary),
267
+ };
268
+ }
@@ -0,0 +1,48 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { TSchema } from "@sinclair/typebox";
3
+ import type { GateId } from "../types.js";
4
+
5
+ export const LspDiagnosticsGateConfigSchema = Type.Object(
6
+ {
7
+ enabled: Type.Boolean(),
8
+ },
9
+ { additionalProperties: false },
10
+ );
11
+
12
+ export const CommandGateConfigSchema = Type.Union([
13
+ Type.Object(
14
+ {
15
+ enabled: Type.Literal(false),
16
+ command: Type.Optional(Type.Union([Type.String(), Type.Null()])),
17
+ },
18
+ { additionalProperties: false },
19
+ ),
20
+ Type.Object(
21
+ {
22
+ enabled: Type.Literal(true),
23
+ command: Type.String({ minLength: 1 }),
24
+ },
25
+ { additionalProperties: false },
26
+ ),
27
+ ]);
28
+
29
+ export const QualityGatesSchema = Type.Object(
30
+ {
31
+ "lsp-diagnostics": Type.Optional(LspDiagnosticsGateConfigSchema),
32
+ lint: Type.Optional(CommandGateConfigSchema),
33
+ typecheck: Type.Optional(CommandGateConfigSchema),
34
+ format: Type.Optional(CommandGateConfigSchema),
35
+ "test-suite": Type.Optional(CommandGateConfigSchema),
36
+ build: Type.Optional(CommandGateConfigSchema),
37
+ },
38
+ { additionalProperties: false },
39
+ );
40
+
41
+ export const GATE_CONFIG_SCHEMAS: Record<GateId, TSchema> = {
42
+ "lsp-diagnostics": LspDiagnosticsGateConfigSchema,
43
+ lint: CommandGateConfigSchema,
44
+ typecheck: CommandGateConfigSchema,
45
+ format: CommandGateConfigSchema,
46
+ "test-suite": CommandGateConfigSchema,
47
+ build: CommandGateConfigSchema,
48
+ };
@@ -0,0 +1,227 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { Platform, PlatformContext } from "../platform/types.js";
4
+ import type {
5
+ ConfigScope,
6
+ ProjectFacts,
7
+ QualityGatesConfig,
8
+ SetupGatesResult,
9
+ SetupProposal,
10
+ } from "../types.js";
11
+ import type { InspectionLoadResult } from "../config/schema.js";
12
+ import { validateQualityGates } from "../config/schema.js";
13
+ import { writeQualityGatesConfig } from "../config/loader.js";
14
+ import { CANONICAL_GATE_ORDER } from "./registry.js";
15
+ import { detectReviewGates } from "./review-gates.js";
16
+ import { suggestQualityGatesWithAi } from "./ai-setup.js";
17
+
18
+ export type GateSetupMode = "deterministic" | "ai-assisted";
19
+
20
+ export type SetupGatesProgressEvent =
21
+ | { type: "collecting-project-facts" }
22
+ | { type: "baseline-ready" }
23
+ | { type: "ai-analysis-started" }
24
+ | { type: "ai-analysis-completed" };
25
+
26
+ export interface SetupGatesOptions {
27
+ mode?: GateSetupMode;
28
+ onProgress?: (event: SetupGatesProgressEvent) => void;
29
+ }
30
+
31
+ export interface GateSetupDialogOptions {
32
+ title?: string;
33
+ intro?: string;
34
+ }
35
+
36
+ export interface SetupGatesDependencies {
37
+ suggestWithAi?: (input: {
38
+ platform: Platform;
39
+ cwd: string;
40
+ projectFacts: ProjectFacts;
41
+ proposal: SetupProposal;
42
+ }) => Promise<QualityGatesConfig>;
43
+ }
44
+
45
+ function readPackageScripts(cwd: string): Record<string, string> {
46
+ const packageJsonPath = path.join(cwd, "package.json");
47
+ if (!fs.existsSync(packageJsonPath)) {
48
+ return {};
49
+ }
50
+
51
+ try {
52
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as {
53
+ scripts?: Record<string, string>;
54
+ };
55
+ return parsed.scripts ?? {};
56
+ } catch {
57
+ return {};
58
+ }
59
+ }
60
+
61
+ function detectLockfiles(cwd: string): string[] {
62
+ return ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"]
63
+ .filter((file) => fs.existsSync(path.join(cwd, file)));
64
+ }
65
+
66
+ export function collectProjectFacts(
67
+ cwd: string,
68
+ inspection: InspectionLoadResult,
69
+ activeTools: string[],
70
+ ): ProjectFacts {
71
+ return {
72
+ cwd,
73
+ packageScripts: readPackageScripts(cwd),
74
+ lockfiles: detectLockfiles(cwd),
75
+ activeTools,
76
+ existingGates: inspection.effectiveConfig?.quality.gates ?? {},
77
+ };
78
+ }
79
+
80
+ export function summarizeEnabledGates(gates: QualityGatesConfig): string {
81
+ const enabled = CANONICAL_GATE_ORDER.filter((gateId) => gates[gateId]?.enabled === true);
82
+
83
+ return enabled.length > 0 ? enabled.join(", ") : "none";
84
+ }
85
+
86
+ export function formatGateProposal(proposal: SetupProposal): string {
87
+ const entries = CANONICAL_GATE_ORDER
88
+ .filter((gateId) => proposal.gates[gateId] !== undefined)
89
+ .map((gateId) => [gateId, proposal.gates[gateId]] as const);
90
+ if (entries.length === 0) {
91
+ return "No gates suggested.";
92
+ }
93
+
94
+ return entries
95
+ .map(([gateId, config]) => `${gateId}: ${JSON.stringify(config)}`)
96
+ .join("\n");
97
+ }
98
+
99
+ export function buildDeterministicSuggestion(projectFacts: ProjectFacts): SetupProposal {
100
+ return { gates: detectReviewGates(projectFacts) };
101
+ }
102
+
103
+ export async function setupGates(
104
+ platform: Platform,
105
+ cwd: string,
106
+ inspection: InspectionLoadResult,
107
+ options: SetupGatesOptions = {},
108
+ deps: SetupGatesDependencies = {},
109
+ ): Promise<SetupGatesResult> {
110
+ options.onProgress?.({ type: "collecting-project-facts" });
111
+ const projectFacts = collectProjectFacts(cwd, inspection, platform.getActiveTools());
112
+ let proposal = buildDeterministicSuggestion(projectFacts);
113
+ options.onProgress?.({ type: "baseline-ready" });
114
+
115
+ if (options.mode === "ai-assisted") {
116
+ options.onProgress?.({ type: "ai-analysis-started" });
117
+ const suggestWithAi = deps.suggestWithAi ?? suggestQualityGatesWithAi;
118
+ proposal = {
119
+ gates: await suggestWithAi({
120
+ platform,
121
+ cwd,
122
+ projectFacts,
123
+ proposal,
124
+ }),
125
+ };
126
+ options.onProgress?.({ type: "ai-analysis-completed" });
127
+ }
128
+
129
+ const validation = validateQualityGates(proposal.gates);
130
+ if (!validation.valid) {
131
+ return {
132
+ status: "invalid",
133
+ proposal,
134
+ errors: validation.errors,
135
+ };
136
+ }
137
+
138
+ return {
139
+ status: "proposed",
140
+ proposal,
141
+ };
142
+ }
143
+
144
+ function parseRevisedProposal(raw: string): SetupProposal {
145
+ const parsed = JSON.parse(raw) as QualityGatesConfig;
146
+ const validation = validateQualityGates(parsed);
147
+ if (!validation.valid) {
148
+ throw new Error(validation.errors.join("\n"));
149
+ }
150
+
151
+ return { gates: parsed };
152
+ }
153
+
154
+ function labelForScope(scope: ConfigScope): string {
155
+ return scope === "project"
156
+ ? "Project (.omp/supipowers/config.json)"
157
+ : "Global (~/.omp/supipowers/config.json)";
158
+ }
159
+
160
+ async function selectSaveScope(ctx: PlatformContext): Promise<ConfigScope | null> {
161
+ const choice = await ctx.ui.select(
162
+ "Save quality gates to",
163
+ [labelForScope("project"), labelForScope("global"), "Cancel"],
164
+ {
165
+ initialIndex: 0,
166
+ helpText: "Choose whether review gates apply only to this project or all projects.",
167
+ },
168
+ );
169
+
170
+ if (!choice || choice === "Cancel") {
171
+ return null;
172
+ }
173
+
174
+ return choice === labelForScope("global") ? "global" : "project";
175
+ }
176
+
177
+ function buildProposalHelpText(proposal: SetupProposal, options?: GateSetupDialogOptions): string {
178
+ return [options?.intro, formatGateProposal(proposal)].filter(Boolean).join("\n\n");
179
+ }
180
+
181
+ export async function interactivelySaveGateSetup(
182
+ ctx: PlatformContext,
183
+ paths: Platform["paths"],
184
+ cwd: string,
185
+ initial: SetupProposal,
186
+ options: GateSetupDialogOptions = {},
187
+ ): Promise<"saved" | "cancelled"> {
188
+ let proposal = initial;
189
+
190
+ while (true) {
191
+ const choice = await ctx.ui.select(
192
+ options.title ?? "Quality gate setup",
193
+ ["Accept", "Revise", "Cancel"],
194
+ { helpText: buildProposalHelpText(proposal, options) },
195
+ );
196
+
197
+ if (!choice || choice === "Cancel") {
198
+ return "cancelled";
199
+ }
200
+
201
+ if (choice === "Revise") {
202
+ const revised = await ctx.ui.input(
203
+ "Edit quality.gates JSON",
204
+ { value: JSON.stringify(proposal.gates, null, 2) },
205
+ );
206
+
207
+ if (!revised) {
208
+ continue;
209
+ }
210
+
211
+ try {
212
+ proposal = parseRevisedProposal(revised);
213
+ } catch (error) {
214
+ ctx.ui.notify((error as Error).message, "error");
215
+ }
216
+ continue;
217
+ }
218
+
219
+ const scope = await selectSaveScope(ctx);
220
+ if (!scope) {
221
+ return "cancelled";
222
+ }
223
+
224
+ writeQualityGatesConfig(paths, cwd, scope, proposal.gates);
225
+ return "saved";
226
+ }
227
+ }
@@ -1,6 +1,8 @@
1
1
  // src/release/changelog.ts — Conventional commit parsing and changelog generation
2
2
 
3
3
  import type { CategorizedCommits, CommitEntry } from "../types.js";
4
+ import { normalizeLineEndings } from "../text.js";
5
+ import { formatTag } from "./version.js";
4
6
  import { IMPROVEMENT_TYPES, MAINTENANCE_TYPES, type ConventionalCommitType } from "./commit-types.js";
5
7
 
6
8
  // Matches: feat(scope)!: message or feat!: message or feat(scope): message or feat: message
@@ -39,6 +41,7 @@ function parseLine(line: string): { hash: string; raw: string } | null {
39
41
  */
40
42
 
41
43
  export function parseConventionalCommits(gitLog: string): CategorizedCommits {
44
+ const normalizedGitLog = normalizeLineEndings(gitLog);
42
45
  const result: CategorizedCommits = {
43
46
  features: [],
44
47
  fixes: [],
@@ -48,7 +51,7 @@ export function parseConventionalCommits(gitLog: string): CategorizedCommits {
48
51
  other: [],
49
52
  };
50
53
 
51
- for (const line of gitLog.split("\n")) {
54
+ for (const line of normalizedGitLog.split("\n")) {
52
55
  const parsed = parseLine(line);
53
56
  if (!parsed) continue;
54
57
 
@@ -106,11 +109,12 @@ function formatEntry(entry: CommitEntry): string {
106
109
  */
107
110
  export function buildChangelogMarkdown(
108
111
  commits: CategorizedCommits,
109
- version: string
112
+ version: string,
113
+ tagFormat: string = "v${version}",
110
114
  ): string {
111
115
  const lines: string[] = [];
112
116
 
113
- lines.push(`## v${version}`);
117
+ lines.push(`## ${formatTag(version, tagFormat)}`);
114
118
  lines.push(`_${isoDate(new Date())}_`);
115
119
  lines.push("");
116
120
 
@@ -0,0 +1,43 @@
1
+ // src/release/channels/custom.ts — Wraps user-defined custom channel config into a ChannelHandler
2
+ import type { CustomChannelConfig } from "../../types.js";
3
+ import type { ChannelHandler, ChannelPublishContext, ChannelStatus, ExecFn } from "./types.js";
4
+
5
+
6
+ export function createCustomHandler(id: string, config: CustomChannelConfig): ChannelHandler {
7
+ return {
8
+ id,
9
+ label: config.label,
10
+
11
+ async detect(exec: ExecFn, cwd: string): Promise<ChannelStatus> {
12
+ if (!config.detectCommand) {
13
+ return { channel: id, available: true, detail: "No detect command configured — assumed available" };
14
+ }
15
+ try {
16
+ const result = await exec("sh", ["-c", config.detectCommand], { cwd });
17
+ if (result.code === 0) {
18
+ return { channel: id, available: true, detail: `Detect command succeeded` };
19
+ }
20
+ return { channel: id, available: false, detail: `Detect command exited with code ${result.code}` };
21
+ } catch {
22
+ return { channel: id, available: false, detail: "Detect command failed to execute" };
23
+ }
24
+ },
25
+
26
+ async publish(exec: ExecFn, ctx: ChannelPublishContext): Promise<{ success: boolean; error?: string }> {
27
+ try {
28
+ // `${tag}`/`${version}`/`${changelog}` stay in the shell template, but
29
+ // their values cross the shell boundary via environment variables.
30
+ const result = await exec("sh", ["-c", config.publishCommand], {
31
+ cwd: ctx.cwd,
32
+ env: { tag: ctx.tag, version: ctx.version, changelog: ctx.changelog },
33
+ });
34
+ if (result.code !== 0) {
35
+ return { success: false, error: result.stderr || result.stdout || `Custom channel '${id}' exited with code ${result.code}` };
36
+ }
37
+ return { success: true };
38
+ } catch (err) {
39
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
40
+ }
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,35 @@
1
+ // src/release/channels/gitea.ts — Built-in Gitea/Forgejo release channel
2
+ import type { ChannelHandler, ChannelPublishContext, ChannelStatus, ExecFn } from "./types.js";
3
+
4
+ export const gitea: ChannelHandler = {
5
+ id: "gitea",
6
+ label: "Gitea Releases",
7
+
8
+ async detect(exec: ExecFn, cwd: string): Promise<ChannelStatus> {
9
+ try {
10
+ const result = await exec("tea", ["login", "list"], { cwd });
11
+ if (result.code === 0 && result.stdout.trim().length > 0) {
12
+ return { channel: "gitea", available: true, detail: "Authenticated with Gitea CLI" };
13
+ }
14
+ return { channel: "gitea", available: false, detail: "Gitea CLI not authenticated (run: tea login add)" };
15
+ } catch {
16
+ return { channel: "gitea", available: false, detail: "Gitea CLI not installed (install: https://gitea.com/gitea/tea)" };
17
+ }
18
+ },
19
+
20
+ async publish(exec: ExecFn, ctx: ChannelPublishContext): Promise<{ success: boolean; error?: string }> {
21
+ try {
22
+ const result = await exec(
23
+ "tea",
24
+ ["release", "create", "--tag", ctx.tag, "--title", ctx.tag, "--note", ctx.changelog],
25
+ { cwd: ctx.cwd },
26
+ );
27
+ if (result.code !== 0) {
28
+ return { success: false, error: result.stderr || result.stdout || `tea release create exited with code ${result.code}` };
29
+ }
30
+ return { success: true };
31
+ } catch (err) {
32
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
33
+ }
34
+ },
35
+ };
@@ -0,0 +1,35 @@
1
+ // src/release/channels/github.ts — Built-in GitHub release channel
2
+ import type { ChannelHandler, ChannelPublishContext, ChannelStatus, ExecFn } from "./types.js";
3
+
4
+ export const github: ChannelHandler = {
5
+ id: "github",
6
+ label: "GitHub Releases",
7
+
8
+ async detect(exec: ExecFn, cwd: string): Promise<ChannelStatus> {
9
+ try {
10
+ const result = await exec("gh", ["auth", "status"], { cwd });
11
+ if (result.code === 0) {
12
+ return { channel: "github", available: true, detail: "Authenticated with GitHub CLI" };
13
+ }
14
+ return { channel: "github", available: false, detail: "GitHub CLI not authenticated (run: gh auth login)" };
15
+ } catch {
16
+ return { channel: "github", available: false, detail: "GitHub CLI not installed (install: https://cli.github.com)" };
17
+ }
18
+ },
19
+
20
+ async publish(exec: ExecFn, ctx: ChannelPublishContext): Promise<{ success: boolean; error?: string }> {
21
+ try {
22
+ const result = await exec(
23
+ "gh",
24
+ ["release", "create", ctx.tag, "--title", ctx.tag, "--notes", ctx.changelog],
25
+ { cwd: ctx.cwd },
26
+ );
27
+ if (result.code !== 0) {
28
+ return { success: false, error: result.stderr || result.stdout || `gh release create exited with code ${result.code}` };
29
+ }
30
+ return { success: true };
31
+ } catch (err) {
32
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
33
+ }
34
+ },
35
+ };