@kodrunhq/opencode-autopilot 1.3.0 → 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 (50) hide show
  1. package/assets/commands/brainstorm.md +7 -0
  2. package/assets/commands/stocktake.md +7 -0
  3. package/assets/commands/tdd.md +7 -0
  4. package/assets/commands/update-docs.md +7 -0
  5. package/assets/commands/write-plan.md +7 -0
  6. package/assets/skills/brainstorming/SKILL.md +295 -0
  7. package/assets/skills/code-review/SKILL.md +241 -0
  8. package/assets/skills/e2e-testing/SKILL.md +266 -0
  9. package/assets/skills/git-worktrees/SKILL.md +296 -0
  10. package/assets/skills/go-patterns/SKILL.md +240 -0
  11. package/assets/skills/plan-executing/SKILL.md +258 -0
  12. package/assets/skills/plan-writing/SKILL.md +278 -0
  13. package/assets/skills/python-patterns/SKILL.md +255 -0
  14. package/assets/skills/rust-patterns/SKILL.md +293 -0
  15. package/assets/skills/strategic-compaction/SKILL.md +217 -0
  16. package/assets/skills/systematic-debugging/SKILL.md +299 -0
  17. package/assets/skills/tdd-workflow/SKILL.md +311 -0
  18. package/assets/skills/typescript-patterns/SKILL.md +278 -0
  19. package/assets/skills/verification/SKILL.md +240 -0
  20. package/package.json +1 -1
  21. package/src/index.ts +72 -1
  22. package/src/observability/context-monitor.ts +102 -0
  23. package/src/observability/event-emitter.ts +136 -0
  24. package/src/observability/event-handlers.ts +322 -0
  25. package/src/observability/event-store.ts +226 -0
  26. package/src/observability/index.ts +53 -0
  27. package/src/observability/log-reader.ts +152 -0
  28. package/src/observability/log-writer.ts +93 -0
  29. package/src/observability/mock/mock-provider.ts +72 -0
  30. package/src/observability/mock/types.ts +31 -0
  31. package/src/observability/retention.ts +57 -0
  32. package/src/observability/schemas.ts +83 -0
  33. package/src/observability/session-logger.ts +63 -0
  34. package/src/observability/summary-generator.ts +209 -0
  35. package/src/observability/token-tracker.ts +97 -0
  36. package/src/observability/types.ts +24 -0
  37. package/src/orchestrator/skill-injection.ts +38 -0
  38. package/src/review/sanitize.ts +1 -1
  39. package/src/skills/adaptive-injector.ts +122 -0
  40. package/src/skills/dependency-resolver.ts +88 -0
  41. package/src/skills/linter.ts +113 -0
  42. package/src/skills/loader.ts +88 -0
  43. package/src/templates/skill-template.ts +4 -0
  44. package/src/tools/create-skill.ts +12 -0
  45. package/src/tools/logs.ts +178 -0
  46. package/src/tools/mock-fallback.ts +100 -0
  47. package/src/tools/pipeline-report.ts +148 -0
  48. package/src/tools/session-stats.ts +185 -0
  49. package/src/tools/stocktake.ts +170 -0
  50. package/src/tools/update-docs.ts +116 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Topological sort with cycle detection for skill dependencies.
3
+ *
4
+ * Uses iterative DFS-based topological ordering. Skills not in the map are
5
+ * silently skipped (graceful degradation). All cycle participants are reported
6
+ * in the `cycles` array so callers can exclude them.
7
+ */
8
+
9
+ export interface DependencyNode {
10
+ readonly requires: readonly string[];
11
+ }
12
+
13
+ export interface ResolutionResult {
14
+ readonly ordered: readonly string[];
15
+ readonly cycles: readonly string[];
16
+ }
17
+
18
+ /** Hard cap on skill count to prevent DoS via crafted dependency chains. */
19
+ const MAX_SKILLS = 500;
20
+
21
+ /**
22
+ * Iterative topological sort with cycle detection.
23
+ * Skills not in the map are silently skipped (graceful degradation).
24
+ * All nodes participating in a cycle are reported (not just the re-entry point).
25
+ */
26
+ export function resolveDependencyOrder(
27
+ skills: ReadonlyMap<string, DependencyNode>,
28
+ ): ResolutionResult {
29
+ if (skills.size > MAX_SKILLS) {
30
+ return { ordered: [], cycles: [...skills.keys()] };
31
+ }
32
+
33
+ const visited = new Set<string>();
34
+ const inStack = new Set<string>();
35
+ const stackArr: string[] = [];
36
+ const ordered: string[] = [];
37
+ const cycleSet = new Set<string>();
38
+
39
+ for (const startName of skills.keys()) {
40
+ if (visited.has(startName)) continue;
41
+
42
+ // Iterative DFS using explicit stack
43
+ const dfsStack: Array<{ name: string; depIndex: number }> = [{ name: startName, depIndex: 0 }];
44
+ inStack.add(startName);
45
+ visited.add(startName);
46
+ stackArr.push(startName);
47
+
48
+ while (dfsStack.length > 0) {
49
+ const frame = dfsStack[dfsStack.length - 1];
50
+ const skill = skills.get(frame.name);
51
+ const deps = skill ? skill.requires : [];
52
+
53
+ if (frame.depIndex < deps.length) {
54
+ const dep = deps[frame.depIndex];
55
+ frame.depIndex++;
56
+
57
+ if (!skills.has(dep)) continue; // skip unknown deps
58
+
59
+ if (inStack.has(dep)) {
60
+ // Cycle detected — record all nodes in the cycle path
61
+ const cycleStart = stackArr.indexOf(dep);
62
+ for (let i = cycleStart; i < stackArr.length; i++) {
63
+ cycleSet.add(stackArr[i]);
64
+ }
65
+ continue;
66
+ }
67
+
68
+ if (!visited.has(dep)) {
69
+ visited.add(dep);
70
+ inStack.add(dep);
71
+ stackArr.push(dep);
72
+ dfsStack.push({ name: dep, depIndex: 0 });
73
+ }
74
+ } else {
75
+ // All deps processed — pop this node
76
+ dfsStack.pop();
77
+ inStack.delete(frame.name);
78
+ stackArr.pop();
79
+ ordered.push(frame.name);
80
+ }
81
+ }
82
+ }
83
+
84
+ return Object.freeze({
85
+ ordered: Object.freeze(ordered),
86
+ cycles: Object.freeze([...cycleSet]),
87
+ });
88
+ }
@@ -0,0 +1,113 @@
1
+ import { parse } from "yaml";
2
+
3
+ interface LintResult {
4
+ readonly valid: boolean;
5
+ readonly errors: readonly string[];
6
+ readonly warnings: readonly string[];
7
+ }
8
+
9
+ /** Parse YAML frontmatter from markdown content. Supports LF and CRLF. */
10
+ function extractFrontmatter(content: string): Record<string, unknown> | null {
11
+ const match = content.match(/^---\r?\n([\s\S]*?\r?\n)?---/);
12
+ if (!match) return null;
13
+ try {
14
+ const parsed = parse(match[1] ?? "");
15
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
16
+ return parsed as Record<string, unknown>;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /** Freeze a LintResult including nested arrays. */
23
+ function freezeResult(valid: boolean, errors: string[], warnings: string[]): LintResult {
24
+ return Object.freeze({
25
+ valid,
26
+ errors: Object.freeze(errors),
27
+ warnings: Object.freeze(warnings),
28
+ });
29
+ }
30
+
31
+ /** Lint a skill SKILL.md file for valid YAML frontmatter and required fields. */
32
+ export function lintSkill(content: string): LintResult {
33
+ const errors: string[] = [];
34
+ const warnings: string[] = [];
35
+
36
+ const fm = extractFrontmatter(content);
37
+ if (!fm) {
38
+ return freezeResult(false, ["Missing YAML frontmatter"], []);
39
+ }
40
+
41
+ // Required fields
42
+ if (typeof fm.name !== "string" || fm.name.length === 0) {
43
+ errors.push("Missing required field: name");
44
+ }
45
+ if (typeof fm.description !== "string" || fm.description.length === 0) {
46
+ errors.push("Missing required field: description");
47
+ }
48
+
49
+ // Optional but recommended fields
50
+ if (!Array.isArray(fm.stacks)) {
51
+ warnings.push(
52
+ "Missing recommended field: stacks (add stacks: [] for methodology skills or stacks: [lang] for language skills)",
53
+ );
54
+ }
55
+ if (!Array.isArray(fm.requires)) {
56
+ warnings.push("Missing recommended field: requires (add requires: [] if no dependencies)");
57
+ }
58
+
59
+ // Validate stacks entries are strings
60
+ if (Array.isArray(fm.stacks) && fm.stacks.some((s: unknown) => typeof s !== "string")) {
61
+ errors.push("stacks must contain only strings");
62
+ }
63
+ if (Array.isArray(fm.requires) && fm.requires.some((s: unknown) => typeof s !== "string")) {
64
+ errors.push("requires must contain only strings");
65
+ }
66
+
67
+ // Content validation (CRLF-safe)
68
+ const body = content.replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---/, "").trim();
69
+ if (body.length === 0) {
70
+ warnings.push("Skill has no content after frontmatter");
71
+ }
72
+
73
+ return freezeResult(errors.length === 0, errors, warnings);
74
+ }
75
+
76
+ /** Lint a command markdown file for valid YAML frontmatter and required fields. */
77
+ export function lintCommand(content: string): LintResult {
78
+ const errors: string[] = [];
79
+ const warnings: string[] = [];
80
+
81
+ const fm = extractFrontmatter(content);
82
+ if (!fm) {
83
+ return freezeResult(false, ["Missing YAML frontmatter"], []);
84
+ }
85
+
86
+ if (typeof fm.description !== "string" || fm.description.length === 0) {
87
+ errors.push("Missing required field: description");
88
+ }
89
+
90
+ const body = content.replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---/, "").trim();
91
+ if (body.length === 0) {
92
+ warnings.push("Command has no content after frontmatter");
93
+ }
94
+
95
+ return freezeResult(errors.length === 0, errors, warnings);
96
+ }
97
+
98
+ /** Lint an agent markdown file for valid YAML frontmatter and required fields. */
99
+ export function lintAgent(content: string): LintResult {
100
+ const errors: string[] = [];
101
+ const warnings: string[] = [];
102
+
103
+ const fm = extractFrontmatter(content);
104
+ if (!fm) {
105
+ return freezeResult(false, ["Missing YAML frontmatter"], []);
106
+ }
107
+
108
+ if (typeof fm.name !== "string" || fm.name.length === 0) {
109
+ errors.push("Missing required field: name");
110
+ }
111
+
112
+ return freezeResult(errors.length === 0, errors, warnings);
113
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Skill frontmatter parser and file loader.
3
+ *
4
+ * Loads SKILL.md files from the global skills directory, parses their
5
+ * YAML frontmatter, and returns structured skill metadata + content.
6
+ * Uses the `yaml` package for parsing (not regex — per "Don't Hand-Roll" guideline).
7
+ */
8
+
9
+ import { readdir, readFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { parse } from "yaml";
12
+ import { isEnoentError } from "../utils/fs-helpers";
13
+
14
+ export interface SkillFrontmatter {
15
+ readonly name: string;
16
+ readonly description: string;
17
+ readonly stacks: readonly string[];
18
+ readonly requires: readonly string[];
19
+ }
20
+
21
+ export interface LoadedSkill {
22
+ readonly frontmatter: SkillFrontmatter;
23
+ readonly content: string;
24
+ readonly path: string;
25
+ }
26
+
27
+ /**
28
+ * Parse YAML frontmatter from SKILL.md content.
29
+ * Returns null if no valid frontmatter block is found or parsing fails.
30
+ */
31
+ export function parseSkillFrontmatter(content: string): SkillFrontmatter | null {
32
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
33
+ if (!match) return null;
34
+
35
+ try {
36
+ const parsed = parse(match[1]);
37
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
38
+ const fm = parsed as Record<string, unknown>;
39
+ return {
40
+ name: typeof fm.name === "string" ? fm.name : "",
41
+ description: typeof fm.description === "string" ? fm.description : "",
42
+ stacks: Array.isArray(fm.stacks)
43
+ ? fm.stacks.filter((s): s is string => typeof s === "string")
44
+ : [],
45
+ requires: Array.isArray(fm.requires)
46
+ ? fm.requires.filter((s): s is string => typeof s === "string")
47
+ : [],
48
+ };
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Load all skills from a base directory (e.g., ~/.config/opencode/skills/).
56
+ * Returns a map of skill name -> LoadedSkill. Best-effort: skips invalid skills.
57
+ */
58
+ export async function loadAllSkills(skillsDir: string): Promise<ReadonlyMap<string, LoadedSkill>> {
59
+ const skills = new Map<string, LoadedSkill>();
60
+
61
+ try {
62
+ const entries = await readdir(skillsDir, { withFileTypes: true });
63
+ await Promise.all(
64
+ entries
65
+ .filter((e) => e.isDirectory() && e.name !== ".gitkeep")
66
+ .map(async (dir) => {
67
+ try {
68
+ const skillPath = join(skillsDir, dir.name, "SKILL.md");
69
+ const content = await readFile(skillPath, "utf-8");
70
+ const fm = parseSkillFrontmatter(content);
71
+ if (fm?.name) {
72
+ skills.set(fm.name, { frontmatter: fm, content, path: skillPath });
73
+ }
74
+ } catch (error: unknown) {
75
+ // Skip ENOENT/IO errors (expected for invalid skills)
76
+ if (!isEnoentError(error) && !(error instanceof SyntaxError)) {
77
+ const errObj = error as { code?: unknown };
78
+ if (typeof errObj?.code !== "string") throw error;
79
+ }
80
+ }
81
+ }),
82
+ );
83
+ } catch (error: unknown) {
84
+ if (!isEnoentError(error)) throw error;
85
+ }
86
+
87
+ return skills;
88
+ }
@@ -5,12 +5,16 @@ export interface SkillTemplateInput {
5
5
  readonly description: string;
6
6
  readonly license?: string;
7
7
  readonly compatibility?: string;
8
+ readonly stacks?: readonly string[];
9
+ readonly requires?: readonly string[];
8
10
  }
9
11
 
10
12
  export function generateSkillMarkdown(input: SkillTemplateInput): string {
11
13
  const frontmatter: Record<string, unknown> = {
12
14
  name: input.name,
13
15
  description: input.description,
16
+ stacks: input.stacks ?? [],
17
+ requires: input.requires ?? [],
14
18
  ...(input.license !== undefined && { license: input.license }),
15
19
  ...(input.compatibility !== undefined && { compatibility: input.compatibility }),
16
20
  };
@@ -11,6 +11,8 @@ interface CreateSkillArgs {
11
11
  readonly description: string;
12
12
  readonly license?: string;
13
13
  readonly compatibility?: string;
14
+ readonly stacks?: readonly string[];
15
+ readonly requires?: readonly string[];
14
16
  }
15
17
 
16
18
  export async function createSkillCore(args: CreateSkillArgs, baseDir: string): Promise<string> {
@@ -27,6 +29,8 @@ export async function createSkillCore(args: CreateSkillArgs, baseDir: string): P
27
29
  description: args.description,
28
30
  license: args.license,
29
31
  compatibility: args.compatibility,
32
+ stacks: args.stacks,
33
+ requires: args.requires,
30
34
  });
31
35
 
32
36
  try {
@@ -67,6 +71,14 @@ export const ocCreateSkill = tool({
67
71
  .max(64)
68
72
  .optional()
69
73
  .describe("Compatibility (e.g., 'opencode')"),
74
+ stacks: tool.schema
75
+ .array(tool.schema.string())
76
+ .optional()
77
+ .describe("Stack tags for adaptive loading (e.g., ['typescript', 'bun'])"),
78
+ requires: tool.schema
79
+ .array(tool.schema.string())
80
+ .optional()
81
+ .describe("Required skill dependencies (e.g., ['coding-standards'])"),
70
82
  },
71
83
  async execute(args) {
72
84
  return createSkillCore(args, getGlobalConfigDir());
@@ -0,0 +1,178 @@
1
+ /**
2
+ * oc_logs tool - Session log dashboard.
3
+ *
4
+ * Provides three modes:
5
+ * - "list": List all session logs with summary info
6
+ * - "detail": View full session log with markdown summary
7
+ * - "search": Filter events by type and time range
8
+ *
9
+ * Follows the *Core + tool() wrapper pattern per CLAUDE.md.
10
+ * Returns JSON with displayText field following oc_doctor pattern.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { tool } from "@opencode-ai/plugin";
16
+ import { z } from "zod";
17
+ import {
18
+ listSessionLogs,
19
+ readLatestSessionLog,
20
+ readSessionLog,
21
+ searchEvents,
22
+ } from "../observability/log-reader";
23
+ import { generateSessionSummary } from "../observability/summary-generator";
24
+
25
+ /**
26
+ * Options for logsCore search/detail modes.
27
+ */
28
+ interface LogsOptions {
29
+ readonly sessionID?: string;
30
+ readonly eventType?: string;
31
+ readonly after?: string;
32
+ readonly before?: string;
33
+ }
34
+
35
+ /**
36
+ * Formats a session list as a human-readable table.
37
+ */
38
+ function formatSessionTable(
39
+ sessions: readonly {
40
+ readonly sessionId: string;
41
+ readonly startedAt: string;
42
+ readonly endedAt: string | null;
43
+ readonly eventCount: number;
44
+ readonly decisionCount: number;
45
+ readonly errorCount: number;
46
+ }[],
47
+ ): string {
48
+ if (sessions.length === 0) {
49
+ return "No session logs found.";
50
+ }
51
+
52
+ const lines = [
53
+ "Session Logs",
54
+ "",
55
+ "| Session ID | Started | Events | Decisions | Errors |",
56
+ "|------------|---------|--------|-----------|--------|",
57
+ ];
58
+
59
+ for (const s of sessions) {
60
+ const started = s.startedAt.replace("T", " ").replace(/\.\d+Z$/, "Z");
61
+ lines.push(
62
+ `| ${s.sessionId} | ${started} | ${s.eventCount} | ${s.decisionCount} | ${s.errorCount} |`,
63
+ );
64
+ }
65
+
66
+ lines.push("", `${sessions.length} session(s) total`);
67
+ return lines.join("\n");
68
+ }
69
+
70
+ /**
71
+ * Core function for the oc_logs tool.
72
+ *
73
+ * @param mode - "list", "detail", or "search"
74
+ * @param options - Optional filters (sessionID, eventType, after, before)
75
+ * @param logsDir - Optional override for logs directory (for testing)
76
+ */
77
+ export async function logsCore(
78
+ mode: "list" | "detail" | "search",
79
+ options?: LogsOptions,
80
+ logsDir?: string,
81
+ ): Promise<string> {
82
+ switch (mode) {
83
+ case "list": {
84
+ const sessions = await listSessionLogs(logsDir);
85
+
86
+ return JSON.stringify({
87
+ action: "logs_list",
88
+ sessions,
89
+ displayText: formatSessionTable(sessions),
90
+ });
91
+ }
92
+
93
+ case "detail": {
94
+ const log = options?.sessionID
95
+ ? await readSessionLog(options.sessionID, logsDir)
96
+ : await readLatestSessionLog(logsDir);
97
+
98
+ if (!log) {
99
+ const target = options?.sessionID
100
+ ? `Session "${options.sessionID}" not found.`
101
+ : "No session logs found.";
102
+ return JSON.stringify({
103
+ action: "error",
104
+ message: target,
105
+ });
106
+ }
107
+
108
+ const summary = generateSessionSummary(log);
109
+
110
+ return JSON.stringify({
111
+ action: "logs_detail",
112
+ sessionLog: log,
113
+ summary,
114
+ displayText: summary,
115
+ });
116
+ }
117
+
118
+ case "search": {
119
+ const log = options?.sessionID
120
+ ? await readSessionLog(options.sessionID, logsDir)
121
+ : await readLatestSessionLog(logsDir);
122
+
123
+ if (!log) {
124
+ const target = options?.sessionID
125
+ ? `Session "${options.sessionID}" not found.`
126
+ : "No session logs found.";
127
+ return JSON.stringify({
128
+ action: "error",
129
+ message: target,
130
+ });
131
+ }
132
+
133
+ const filtered = searchEvents(log.events, {
134
+ type: options?.eventType,
135
+ after: options?.after,
136
+ before: options?.before,
137
+ });
138
+
139
+ const displayLines = [
140
+ `Search Results (${filtered.length} event(s))`,
141
+ "",
142
+ ...filtered.map((e) => `[${e.timestamp}] ${e.type}: ${JSON.stringify(e)}`),
143
+ ];
144
+
145
+ return JSON.stringify({
146
+ action: "logs_search",
147
+ sessionId: log.sessionId,
148
+ events: filtered,
149
+ displayText: displayLines.join("\n"),
150
+ });
151
+ }
152
+ }
153
+ }
154
+
155
+ // --- Tool wrapper ---
156
+
157
+ export const ocLogs = tool({
158
+ description:
159
+ "View session logs. Modes: 'list' shows all sessions, 'detail' shows full log with " +
160
+ "summary, 'search' filters events by type/time. Use to inspect session history and errors.",
161
+ args: {
162
+ mode: z.enum(["list", "detail", "search"]).describe("View mode: list, detail, or search"),
163
+ sessionID: z
164
+ .string()
165
+ .regex(/^[a-zA-Z0-9_-]{1,256}$/)
166
+ .optional()
167
+ .describe("Session ID to view (uses latest if omitted)"),
168
+ eventType: z.string().optional().describe("Filter events by type (for search mode)"),
169
+ after: z.string().optional().describe("Only events after this ISO timestamp (for search mode)"),
170
+ before: z
171
+ .string()
172
+ .optional()
173
+ .describe("Only events before this ISO timestamp (for search mode)"),
174
+ },
175
+ async execute({ mode, sessionID, eventType, after, before }) {
176
+ return logsCore(mode, { sessionID, eventType, after, before });
177
+ },
178
+ });
@@ -0,0 +1,100 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { z } from "zod";
3
+ import { createMockError } from "../observability/mock/mock-provider";
4
+ import type { MockFailureMode } from "../observability/mock/types";
5
+ import { FAILURE_MODES } from "../observability/mock/types";
6
+ import { classifyErrorType, isRetryableError } from "../orchestrator/fallback/error-classifier";
7
+
8
+ /**
9
+ * Default retryable status codes matching the standard fallback config.
10
+ */
11
+ const DEFAULT_RETRY_CODES: readonly number[] = Object.freeze([429, 503, 529]);
12
+
13
+ /**
14
+ * Human-readable descriptions for each failure mode.
15
+ */
16
+ const MODE_DESCRIPTIONS: Readonly<Record<MockFailureMode, string>> = Object.freeze({
17
+ rate_limit: "Simulates HTTP 429 rate limit response",
18
+ quota_exceeded: "Simulates HTTP 402 quota/billing error",
19
+ timeout: "Simulates HTTP 504 gateway timeout (classifies as service_unavailable)",
20
+ malformed: "Simulates unparseable/corrupt response (not retryable)",
21
+ service_unavailable: "Simulates HTTP 503 service outage",
22
+ });
23
+
24
+ /**
25
+ * Core function for mock fallback testing tool.
26
+ * Follows the *Core + tool() wrapper pattern per CLAUDE.md.
27
+ *
28
+ * - "list" mode returns all available failure modes with descriptions
29
+ * - Any valid failure mode generates and classifies the mock error
30
+ * - Invalid modes return an error JSON
31
+ *
32
+ * This tool does NOT trigger fallback in a live session. It generates and
33
+ * classifies errors, showing what the fallback system would see.
34
+ */
35
+ export async function mockFallbackCore(mode: string): Promise<string> {
36
+ if (mode === "list") {
37
+ const modeLines = FAILURE_MODES.map((m) => ` ${m}: ${MODE_DESCRIPTIONS[m]}`).join("\n");
38
+
39
+ return JSON.stringify({
40
+ action: "mock_fallback_list",
41
+ modes: [...FAILURE_MODES],
42
+ displayText: `Available failure modes:\n${modeLines}`,
43
+ });
44
+ }
45
+
46
+ // Validate mode
47
+ if (!FAILURE_MODES.includes(mode as MockFailureMode)) {
48
+ return JSON.stringify({
49
+ action: "error",
50
+ message: "Invalid failure mode. Use 'list' to see available modes.",
51
+ });
52
+ }
53
+
54
+ const failureMode = mode as MockFailureMode;
55
+ const error = createMockError(failureMode);
56
+ const classification = classifyErrorType(error);
57
+ const retryable = isRetryableError(error, DEFAULT_RETRY_CODES);
58
+
59
+ // Extract error fields for the response
60
+ const errorObj = error as Record<string, unknown>;
61
+ const errorSummary: Record<string, unknown> = {
62
+ name: errorObj.name,
63
+ message: errorObj.message,
64
+ };
65
+ if (errorObj.status !== undefined) {
66
+ errorSummary.status = errorObj.status;
67
+ }
68
+
69
+ const displayText = [
70
+ `Mock ${failureMode} error generated.`,
71
+ `Classification: ${classification}`,
72
+ `Retryable: ${retryable}`,
73
+ "",
74
+ "To test fallback chain: inject this error into FallbackManager.handleError() in a test,",
75
+ "or use oc_mock_fallback in a session to verify error classification behavior.",
76
+ ].join("\n");
77
+
78
+ return JSON.stringify({
79
+ action: "mock_fallback",
80
+ mode: failureMode,
81
+ error: errorSummary,
82
+ classification,
83
+ retryable,
84
+ displayText,
85
+ });
86
+ }
87
+
88
+ // --- Tool wrapper ---
89
+
90
+ export const ocMockFallback = tool({
91
+ description:
92
+ "Generate mock errors for fallback chain testing. " +
93
+ "Use 'list' to see available failure modes.",
94
+ args: {
95
+ mode: z.string().describe("Failure mode to simulate or 'list' for available modes"),
96
+ },
97
+ async execute({ mode }) {
98
+ return mockFallbackCore(mode);
99
+ },
100
+ });