@kodrunhq/opencode-autopilot 1.17.0 → 1.19.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 (118) hide show
  1. package/README.md +95 -13
  2. package/assets/commands/oc-doctor.md +17 -0
  3. package/assets/commands/oc-update-docs.md +1 -1
  4. package/bin/configure-tui.ts +1 -1
  5. package/package.json +1 -1
  6. package/src/agents/index.ts +0 -12
  7. package/src/agents/pipeline/index.ts +0 -4
  8. package/src/autonomy/completion.ts +52 -0
  9. package/src/autonomy/controller.ts +144 -0
  10. package/src/autonomy/index.ts +25 -0
  11. package/src/autonomy/injector.ts +49 -0
  12. package/src/autonomy/state.ts +91 -0
  13. package/src/autonomy/types.ts +30 -0
  14. package/src/autonomy/verification.ts +86 -0
  15. package/src/background/database.ts +170 -0
  16. package/src/background/executor.ts +174 -0
  17. package/src/background/index.ts +8 -0
  18. package/src/background/manager.ts +232 -0
  19. package/src/background/repository.ts +174 -0
  20. package/src/background/schema.ts +24 -0
  21. package/src/background/sdk-runner.ts +40 -0
  22. package/src/background/slot-manager.ts +41 -0
  23. package/src/background/state-machine.ts +19 -0
  24. package/src/config/v7.ts +3 -3
  25. package/src/config.ts +105 -21
  26. package/src/context/budget.ts +45 -0
  27. package/src/context/compaction-handler.ts +58 -0
  28. package/src/context/discovery.ts +94 -0
  29. package/src/context/index.ts +14 -0
  30. package/src/context/injector.ts +119 -0
  31. package/src/context/types.ts +24 -0
  32. package/src/health/checks.ts +214 -3
  33. package/src/health/index.ts +7 -1
  34. package/src/health/runner.ts +14 -2
  35. package/src/index.ts +113 -6
  36. package/src/installer.ts +13 -0
  37. package/src/kernel/index.ts +6 -0
  38. package/src/kernel/migrations.ts +50 -0
  39. package/src/kernel/retry.ts +49 -0
  40. package/src/kernel/schema.ts +9 -1
  41. package/src/kernel/transaction.ts +40 -12
  42. package/src/logging/forensic-writer.ts +6 -2
  43. package/src/logging/index.ts +2 -0
  44. package/src/mcp/index.ts +34 -0
  45. package/src/mcp/manager.ts +206 -0
  46. package/src/mcp/scope-filter.ts +44 -0
  47. package/src/mcp/types.ts +38 -0
  48. package/src/orchestrator/arena.ts +7 -1
  49. package/src/orchestrator/fallback/event-handler.ts +12 -1
  50. package/src/orchestrator/handlers/challenge.ts +8 -1
  51. package/src/orchestrator/handlers/plan.ts +8 -1
  52. package/src/orchestrator/handlers/recon.ts +8 -1
  53. package/src/orchestrator/handlers/types.ts +2 -2
  54. package/src/orchestrator/lesson-memory.ts +6 -1
  55. package/src/orchestrator/orchestration-logger.ts +15 -3
  56. package/src/orchestrator/skill-injection.ts +7 -1
  57. package/src/orchestrator/state.ts +6 -1
  58. package/src/recovery/classifier.ts +127 -0
  59. package/src/recovery/event-handler.ts +263 -0
  60. package/src/recovery/index.ts +20 -0
  61. package/src/recovery/orchestrator.ts +180 -0
  62. package/src/recovery/persistence.ts +87 -0
  63. package/src/recovery/strategies.ts +107 -0
  64. package/src/recovery/types.ts +31 -0
  65. package/src/registry/model-groups.ts +2 -19
  66. package/src/registry/resolver.ts +38 -9
  67. package/src/review/agent-catalog.ts +83 -251
  68. package/src/review/agents/architecture-verifier.ts +41 -0
  69. package/src/review/agents/code-hygiene-auditor.ts +40 -0
  70. package/src/review/agents/correctness-auditor.ts +41 -0
  71. package/src/review/agents/frontend-auditor.ts +39 -0
  72. package/src/review/agents/index.ts +15 -42
  73. package/src/review/agents/language-idioms-auditor.ts +39 -0
  74. package/src/review/agents/security-auditor.ts +12 -8
  75. package/src/review/stack-gate.ts +2 -6
  76. package/src/routing/categories.ts +111 -0
  77. package/src/routing/classifier.ts +152 -0
  78. package/src/routing/engine.ts +89 -0
  79. package/src/routing/index.ts +4 -0
  80. package/src/routing/types.ts +14 -0
  81. package/src/skills/adaptive-injector.ts +34 -3
  82. package/src/skills/loader.ts +4 -0
  83. package/src/tools/background.ts +196 -0
  84. package/src/tools/configure.ts +1 -1
  85. package/src/tools/delegate.ts +205 -0
  86. package/src/tools/loop.ts +94 -0
  87. package/src/tools/recover.ts +172 -0
  88. package/src/types/background.ts +51 -0
  89. package/src/types/mcp.ts +27 -0
  90. package/src/types/recovery.ts +49 -0
  91. package/src/types/routing.ts +39 -0
  92. package/src/ux/context-warnings.ts +81 -0
  93. package/src/ux/error-hints.ts +38 -0
  94. package/src/ux/index.ts +7 -0
  95. package/src/ux/notifications.ts +67 -0
  96. package/src/ux/progress.ts +77 -0
  97. package/src/ux/session-summary.ts +67 -0
  98. package/src/ux/task-status.ts +109 -0
  99. package/src/ux/types.ts +24 -0
  100. package/src/agents/db-specialist.ts +0 -295
  101. package/src/agents/devops.ts +0 -352
  102. package/src/agents/documenter.ts +0 -44
  103. package/src/agents/frontend-engineer.ts +0 -541
  104. package/src/agents/pipeline/oc-explorer.ts +0 -46
  105. package/src/agents/pipeline/oc-retrospector.ts +0 -42
  106. package/src/review/agents/auth-flow-verifier.ts +0 -47
  107. package/src/review/agents/concurrency-checker.ts +0 -47
  108. package/src/review/agents/dead-code-scanner.ts +0 -47
  109. package/src/review/agents/go-idioms-auditor.ts +0 -46
  110. package/src/review/agents/python-django-auditor.ts +0 -46
  111. package/src/review/agents/react-patterns-auditor.ts +0 -46
  112. package/src/review/agents/rust-safety-auditor.ts +0 -46
  113. package/src/review/agents/scope-intent-verifier.ts +0 -45
  114. package/src/review/agents/silent-failure-hunter.ts +0 -45
  115. package/src/review/agents/spec-checker.ts +0 -45
  116. package/src/review/agents/state-mgmt-auditor.ts +0 -46
  117. package/src/review/agents/type-soundness.ts +0 -46
  118. package/src/review/agents/wiring-inspector.ts +0 -46
@@ -0,0 +1,94 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import type { ContextSource, DiscoveryOptions } from "./types";
4
+
5
+ const CHARS_PER_TOKEN = 4;
6
+ const DEFAULT_MAX_DEPTH = 3;
7
+ const PRIORITY_DEPTH_BONUS = 10;
8
+
9
+ const CONTEXT_FILE_DEFINITIONS = Object.freeze([
10
+ Object.freeze({ name: "AGENTS.md", relativePath: "AGENTS.md", priority: 90 }),
11
+ Object.freeze({ name: "CLAUDE.md", relativePath: "CLAUDE.md", priority: 85 }),
12
+ Object.freeze({ name: "README.md", relativePath: "README.md", priority: 50 }),
13
+ Object.freeze({ name: ".opencode/agents.md", relativePath: ".opencode/agents.md", priority: 80 }),
14
+ ]);
15
+
16
+ const discoveryCache = new Map<string, readonly ContextSource[]>();
17
+
18
+ function createCacheKey(projectRoot: string, maxDepth: number): string {
19
+ return `${resolve(projectRoot)}::${maxDepth}`;
20
+ }
21
+
22
+ function buildSearchRoots(projectRoot: string, maxDepth: number): readonly string[] {
23
+ const roots: string[] = [];
24
+ let currentRoot = resolve(projectRoot);
25
+
26
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
27
+ roots.push(currentRoot);
28
+ const parentRoot = dirname(currentRoot);
29
+ if (parentRoot === currentRoot) {
30
+ break;
31
+ }
32
+ currentRoot = parentRoot;
33
+ }
34
+
35
+ return roots;
36
+ }
37
+
38
+ function estimateTokens(content: string): number {
39
+ return Math.ceil(content.length / CHARS_PER_TOKEN);
40
+ }
41
+
42
+ export function clearContextDiscoveryCache(projectRoot?: string): void {
43
+ if (!projectRoot) {
44
+ discoveryCache.clear();
45
+ return;
46
+ }
47
+
48
+ const projectRootPrefix = `${resolve(projectRoot)}::`;
49
+ for (const key of discoveryCache.keys()) {
50
+ if (key.startsWith(projectRootPrefix)) {
51
+ discoveryCache.delete(key);
52
+ }
53
+ }
54
+ }
55
+
56
+ export async function discoverContextFiles(
57
+ options: DiscoveryOptions,
58
+ ): Promise<readonly ContextSource[]> {
59
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
60
+ const cacheKey = createCacheKey(options.projectRoot, maxDepth);
61
+ const cached = discoveryCache.get(cacheKey);
62
+ if (cached !== undefined) {
63
+ return cached;
64
+ }
65
+
66
+ const roots = buildSearchRoots(options.projectRoot, maxDepth);
67
+ const sources: ContextSource[] = [];
68
+
69
+ for (const [depth, root] of roots.entries()) {
70
+ for (const definition of CONTEXT_FILE_DEFINITIONS) {
71
+ const filePath = join(root, definition.relativePath);
72
+ try {
73
+ const content = await readFile(filePath, "utf-8");
74
+ sources.push({
75
+ name: definition.name,
76
+ filePath,
77
+ content,
78
+ priority: definition.priority + (maxDepth - depth + 1) * PRIORITY_DEPTH_BONUS,
79
+ tokenEstimate: estimateTokens(content),
80
+ });
81
+ } catch (error: unknown) {
82
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
83
+ throw error;
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ const orderedSources = [...sources].sort(
90
+ (left, right) => right.priority - left.priority || left.filePath.localeCompare(right.filePath),
91
+ );
92
+ discoveryCache.set(cacheKey, orderedSources);
93
+ return orderedSources;
94
+ }
@@ -0,0 +1,14 @@
1
+ export { allocateBudget, truncateToTokens } from "./budget";
2
+ export { createCompactionHandler } from "./compaction-handler";
3
+ export { clearContextDiscoveryCache, discoverContextFiles } from "./discovery";
4
+ export {
5
+ type ContextInjector,
6
+ type ContextInjectorOptions,
7
+ createContextInjector,
8
+ } from "./injector";
9
+ export type {
10
+ ContextBudget,
11
+ ContextInjectionResult,
12
+ ContextSource,
13
+ DiscoveryOptions,
14
+ } from "./types";
@@ -0,0 +1,119 @@
1
+ import { getLogger } from "../logging/domains";
2
+ import { allocateBudget, truncateToTokens } from "./budget";
3
+ import { clearContextDiscoveryCache, discoverContextFiles } from "./discovery";
4
+ import type { ContextInjectionResult, ContextSource, DiscoveryOptions } from "./types";
5
+
6
+ const DEFAULT_TOTAL_BUDGET = 4000;
7
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
8
+ const logger = getLogger("context", "injector");
9
+
10
+ export interface ContextInjectorOptions {
11
+ readonly projectRoot: string;
12
+ readonly totalBudget?: number;
13
+ readonly ttlMs?: number;
14
+ readonly now?: () => number;
15
+ readonly discover?: (options: DiscoveryOptions) => Promise<readonly ContextSource[]>;
16
+ }
17
+
18
+ interface InjectorInput {
19
+ readonly sessionID?: string;
20
+ }
21
+
22
+ interface InjectorOutput {
23
+ system: string[];
24
+ }
25
+
26
+ interface CacheEntry {
27
+ readonly result: ContextInjectionResult;
28
+ readonly expiresAt: number;
29
+ }
30
+
31
+ export interface ContextInjector {
32
+ (input: InjectorInput, output: InjectorOutput): Promise<void>;
33
+ clearCache(sessionID?: string): void;
34
+ }
35
+
36
+ function buildInjectionText(
37
+ sources: readonly ContextSource[],
38
+ allocations: ReadonlyMap<string, number>,
39
+ ): {
40
+ readonly injectedText: string;
41
+ readonly truncated: boolean;
42
+ } {
43
+ let truncated = false;
44
+ const sections: string[] = [];
45
+
46
+ for (const source of sources) {
47
+ const allocation = allocations.get(source.filePath) ?? 0;
48
+ if (allocation <= 0) {
49
+ continue;
50
+ }
51
+
52
+ if (allocation < source.tokenEstimate) {
53
+ truncated = true;
54
+ }
55
+
56
+ sections.push(
57
+ `---\n[Source: ${source.name}]\n${truncateToTokens(source.content, allocation)}\n---`,
58
+ );
59
+ }
60
+
61
+ return {
62
+ injectedText: sections.length > 0 ? `\n${sections.join("\n")}\n` : "",
63
+ truncated,
64
+ };
65
+ }
66
+
67
+ export function createContextInjector(options: ContextInjectorOptions): ContextInjector {
68
+ const discover = options.discover ?? discoverContextFiles;
69
+ const now = options.now ?? Date.now;
70
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
71
+ const totalBudget = options.totalBudget ?? DEFAULT_TOTAL_BUDGET;
72
+ const cache = new Map<string, CacheEntry>();
73
+
74
+ const injector: ContextInjector = async (input, output) => {
75
+ try {
76
+ if (!input.sessionID) {
77
+ return;
78
+ }
79
+
80
+ const cached = cache.get(input.sessionID);
81
+ if (cached !== undefined && cached.expiresAt > now()) {
82
+ if (cached.result.injectedText.length > 0) {
83
+ output.system.push(cached.result.injectedText);
84
+ }
85
+ return;
86
+ }
87
+
88
+ const sources = await discover({ projectRoot: options.projectRoot, maxDepth: 3 });
89
+ const { allocations, totalUsed } = allocateBudget(sources, totalBudget);
90
+ const { injectedText, truncated } = buildInjectionText(sources, allocations);
91
+
92
+ const result: ContextInjectionResult = {
93
+ injectedText,
94
+ sources,
95
+ totalTokens: totalUsed,
96
+ truncated,
97
+ };
98
+
99
+ cache.set(input.sessionID, { result, expiresAt: now() + ttlMs });
100
+
101
+ if (injectedText.length > 0) {
102
+ output.system.push(injectedText);
103
+ }
104
+ } catch (error) {
105
+ logger.warn("context injection failed", { error: String(error) });
106
+ }
107
+ };
108
+
109
+ injector.clearCache = (sessionID?: string) => {
110
+ if (sessionID) {
111
+ cache.delete(sessionID);
112
+ } else {
113
+ cache.clear();
114
+ }
115
+ clearContextDiscoveryCache(options.projectRoot);
116
+ };
117
+
118
+ return injector;
119
+ }
@@ -0,0 +1,24 @@
1
+ export interface ContextSource {
2
+ readonly name: string;
3
+ readonly filePath: string;
4
+ readonly content: string;
5
+ readonly priority: number;
6
+ readonly tokenEstimate: number;
7
+ }
8
+
9
+ export interface ContextBudget {
10
+ readonly totalTokens: number;
11
+ readonly allocations: ReadonlyMap<string, number>;
12
+ }
13
+
14
+ export interface ContextInjectionResult {
15
+ readonly injectedText: string;
16
+ readonly sources: readonly ContextSource[];
17
+ readonly totalTokens: number;
18
+ readonly truncated: boolean;
19
+ }
20
+
21
+ export interface DiscoveryOptions {
22
+ readonly projectRoot: string;
23
+ readonly maxDepth?: number;
24
+ }
@@ -1,10 +1,13 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { access, readFile, stat } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import type { Config } from "@opencode-ai/plugin";
5
5
  import { parse } from "yaml";
6
6
  import { loadConfig } from "../config";
7
+ import { getGlobalMcpManager } from "../mcp";
7
8
  import { AGENT_NAMES } from "../orchestrator/handlers/types";
9
+ import { ALL_GROUP_IDS } from "../registry/model-groups";
10
+ import { getAllCategories } from "../routing";
8
11
  import { detectProjectStackTags, filterSkillsByStack } from "../skills/adaptive-injector";
9
12
  import { loadAllSkills } from "../skills/loader";
10
13
  import {
@@ -15,6 +18,8 @@ import {
15
18
  } from "../utils/paths";
16
19
  import type { HealthResult } from "./types";
17
20
 
21
+ const VALID_CATEGORY_NAMES = new Set(getAllCategories().map((definition) => definition.category));
22
+
18
23
  /**
19
24
  * Check that the plugin config file exists and passes Zod validation.
20
25
  * loadConfig returns null when the file is missing, and throws on invalid JSON/schema.
@@ -44,7 +49,7 @@ export async function configHealthCheck(configPath?: string): Promise<HealthResu
44
49
  }
45
50
  }
46
51
 
47
- const LATEST_CONFIG_VERSION = 6;
52
+ const LATEST_CONFIG_VERSION = 7;
48
53
 
49
54
  export async function configVersionCheck(configPath?: string): Promise<HealthResult> {
50
55
  try {
@@ -141,13 +146,85 @@ export async function configGroupsCheck(configPath?: string): Promise<HealthResu
141
146
  }
142
147
  }
143
148
 
149
+ /** v7 config fields that must be present on a v7 config. */
150
+ const V7_REQUIRED_FIELDS: readonly string[] = Object.freeze([
151
+ "background",
152
+ "routing",
153
+ "recovery",
154
+ "mcp",
155
+ ]);
156
+
157
+ /**
158
+ * Check that v7 configs contain all four new top-level fields introduced in v7:
159
+ * background, routing, recovery, and mcp.
160
+ * Inspects the raw on-disk JSON so that Zod default-filling does not mask
161
+ * actually-missing fields. Pre-v7 configs receive a pass with a migration notice.
162
+ */
163
+ export async function configV7FieldsCheck(configPath?: string): Promise<HealthResult> {
164
+ const resolvedPath = configPath ?? join(getGlobalConfigDir(), "opencode-autopilot.json");
165
+ try {
166
+ let raw: Record<string, unknown>;
167
+ try {
168
+ const content = await readFile(resolvedPath, "utf-8");
169
+ raw = JSON.parse(content) as Record<string, unknown>;
170
+ } catch (error: unknown) {
171
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
172
+ return Object.freeze({
173
+ name: "config-v7-fields",
174
+ status: "fail" as const,
175
+ message: "Config file not found",
176
+ });
177
+ }
178
+ throw error;
179
+ }
180
+
181
+ const version = typeof raw.version === "number" ? raw.version : 0;
182
+
183
+ if (version < 7) {
184
+ return Object.freeze({
185
+ name: "config-v7-fields",
186
+ status: "pass" as const,
187
+ message: `Config v${version} will gain v7 fields (background, routing, recovery, mcp) on next load`,
188
+ });
189
+ }
190
+
191
+ const missingFields = V7_REQUIRED_FIELDS.filter((field) => !(field in raw));
192
+
193
+ if (missingFields.length > 0) {
194
+ return Object.freeze({
195
+ name: "config-v7-fields",
196
+ status: "fail" as const,
197
+ message: `Config v7 is missing required fields: ${missingFields.join(", ")}`,
198
+ details: Object.freeze(missingFields),
199
+ });
200
+ }
201
+
202
+ return Object.freeze({
203
+ name: "config-v7-fields",
204
+ status: "pass" as const,
205
+ message: `Config v7 fields present: ${V7_REQUIRED_FIELDS.join(", ")}`,
206
+ });
207
+ } catch (error: unknown) {
208
+ const msg = error instanceof Error ? error.message : String(error);
209
+ return Object.freeze({
210
+ name: "config-v7-fields",
211
+ status: "fail" as const,
212
+ message: `Config v7 fields check failed: ${msg}`,
213
+ });
214
+ }
215
+ }
216
+
144
217
  /** Standard agent names, derived from the agents barrel export. */
145
218
  const STANDARD_AGENT_NAMES: readonly string[] = Object.freeze([
146
219
  "researcher",
147
220
  "metaprompter",
148
- "documenter",
149
221
  "pr-reviewer",
150
222
  "autopilot",
223
+ "coder",
224
+ "debugger",
225
+ "planner",
226
+ "reviewer",
227
+ "security-auditor",
151
228
  ]);
152
229
 
153
230
  /** Pipeline agent names, derived from AGENT_NAMES in the orchestrator. */
@@ -342,6 +419,88 @@ export async function skillHealthCheck(
342
419
  }
343
420
  }
344
421
 
422
+ export async function mcpHealthCheck(configPath?: string): Promise<HealthResult> {
423
+ try {
424
+ const config = await loadConfig(configPath);
425
+ if (config === null) {
426
+ return Object.freeze({
427
+ name: "mcp-health",
428
+ status: "fail" as const,
429
+ message: "Plugin config file not found",
430
+ });
431
+ }
432
+
433
+ if (!config.mcp.enabled) {
434
+ return Object.freeze({
435
+ name: "mcp-health",
436
+ status: "pass" as const,
437
+ message: "MCP disabled in config",
438
+ });
439
+ }
440
+
441
+ const manager = getGlobalMcpManager();
442
+ if (manager) {
443
+ const healthResults = await manager.healthCheckAll();
444
+ if (healthResults.length === 0) {
445
+ return Object.freeze({
446
+ name: "mcp-health",
447
+ status: "pass" as const,
448
+ message: "MCP enabled, no servers running",
449
+ });
450
+ }
451
+
452
+ const unhealthy = healthResults.filter((result) => result.state !== "healthy");
453
+ const details = Object.freeze(
454
+ healthResults.map((result) => {
455
+ const status = result.state === "healthy" ? "ok" : result.state;
456
+ const errorSuffix = result.error ? ` - ${result.error}` : "";
457
+ return `${result.serverName} (${result.skillName}): ${status}${errorSuffix}`;
458
+ }),
459
+ );
460
+
461
+ if (unhealthy.length > 0) {
462
+ return Object.freeze({
463
+ name: "mcp-health",
464
+ status: "warn" as const,
465
+ message: `MCP enabled: ${healthResults.length} server(s), ${unhealthy.length} unhealthy`,
466
+ details,
467
+ });
468
+ }
469
+
470
+ return Object.freeze({
471
+ name: "mcp-health",
472
+ status: "pass" as const,
473
+ message: `MCP enabled: ${healthResults.length} server(s) healthy`,
474
+ details,
475
+ });
476
+ }
477
+
478
+ const skillsDir = join(configPath ? dirname(configPath) : getGlobalConfigDir(), "skills");
479
+ const skills = await loadAllSkills(skillsDir);
480
+ const mcpSkills = [...skills.values()].filter((skill) => skill.frontmatter.mcp !== null);
481
+ const details = Object.freeze(
482
+ mcpSkills.map((skill) => {
483
+ const mcp = skill.frontmatter.mcp;
484
+ return `${skill.frontmatter.name}: ${mcp?.serverName} (${mcp?.transport})`;
485
+ }),
486
+ );
487
+
488
+ return Object.freeze({
489
+ name: "mcp-health",
490
+ status: "pass" as const,
491
+ message: `MCP enabled with ${mcpSkills.length} MCP-capable skill${mcpSkills.length === 1 ? "" : "s"} (manager not initialized)`,
492
+ details,
493
+ });
494
+ } catch (error: unknown) {
495
+ const msg = error instanceof Error ? error.message : String(error);
496
+ return Object.freeze({
497
+ name: "mcp-health",
498
+ status: "fail" as const,
499
+ message: `MCP health check failed: ${msg}`,
500
+ });
501
+ }
502
+ }
503
+
345
504
  /**
346
505
  * Check memory DB health: existence, readability, observation count.
347
506
  * Does NOT call getMemoryDb() to avoid creating an empty DB as a side effect.
@@ -505,3 +664,55 @@ export async function commandHealthCheck(targetDir?: string): Promise<HealthResu
505
664
  details: Object.freeze(issues),
506
665
  });
507
666
  }
667
+
668
+ export async function routingHealthCheck(configPath?: string): Promise<HealthResult> {
669
+ try {
670
+ const invalidDefinitions = getAllCategories().flatMap((definition) => {
671
+ const issues: string[] = [];
672
+ if (!VALID_CATEGORY_NAMES.has(definition.category)) {
673
+ issues.push(`${definition.category}: unknown category`);
674
+ }
675
+ if (!ALL_GROUP_IDS.includes(definition.modelGroup as (typeof ALL_GROUP_IDS)[number])) {
676
+ issues.push(`${definition.category}: invalid model group '${definition.modelGroup}'`);
677
+ }
678
+ if (definition.maxIterations < 1) {
679
+ issues.push(`${definition.category}: maxIterations must be >= 1`);
680
+ }
681
+ if (definition.timeoutSeconds < 1) {
682
+ issues.push(`${definition.category}: timeoutSeconds must be >= 1`);
683
+ }
684
+ return issues;
685
+ });
686
+
687
+ const config = await loadConfig(configPath);
688
+ const invalidOverrides = Object.keys(config?.routing.categories ?? {}).flatMap(
689
+ (categoryName) =>
690
+ VALID_CATEGORY_NAMES.has(categoryName as import("../types/routing").Category)
691
+ ? []
692
+ : [`Invalid routing override category '${categoryName}'`],
693
+ );
694
+
695
+ const issues = [...invalidDefinitions, ...invalidOverrides];
696
+ if (issues.length > 0) {
697
+ return Object.freeze({
698
+ name: "routing-health",
699
+ status: "fail" as const,
700
+ message: `${issues.length} routing issue(s) found`,
701
+ details: Object.freeze(issues),
702
+ });
703
+ }
704
+
705
+ return Object.freeze({
706
+ name: "routing-health",
707
+ status: "pass" as const,
708
+ message: `All ${getAllCategories().length} routing categories and overrides are valid`,
709
+ });
710
+ } catch (error: unknown) {
711
+ const msg = error instanceof Error ? error.message : String(error);
712
+ return Object.freeze({
713
+ name: "routing-health",
714
+ status: "fail" as const,
715
+ message: `Routing health check failed: ${msg}`,
716
+ });
717
+ }
718
+ }
@@ -1,3 +1,9 @@
1
- export { agentHealthCheck, assetHealthCheck, configHealthCheck } from "./checks";
1
+ export {
2
+ agentHealthCheck,
3
+ assetHealthCheck,
4
+ configHealthCheck,
5
+ mcpHealthCheck,
6
+ routingHealthCheck,
7
+ } from "./checks";
2
8
  export { runHealthChecks } from "./runner";
3
9
  export type { HealthReport, HealthResult } from "./types";
@@ -4,8 +4,11 @@ import {
4
4
  assetHealthCheck,
5
5
  commandHealthCheck,
6
6
  configHealthCheck,
7
+ configV7FieldsCheck,
8
+ mcpHealthCheck,
7
9
  memoryHealthCheck,
8
10
  nativeAgentSuppressionHealthCheck,
11
+ routingHealthCheck,
9
12
  skillHealthCheck,
10
13
  } from "./checks";
11
14
  import type { HealthReport, HealthResult } from "./types";
@@ -43,16 +46,22 @@ export async function runHealthChecks(options?: {
43
46
  }): Promise<HealthReport> {
44
47
  const start = Date.now();
45
48
 
49
+ const configOutcome = await Promise.allSettled([configHealthCheck(options?.configPath)]);
50
+
46
51
  const settled = await Promise.allSettled([
47
- configHealthCheck(options?.configPath),
48
52
  agentHealthCheck(options?.openCodeConfig ?? null),
49
53
  nativeAgentSuppressionHealthCheck(options?.openCodeConfig ?? null),
50
54
  assetHealthCheck(options?.assetsDir, options?.targetDir),
51
55
  skillHealthCheck(options?.projectRoot ?? process.cwd()),
52
56
  memoryHealthCheck(options?.targetDir),
53
57
  commandHealthCheck(options?.targetDir),
58
+ configV7FieldsCheck(options?.configPath),
59
+ routingHealthCheck(options?.configPath),
60
+ mcpHealthCheck(options?.configPath),
54
61
  ]);
55
62
 
63
+ const allSettled = [...configOutcome, ...settled];
64
+
56
65
  const fallbackNames = [
57
66
  "config-validity",
58
67
  "agent-injection",
@@ -61,9 +70,12 @@ export async function runHealthChecks(options?: {
61
70
  "skill-loading",
62
71
  "memory-db",
63
72
  "command-accessibility",
73
+ "config-v7-fields",
74
+ "routing-health",
75
+ "mcp-health",
64
76
  ];
65
77
  const results: readonly HealthResult[] = Object.freeze(
66
- settled.map((outcome, i) => settledToResult(outcome, fallbackNames[i])),
78
+ allSettled.map((outcome, i) => settledToResult(outcome, fallbackNames[i])),
67
79
  );
68
80
 
69
81
  const allPassed = results.every((r) => r.status === "pass");