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
@@ -1,9 +1,36 @@
1
1
  // src/config/loader.ts
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import type { SupipowersConfig, ReleaseChannel } from "../types.js";
4
+ import type {
5
+ ConfigScope,
6
+ SupipowersConfig,
7
+ ReleaseChannel,
8
+ QualityGatesConfig,
9
+ } from "../types.js";
5
10
  import type { PlatformPaths } from "../platform/types.js";
6
11
  import { DEFAULT_CONFIG } from "./defaults.js";
12
+ import {
13
+ collectConfigValidationErrors,
14
+ type ConfigParseError,
15
+ type ConfigValidationError,
16
+ type InspectionLoadResult,
17
+ } from "./schema.js";
18
+
19
+ export interface ScopedConfigInspection {
20
+ scope: ConfigScope;
21
+ path: string;
22
+ data: Record<string, unknown> | null;
23
+ parseError: ConfigParseError | null;
24
+ validationErrors: ConfigValidationError[];
25
+ qualityGateValidationErrors: ConfigValidationError[];
26
+ otherValidationErrors: ConfigValidationError[];
27
+ hasOwnQualityGates: boolean;
28
+ recoverableInvalidQualityGates: boolean;
29
+ }
30
+
31
+ export interface QualityGateRecoveryInspection {
32
+ scopes: ScopedConfigInspection[];
33
+ }
7
34
 
8
35
  function getProjectConfigPath(paths: PlatformPaths, cwd: string): string {
9
36
  return paths.project(cwd, "config.json");
@@ -13,19 +40,39 @@ function getGlobalConfigPath(paths: PlatformPaths): string {
13
40
  return paths.global("config.json");
14
41
  }
15
42
 
16
- function readJsonSafe(filePath: string): Record<string, unknown> | null {
43
+ function getConfigPath(paths: PlatformPaths, cwd: string, scope: ConfigScope): string {
44
+ return scope === "global" ? getGlobalConfigPath(paths) : getProjectConfigPath(paths, cwd);
45
+ }
46
+
47
+ function readJsonFile(
48
+ source: ConfigParseError["source"],
49
+ filePath: string,
50
+ ): { data: Record<string, unknown> | null; error: ConfigParseError | null } {
17
51
  try {
18
- if (!fs.existsSync(filePath)) return null;
19
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
20
- } catch {
21
- return null;
52
+ if (!fs.existsSync(filePath)) {
53
+ return { data: null, error: null };
54
+ }
55
+
56
+ return {
57
+ data: JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, unknown>,
58
+ error: null,
59
+ };
60
+ } catch (error) {
61
+ return {
62
+ data: null,
63
+ error: {
64
+ source,
65
+ path: filePath,
66
+ message: (error as Error).message,
67
+ },
68
+ };
22
69
  }
23
70
  }
24
71
 
25
72
  /** Deep merge source into target. Source values override target. */
26
73
  export function deepMerge<T extends object>(
27
74
  target: T,
28
- source: Record<string, unknown>
75
+ source: Record<string, unknown>,
29
76
  ): T {
30
77
  const result = { ...target } as Record<string, unknown>;
31
78
  for (const key of Object.keys(source)) {
@@ -41,7 +88,7 @@ export function deepMerge<T extends object>(
41
88
  ) {
42
89
  result[key] = deepMerge(
43
90
  targetVal as Record<string, unknown>,
44
- sourceVal as Record<string, unknown>
91
+ sourceVal as Record<string, unknown>,
45
92
  );
46
93
  } else {
47
94
  result[key] = sourceVal;
@@ -50,58 +97,359 @@ export function deepMerge<T extends object>(
50
97
  return result as T;
51
98
  }
52
99
 
53
- /** Load config with global -> project layering over defaults.
54
- * Validates and migrates if version is outdated. */
55
- export function loadConfig(paths: PlatformPaths, cwd: string): SupipowersConfig {
56
- const globalData = readJsonSafe(getGlobalConfigPath(paths));
57
- const projectData = readJsonSafe(getProjectConfigPath(paths, cwd));
100
+ function hasOwnNestedProperty(
101
+ value: Record<string, unknown>,
102
+ topLevelKey: string,
103
+ nestedKey: string,
104
+ ): boolean {
105
+ if (!(topLevelKey in value)) {
106
+ return false;
107
+ }
108
+
109
+ const nested = value[topLevelKey];
110
+ return !!nested && typeof nested === "object" && nestedKey in (nested as Record<string, unknown>);
111
+ }
112
+
113
+ function applyConfigOverride(
114
+ base: Record<string, unknown>,
115
+ override: Record<string, unknown>,
116
+ ): Record<string, unknown> {
117
+ const merged = deepMerge(base, override) as Record<string, unknown>;
118
+
119
+ if (hasOwnNestedProperty(override, "quality", "gates")) {
120
+ const mergedQuality =
121
+ merged.quality && typeof merged.quality === "object" && !Array.isArray(merged.quality)
122
+ ? (merged.quality as Record<string, unknown>)
123
+ : {};
124
+ const overrideQuality = override.quality as Record<string, unknown>;
125
+ mergedQuality.gates = overrideQuality.gates;
126
+ merged.quality = mergedQuality;
127
+ }
128
+
129
+ return merged;
130
+ }
131
+
132
+ /** Known legacy config shapes are normalized before validation. */
133
+ function asRecord(value: unknown): Record<string, unknown> | null {
134
+ return value !== null && typeof value === "object" && !Array.isArray(value)
135
+ ? (value as Record<string, unknown>)
136
+ : null;
137
+ }
138
+
139
+ function normalizeReleaseChannels(
140
+ existingChannels: unknown,
141
+ pipeline: string | null,
142
+ ): ReleaseChannel[] {
143
+ if (Array.isArray(existingChannels)) {
144
+ const channels = existingChannels.filter(
145
+ (channel): channel is string => typeof channel === "string" && channel.length > 0,
146
+ );
147
+ if (channels.length > 0) {
148
+ return [...new Set(channels)];
149
+ }
150
+ }
151
+
152
+ if (typeof pipeline === "string" && pipeline.length > 0) return [pipeline];
153
+ return [];
154
+ }
155
+
156
+ function legacyGatesFromProfile(
157
+ profileName: string | null,
158
+ legacyTestCommand: string | null,
159
+ ): QualityGatesConfig | null {
160
+ const gates: QualityGatesConfig = {};
161
+
162
+ if (profileName === "quick" || profileName === "thorough" || profileName === "full-regression") {
163
+ gates["lsp-diagnostics"] = { enabled: true };
164
+ }
165
+
166
+ if (legacyTestCommand) {
167
+ gates["test-suite"] = { enabled: true, command: legacyTestCommand };
168
+ }
169
+
170
+ return Object.keys(gates).length > 0 ? gates : null;
171
+ }
172
+
173
+ /** Migrate config from older shapes to the canonical schema. */
174
+ function migrateConfig(config: Record<string, unknown>): Record<string, unknown> {
175
+ const migrated = structuredClone(config) as Record<string, unknown>;
176
+ const legacyProfile = typeof migrated.defaultProfile === "string" ? migrated.defaultProfile : null;
177
+ delete migrated.defaultProfile;
178
+ delete migrated.orchestration;
179
+
180
+ const qa = asRecord(migrated.qa);
181
+ const legacyTestCommand =
182
+ qa && typeof qa.command === "string" && qa.command.trim().length > 0
183
+ ? qa.command.trim()
184
+ : null;
185
+ if (qa && "command" in qa) {
186
+ delete qa.command;
187
+ migrated.qa = qa;
188
+ }
189
+
190
+ const release = asRecord(migrated.release);
191
+ if (release) {
192
+ if ("pipeline" in release || "channels" in release) {
193
+ const pipeline = typeof release.pipeline === "string" ? release.pipeline : null;
194
+ release.channels = normalizeReleaseChannels(release.channels, pipeline);
195
+ delete release.pipeline;
196
+ }
197
+ // Strip legacy "npm" values that may linger in saved configs
198
+ if (Array.isArray(release.channels)) {
199
+ release.channels = (release.channels as string[]).filter((c) => c !== "npm");
200
+ }
201
+ migrated.release = release;
202
+ }
203
+
204
+ const quality = asRecord(migrated.quality);
205
+ if (quality) {
206
+ const gates = asRecord(quality.gates);
207
+ if (gates) {
208
+ // Strip legacy ai-review gate — removed from the schema in the checks/review split.
209
+ delete gates["ai-review"];
210
+ }
211
+ if (!gates || Object.keys(gates).length === 0) {
212
+ const legacyGates = legacyGatesFromProfile(legacyProfile, legacyTestCommand);
213
+ if (legacyGates) {
214
+ quality.gates = legacyGates as unknown as Record<string, unknown>;
215
+ }
216
+ } else if (legacyTestCommand && !("test-suite" in gates)) {
217
+ gates["test-suite"] = { enabled: true, command: legacyTestCommand };
218
+ quality.gates = gates;
219
+ }
220
+ migrated.quality = quality;
221
+ }
222
+
223
+ migrated.version = DEFAULT_CONFIG.version;
224
+ return migrated;
225
+ }
226
+
227
+ function mergeConfigLayers(
228
+ defaults: SupipowersConfig,
229
+ globalData: Record<string, unknown> | null,
230
+ projectData: Record<string, unknown> | null,
231
+ ): Record<string, unknown> {
232
+ let merged = structuredClone(defaults) as unknown as Record<string, unknown>;
233
+
234
+ if (globalData) {
235
+ merged = applyConfigOverride(merged, globalData);
236
+ }
237
+
238
+ if (projectData) {
239
+ merged = applyConfigOverride(merged, projectData);
240
+ }
241
+
242
+ // The config schema changed without a version bump, so normalize known
243
+ // legacy fields on every load before strict validation runs.
244
+ merged = migrateConfig(merged);
245
+
246
+ return merged;
247
+ }
248
+
249
+ function inspectScopeConfig(
250
+ paths: PlatformPaths,
251
+ cwd: string,
252
+ scope: ConfigScope,
253
+ ): ScopedConfigInspection {
254
+ const filePath = getConfigPath(paths, cwd, scope);
255
+ const readResult = readJsonFile(scope, filePath);
256
+
257
+ if (readResult.error) {
258
+ return {
259
+ scope,
260
+ path: filePath,
261
+ data: null,
262
+ parseError: readResult.error,
263
+ validationErrors: [],
264
+ qualityGateValidationErrors: [],
265
+ otherValidationErrors: [],
266
+ hasOwnQualityGates: false,
267
+ recoverableInvalidQualityGates: false,
268
+ };
269
+ }
270
+
271
+ const hasOwnQualityGates = !!readResult.data && hasOwnNestedProperty(readResult.data, "quality", "gates");
272
+ const mergedConfig =
273
+ scope === "global"
274
+ ? mergeConfigLayers(DEFAULT_CONFIG, readResult.data, null)
275
+ : mergeConfigLayers(DEFAULT_CONFIG, null, readResult.data);
276
+ const validationErrors = collectConfigValidationErrors(mergedConfig);
277
+ const qualityGateValidationErrors = hasOwnQualityGates
278
+ ? validationErrors.filter((error) => error.path === "quality.gates" || error.path.startsWith("quality.gates."))
279
+ : [];
280
+ const otherValidationErrors = validationErrors.filter(
281
+ (error) => !qualityGateValidationErrors.includes(error),
282
+ );
283
+
284
+ return {
285
+ scope,
286
+ path: filePath,
287
+ data: readResult.data,
288
+ parseError: null,
289
+ validationErrors,
290
+ qualityGateValidationErrors,
291
+ otherValidationErrors,
292
+ hasOwnQualityGates,
293
+ recoverableInvalidQualityGates:
294
+ hasOwnQualityGates && qualityGateValidationErrors.length > 0 && otherValidationErrors.length === 0,
295
+ };
296
+ }
297
+
298
+ function removeQualityGatesFromRecord(config: Record<string, unknown>): Record<string, unknown> {
299
+ const next = structuredClone(config) as Record<string, unknown>;
300
+ const quality = asRecord(next.quality);
301
+ if (!quality || !("gates" in quality)) {
302
+ return next;
303
+ }
304
+
305
+ delete quality.gates;
306
+ if (Object.keys(quality).length === 0) {
307
+ delete next.quality;
308
+ } else {
309
+ next.quality = quality;
310
+ }
311
+
312
+ return next;
313
+ }
314
+
315
+ function writeRawConfigFile(filePath: string, config: Record<string, unknown>): void {
316
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
317
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n");
318
+ }
319
+
320
+ export function inspectQualityGateRecovery(
321
+ paths: PlatformPaths,
322
+ cwd: string,
323
+ ): QualityGateRecoveryInspection {
324
+ return {
325
+ scopes: (["global", "project"] as ConfigScope[]).map((scope) =>
326
+ inspectScopeConfig(paths, cwd, scope),
327
+ ),
328
+ };
329
+ }
58
330
 
59
- let config = { ...DEFAULT_CONFIG };
60
- if (globalData) config = deepMerge(config, globalData);
61
- if (projectData) config = deepMerge(config, projectData);
331
+ export function writeQualityGatesConfig(
332
+ paths: PlatformPaths,
333
+ cwd: string,
334
+ scope: ConfigScope,
335
+ gates: QualityGatesConfig,
336
+ ): void {
337
+ const configPath = getConfigPath(paths, cwd, scope);
338
+ const current = readJsonFile(scope, configPath);
339
+ if (current.error) {
340
+ throw new Error(`${scope} config ${configPath}: ${current.error.message}`);
341
+ }
342
+
343
+ const next = current.data ? structuredClone(current.data) as Record<string, unknown> : {};
344
+ const quality =
345
+ next.quality && typeof next.quality === "object" && !Array.isArray(next.quality)
346
+ ? { ...(next.quality as Record<string, unknown>) }
347
+ : {};
348
+
349
+ quality.gates = gates;
350
+ next.quality = quality;
351
+ writeRawConfigFile(configPath, next);
352
+ }
353
+
354
+ export function removeQualityGatesConfig(
355
+ paths: PlatformPaths,
356
+ cwd: string,
357
+ scope: ConfigScope,
358
+ ): boolean {
359
+ const configPath = getConfigPath(paths, cwd, scope);
360
+ const current = readJsonFile(scope, configPath);
361
+ if (current.error) {
362
+ throw new Error(`${scope} config ${configPath}: ${current.error.message}`);
363
+ }
364
+ if (!current.data || !hasOwnNestedProperty(current.data, "quality", "gates")) {
365
+ return false;
366
+ }
367
+
368
+ writeRawConfigFile(configPath, removeQualityGatesFromRecord(current.data));
369
+ return true;
370
+ }
371
+
372
+ export function formatConfigErrors(result: InspectionLoadResult): string {
373
+ const messages = [
374
+ ...result.parseErrors.map(
375
+ (error) => `${error.source} config ${error.path}: ${error.message}`,
376
+ ),
377
+ ...result.validationErrors.map(
378
+ (error) => `${error.path}: ${error.message}`,
379
+ ),
380
+ ];
381
+
382
+ return messages.join("\n") || "Unknown config error";
383
+ }
384
+
385
+ export function inspectConfig(paths: PlatformPaths, cwd: string): InspectionLoadResult {
386
+ const globalRead = readJsonFile("global", getGlobalConfigPath(paths));
387
+ const projectRead = readJsonFile("project", getProjectConfigPath(paths, cwd));
388
+ const mergedConfig = mergeConfigLayers(DEFAULT_CONFIG, globalRead.data, projectRead.data);
389
+ const parseErrors = [globalRead.error, projectRead.error].filter(
390
+ (error): error is ConfigParseError => error !== null,
391
+ );
392
+ const validationErrors = collectConfigValidationErrors(mergedConfig);
393
+
394
+ return {
395
+ mergedConfig,
396
+ effectiveConfig:
397
+ parseErrors.length === 0 && validationErrors.length === 0
398
+ ? (mergedConfig as unknown as SupipowersConfig)
399
+ : null,
400
+ parseErrors,
401
+ validationErrors,
402
+ };
403
+ }
404
+
405
+ /** Load config with global -> project layering over defaults. */
406
+ export function loadConfig(paths: PlatformPaths, cwd: string): SupipowersConfig {
407
+ const result = inspectConfig(paths, cwd);
62
408
 
63
- // Migrate if version is older than current default
64
- if (config.version !== DEFAULT_CONFIG.version) {
65
- config = migrateConfig(config);
66
- // Persist migrated config if project-level exists
67
- if (projectData) saveConfig(paths, cwd, config);
409
+ if (!result.effectiveConfig) {
410
+ throw new Error(formatConfigErrors(result));
68
411
  }
69
412
 
70
- return config;
413
+ return result.effectiveConfig;
71
414
  }
72
415
 
73
- /** Migrate config from older versions to current */
74
- function migrateConfig(config: SupipowersConfig): SupipowersConfig {
75
- const raw = config as Record<string, any>;
416
+ function assertValidConfig(data: unknown): void {
417
+ const validationErrors = collectConfigValidationErrors(data);
76
418
 
77
- // Migrate release.pipeline (string | null) → release.channels (ReleaseChannel[])
78
- if (raw.release && "pipeline" in raw.release) {
79
- const pipeline = raw.release.pipeline as string | null;
80
- let channels: ReleaseChannel[] = [];
81
- if (pipeline === "npm") channels = ["npm"];
82
- else if (pipeline === "github") channels = ["github"];
83
- // "manual" and null both map to empty array
84
- raw.release = { channels };
419
+ if (validationErrors.length === 0) {
420
+ return;
85
421
  }
86
422
 
87
- return { ...config, version: DEFAULT_CONFIG.version };
423
+ throw new Error(
424
+ validationErrors
425
+ .map((error) => `${error.path}: ${error.message}`)
426
+ .join("\n"),
427
+ );
88
428
  }
89
429
 
90
- /** Save project-level config */
430
+
431
+ /** Save project-level config. */
91
432
  export function saveConfig(paths: PlatformPaths, cwd: string, config: SupipowersConfig): void {
433
+ assertValidConfig(config);
434
+
92
435
  const configPath = getProjectConfigPath(paths, cwd);
93
436
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
94
437
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
95
438
  }
96
439
 
97
- /** Update specific config fields (deep merge into current) */
440
+ /** Update specific config fields (deep merge into current). */
98
441
  export function updateConfig(
99
442
  paths: PlatformPaths,
100
443
  cwd: string,
101
- updates: Record<string, unknown>
444
+ updates: Record<string, unknown>,
102
445
  ): SupipowersConfig {
103
446
  const current = loadConfig(paths, cwd);
104
- const updated = deepMerge(current, updates) as SupipowersConfig;
105
- saveConfig(paths, cwd, updated);
106
- return updated;
447
+ const updated = applyConfigOverride(
448
+ structuredClone(current) as unknown as Record<string, unknown>,
449
+ updates,
450
+ );
451
+ assertValidConfig(updated);
452
+
453
+ saveConfig(paths, cwd, updated as unknown as SupipowersConfig);
454
+ return updated as unknown as SupipowersConfig;
107
455
  }
@@ -123,31 +123,33 @@ export function createModelBridge(platform: Platform): ModelPlatformBridge {
123
123
  * @param ctx - Command handler context (must have modelRegistry.getAvailable())
124
124
  * @param actionId - The action being configured (e.g. "plan", "review") — used in notification
125
125
  * @param resolved - The resolved model from resolveModelForAction()
126
- * @returns true if model was applied, false if skipped or failed
126
+ * @returns cleanup function call in `finally` to clear the status bar and restore the
127
+ * original model. Safe to call multiple times (idempotent). Returns a no-op if nothing
128
+ * was applied.
127
129
  */
128
130
  export async function applyModelOverride(
129
131
  platform: Platform,
130
132
  ctx: any,
131
133
  actionId: string,
132
134
  resolved: ResolvedModel,
133
- ): Promise<boolean> {
135
+ ): Promise<() => Promise<void>> {
134
136
  // Skip if resolution fell through to the main session model (nothing to change)
135
- if (resolved.source === "main") return false;
137
+ if (resolved.source === "main") return async () => {};
136
138
 
137
139
  const modelId = resolved.model;
138
- if (!modelId) return false;
140
+ if (!modelId) return async () => {};
139
141
 
140
142
  // Apply thinking level (independent of model switch success)
141
143
  if (resolved.thinkingLevel && platform.setThinkingLevel) {
142
144
  platform.setThinkingLevel(resolved.thinkingLevel);
143
145
  }
144
146
 
145
- if (!platform.setModel) return false;
147
+ if (!platform.setModel) return async () => {};
146
148
 
147
149
  // Resolve string model ID to full OMP Model object via the context's model registry.
148
150
  // OMP's setModel expects a Model object (with provider, id, api, etc.), not a string.
149
151
  const available = ctx.modelRegistry?.getAvailable?.() as any[] | undefined;
150
- if (!available) return false;
152
+ if (!available) return async () => {};
151
153
 
152
154
  const modelObj = available.find((m: any) => {
153
155
  if (!m?.id) return false;
@@ -156,7 +158,7 @@ export async function applyModelOverride(
156
158
  return modelId.includes("/") ? false : m.id === modelId;
157
159
  });
158
160
 
159
- if (!modelObj) return false;
161
+ if (!modelObj) return async () => {};
160
162
 
161
163
  // Save current model so we can restore after the agent turn completes.
162
164
  // OMP's extension API setModel() persists to settings (calls session.setModel,
@@ -165,7 +167,7 @@ export async function applyModelOverride(
165
167
  const originalModel = ctx.model;
166
168
 
167
169
  const applied = await platform.setModel(modelObj);
168
- if (!applied) return false;
170
+ if (!applied) return async () => {};
169
171
 
170
172
  // Show persistent model override info in the footer status bar.
171
173
  // ctx.ui.notify() is transient and gets immediately replaced by progress widgets;
@@ -182,19 +184,21 @@ export async function applyModelOverride(
182
184
  }
183
185
  ctx.ui?.setStatus?.(STATUS_KEY, `Model: ${displayName} (${detail})`);
184
186
 
185
- // Register a one-shot agent_end hook to restore the original model
186
- // and clear the status bar entry.
187
- {
188
- let restored = false;
189
- platform.on("agent_end", async () => {
190
- if (restored) return;
191
- restored = true;
192
- ctx.ui?.setStatus?.(STATUS_KEY, undefined);
193
- if (originalModel) {
194
- await platform.setModel!(originalModel);
195
- }
196
- });
197
- }
187
+ // Cleanup: clear the status bar entry and restore the original model.
188
+ // Idempotent safe to call multiple times.
189
+ let cleaned = false;
190
+ const cleanup = async () => {
191
+ if (cleaned) return;
192
+ cleaned = true;
193
+ ctx.ui?.setStatus?.(STATUS_KEY, undefined);
194
+ if (originalModel) {
195
+ await platform.setModel!(originalModel);
196
+ }
197
+ };
198
+
199
+ // Safety net for agent-driven commands: if the caller hands off to an OMP agent
200
+ // session and never calls cleanup explicitly, agent_end will still clear the status.
201
+ platform.on("agent_end", cleanup);
198
202
 
199
- return true;
203
+ return cleanup;
200
204
  }