flowcat 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 (51) hide show
  1. package/core/addTask.ts +121 -0
  2. package/core/aliasReconcile.ts +41 -0
  3. package/core/aliasRepo.ts +92 -0
  4. package/core/autoCommit.ts +84 -0
  5. package/core/commitMessage.ts +95 -0
  6. package/core/config.ts +110 -0
  7. package/core/constants.ts +8 -0
  8. package/core/dayAssignments.ts +198 -0
  9. package/core/edit.ts +59 -0
  10. package/core/format.ts +63 -0
  11. package/core/fsm.ts +28 -0
  12. package/core/git.ts +117 -0
  13. package/core/gitignore.ts +30 -0
  14. package/core/id.ts +41 -0
  15. package/core/initFlow.ts +122 -0
  16. package/core/json.ts +13 -0
  17. package/core/lock.ts +31 -0
  18. package/core/plugin.ts +99 -0
  19. package/core/pluginErrors.ts +26 -0
  20. package/core/pluginLoader.ts +222 -0
  21. package/core/pluginManager.ts +75 -0
  22. package/core/pluginRunner.ts +217 -0
  23. package/core/pr.ts +89 -0
  24. package/core/prWorkflow.ts +185 -0
  25. package/core/schemas/dayAssignment.ts +11 -0
  26. package/core/schemas/logEntry.ts +11 -0
  27. package/core/schemas/pr.ts +29 -0
  28. package/core/schemas/task.ts +41 -0
  29. package/core/search.ts +25 -0
  30. package/core/taskFactory.ts +29 -0
  31. package/core/taskOperations.ts +104 -0
  32. package/core/taskRepo.ts +109 -0
  33. package/core/taskResolver.ts +44 -0
  34. package/core/taskStore.ts +14 -0
  35. package/core/time.ts +63 -0
  36. package/core/types.ts +7 -0
  37. package/core/workspace.ts +133 -0
  38. package/dist/fc.mjs +87532 -0
  39. package/dist/highlights-eq9cgrbb.scm +604 -0
  40. package/dist/highlights-ghv9g403.scm +205 -0
  41. package/dist/highlights-hk7bwhj4.scm +284 -0
  42. package/dist/highlights-r812a2qc.scm +150 -0
  43. package/dist/highlights-x6tmsnaa.scm +115 -0
  44. package/dist/index.mjs +5221 -2724
  45. package/dist/injections-73j83es3.scm +27 -0
  46. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  47. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  48. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  49. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  50. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  51. package/package.json +25 -9
package/core/lock.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import lockfile from "proper-lockfile";
5
+
6
+ import { LOCK_DIR, LOCK_FILE } from "./constants";
7
+ import { ensureWorkspaceLayout } from "./workspace";
8
+
9
+ export const withWorkspaceLock = async <T>(
10
+ workspaceRoot: string,
11
+ fn: () => Promise<T>,
12
+ ): Promise<T> => {
13
+ await ensureWorkspaceLayout(workspaceRoot);
14
+ const lockPath = path.join(workspaceRoot, LOCK_DIR, LOCK_FILE);
15
+ await writeFile(lockPath, "", { flag: "a" });
16
+ const release = await lockfile.lock(lockPath, {
17
+ stale: 60_000,
18
+ retries: {
19
+ retries: 5,
20
+ factor: 1.5,
21
+ minTimeout: 50,
22
+ maxTimeout: 1_000,
23
+ },
24
+ });
25
+
26
+ try {
27
+ return await fn();
28
+ } finally {
29
+ await release();
30
+ }
31
+ };
package/core/plugin.ts ADDED
@@ -0,0 +1,99 @@
1
+ import type { Task } from "./schemas/task";
2
+ import type { TaskAction, TaskStatus } from "./types";
3
+
4
+ /**
5
+ * Logger interface for plugins
6
+ */
7
+ export type PluginLogger = {
8
+ debug: (message: string) => void;
9
+ info: (message: string) => void;
10
+ warn: (message: string) => void;
11
+ error: (message: string) => void;
12
+ };
13
+
14
+ /**
15
+ * Context provided to all hook handlers
16
+ */
17
+ export type PluginContext = {
18
+ /** Root path of the current workspace */
19
+ workspaceRoot: string;
20
+ /** Plugin-specific configuration from config.json */
21
+ config: Record<string, unknown>;
22
+ /** Logger for plugin output */
23
+ logger: PluginLogger;
24
+ };
25
+
26
+ /**
27
+ * Result returned from a hook handler
28
+ */
29
+ export type HookResult = {
30
+ /** Return modified task, or omit to keep original */
31
+ task?: Task;
32
+ };
33
+
34
+ /**
35
+ * Hook event payloads for each lifecycle event
36
+ */
37
+ export type HookEvents = {
38
+ "task:created": {
39
+ task: Task;
40
+ alias: string;
41
+ };
42
+ "task:transitioned": {
43
+ task: Task;
44
+ previousStatus: TaskStatus;
45
+ action: TaskAction;
46
+ };
47
+ "task:logged": {
48
+ task: Task;
49
+ message: string;
50
+ };
51
+ "task:edited": {
52
+ task: Task;
53
+ path: string;
54
+ previousValue: unknown;
55
+ newValue: unknown;
56
+ };
57
+ "pr:attached": {
58
+ task: Task;
59
+ prUrl: string;
60
+ };
61
+ "pr:refreshed": {
62
+ task: Task;
63
+ previousState?: "open" | "closed" | "merged";
64
+ newState: "open" | "closed" | "merged";
65
+ };
66
+ };
67
+
68
+ /**
69
+ * Hook handler function type
70
+ */
71
+ export type HookHandler<K extends keyof HookEvents> = (
72
+ event: HookEvents[K],
73
+ context: PluginContext,
74
+ ) => Promise<HookResult | undefined>;
75
+
76
+ /**
77
+ * Plugin definition
78
+ */
79
+ export type FlowcatPlugin = {
80
+ /** Unique plugin name (e.g., "llm-pr-analyzer") */
81
+ name: string;
82
+
83
+ /** Semantic version */
84
+ version: string;
85
+
86
+ /** Human-readable description */
87
+ description?: string;
88
+
89
+ /** Hook handlers */
90
+ hooks?: {
91
+ [K in keyof HookEvents]?: HookHandler<K>;
92
+ };
93
+
94
+ /** Called once when plugin loads (optional) */
95
+ onLoad?: (context: PluginContext) => Promise<void>;
96
+
97
+ /** Called when plugin unloads (optional) */
98
+ onUnload?: (context: PluginContext) => Promise<void>;
99
+ };
@@ -0,0 +1,26 @@
1
+ export type PluginErrorCode =
2
+ | "PLUGIN_LOAD_FAILED"
3
+ | "PLUGIN_INIT_FAILED"
4
+ | "HOOK_EXECUTION_FAILED"
5
+ | "HOOK_TIMEOUT"
6
+ | "INVALID_PLUGIN";
7
+
8
+ export class PluginError extends Error {
9
+ code: PluginErrorCode;
10
+ pluginName?: string;
11
+ hookName?: string;
12
+ cause?: Error;
13
+
14
+ constructor(
15
+ code: PluginErrorCode,
16
+ message: string,
17
+ options?: { pluginName?: string; hookName?: string; cause?: Error },
18
+ ) {
19
+ super(message);
20
+ this.name = "PluginError";
21
+ this.code = code;
22
+ this.pluginName = options?.pluginName;
23
+ this.hookName = options?.hookName;
24
+ this.cause = options?.cause;
25
+ }
26
+ }
@@ -0,0 +1,222 @@
1
+ import { access } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { createJiti } from "jiti";
6
+
7
+ import {
8
+ readConfigFile,
9
+ resolveGlobalConfigPath,
10
+ resolveWorkspaceConfigPath,
11
+ type PluginEntry,
12
+ } from "./config";
13
+ import { APP_NAME, PLUGINS_DIR } from "./constants";
14
+ import { PluginError } from "./pluginErrors";
15
+ import type { FlowcatPlugin } from "./plugin";
16
+
17
+ export type LoadedPlugin = {
18
+ plugin: FlowcatPlugin;
19
+ config: Record<string, unknown>;
20
+ enabled: boolean;
21
+ };
22
+
23
+ export type PluginLoadError = {
24
+ path: string;
25
+ error: Error;
26
+ };
27
+
28
+ export type PluginLoadResult = {
29
+ plugins: LoadedPlugin[];
30
+ errors: PluginLoadError[];
31
+ };
32
+
33
+ const exists = async (filePath: string): Promise<boolean> => {
34
+ try {
35
+ await access(filePath);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ };
41
+
42
+ /**
43
+ * Resolve the global plugins directory
44
+ */
45
+ export const resolveGlobalPluginsDirectory = (): string => {
46
+ const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
47
+ return path.join(configHome, APP_NAME, PLUGINS_DIR);
48
+ };
49
+
50
+ /**
51
+ * Resolve the plugins directory for a workspace
52
+ */
53
+ export const resolvePluginsDirectory = (
54
+ workspaceRoot: string,
55
+ configOverride?: string,
56
+ ): string => {
57
+ if (configOverride) {
58
+ return path.isAbsolute(configOverride)
59
+ ? configOverride
60
+ : path.join(workspaceRoot, configOverride);
61
+ }
62
+
63
+ return path.join(workspaceRoot, PLUGINS_DIR);
64
+ };
65
+
66
+ /**
67
+ * Resolve a plugin path to an actual file
68
+ */
69
+ const resolvePluginPath = async (
70
+ pluginPath: string,
71
+ pluginsDir: string,
72
+ ): Promise<string | null> => {
73
+ // Absolute path
74
+ if (path.isAbsolute(pluginPath)) {
75
+ if (await exists(pluginPath)) {
76
+ return pluginPath;
77
+ }
78
+ // Try adding extensions
79
+ for (const ext of ["/index.ts", "/index.js", ".ts", ".js"]) {
80
+ const withExt = pluginPath + ext;
81
+ if (await exists(withExt)) {
82
+ return withExt;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // Relative path (./plugin or ../plugin)
89
+ if (pluginPath.startsWith("./") || pluginPath.startsWith("../")) {
90
+ const resolved = path.resolve(pluginsDir, pluginPath);
91
+ return resolvePluginPath(resolved, pluginsDir);
92
+ }
93
+
94
+ // Plugin name - look in plugins directory
95
+ const basePath = path.join(pluginsDir, pluginPath);
96
+
97
+ // Try different extensions in order
98
+ const candidates = [
99
+ path.join(basePath, "index.ts"),
100
+ `${basePath}.ts`,
101
+ path.join(basePath, "index.js"),
102
+ `${basePath}.js`,
103
+ ];
104
+
105
+ for (const candidate of candidates) {
106
+ if (await exists(candidate)) {
107
+ return candidate;
108
+ }
109
+ }
110
+
111
+ return null;
112
+ };
113
+
114
+ /**
115
+ * Validate that an object is a valid FlowcatPlugin
116
+ */
117
+ const validatePlugin = (obj: unknown, pluginPath: string): FlowcatPlugin => {
118
+ if (!obj || typeof obj !== "object") {
119
+ throw new PluginError(
120
+ "INVALID_PLUGIN",
121
+ `Plugin at ${pluginPath} must export a default FlowcatPlugin object`,
122
+ );
123
+ }
124
+
125
+ const plugin = obj as Record<string, unknown>;
126
+
127
+ if (!plugin.name || typeof plugin.name !== "string") {
128
+ throw new PluginError("INVALID_PLUGIN", `Plugin at ${pluginPath} must have a name`);
129
+ }
130
+
131
+ if (!plugin.version || typeof plugin.version !== "string") {
132
+ throw new PluginError("INVALID_PLUGIN", `Plugin at ${pluginPath} must have a version`);
133
+ }
134
+
135
+ return plugin as unknown as FlowcatPlugin;
136
+ };
137
+
138
+ /**
139
+ * Load a single plugin from a path using jiti
140
+ */
141
+ export const loadPluginFromPath = async (
142
+ pluginPath: string,
143
+ pluginsDir: string,
144
+ ): Promise<FlowcatPlugin> => {
145
+ const resolvedPath = await resolvePluginPath(pluginPath, pluginsDir);
146
+
147
+ if (!resolvedPath) {
148
+ throw new PluginError(
149
+ "PLUGIN_LOAD_FAILED",
150
+ `Could not find plugin at ${pluginPath}`,
151
+ );
152
+ }
153
+
154
+ try {
155
+ const jiti = createJiti(import.meta.url);
156
+ const module = await jiti.import(resolvedPath);
157
+ const defaultExport = (module as { default?: unknown }).default;
158
+
159
+ if (!defaultExport) {
160
+ throw new PluginError(
161
+ "INVALID_PLUGIN",
162
+ `Plugin at ${resolvedPath} must have a default export`,
163
+ );
164
+ }
165
+
166
+ return validatePlugin(defaultExport, resolvedPath);
167
+ } catch (error) {
168
+ if (error instanceof PluginError) {
169
+ throw error;
170
+ }
171
+
172
+ throw new PluginError(
173
+ "PLUGIN_LOAD_FAILED",
174
+ `Failed to load plugin at ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`,
175
+ { cause: error instanceof Error ? error : undefined },
176
+ );
177
+ }
178
+ };
179
+
180
+ /**
181
+ * Load all configured plugins for a workspace
182
+ */
183
+ export const loadPlugins = async (workspaceRoot: string): Promise<PluginLoadResult> => {
184
+ const workspaceConfig = await readConfigFile(resolveWorkspaceConfigPath(workspaceRoot));
185
+ const globalConfig = await readConfigFile(resolveGlobalConfigPath());
186
+
187
+ // Get plugin entries (workspace config takes precedence)
188
+ const entries: PluginEntry[] =
189
+ workspaceConfig.plugins?.entries ?? globalConfig.plugins?.entries ?? [];
190
+
191
+ // Get plugins directory
192
+ const pluginsDir = resolvePluginsDirectory(
193
+ workspaceRoot,
194
+ workspaceConfig.plugins?.directory ?? globalConfig.plugins?.directory,
195
+ );
196
+
197
+ const plugins: LoadedPlugin[] = [];
198
+ const errors: PluginLoadError[] = [];
199
+
200
+ for (const entry of entries) {
201
+ // Skip disabled plugins
202
+ if (entry.enabled === false) {
203
+ continue;
204
+ }
205
+
206
+ try {
207
+ const plugin = await loadPluginFromPath(entry.path, pluginsDir);
208
+ plugins.push({
209
+ plugin,
210
+ config: entry.config ?? {},
211
+ enabled: true,
212
+ });
213
+ } catch (error) {
214
+ errors.push({
215
+ path: entry.path,
216
+ error: error instanceof Error ? error : new Error(String(error)),
217
+ });
218
+ }
219
+ }
220
+
221
+ return { plugins, errors };
222
+ };
@@ -0,0 +1,75 @@
1
+ import { readConfigFile, resolveGlobalConfigPath, resolveWorkspaceConfigPath } from "./config";
2
+ import type { HookEvents, HookResult } from "./plugin";
3
+ import { loadPlugins } from "./pluginLoader";
4
+ import { PluginRunner } from "./pluginRunner";
5
+
6
+ // Cache of plugin runners per workspace
7
+ const runners = new Map<string, PluginRunner>();
8
+
9
+ /**
10
+ * Get or create the plugin runner for a workspace
11
+ * Lazy initialization - plugins are loaded on first access
12
+ */
13
+ export const getPluginRunner = async (workspaceRoot: string): Promise<PluginRunner> => {
14
+ const existing = runners.get(workspaceRoot);
15
+ if (existing) {
16
+ return existing;
17
+ }
18
+
19
+ const { plugins, errors } = await loadPlugins(workspaceRoot);
20
+
21
+ // Log loading errors
22
+ for (const { path, error } of errors) {
23
+ console.warn(`[plugins] Failed to load ${path}: ${error.message}`);
24
+ }
25
+
26
+ const workspaceConfig = await readConfigFile(resolveWorkspaceConfigPath(workspaceRoot));
27
+ const globalConfig = await readConfigFile(resolveGlobalConfigPath());
28
+ const settings = workspaceConfig.plugins?.settings ?? globalConfig.plugins?.settings;
29
+
30
+ const runner = new PluginRunner({
31
+ plugins,
32
+ workspaceRoot,
33
+ settings,
34
+ });
35
+
36
+ await runner.initialize();
37
+
38
+ runners.set(workspaceRoot, runner);
39
+
40
+ return runner;
41
+ };
42
+
43
+ /**
44
+ * Emit a hook event to all plugins in a workspace
45
+ * Convenience function that handles runner initialization
46
+ */
47
+ export const emitHook = async <K extends keyof HookEvents>(
48
+ workspaceRoot: string,
49
+ hookName: K,
50
+ event: HookEvents[K],
51
+ ): Promise<HookResult> => {
52
+ const runner = await getPluginRunner(workspaceRoot);
53
+ return runner.emit(hookName, event);
54
+ };
55
+
56
+ /**
57
+ * Clear the plugin runner cache for a workspace
58
+ * Useful for testing or when plugins config changes
59
+ */
60
+ export const clearPluginRunner = async (workspaceRoot: string): Promise<void> => {
61
+ const runner = runners.get(workspaceRoot);
62
+ if (runner) {
63
+ await runner.shutdown();
64
+ runners.delete(workspaceRoot);
65
+ }
66
+ };
67
+
68
+ /**
69
+ * Clear all plugin runner caches
70
+ */
71
+ export const clearAllPluginRunners = async (): Promise<void> => {
72
+ for (const [workspaceRoot] of runners) {
73
+ await clearPluginRunner(workspaceRoot);
74
+ }
75
+ };
@@ -0,0 +1,217 @@
1
+ import type { PluginSettings } from "./config";
2
+ import type { HookEvents, HookResult, PluginContext, PluginLogger } from "./plugin";
3
+ import { PluginError } from "./pluginErrors";
4
+ import type { LoadedPlugin } from "./pluginLoader";
5
+
6
+ export type PluginRunnerOptions = {
7
+ plugins: LoadedPlugin[];
8
+ workspaceRoot: string;
9
+ settings?: PluginSettings;
10
+ };
11
+
12
+ /**
13
+ * Expand environment variables in config values
14
+ * Supports ${VAR_NAME} syntax
15
+ */
16
+ const expandEnvVars = (value: unknown): unknown => {
17
+ if (typeof value === "string") {
18
+ return value.replace(/\$\{(\w+)\}/g, (_, name: string) => process.env[name] ?? "");
19
+ }
20
+ if (Array.isArray(value)) {
21
+ return value.map(expandEnvVars);
22
+ }
23
+ if (value && typeof value === "object") {
24
+ const result: Record<string, unknown> = {};
25
+ for (const [k, v] of Object.entries(value)) {
26
+ result[k] = expandEnvVars(v);
27
+ }
28
+ return result;
29
+ }
30
+ return value;
31
+ };
32
+
33
+ /**
34
+ * Create a logger for a plugin
35
+ */
36
+ const createPluginLogger = (
37
+ pluginName: string,
38
+ logLevel: PluginSettings["logLevel"] = "info",
39
+ ): PluginLogger => {
40
+ const levels = ["debug", "info", "warn", "error"] as const;
41
+ const minLevel = levels.indexOf(logLevel);
42
+
43
+ const shouldLog = (level: (typeof levels)[number]): boolean => {
44
+ return levels.indexOf(level) >= minLevel;
45
+ };
46
+
47
+ const format = (level: string, message: string): string => {
48
+ return `[plugin:${pluginName}] [${level.toUpperCase()}] ${message}`;
49
+ };
50
+
51
+ return {
52
+ debug: (message) => {
53
+ if (shouldLog("debug")) {
54
+ console.debug(format("debug", message));
55
+ }
56
+ },
57
+ info: (message) => {
58
+ if (shouldLog("info")) {
59
+ console.info(format("info", message));
60
+ }
61
+ },
62
+ warn: (message) => {
63
+ if (shouldLog("warn")) {
64
+ console.warn(format("warn", message));
65
+ }
66
+ },
67
+ error: (message) => {
68
+ if (shouldLog("error")) {
69
+ console.error(format("error", message));
70
+ }
71
+ },
72
+ };
73
+ };
74
+
75
+ /**
76
+ * Run a function with a timeout
77
+ */
78
+ const runWithTimeout = async <T>(
79
+ fn: () => Promise<T>,
80
+ timeoutMs: number,
81
+ description: string,
82
+ ): Promise<T> => {
83
+ return new Promise((resolve, reject) => {
84
+ const timer = setTimeout(() => {
85
+ reject(new PluginError("HOOK_TIMEOUT", `${description} timed out after ${timeoutMs}ms`));
86
+ }, timeoutMs);
87
+
88
+ fn()
89
+ .then((result) => {
90
+ clearTimeout(timer);
91
+ resolve(result);
92
+ })
93
+ .catch((error) => {
94
+ clearTimeout(timer);
95
+ reject(error);
96
+ });
97
+ });
98
+ };
99
+
100
+ export class PluginRunner {
101
+ private plugins: LoadedPlugin[];
102
+ private workspaceRoot: string;
103
+ private settings: PluginSettings;
104
+ private initialized = false;
105
+
106
+ constructor(options: PluginRunnerOptions) {
107
+ this.plugins = options.plugins;
108
+ this.workspaceRoot = options.workspaceRoot;
109
+ this.settings = options.settings ?? {};
110
+ }
111
+
112
+ /**
113
+ * Initialize all plugins (call onLoad)
114
+ */
115
+ async initialize(): Promise<void> {
116
+ if (this.initialized) {
117
+ return;
118
+ }
119
+
120
+ for (const { plugin, config } of this.plugins) {
121
+ if (plugin.onLoad) {
122
+ const context = this.createContext(plugin.name, config);
123
+ try {
124
+ await plugin.onLoad(context);
125
+ } catch (error) {
126
+ context.logger.error(`Failed to initialize: ${error}`);
127
+ if (this.settings.continueOnError === false) {
128
+ throw new PluginError(
129
+ "PLUGIN_INIT_FAILED",
130
+ `Plugin ${plugin.name} failed to initialize: ${error}`,
131
+ { pluginName: plugin.name, cause: error instanceof Error ? error : undefined },
132
+ );
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ this.initialized = true;
139
+ }
140
+
141
+ /**
142
+ * Emit a hook event to all plugins
143
+ * Returns the final modified task (if any plugin modified it)
144
+ */
145
+ async emit<K extends keyof HookEvents>(hookName: K, event: HookEvents[K]): Promise<HookResult> {
146
+ const timeout = this.settings.hookTimeout ?? 30000;
147
+ let currentEvent = { ...event };
148
+ let finalResult: HookResult = {};
149
+
150
+ // Run plugins sequentially in config order
151
+ for (const { plugin, config } of this.plugins) {
152
+ const handler = plugin.hooks?.[hookName];
153
+ if (!handler) {
154
+ continue;
155
+ }
156
+
157
+ const context = this.createContext(plugin.name, config);
158
+
159
+ try {
160
+ const result = await runWithTimeout(
161
+ () => handler(currentEvent, context),
162
+ timeout,
163
+ `${plugin.name}:${hookName}`,
164
+ );
165
+
166
+ // If plugin returned a modified task, use it for subsequent hooks
167
+ if (result?.task) {
168
+ currentEvent = { ...currentEvent, task: result.task };
169
+ finalResult = { task: result.task };
170
+ }
171
+ } catch (error) {
172
+ const errorMessage =
173
+ error instanceof Error ? error.message : String(error);
174
+ context.logger.error(`Hook ${hookName} failed: ${errorMessage}`);
175
+
176
+ if (this.settings.continueOnError === false) {
177
+ throw new PluginError(
178
+ "HOOK_EXECUTION_FAILED",
179
+ `Plugin ${plugin.name} hook ${hookName} failed: ${errorMessage}`,
180
+ {
181
+ pluginName: plugin.name,
182
+ hookName,
183
+ cause: error instanceof Error ? error : undefined,
184
+ },
185
+ );
186
+ }
187
+ }
188
+ }
189
+
190
+ return finalResult;
191
+ }
192
+
193
+ /**
194
+ * Shutdown all plugins (call onUnload)
195
+ */
196
+ async shutdown(): Promise<void> {
197
+ for (const { plugin, config } of this.plugins) {
198
+ if (plugin.onUnload) {
199
+ const context = this.createContext(plugin.name, config);
200
+ try {
201
+ await plugin.onUnload(context);
202
+ } catch (error) {
203
+ context.logger.error(`Failed to shutdown: ${error}`);
204
+ // Always continue during shutdown, even if continueOnError is false
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ private createContext(pluginName: string, config: Record<string, unknown>): PluginContext {
211
+ return {
212
+ workspaceRoot: this.workspaceRoot,
213
+ config: expandEnvVars(config) as Record<string, unknown>,
214
+ logger: createPluginLogger(pluginName, this.settings.logLevel),
215
+ };
216
+ }
217
+ }