alchemylab 1.0.3 → 1.0.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,278 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode Event Hooks Plugin — Thin Event Emitter
4
+ *
5
+ * Detects events from OpenCode (tool execution, session lifecycle) and POSTs
6
+ * them to the action server's /api/hook-event endpoint. All hook logic
7
+ * (fetching, condition evaluation, template interpolation, execution) lives
8
+ * in the action server.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ const path_1 = require("path");
12
+ const fs_1 = require("fs");
13
+ const ACTION_SERVER_PORT = 3456;
14
+ const ACTION_SERVER_URL = `http://localhost:${ACTION_SERVER_PORT}/api/hook-event`;
15
+ // Singleton guard — OpenCode calls plugin init twice
16
+ let initializedDirectory = null;
17
+ let cachedHandlers = null;
18
+ /**
19
+ * Extract request ID from tool output
20
+ */
21
+ function extractRequestId(text) {
22
+ const patterns = [
23
+ /Request completed:\s*(req\d+)/i,
24
+ /Request (req\d+) completed/i,
25
+ /completed.*?(req\d{6})/i,
26
+ /Request finalized:\s*(req\d+)/i,
27
+ /Request (req\d+) finalized/i,
28
+ /finalized.*?(req\d{6})/i,
29
+ ];
30
+ for (const pattern of patterns) {
31
+ const match = text.match(pattern);
32
+ if (match)
33
+ return match[1];
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Extract task ID from tool output
39
+ */
40
+ function extractTaskId(text) {
41
+ const match = text.match(/Task\s+(tas\d+).*(?:completed|marked as completed)/i);
42
+ return match ? match[1] : null;
43
+ }
44
+ /**
45
+ * Fire-and-forget POST to action server. Does not throw.
46
+ */
47
+ function postHookEvent(eventType, sessionId, variables, timing) {
48
+ fetch(ACTION_SERVER_URL, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ eventType, sessionId, variables, timing }),
52
+ }).catch(() => { }); // Fire-and-forget
53
+ }
54
+ /**
55
+ * Await POST to action server. Returns silently on failure.
56
+ */
57
+ async function postHookEventAsync(eventType, sessionId, variables, timing) {
58
+ try {
59
+ await fetch(ACTION_SERVER_URL, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ eventType, sessionId, variables, timing }),
63
+ });
64
+ }
65
+ catch {
66
+ // Non-fatal
67
+ }
68
+ }
69
+ /**
70
+ * Detect tool type and determine event type + variables from tool execution context.
71
+ */
72
+ function extractToolEventInfo(toolName, input, resultText, sessionId) {
73
+ const events = [];
74
+ const baseVars = {
75
+ output: resultText || '',
76
+ toolName,
77
+ sessionId: sessionId || '',
78
+ };
79
+ const isRequestOps = toolName.includes('request_operations') || toolName.includes('complete_request');
80
+ const isTaskOps = toolName.includes('task_operations') || toolName.includes('complete_task');
81
+ const isWriteFile = toolName.includes('write_file');
82
+ const isReplaceInFiles = toolName.includes('replace_in_files');
83
+ const isQuickEdit = toolName.includes('quick_write') || toolName.includes('quick_replace') || toolName.includes('quick_manage');
84
+ const isReadFiles = toolName.includes('read_files');
85
+ const isSearch = toolName.includes('search');
86
+ const isManageFiles = toolName.includes('manage_files');
87
+ const isDocumentOps = toolName.includes('document_operations');
88
+ const isQueryHistory = toolName.includes('query_history');
89
+ if (isRequestOps && resultText) {
90
+ const requestId = extractRequestId(resultText);
91
+ if (requestId) {
92
+ events.push({
93
+ eventType: 'request_completed',
94
+ variables: { ...baseVars, requestId },
95
+ });
96
+ }
97
+ }
98
+ else if (isTaskOps && resultText) {
99
+ const taskId = extractTaskId(resultText);
100
+ if (taskId) {
101
+ events.push({
102
+ eventType: 'task_completed',
103
+ variables: { ...baseVars, taskId },
104
+ });
105
+ }
106
+ }
107
+ else if (isWriteFile) {
108
+ const pathMatch = resultText?.match(/(?:Created|Updated|Wrote)\s+(?:file\s+)?([^\s\n]+)/i);
109
+ events.push({
110
+ eventType: 'write_file',
111
+ variables: { ...baseVars, path: pathMatch?.[1] || '' },
112
+ });
113
+ }
114
+ else if (isReplaceInFiles) {
115
+ const operations = input?.input?.operations || [];
116
+ const paths = operations.map((op) => op.path || op.documentId).filter(Boolean);
117
+ events.push({
118
+ eventType: 'replace_in_files',
119
+ variables: { ...baseVars, paths: paths.join(', '), fileCount: String(paths.length) },
120
+ });
121
+ }
122
+ else if (isQuickEdit) {
123
+ const vars = {
124
+ ...baseVars,
125
+ path: input?.input?.operationArgs?.path || '',
126
+ title: input?.input?.title || '',
127
+ };
128
+ const qeRequestMatch = resultText?.match(/\*\*Request:\*\*\s*(req\d+)/i);
129
+ if (qeRequestMatch)
130
+ vars.requestId = qeRequestMatch[1];
131
+ events.push({ eventType: 'quick_edit', variables: vars });
132
+ // Quick-edit auto-completes requests — also emit request_completed
133
+ if (vars.requestId) {
134
+ events.push({
135
+ eventType: 'request_completed',
136
+ variables: { ...baseVars, requestId: vars.requestId },
137
+ });
138
+ }
139
+ }
140
+ else if (isReadFiles) {
141
+ const pathsInput = input?.input?.paths || [];
142
+ const paths = pathsInput.map((p) => p.path || p.documentId).filter(Boolean);
143
+ events.push({
144
+ eventType: 'read_files',
145
+ variables: { ...baseVars, paths: paths.join(', '), fileCount: String(paths.length) },
146
+ });
147
+ }
148
+ else if (isSearch) {
149
+ events.push({
150
+ eventType: 'search',
151
+ variables: {
152
+ ...baseVars,
153
+ query: input?.input?.needle || '',
154
+ directory: input?.input?.directory || '',
155
+ fileExtension: input?.input?.options?.fileExtension || '*',
156
+ },
157
+ });
158
+ }
159
+ else if (isManageFiles) {
160
+ const operations = input?.input?.operations || [];
161
+ const opTypes = operations.map((op) => op.operationType).filter(Boolean);
162
+ events.push({
163
+ eventType: 'manage_files',
164
+ variables: { ...baseVars, operationTypes: [...new Set(opTypes)].join(', ') },
165
+ });
166
+ }
167
+ else if (isDocumentOps) {
168
+ events.push({ eventType: 'document_operations', variables: baseVars });
169
+ }
170
+ else if (isQueryHistory) {
171
+ events.push({ eventType: 'query_history', variables: baseVars });
172
+ }
173
+ // Always emit tool_executed for any tool
174
+ events.push({
175
+ eventType: 'tool_executed',
176
+ variables: {
177
+ tool: toolName,
178
+ toolName,
179
+ output: resultText || '',
180
+ sessionId: sessionId || '',
181
+ },
182
+ });
183
+ return events;
184
+ }
185
+ /**
186
+ * Fetch recent output from a session (for lastOutput variable in session_idle).
187
+ */
188
+ async function fetchRecentSessionOutput(sessionId, directory) {
189
+ try {
190
+ const configPath = (0, path_1.join)(directory, '.alchemy_lab', 'opencode.json');
191
+ if (!(0, fs_1.existsSync)(configPath))
192
+ return { output: '' };
193
+ const config = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
194
+ const serverUrl = config.port ? `http://127.0.0.1:${config.port}` : null;
195
+ if (!serverUrl)
196
+ return { output: '' };
197
+ const url = `${serverUrl}/session/${sessionId}/message?limit=5`;
198
+ const response = await fetch(url);
199
+ if (!response.ok)
200
+ return { output: '' };
201
+ const messages = await response.json();
202
+ const assistantMessages = messages.filter(m => m.info.role === 'assistant');
203
+ const lastAssistantMessage = assistantMessages[0];
204
+ const assistantOutput = assistantMessages
205
+ .flatMap(m => m.parts)
206
+ .filter(p => p.type === 'text' && p.text)
207
+ .map(p => p.text)
208
+ .join('\n');
209
+ return { output: assistantOutput, lastMessageId: lastAssistantMessage?.info?.id };
210
+ }
211
+ catch {
212
+ return { output: '' };
213
+ }
214
+ }
215
+ exports.default = async ({ directory }) => {
216
+ const dir = directory || process.cwd();
217
+ // Singleton guard
218
+ if (initializedDirectory === dir && cachedHandlers) {
219
+ return cachedHandlers;
220
+ }
221
+ initializedDirectory = dir;
222
+ const handlers = {
223
+ "tool.execute.before": async (input) => {
224
+ const toolName = input?.tool || input?.name;
225
+ if (!toolName)
226
+ return;
227
+ const sessionId = input?.sessionID || '';
228
+ const inputText = JSON.stringify(input?.input || {}).substring(0, 1000);
229
+ postHookEvent('tool_executed', sessionId, {
230
+ tool: toolName,
231
+ toolName,
232
+ input: inputText,
233
+ }, 'before');
234
+ },
235
+ "tool.execute.after": async (input, output) => {
236
+ const toolName = input?.tool || input?.name;
237
+ if (!toolName)
238
+ return;
239
+ const sessionId = input?.sessionID || '';
240
+ const resultText = output?.content?.[0]?.text;
241
+ if (!resultText)
242
+ return;
243
+ const events = extractToolEventInfo(toolName, input, resultText, sessionId);
244
+ for (const { eventType, variables } of events) {
245
+ await postHookEventAsync(eventType, sessionId, variables, 'after');
246
+ }
247
+ },
248
+ event: async ({ event }) => {
249
+ switch (event.type) {
250
+ case 'session.idle': {
251
+ const sessionId = event.properties.sessionID || '';
252
+ const { output: lastOutput, lastMessageId } = await fetchRecentSessionOutput(sessionId, dir);
253
+ // Fire-and-forget — process is dying
254
+ postHookEvent('session_idle', sessionId, {
255
+ sessionId,
256
+ lastOutput,
257
+ lastMessageId: lastMessageId || '',
258
+ });
259
+ return;
260
+ }
261
+ case 'session.created': {
262
+ const sessionInfo = event.properties.info;
263
+ const sessionId = sessionInfo?.id || '';
264
+ if (!sessionId)
265
+ return;
266
+ // Delay to allow workflow assignment
267
+ setTimeout(() => {
268
+ postHookEvent('session_created', sessionId, { sessionId });
269
+ }, 500);
270
+ return;
271
+ }
272
+ }
273
+ },
274
+ };
275
+ cachedHandlers = handlers;
276
+ return handlers;
277
+ };
278
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode Inject Session Plugin
4
+ *
5
+ * Injects the current session ID into spawn-background-work tool calls
6
+ * so that spawned background sessions can be linked as children of the parent.
7
+ *
8
+ * Hooks into tool.execute.before to modify args before the MCP tool executes.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ const fs_1 = require("fs");
12
+ const path_1 = require("path");
13
+ // Log immediately when module is loaded
14
+ try {
15
+ const logFile = (0, path_1.join)(process.env.HOME || '/tmp', 'inject-session-plugin.log');
16
+ (0, fs_1.appendFileSync)(logFile, `[${new Date().toISOString()}] MODULE LOADED - file was imported\n`);
17
+ }
18
+ catch (e) {
19
+ // ignore
20
+ }
21
+ const debugLog = (msg, data) => {
22
+ const logFile = (0, path_1.join)(process.env.HOME || '/tmp', 'inject-session-plugin.log');
23
+ const line = `[${new Date().toISOString()}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}\n`;
24
+ try {
25
+ (0, fs_1.appendFileSync)(logFile, line);
26
+ }
27
+ catch (e) {
28
+ // ignore
29
+ }
30
+ console.log('[inject-session-plugin]', msg, data || '');
31
+ };
32
+ exports.default = async ({ project, directory }) => {
33
+ debugLog('Plugin loaded', { project: project?.id, directory });
34
+ return {
35
+ "tool.execute.before": async (context, data) => {
36
+ debugLog('tool.execute.before called', {
37
+ tool: context.tool,
38
+ sessionID: context.sessionID,
39
+ hasArgs: !!data?.args
40
+ });
41
+ try {
42
+ // Check if this is a spawn-background-work tool
43
+ // MCP tool names can use dashes or underscores depending on how they're registered
44
+ const toolName = context.tool;
45
+ if (!toolName || (!toolName.includes('spawn_background_work') && !toolName.includes('spawn-background-work'))) {
46
+ return; // Not logging every non-matching tool to avoid spam
47
+ }
48
+ debugLog('Matched spawn-background-work tool!');
49
+ // Inject parentSessionId into args if not already set
50
+ if (!data.args.parentSessionId && context.sessionID) {
51
+ data.args.parentSessionId = context.sessionID;
52
+ debugLog('Injected parentSessionId', context.sessionID);
53
+ }
54
+ else {
55
+ debugLog('parentSessionId already set or no sessionID', {
56
+ existing: data.args.parentSessionId,
57
+ sessionID: context.sessionID
58
+ });
59
+ }
60
+ }
61
+ catch (error) {
62
+ debugLog('Error', { error: String(error) });
63
+ }
64
+ }
65
+ };
66
+ };
67
+ //# sourceMappingURL=inject-session.js.map
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode Notify Parent Plugin
4
+ *
5
+ * Sends a toast notification to the parent session when a request is completed.
6
+ * Only active when OPENCODE_PARENT_SERVER env var is set (indicating this is
7
+ * a background subagent spawned by another session).
8
+ *
9
+ * Hooks into tool.execute.after to detect request-operations complete calls.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ // Deduplication: track recent tool calls to prevent double-triggering
13
+ // OpenCode sometimes fires tool.execute.after twice for the same call
14
+ const recentToolCalls = new Map();
15
+ const DEDUP_WINDOW = 2000; // 2 second window
16
+ function createToolCallHash(toolName, outputText) {
17
+ return `${toolName}:${outputText?.substring(0, 100) || ''}`;
18
+ }
19
+ function isDuplicateToolCall(hash, now) {
20
+ const lastTime = recentToolCalls.get(hash);
21
+ if (lastTime && (now - lastTime) < DEDUP_WINDOW) {
22
+ return true;
23
+ }
24
+ // Clean up old entries
25
+ for (const [key, time] of recentToolCalls.entries()) {
26
+ if (now - time > DEDUP_WINDOW * 2) {
27
+ recentToolCalls.delete(key);
28
+ }
29
+ }
30
+ recentToolCalls.set(hash, now);
31
+ return false;
32
+ }
33
+ exports.default = async ({ project, directory }) => {
34
+ // Only activate if we have a parent server to notify
35
+ const parentServerUrl = process.env.OPENCODE_PARENT_SERVER;
36
+ if (!parentServerUrl) {
37
+ return {}; // No hooks if no parent to notify
38
+ }
39
+ return {
40
+ "tool.execute.after": async (input, output) => {
41
+ try {
42
+ // Check if this is request-operations (with underscores)
43
+ const toolName = input?.tool || input?.name;
44
+ if (!toolName || !toolName.includes('request_operations')) {
45
+ return; // Not the tool we're looking for
46
+ }
47
+ // Deduplication check - OpenCode fires hooks twice
48
+ const outputText = output?.content?.[0]?.text || '';
49
+ const callHash = createToolCallHash(toolName, outputText);
50
+ if (isDuplicateToolCall(callHash, Date.now())) {
51
+ return; // Skip duplicate
52
+ }
53
+ // Check the output content for the operation result
54
+ if (output?.content && Array.isArray(output.content)) {
55
+ if (output.content[0]?.text) {
56
+ const resultText = output.content[0].text;
57
+ // Check if this was a complete operation
58
+ const completionMatch = resultText.match(/(?:Request|request)\s+(req\d+)\s+(?:completed|complete)/i);
59
+ if (completionMatch) {
60
+ const requestId = completionMatch[1];
61
+ // Send toast notification to parent
62
+ await sendToastToParent(parentServerUrl, {
63
+ title: "Background Work Complete",
64
+ message: `Request ${requestId} has been completed`,
65
+ variant: "success",
66
+ duration: 8000
67
+ });
68
+ }
69
+ }
70
+ }
71
+ }
72
+ catch (error) {
73
+ // Silently ignore errors to avoid breaking the main session
74
+ // Could log to file if debugging is needed
75
+ }
76
+ }
77
+ };
78
+ };
79
+ /**
80
+ * Send a toast notification to the parent OpenCode session
81
+ */
82
+ async function sendToastToParent(parentServerUrl, toast) {
83
+ const toastUrl = `${parentServerUrl.replace(/\/$/, '')}/tui/toast`;
84
+ const response = await fetch(toastUrl, {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json'
88
+ },
89
+ body: JSON.stringify(toast)
90
+ });
91
+ if (!response.ok) {
92
+ throw new Error(`Failed to send toast: ${response.status} ${response.statusText}`);
93
+ }
94
+ }
95
+ //# sourceMappingURL=notify-parent.js.map
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode System Logger Plugin
4
+ *
5
+ * Hooks into experimental.chat.system.transform to log the full system prompt
6
+ * to .alchemy_lab/system-prompt.log. Used to diagnose whether entity context
7
+ * (from @ mentions) reaches the LLM via the user.system field.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ const fs_1 = require("fs");
11
+ const path_1 = require("path");
12
+ const getLogFile = (directory) => (0, path_1.join)(directory, '.alchemy_lab', 'system-prompt.log');
13
+ const debugLog = (logFile, msg, data) => {
14
+ const line = `[${new Date().toISOString()}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}\n`;
15
+ try {
16
+ (0, fs_1.mkdirSync)((0, path_1.join)(logFile, '..'), { recursive: true });
17
+ (0, fs_1.appendFileSync)(logFile, line);
18
+ }
19
+ catch (e) {
20
+ // ignore
21
+ }
22
+ };
23
+ /**
24
+ * Strip Task tool references from system prompt for alchemist sessions.
25
+ * The OpenCode base prompt heavily promotes using the Task tool, which causes
26
+ * the alchemist to hallucinate the tool even when it's disabled in tool config.
27
+ */
28
+ function stripTaskReferences(system) {
29
+ for (let i = 0; i < system.length; i++) {
30
+ let s = system[i];
31
+ // Remove lines that promote the Task tool
32
+ s = s.replace(/^- When doing file search, prefer to use the Task tool.*$/gm, '');
33
+ s = s.replace(/^- You should proactively use the Task tool.*$/gm, '');
34
+ s = s.replace(/^- VERY IMPORTANT:.*use the Task tool instead of running search commands directly\.$/gm, '');
35
+ s = s.replace(/^- If the user specifies that they want you to run tools "in parallel".*multiple Task tool calls\.$/gm, '');
36
+ // Remove Task tool examples
37
+ s = s.replace(/<example>\n.*?\[Uses the Task tool.*?<\/example>/gs, '');
38
+ // Clean up excessive blank lines left behind
39
+ s = s.replace(/\n{3,}/g, '\n\n');
40
+ system[i] = s;
41
+ }
42
+ }
43
+ exports.default = async ({ directory }) => {
44
+ const logFile = getLogFile(directory);
45
+ debugLog(logFile, 'system-logger plugin loaded', { directory });
46
+ return {
47
+ "experimental.chat.system.transform": async (hookInput, output) => {
48
+ // Strip Task tool references for alchemist sessions
49
+ const isAlchemist = output.system.some((s) => s.includes('head of engineering'));
50
+ debugLog(logFile, 'Alchemist detection', { isAlchemist, sessionID: hookInput.sessionID, systemParts: output.system.length, partLengths: output.system.map((s) => s.length), hasSpecialist: output.system.map((s) => s.includes('specialist_ops')) });
51
+ if (isAlchemist) {
52
+ stripTaskReferences(output.system);
53
+ debugLog(logFile, 'Stripped Task tool references for alchemist session', { sessionID: hookInput.sessionID });
54
+ }
55
+ const hasEntityContext = output.system.some((s) => s.includes('referenced the following') || s.includes('entity'));
56
+ const lines = [
57
+ '='.repeat(80),
58
+ `Timestamp: ${new Date().toISOString()}`,
59
+ `Session: ${hookInput.sessionID || 'N/A'}`,
60
+ `Model: ${hookInput.model?.providerID || '?'}/${hookInput.model?.modelID || '?'}`,
61
+ `System parts: ${output.system.length}`,
62
+ `Has entity context: ${hasEntityContext}`,
63
+ '-'.repeat(80),
64
+ ...output.system.map((s, i) => {
65
+ const preview = s.length > 500
66
+ ? s.slice(0, 250) + '\n...[truncated]...\n' + s.slice(-250)
67
+ : s;
68
+ return `--- Part ${i} (${s.length} chars) ---\n${preview}`;
69
+ }),
70
+ '='.repeat(80),
71
+ '',
72
+ ];
73
+ try {
74
+ (0, fs_1.mkdirSync)((0, path_1.join)(logFile, '..'), { recursive: true });
75
+ (0, fs_1.appendFileSync)(logFile, lines.join('\n') + '\n');
76
+ // Also write full untruncated prompt to separate file (overwrite each time)
77
+ const fullLogFile = (0, path_1.join)(logFile, '..', 'system-prompt-full.log');
78
+ const fullLines = [
79
+ `Timestamp: ${new Date().toISOString()}`,
80
+ `Session: ${hookInput.sessionID || 'N/A'}`,
81
+ `IsAlchemist: ${isAlchemist}`,
82
+ `System parts: ${output.system.length}`,
83
+ ...output.system.map((s, i) => `--- Part ${i} (${s.length} chars) ---\n${s}`),
84
+ '='.repeat(80),
85
+ ];
86
+ const { writeFileSync } = require('fs');
87
+ writeFileSync(fullLogFile, fullLines.join('\n') + '\n');
88
+ }
89
+ catch (e) {
90
+ // ignore
91
+ }
92
+ },
93
+ };
94
+ };
95
+ //# sourceMappingURL=system-logger.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemylab",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "private": false,
5
5
  "description": "AlchemyLab - agentic coding IDEm",
6
6
  "author": "Testfire Software",
@@ -22,13 +22,15 @@
22
22
  },
23
23
  "files": [
24
24
  "dist/**/*.js",
25
+ "dist/plugins/**/*.js",
25
26
  "package.json",
26
27
  "README.md"
27
28
  ],
28
29
  "scripts": {
29
30
  "build": "npm run build:dev",
30
31
  "build:dev": "tsc -p tsconfig.json && chmod +x dist-dev/*.js",
31
- "build:prod": "webpack --mode=production && chmod +x dist/*.js",
32
+ "build:prod": "webpack --mode=production && chmod +x dist/*.js && npm run copy:plugins",
33
+ "copy:plugins": "cp -r dist-dev/plugins dist/plugins",
32
34
  "build:analyze": "ANALYZE=true webpack --mode=production",
33
35
  "test": "npm run build:dev && mocha --require ts-node/register test/**/*.test.ts",
34
36
  "test:watch": "mocha --require ts-node/register test/**/*.test.ts --watch --watch-files src/**/*.ts,test/**/*.ts",