flowcat 1.3.0 → 1.4.4

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.
@@ -0,0 +1,198 @@
1
+ import { withWorkspaceLock } from "./lock";
2
+ import type { DayAssignment } from "./schemas/dayAssignment";
3
+ import type { Task } from "./schemas/task";
4
+ import { appendDayAssignment, getLatestDayAssignment, isAssignedOnDate } from "./taskOperations";
5
+ import type { StoredTask } from "./taskRepo";
6
+ import { listAllTasks, saveTask } from "./taskRepo";
7
+ import { resolveTask } from "./taskResolver";
8
+
9
+ export type OrderedAssignment = {
10
+ stored: StoredTask;
11
+ lastEvent: DayAssignment;
12
+ lastIndex: number;
13
+ };
14
+
15
+ export type AssignToDateResult =
16
+ | { status: "assigned"; task: Task; order: number }
17
+ | { status: "already_assigned"; task: Task };
18
+
19
+ export type MoveAssignmentResult =
20
+ | { status: "moved"; task: Task; position: number }
21
+ | { status: "no_change"; task: Task; position: number }
22
+ | { status: "not_assigned"; task: Task };
23
+
24
+ export type UnassignResult =
25
+ | { status: "unassigned"; task: Task }
26
+ | { status: "already_unassigned"; task: Task };
27
+
28
+ type MovePosition = "up" | "down" | "top" | "bottom";
29
+ type MovePositionInput = MovePosition | number | string;
30
+
31
+ export class DayAssignmentError extends Error {
32
+ code: "INVALID_POSITION" | "POSITION_OUT_OF_RANGE";
33
+
34
+ constructor(code: "INVALID_POSITION" | "POSITION_OUT_OF_RANGE", message: string) {
35
+ super(message);
36
+ this.code = code;
37
+ this.name = "DayAssignmentError";
38
+ }
39
+ }
40
+
41
+ const collectAssignments = (tasks: StoredTask[], date: string): OrderedAssignment[] => {
42
+ const entries: OrderedAssignment[] = [];
43
+ for (const stored of tasks) {
44
+ const latest = getLatestDayAssignment(stored.task, date);
45
+ if (!latest) {
46
+ continue;
47
+ }
48
+
49
+ if (latest.event.action === "assign" || latest.event.action === "reorder") {
50
+ entries.push({ stored, lastEvent: latest.event, lastIndex: latest.index });
51
+ }
52
+ }
53
+
54
+ return entries;
55
+ };
56
+
57
+ const sortAssignments = (entries: OrderedAssignment[]): OrderedAssignment[] => {
58
+ return [...entries].sort((a, b) => {
59
+ const orderA = a.lastEvent.order;
60
+ const orderB = b.lastEvent.order;
61
+ if (orderA != null && orderB != null) {
62
+ return orderA - orderB;
63
+ }
64
+ if (orderA != null) {
65
+ return -1;
66
+ }
67
+ if (orderB != null) {
68
+ return 1;
69
+ }
70
+ return a.lastIndex - b.lastIndex;
71
+ });
72
+ };
73
+
74
+ export const buildOrderedAssignments = (tasks: StoredTask[], date: string): OrderedAssignment[] => {
75
+ const entries = collectAssignments(tasks, date);
76
+ return sortAssignments(entries);
77
+ };
78
+
79
+ export const resolveMoveTarget = (
80
+ position: MovePositionInput,
81
+ currentIndex: number,
82
+ total: number,
83
+ ): number => {
84
+ if (position === "up") {
85
+ return Math.max(0, currentIndex - 1);
86
+ }
87
+ if (position === "down") {
88
+ return Math.min(total - 1, currentIndex + 1);
89
+ }
90
+ if (position === "top") {
91
+ return 0;
92
+ }
93
+ if (position === "bottom") {
94
+ return total - 1;
95
+ }
96
+
97
+ const parsed = typeof position === "number" ? position : Number(position);
98
+ if (!Number.isInteger(parsed)) {
99
+ throw new DayAssignmentError("INVALID_POSITION", `Invalid position: ${position}`);
100
+ }
101
+
102
+ if (parsed < 1 || parsed > total) {
103
+ throw new DayAssignmentError("POSITION_OUT_OF_RANGE", `Position out of range: ${position}`);
104
+ }
105
+
106
+ return parsed - 1;
107
+ };
108
+
109
+ export const assignTaskToDate = async (input: {
110
+ workspaceRoot: string;
111
+ identifier: string;
112
+ date: string;
113
+ message?: string;
114
+ }): Promise<AssignToDateResult> => {
115
+ return withWorkspaceLock(input.workspaceRoot, async () => {
116
+ const tasks = await listAllTasks(input.workspaceRoot);
117
+ const stored = await resolveTask(input.workspaceRoot, input.identifier);
118
+ if (isAssignedOnDate(stored.task, input.date)) {
119
+ return { status: "already_assigned", task: stored.task };
120
+ }
121
+
122
+ const ordered = buildOrderedAssignments(tasks, input.date);
123
+ const order = ordered.length + 1;
124
+ const updated = appendDayAssignment(stored.task, input.date, "assign", input.message, order);
125
+ await saveTask(input.workspaceRoot, stored.status, updated);
126
+ return { status: "assigned", task: updated, order };
127
+ });
128
+ };
129
+
130
+ export const moveAssignmentForDate = async (input: {
131
+ workspaceRoot: string;
132
+ identifier: string;
133
+ date: string;
134
+ position: MovePositionInput;
135
+ }): Promise<MoveAssignmentResult> => {
136
+ return withWorkspaceLock(input.workspaceRoot, async () => {
137
+ const stored = await resolveTask(input.workspaceRoot, input.identifier);
138
+ const tasks = await listAllTasks(input.workspaceRoot);
139
+ const ordered = buildOrderedAssignments(tasks, input.date);
140
+ const currentIndex = ordered.findIndex((entry) => entry.stored.task.id === stored.task.id);
141
+
142
+ if (currentIndex === -1) {
143
+ return { status: "not_assigned", task: stored.task };
144
+ }
145
+
146
+ const targetIndex = resolveMoveTarget(input.position, currentIndex, ordered.length);
147
+ if (targetIndex === currentIndex) {
148
+ return { status: "no_change", task: stored.task, position: currentIndex + 1 };
149
+ }
150
+
151
+ const reordered = [...ordered];
152
+ const [moved] = reordered.splice(currentIndex, 1);
153
+ if (!moved) {
154
+ return { status: "not_assigned", task: stored.task };
155
+ }
156
+
157
+ reordered.splice(targetIndex, 0, moved);
158
+
159
+ let updatedTask = stored.task;
160
+ for (const [index, entry] of reordered.entries()) {
161
+ const nextOrder = index + 1;
162
+ if (entry.lastEvent.order === nextOrder) {
163
+ continue;
164
+ }
165
+
166
+ const updated = appendDayAssignment(
167
+ entry.stored.task,
168
+ input.date,
169
+ "reorder",
170
+ undefined,
171
+ nextOrder,
172
+ );
173
+ await saveTask(input.workspaceRoot, entry.stored.status, updated);
174
+ if (entry.stored.task.id === stored.task.id) {
175
+ updatedTask = updated;
176
+ }
177
+ }
178
+
179
+ return { status: "moved", task: updatedTask, position: targetIndex + 1 };
180
+ });
181
+ };
182
+
183
+ export const unassignTaskFromDate = async (input: {
184
+ workspaceRoot: string;
185
+ identifier: string;
186
+ date: string;
187
+ }): Promise<UnassignResult> => {
188
+ return withWorkspaceLock(input.workspaceRoot, async () => {
189
+ const stored = await resolveTask(input.workspaceRoot, input.identifier);
190
+ if (!isAssignedOnDate(stored.task, input.date)) {
191
+ return { status: "already_unassigned", task: stored.task };
192
+ }
193
+
194
+ const updated = appendDayAssignment(stored.task, input.date, "unassign");
195
+ await saveTask(input.workspaceRoot, stored.status, updated);
196
+ return { status: "unassigned", task: updated };
197
+ });
198
+ };
package/core/edit.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { buildPrAttachment, parseGitHubPrUrl } from "./pr";
2
+ import type { Task } from "./schemas/task";
3
+ import { validateTaskOrThrow } from "./schemas/task";
4
+ import { nowIso } from "./time";
5
+
6
+ const parseValue = (raw: string): unknown => {
7
+ try {
8
+ return JSON.parse(raw);
9
+ } catch {
10
+ return raw;
11
+ }
12
+ };
13
+
14
+ export const applyTaskEdit = (task: Task, dottedPath: string, rawValue: string): Task => {
15
+ if (dottedPath === "status" || dottedPath.startsWith("status.")) {
16
+ throw new Error("Use status commands to change status");
17
+ }
18
+
19
+ const value = parseValue(rawValue);
20
+ const updated = structuredClone(task);
21
+ const segments = dottedPath.split(".");
22
+
23
+ let current: Record<string, unknown> = updated as unknown as Record<string, unknown>;
24
+ for (const segment of segments.slice(0, -1)) {
25
+ if (!(segment in current)) {
26
+ current[segment] = {};
27
+ }
28
+
29
+ const next = current[segment];
30
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
31
+ throw new Error(`Cannot set ${dottedPath} on non-object path`);
32
+ }
33
+
34
+ current = next as Record<string, unknown>;
35
+ }
36
+
37
+ const last = segments[segments.length - 1];
38
+ current[last] = value;
39
+
40
+ if (dottedPath === "metadata.pr.url") {
41
+ if (typeof value !== "string") {
42
+ throw new Error("metadata.pr.url must be a string");
43
+ }
44
+
45
+ const parsed = parseGitHubPrUrl(value);
46
+ if (!parsed) {
47
+ throw new Error("Invalid PR URL");
48
+ }
49
+
50
+ updated.metadata.pr = buildPrAttachment(parsed, updated.metadata.pr?.fetched);
51
+ }
52
+
53
+ const nextTask: Task = {
54
+ ...updated,
55
+ updated_at: nowIso(),
56
+ };
57
+
58
+ return validateTaskOrThrow(nextTask);
59
+ };
package/core/format.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { AliasMap } from "./aliasRepo";
2
+ import type { Task } from "./schemas/task";
3
+
4
+ export const buildAliasLookup = (aliases: AliasMap): Map<string, string> => {
5
+ const map = new Map<string, string>();
6
+ for (const [key, value] of Object.entries(aliases.T)) {
7
+ map.set(value, `T${key}`);
8
+ }
9
+ for (const [key, value] of Object.entries(aliases.R)) {
10
+ map.set(value, `R${key}`);
11
+ }
12
+ return map;
13
+ };
14
+
15
+ export const formatTaskListLine = (task: Task, alias?: string): string => {
16
+ const aliasText = alias ?? "--";
17
+ return `${aliasText.padEnd(4)} ${task.ref.padEnd(9)} ${task.status.padEnd(9)} ${task.title}`;
18
+ };
19
+
20
+ export const formatTaskDetails = (task: Task, alias?: string): string => {
21
+ const lines: string[] = [];
22
+ lines.push(`${alias ?? "--"} ${task.ref} ${task.title}`);
23
+ lines.push(`id: ${task.id}`);
24
+ lines.push(`type: ${task.type}`);
25
+ lines.push(`status: ${task.status}`);
26
+ lines.push(`created: ${task.created_at}`);
27
+ lines.push(`updated: ${task.updated_at}`);
28
+
29
+ if (task.metadata.url) {
30
+ lines.push(`url: ${task.metadata.url}`);
31
+ }
32
+
33
+ if (task.metadata.pr?.url) {
34
+ lines.push(`pr: ${task.metadata.pr.url}`);
35
+ if (task.metadata.pr.fetched) {
36
+ const fetched = task.metadata.pr.fetched;
37
+ lines.push(`pr_state: ${fetched.state}`);
38
+ lines.push(`pr_title: ${fetched.title}`);
39
+ lines.push(`pr_author: ${fetched.author.login}`);
40
+ lines.push(`pr_updated_at: ${fetched.updated_at}`);
41
+ lines.push(`pr_refreshed_at: ${fetched.at}`);
42
+ }
43
+ }
44
+
45
+ if (task.logs.length > 0) {
46
+ lines.push("logs:");
47
+ for (const log of task.logs) {
48
+ const status = log.status ? ` [${log.status}]` : "";
49
+ lines.push(`- ${log.ts}${status} ${log.msg}`);
50
+ }
51
+ }
52
+
53
+ if (task.day_assignments.length > 0) {
54
+ lines.push("day_assignments:");
55
+ for (const entry of task.day_assignments) {
56
+ const order = entry.order ? ` order:${entry.order}` : "";
57
+ const msg = entry.msg ? ` (${entry.msg})` : "";
58
+ lines.push(`- ${entry.date} ${entry.action}${order} ${entry.ts}${msg}`);
59
+ }
60
+ }
61
+
62
+ return lines.join("\n");
63
+ };
package/core/fsm.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { TaskAction, TaskStatus } from "./types";
2
+
3
+ export const transitionTable: Record<TaskStatus, Partial<Record<TaskAction, TaskStatus>>> = {
4
+ backlog: {
5
+ start: "active",
6
+ cancel: "cancelled",
7
+ },
8
+ active: {
9
+ pause: "paused",
10
+ complete: "completed",
11
+ cancel: "cancelled",
12
+ },
13
+ paused: {
14
+ start: "active",
15
+ complete: "completed",
16
+ cancel: "cancelled",
17
+ },
18
+ completed: {},
19
+ cancelled: {},
20
+ };
21
+
22
+ export const getNextStatus = (current: TaskStatus, action: TaskAction): TaskStatus => {
23
+ const next = transitionTable[current][action];
24
+ if (!next) {
25
+ throw new Error(`Invalid transition: ${current} -> ${action}`);
26
+ }
27
+ return next;
28
+ };
package/core/git.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export type GitCommitOptions = {
8
+ message: string;
9
+ files?: string[];
10
+ author?: {
11
+ name: string;
12
+ email: string;
13
+ };
14
+ };
15
+
16
+ export const isGitRepository = async (dir: string): Promise<boolean> => {
17
+ try {
18
+ await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"], {
19
+ encoding: "utf8",
20
+ });
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+
27
+ export const getGitStatus = async (
28
+ dir: string,
29
+ ): Promise<{
30
+ modified: string[];
31
+ added: string[];
32
+ deleted: string[];
33
+ untracked: string[];
34
+ }> => {
35
+ try {
36
+ const { stdout } = await execFileAsync("git", ["-C", dir, "status", "--porcelain"], {
37
+ encoding: "utf8",
38
+ });
39
+
40
+ const modified: string[] = [];
41
+ const added: string[] = [];
42
+ const deleted: string[] = [];
43
+ const untracked: string[] = [];
44
+
45
+ for (const line of stdout.trim().split("\n")) {
46
+ if (!line) continue;
47
+
48
+ const status = line.substring(0, 2);
49
+ const filePath = line.substring(3);
50
+
51
+ if (status === "??") {
52
+ untracked.push(filePath);
53
+ } else if (status[0] === "A" || status[1] === "A") {
54
+ added.push(filePath);
55
+ } else if (status[0] === "D" || status[1] === "D") {
56
+ deleted.push(filePath);
57
+ } else if (status[0] === "M" || status[1] === "M") {
58
+ modified.push(filePath);
59
+ }
60
+ }
61
+
62
+ return { modified, added, deleted, untracked };
63
+ } catch {
64
+ return { modified: [], added: [], deleted: [], untracked: [] };
65
+ }
66
+ };
67
+
68
+ export const stageFiles = async (dir: string, files: string[]): Promise<void> => {
69
+ if (files.length === 0) return;
70
+
71
+ await execFileAsync("git", ["-C", dir, "add", ...files]);
72
+ };
73
+
74
+ export const commitChanges = async (dir: string, options: GitCommitOptions): Promise<void> => {
75
+ const args = ["-C", dir, "commit"];
76
+
77
+ if (options.author) {
78
+ args.push("--author", `${options.author.name} <${options.author.email}>`);
79
+ }
80
+
81
+ args.push("--message", options.message);
82
+
83
+ await execFileAsync("git", args);
84
+ };
85
+
86
+ export const hasChangesToCommit = async (dir: string): Promise<boolean> => {
87
+ const status = await getGitStatus(dir);
88
+ return (
89
+ status.modified.length > 0 ||
90
+ status.added.length > 0 ||
91
+ status.deleted.length > 0 ||
92
+ status.untracked.length > 0
93
+ );
94
+ };
95
+
96
+ export const autoCommit = async (dir: string, options: GitCommitOptions): Promise<boolean> => {
97
+ if (!(await isGitRepository(dir))) {
98
+ return false;
99
+ }
100
+
101
+ if (!(await hasChangesToCommit(dir))) {
102
+ return false;
103
+ }
104
+
105
+ try {
106
+ if (options.files) {
107
+ await stageFiles(dir, options.files);
108
+ } else {
109
+ await stageFiles(dir, ["."]);
110
+ }
111
+
112
+ await commitChanges(dir, options);
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ };
@@ -0,0 +1,30 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { APP_DIR } from "./constants";
5
+
6
+ const ignoredEntries = [`${APP_DIR}/.lock/`, `${APP_DIR}/config.json`];
7
+
8
+ export const ensureWorkspaceIgnored = async (repoRoot: string): Promise<boolean> => {
9
+ const gitignorePath = path.join(repoRoot, ".gitignore");
10
+ let current = "";
11
+
12
+ try {
13
+ current = await readFile(gitignorePath, "utf8");
14
+ } catch {
15
+ current = "";
16
+ }
17
+
18
+ const lines = current.split("\n");
19
+ const missingEntries = ignoredEntries.filter(
20
+ (entry) => !lines.some((line) => line.trim() === entry),
21
+ );
22
+ if (missingEntries.length === 0) {
23
+ return false;
24
+ }
25
+
26
+ const separator = current.endsWith("\n") || current.length === 0 ? "" : "\n";
27
+ const next = `${current}${separator}${missingEntries.join("\n")}\n`;
28
+ await writeFile(gitignorePath, next, "utf8");
29
+ return true;
30
+ };
package/core/id.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { ulid } from "ulid";
4
+
5
+ import type { TaskType } from "./types";
6
+
7
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
8
+
9
+ const base32Encode = (input: Uint8Array): string => {
10
+ let bits = 0;
11
+ let value = 0;
12
+ let output = "";
13
+
14
+ for (const byte of input) {
15
+ value = (value << 8) | byte;
16
+ bits += 8;
17
+
18
+ while (bits >= 5) {
19
+ output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
20
+ bits -= 5;
21
+ }
22
+ }
23
+
24
+ if (bits > 0) {
25
+ output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
26
+ }
27
+
28
+ return output;
29
+ };
30
+
31
+ export const generateTaskId = (taskType: TaskType): string => {
32
+ const prefix = taskType === "review" ? "R" : "T";
33
+ return `${prefix}${ulid()}`;
34
+ };
35
+
36
+ export const generateStableRef = (taskType: TaskType, taskId: string): string => {
37
+ const prefix = taskType === "review" ? "R" : "T";
38
+ const hash = createHash("sha256").update(taskId).digest();
39
+ const encoded = base32Encode(hash).slice(0, 6);
40
+ return `${prefix}-${encoded}`;
41
+ };
package/core/json.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import writeFileAtomic from "write-file-atomic";
4
+
5
+ export const readJsonFile = async <T>(filePath: string): Promise<T> => {
6
+ const raw = await readFile(filePath, "utf8");
7
+ return JSON.parse(raw) as T;
8
+ };
9
+
10
+ export const writeJsonAtomic = async (filePath: string, data: unknown): Promise<void> => {
11
+ const payload = `${JSON.stringify(data, null, 2)}\n`;
12
+ await writeFileAtomic(filePath, payload, { encoding: "utf8", fsync: true });
13
+ };
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
+ };