agent-state-machine 2.1.9 → 2.2.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.
package/README.md CHANGED
@@ -4,6 +4,7 @@ A workflow runner for building **linear, stateful agent workflows** in plain Jav
4
4
 
5
5
  You write normal `async/await` code. The runtime handles:
6
6
  - **Auto-persisted** `memory` (saved to disk on mutation)
7
+ - **Auto-tracked** `fileTree` (detects file changes made by agents via Git)
7
8
  - **Human-in-the-loop** blocking via `askHuman()` or agent-driven interactions
8
9
  - Local **JS agents** + **Markdown agents** (LLM-powered)
9
10
  - **Agent retries** with history logging for failures
@@ -26,7 +27,7 @@ pnpm add -g agent-state-machine
26
27
  ```
27
28
 
28
29
  ### Local Library
29
- Required so your `workflow.js` can `import { agent, memory } from 'agent-state-machine'`.
30
+ Required so your `workflow.js` can `import { agent, memory, fileTree } from 'agent-state-machine'`.
30
31
 
31
32
  ```bash
32
33
  # npm
@@ -163,12 +164,56 @@ Context is explicit: only `params` are provided to agents unless you pass additi
163
164
  A persisted object for your workflow.
164
165
 
165
166
  - Mutations auto-save to `workflows/<name>/state/current.json`.
166
- - Use it as your long-lived state between runs.
167
+ - Use it as your "long-lived state" between runs.
167
168
 
168
169
  ```js
169
170
  memory.count = (memory.count || 0) + 1;
170
171
  ```
171
172
 
173
+ ### `fileTree`
174
+
175
+ Auto-tracked file changes made by agents.
176
+
177
+ - Before each `await agent(...)`, the runtime captures a Git baseline
178
+ - After the agent completes, it detects created/modified/deleted files
179
+ - Changes are stored in `memory.fileTree` and persisted to `current.json`
180
+
181
+ ```js
182
+ // Files are auto-tracked when agents create them
183
+ await agent('code-writer', { task: 'Create auth module' });
184
+
185
+ // Access tracked files
186
+ console.log(memory.fileTree);
187
+ // { "src/auth.js": { status: "created", createdBy: "code-writer", ... } }
188
+
189
+ // Pass file context to other agents
190
+ await agent('code-reviewer', { fileTree: memory.fileTree });
191
+ ```
192
+
193
+ Configuration in `config.js`:
194
+
195
+ ```js
196
+ export const config = {
197
+ // ... models and apiKeys ...
198
+ projectRoot: process.env.PROJECT_ROOT, // defaults to ../.. from workflow
199
+ fileTracking: true, // enable/disable (default: true)
200
+ fileTrackingIgnore: ['node_modules/**', '.git/**', 'dist/**'],
201
+ fileTrackingKeepDeleted: false // keep deleted files in tree
202
+ };
203
+ ```
204
+
205
+ ### `trackFile(path, options?)` / `untrackFile(path)`
206
+
207
+ Manual file tracking utilities:
208
+
209
+ ```js
210
+ import { trackFile, getFileTree, untrackFile } from 'agent-state-machine';
211
+
212
+ trackFile('README.md', { caption: 'Project docs' });
213
+ const tree = getFileTree();
214
+ untrackFile('old-file.js');
215
+ ```
216
+
172
217
  ### `askHuman(question, options?)`
173
218
 
174
219
  Gets user input.
@@ -217,8 +262,13 @@ export default async function handler(context) {
217
262
  // context includes:
218
263
  // - params passed to agent(name, params)
219
264
  // - context._steering (global + optional additional steering content)
220
- // - context._config (models/apiKeys/workflowDir)
221
- return { ok: true };
265
+ // - context._config (models/apiKeys/workflowDir/projectRoot)
266
+
267
+ // Optionally return _files to annotate tracked files
268
+ return {
269
+ ok: true,
270
+ _files: [{ path: 'src/example.js', caption: 'Example module' }]
271
+ };
222
272
  }
223
273
  ```
224
274
 
@@ -308,7 +358,7 @@ The runtime captures the fully-built prompt in `state/history.jsonl`, viewable i
308
358
 
309
359
  Native JS workflows persist to:
310
360
 
311
- - `workflows/<name>/state/current.json` — status, memory, pending interaction
361
+ - `workflows/<name>/state/current.json` — status, memory (includes fileTree), pending interaction
312
362
  - `workflows/<name>/state/history.jsonl` — event log (newest entries first, includes agent retry/failure entries)
313
363
  - `workflows/<name>/interactions/*.md` — human input files (when paused)
314
364
 
@@ -0,0 +1,366 @@
1
+ /**
2
+ * File: /lib/file-tree.js
3
+ *
4
+ * File tree tracking utilities for agent-state-machine.
5
+ * Provides Git-based and filesystem-based change detection.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { exec } from 'child_process';
11
+ import { promisify } from 'util';
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ // Default ignore patterns (mainly for FS fallback, git respects .gitignore)
16
+ export const DEFAULT_IGNORE = [
17
+ 'node_modules/**',
18
+ '.git/**',
19
+ 'dist/**',
20
+ 'build/**',
21
+ 'coverage/**',
22
+ '.next/**',
23
+ '.cache/**',
24
+ 'workflows/**',
25
+ '*.log',
26
+ '.DS_Store'
27
+ ];
28
+
29
+ /**
30
+ * Normalize a path for consistent storage.
31
+ * - Converts backslashes to forward slashes
32
+ * - Rejects absolute paths
33
+ * - Rejects traversal outside projectRoot
34
+ */
35
+ export function normalizePath(relativePath, projectRoot) {
36
+ // Normalize slashes
37
+ const normalized = relativePath.replace(/\\/g, '/');
38
+
39
+ // Reject absolute paths
40
+ if (path.isAbsolute(normalized)) {
41
+ throw new Error(`Absolute path not allowed: ${normalized}`);
42
+ }
43
+
44
+ // Reject traversal outside projectRoot
45
+ const resolved = path.resolve(projectRoot, normalized);
46
+ const normalizedRoot = projectRoot.endsWith(path.sep) ? projectRoot : projectRoot + path.sep;
47
+ if (!resolved.startsWith(normalizedRoot) && resolved !== projectRoot.replace(/\/$/, '')) {
48
+ // Also allow exact match with projectRoot
49
+ if (resolved !== projectRoot) {
50
+ throw new Error(`Path traversal outside projectRoot: ${normalized}`);
51
+ }
52
+ }
53
+
54
+ return normalized;
55
+ }
56
+
57
+ /**
58
+ * Check if a path matches any of the ignore patterns.
59
+ */
60
+ export function shouldIgnore(relativePath, ignorePatterns = DEFAULT_IGNORE) {
61
+ const normalized = relativePath.replace(/\\/g, '/');
62
+
63
+ for (const pattern of ignorePatterns) {
64
+ // Simple glob matching
65
+ if (pattern.endsWith('/**')) {
66
+ const prefix = pattern.slice(0, -3);
67
+ if (normalized.startsWith(prefix + '/') || normalized === prefix) {
68
+ return true;
69
+ }
70
+ } else if (pattern.startsWith('*.')) {
71
+ const ext = pattern.slice(1);
72
+ if (normalized.endsWith(ext)) {
73
+ return true;
74
+ }
75
+ } else if (normalized === pattern || normalized.startsWith(pattern + '/')) {
76
+ return true;
77
+ }
78
+ }
79
+
80
+ return false;
81
+ }
82
+
83
+ // ============================================================================
84
+ // Git-based change detection
85
+ // ============================================================================
86
+
87
+ /**
88
+ * Parse git status --porcelain -z output into a Map.
89
+ * Format: XY PATH\0 (or XY ORIG\0PATH\0 for renames)
90
+ */
91
+ function parseGitStatus(stdout) {
92
+ const files = new Map();
93
+ if (!stdout) return files;
94
+
95
+ const entries = stdout.split('\0').filter(Boolean);
96
+ let i = 0;
97
+
98
+ while (i < entries.length) {
99
+ const entry = entries[i];
100
+ if (entry.length < 3) {
101
+ i++;
102
+ continue;
103
+ }
104
+
105
+ const statusCode = entry.slice(0, 2);
106
+ const filePath = entry.slice(3);
107
+
108
+ // Handle renames (R) - next entry is the new path
109
+ if (statusCode.startsWith('R') || statusCode.startsWith('C')) {
110
+ i++;
111
+ const newPath = entries[i];
112
+ if (newPath) {
113
+ files.set(newPath, statusCode);
114
+ }
115
+ } else {
116
+ files.set(filePath, statusCode);
117
+ }
118
+
119
+ i++;
120
+ }
121
+
122
+ return files;
123
+ }
124
+
125
+ /**
126
+ * Capture current git status as a baseline.
127
+ * Returns Map of file paths to their status codes.
128
+ */
129
+ export async function captureGitBaseline(projectRoot) {
130
+ try {
131
+ // Check if git repo
132
+ await execAsync('git rev-parse --git-dir', { cwd: projectRoot });
133
+
134
+ // Get status: untracked + staged + unstaged
135
+ const { stdout } = await execAsync(
136
+ 'git status --porcelain -z',
137
+ { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 }
138
+ );
139
+
140
+ return { files: parseGitStatus(stdout), isGitRepo: true };
141
+ } catch {
142
+ return { files: new Map(), isGitRepo: false };
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Detect changes between baseline and current state.
148
+ * Only attributes changes made since baseline capture.
149
+ */
150
+ export async function detectGitChanges(projectRoot, baseline, ignorePatterns = DEFAULT_IGNORE) {
151
+ const after = await captureGitBaseline(projectRoot);
152
+
153
+ const created = [];
154
+ const modified = [];
155
+ const deleted = [];
156
+
157
+ // Find new and modified files
158
+ for (const [filePath, status] of after.files) {
159
+ if (shouldIgnore(filePath, ignorePatterns)) continue;
160
+
161
+ if (!baseline.files.has(filePath)) {
162
+ created.push(filePath);
163
+ } else if (baseline.files.get(filePath) !== status) {
164
+ modified.push(filePath);
165
+ }
166
+ }
167
+
168
+ // Find deleted files
169
+ for (const [filePath] of baseline.files) {
170
+ if (shouldIgnore(filePath, ignorePatterns)) continue;
171
+
172
+ if (!after.files.has(filePath)) {
173
+ deleted.push(filePath);
174
+ }
175
+ }
176
+
177
+ // MVP: Treat renames as delete+create
178
+ // Future: Use `git diff --name-status -z -M` for rename detection
179
+ return { created, modified, deleted, renamed: [] };
180
+ }
181
+
182
+ // ============================================================================
183
+ // Filesystem-based change detection (fallback when no git)
184
+ // ============================================================================
185
+
186
+ /**
187
+ * Recursively walk a directory and collect file info.
188
+ */
189
+ async function walkDirectory(dir, baseDir, ignorePatterns, result = new Map()) {
190
+ let entries;
191
+ try {
192
+ entries = fs.readdirSync(dir, { withFileTypes: true });
193
+ } catch {
194
+ return result;
195
+ }
196
+
197
+ for (const entry of entries) {
198
+ const fullPath = path.join(dir, entry.name);
199
+ const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
200
+
201
+ if (shouldIgnore(relativePath, ignorePatterns)) continue;
202
+
203
+ if (entry.isDirectory()) {
204
+ await walkDirectory(fullPath, baseDir, ignorePatterns, result);
205
+ } else if (entry.isFile()) {
206
+ try {
207
+ const stats = fs.statSync(fullPath);
208
+ result.set(relativePath, {
209
+ mtime: stats.mtimeMs,
210
+ size: stats.size
211
+ });
212
+ } catch {
213
+ // File may have been deleted between readdir and stat
214
+ }
215
+ }
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+ /**
222
+ * Capture filesystem snapshot for change detection.
223
+ */
224
+ export async function captureFilesystemSnapshot(projectRoot, ignorePatterns = DEFAULT_IGNORE) {
225
+ const files = await walkDirectory(projectRoot, projectRoot, ignorePatterns);
226
+ return { files, isGitRepo: false };
227
+ }
228
+
229
+ /**
230
+ * Detect filesystem changes between baseline and current state.
231
+ */
232
+ export async function detectFilesystemChanges(projectRoot, baseline, ignorePatterns = DEFAULT_IGNORE) {
233
+ const after = await captureFilesystemSnapshot(projectRoot, ignorePatterns);
234
+
235
+ const created = [];
236
+ const modified = [];
237
+ const deleted = [];
238
+
239
+ // Find new and modified files
240
+ for (const [filePath, info] of after.files) {
241
+ const baselineInfo = baseline.files.get(filePath);
242
+
243
+ if (!baselineInfo) {
244
+ created.push(filePath);
245
+ } else if (baselineInfo.mtime !== info.mtime || baselineInfo.size !== info.size) {
246
+ modified.push(filePath);
247
+ }
248
+ }
249
+
250
+ // Find deleted files
251
+ for (const [filePath] of baseline.files) {
252
+ if (!after.files.has(filePath)) {
253
+ deleted.push(filePath);
254
+ }
255
+ }
256
+
257
+ return { created, modified, deleted, renamed: [] };
258
+ }
259
+
260
+ /**
261
+ * Unified baseline capture - uses Git if available, falls back to filesystem.
262
+ */
263
+ export async function captureBaseline(projectRoot, ignorePatterns = DEFAULT_IGNORE) {
264
+ const gitBaseline = await captureGitBaseline(projectRoot);
265
+
266
+ if (gitBaseline.isGitRepo) {
267
+ return gitBaseline;
268
+ }
269
+
270
+ return captureFilesystemSnapshot(projectRoot, ignorePatterns);
271
+ }
272
+
273
+ /**
274
+ * Unified change detection - uses Git if available, falls back to filesystem.
275
+ */
276
+ export async function detectChanges(projectRoot, baseline, ignorePatterns = DEFAULT_IGNORE) {
277
+ if (baseline.isGitRepo) {
278
+ return detectGitChanges(projectRoot, baseline, ignorePatterns);
279
+ }
280
+
281
+ return detectFilesystemChanges(projectRoot, baseline, ignorePatterns);
282
+ }
283
+
284
+ // ============================================================================
285
+ // Export extraction (best-effort)
286
+ // ============================================================================
287
+
288
+ /**
289
+ * Extract exports from JavaScript/TypeScript file content using regex.
290
+ * This is a best-effort extraction, not a full parser.
291
+ */
292
+ export function extractExportsFromContent(content) {
293
+ if (!content || typeof content !== 'string') return null;
294
+
295
+ const exports = new Set();
296
+
297
+ // export const/let/var/function/class Name
298
+ const namedExportPattern = /export\s+(?:const|let|var|function|class|async\s+function)\s+(\w+)/g;
299
+ let match;
300
+ while ((match = namedExportPattern.exec(content)) !== null) {
301
+ exports.add(match[1]);
302
+ }
303
+
304
+ // export { Name, Name2 as Alias }
305
+ const bracedExportPattern = /export\s*\{([^}]+)\}/g;
306
+ while ((match = bracedExportPattern.exec(content)) !== null) {
307
+ const items = match[1].split(',');
308
+ for (const item of items) {
309
+ const name = item.trim().split(/\s+as\s+/)[0].trim();
310
+ if (name && /^\w+$/.test(name)) {
311
+ exports.add(name);
312
+ }
313
+ }
314
+ }
315
+
316
+ // export default (tracked as 'default')
317
+ if (/export\s+default\s/.test(content)) {
318
+ exports.add('default');
319
+ }
320
+
321
+ // module.exports = { name: ... } (CommonJS)
322
+ const cjsPattern = /module\.exports\s*=\s*\{([^}]+)\}/;
323
+ const cjsMatch = content.match(cjsPattern);
324
+ if (cjsMatch) {
325
+ const items = cjsMatch[1].split(',');
326
+ for (const item of items) {
327
+ const name = item.split(':')[0].trim();
328
+ if (name && /^\w+$/.test(name)) {
329
+ exports.add(name);
330
+ }
331
+ }
332
+ }
333
+
334
+ // module.exports = Name (CommonJS default)
335
+ const cjsDefaultPattern = /module\.exports\s*=\s*(\w+)\s*[;\n]/;
336
+ const cjsDefaultMatch = content.match(cjsDefaultPattern);
337
+ if (cjsDefaultMatch && cjsDefaultMatch[1] !== '{') {
338
+ exports.add('default');
339
+ }
340
+
341
+ return exports.size > 0 ? Array.from(exports).sort() : null;
342
+ }
343
+
344
+ /**
345
+ * Extract exports from a file on disk.
346
+ */
347
+ export function extractExportsFromFile(absolutePath) {
348
+ if (!fs.existsSync(absolutePath)) return null;
349
+
350
+ const ext = path.extname(absolutePath).toLowerCase();
351
+ if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) return null;
352
+
353
+ try {
354
+ const content = fs.readFileSync(absolutePath, 'utf-8');
355
+ return extractExportsFromContent(content);
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
361
+ // ============================================================================
362
+ // File tree management (for manual tracking)
363
+ // ============================================================================
364
+
365
+ // These functions require access to the runtime, so they're implemented
366
+ // in track-changes.js and re-exported from index.js
package/lib/index.js CHANGED
@@ -6,6 +6,12 @@
6
6
 
7
7
  import { setup } from './setup.js';
8
8
  import { llm, llmText, llmJSON, parseJSON, detectAvailableCLIs } from './llm.js';
9
+ import { extractExportsFromContent, extractExportsFromFile } from './file-tree.js';
10
+ import {
11
+ trackFile as trackFileInternal,
12
+ getFileTree as getFileTreeInternal,
13
+ untrackFile as untrackFileInternal
14
+ } from './runtime/track-changes.js';
9
15
 
10
16
  import {
11
17
  WorkflowRuntime,
@@ -83,6 +89,97 @@ export const memory = new Proxy(
83
89
  }
84
90
  );
85
91
 
92
+ /**
93
+ * Live fileTree proxy:
94
+ * - Reads/writes target the current workflow runtime's fileTree in memory
95
+ * - Indexed by relative file path from projectRoot
96
+ */
97
+ export const fileTree = new Proxy(
98
+ {},
99
+ {
100
+ get(_target, prop) {
101
+ const runtime = getCurrentRuntime();
102
+ if (!runtime) return undefined;
103
+ return runtime._rawMemory.fileTree?.[prop];
104
+ },
105
+ set(_target, prop, value) {
106
+ const runtime = getCurrentRuntime();
107
+ if (!runtime) {
108
+ throw new Error('fileTree can only be mutated within a running workflow');
109
+ }
110
+ if (!runtime._rawMemory.fileTree) {
111
+ runtime._rawMemory.fileTree = {};
112
+ }
113
+ runtime._rawMemory.fileTree[prop] = value;
114
+ runtime.memory.fileTree = runtime._rawMemory.fileTree;
115
+ return true;
116
+ },
117
+ deleteProperty(_target, prop) {
118
+ const runtime = getCurrentRuntime();
119
+ if (!runtime) {
120
+ throw new Error('fileTree can only be mutated within a running workflow');
121
+ }
122
+ if (runtime._rawMemory.fileTree) {
123
+ delete runtime._rawMemory.fileTree[prop];
124
+ runtime.memory.fileTree = runtime._rawMemory.fileTree;
125
+ }
126
+ return true;
127
+ },
128
+ has(_target, prop) {
129
+ const runtime = getCurrentRuntime();
130
+ return runtime?._rawMemory.fileTree?.hasOwnProperty(prop) ?? false;
131
+ },
132
+ ownKeys() {
133
+ const runtime = getCurrentRuntime();
134
+ return Object.keys(runtime?._rawMemory.fileTree || {});
135
+ },
136
+ getOwnPropertyDescriptor(_target, prop) {
137
+ const runtime = getCurrentRuntime();
138
+ if (runtime?._rawMemory.fileTree?.hasOwnProperty(prop)) {
139
+ return {
140
+ configurable: true,
141
+ enumerable: true,
142
+ value: runtime._rawMemory.fileTree[prop]
143
+ };
144
+ }
145
+ return undefined;
146
+ }
147
+ }
148
+ );
149
+
150
+ /**
151
+ * Track a file in the fileTree.
152
+ * @param {string} relativePath - Path relative to projectRoot
153
+ * @param {object} options - Tracking options (caption, exports, extractExports, metadata, agentName)
154
+ */
155
+ export function trackFile(relativePath, options = {}) {
156
+ const runtime = getCurrentRuntime();
157
+ if (!runtime) {
158
+ throw new Error('trackFile() must be called within a workflow context');
159
+ }
160
+ return trackFileInternal(runtime, relativePath, options);
161
+ }
162
+
163
+ /**
164
+ * Get all tracked files.
165
+ */
166
+ export function getFileTree() {
167
+ const runtime = getCurrentRuntime();
168
+ if (!runtime) return {};
169
+ return getFileTreeInternal(runtime);
170
+ }
171
+
172
+ /**
173
+ * Remove a file from tracking.
174
+ */
175
+ export function untrackFile(relativePath) {
176
+ const runtime = getCurrentRuntime();
177
+ if (!runtime) {
178
+ throw new Error('untrackFile() must be called within a workflow context');
179
+ }
180
+ return untrackFileInternal(runtime, relativePath);
181
+ }
182
+
86
183
  export {
87
184
  setup,
88
185
  llm,
@@ -108,7 +205,10 @@ export {
108
205
  parallel,
109
206
  parallelLimit,
110
207
  getCurrentRuntime,
111
- getMemory
208
+ getMemory,
209
+ // File tree utilities
210
+ extractExportsFromContent,
211
+ extractExportsFromFile
112
212
  };
113
213
 
114
214
  const api = {
@@ -137,7 +237,14 @@ const api = {
137
237
  parallelLimit,
138
238
  getCurrentRuntime,
139
239
  getMemory,
140
- memory
240
+ memory,
241
+ // File tree
242
+ fileTree,
243
+ trackFile,
244
+ getFileTree,
245
+ untrackFile,
246
+ extractExportsFromContent,
247
+ extractExportsFromFile
141
248
  };
142
249
 
143
250
  export default api;
package/lib/llm.js CHANGED
@@ -162,6 +162,18 @@ export function buildPrompt(context, options) {
162
162
  }
163
163
  }
164
164
 
165
+ // Add file tracking context if enabled
166
+ if (context._config?.fileTracking !== false && context._config?.projectRoot) {
167
+ parts.push('# File Context\n\n');
168
+ parts.push(`You are running from: ${context._config.workflowDir}\n`);
169
+ parts.push(`Project root (where to create files): ${context._config.projectRoot}\n\n`);
170
+ parts.push('When creating or modifying files, use paths relative to the project root.\n');
171
+ parts.push('To annotate files, include `_files` in your JSON response:\n');
172
+ parts.push('```json\n');
173
+ parts.push('{ "_files": [{ "path": "src/file.js", "caption": "Brief description" }] }\n');
174
+ parts.push('```\n---\n');
175
+ }
176
+
165
177
  return parts.join('\n');
166
178
  }
167
179
 
@@ -12,6 +12,7 @@ import { createRequire } from 'module';
12
12
  import { pathToFileURL } from 'url';
13
13
  import { getCurrentRuntime } from './runtime.js';
14
14
  import { formatInteractionPrompt } from './interaction.js';
15
+ import { withChangeTracking } from './track-changes.js';
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
18
 
@@ -164,25 +165,37 @@ async function executeJSAgent(runtime, agentPath, name, params, options = {}) {
164
165
  _config: {
165
166
  models: runtime.workflowConfig.models,
166
167
  apiKeys: runtime.workflowConfig.apiKeys,
167
- workflowDir: runtime.workflowDir
168
+ workflowDir: runtime.workflowDir,
169
+ projectRoot: runtime.workflowConfig.projectRoot
168
170
  }
169
171
  };
170
172
 
171
- let result = await handler(context);
172
- let interactionDepth = 0;
173
-
174
- // Handle interaction response from JS agent (support multiple rounds)
175
- while (result && result._interaction) {
176
- const interactionResponse = await handleInteraction(runtime, result._interaction, name);
177
- const resumedContext = { ...context, userResponse: interactionResponse };
178
- await logAgentStart(runtime, name);
179
- result = await handler(resumedContext);
180
- interactionDepth += 1;
181
- if (interactionDepth > 5) {
182
- throw new Error(`Agent ${name} exceeded maximum interaction depth`);
173
+ // Execute handler with optional file change tracking
174
+ const executeHandler = async () => {
175
+ let result = await handler(context);
176
+ let interactionDepth = 0;
177
+
178
+ // Handle interaction response from JS agent (support multiple rounds)
179
+ while (result && result._interaction) {
180
+ const interactionResponse = await handleInteraction(runtime, result._interaction, name);
181
+ const resumedContext = { ...context, userResponse: interactionResponse };
182
+ await logAgentStart(runtime, name);
183
+ result = await handler(resumedContext);
184
+ interactionDepth += 1;
185
+ if (interactionDepth > 5) {
186
+ throw new Error(`Agent ${name} exceeded maximum interaction depth`);
187
+ }
183
188
  }
184
- }
185
189
 
190
+ return result;
191
+ };
192
+
193
+ let result;
194
+ if (runtime.workflowConfig.fileTracking !== false) {
195
+ result = await withChangeTracking(runtime, name, executeHandler);
196
+ } else {
197
+ result = await executeHandler();
198
+ }
186
199
 
187
200
  // Clean internal properties from result
188
201
  if (result && typeof result === 'object') {
@@ -191,6 +204,7 @@ async function executeJSAgent(runtime, agentPath, name, params, options = {}) {
191
204
  delete cleanResult._config;
192
205
  delete cleanResult._loop;
193
206
  delete cleanResult._interaction;
207
+ delete cleanResult._files;
194
208
 
195
209
  // If agent returned a context-like object, only return non-internal keys
196
210
  const meaningfulKeys = Object.keys(cleanResult).filter((k) => !k.startsWith('_'));
@@ -235,127 +249,141 @@ async function executeMDAgent(runtime, agentPath, name, params, options = {}) {
235
249
  ? runtime.loadSteeringFiles(steeringNames)
236
250
  : runtime.steering;
237
251
 
238
- let response = null;
239
- let output = null;
240
- let interactionDepth = 0;
241
- let currentParams = params;
242
-
243
- while (true) {
244
- // Build context - only spread params, NOT memory (explicit context passing)
245
- const context = {
246
- ...currentParams,
247
- _steering: steeringContext,
248
- _config: {
249
- models: runtime.workflowConfig.models,
250
- apiKeys: runtime.workflowConfig.apiKeys,
251
- workflowDir: runtime.workflowDir
252
- }
253
- };
254
-
255
- // Interpolate variables in prompt
256
- const interpolatedPrompt = interpolatePrompt(prompt, context);
252
+ // Build base config (used for all iterations)
253
+ const baseConfig = {
254
+ models: runtime.workflowConfig.models,
255
+ apiKeys: runtime.workflowConfig.apiKeys,
256
+ workflowDir: runtime.workflowDir,
257
+ projectRoot: runtime.workflowConfig.projectRoot
258
+ };
257
259
 
258
- const model = config.model || 'fast';
260
+ // Execute the MD agent core logic
261
+ const executeMDAgentCore = async () => {
262
+ let response = null;
263
+ let output = null;
264
+ let interactionDepth = 0;
265
+ let currentParams = params;
266
+
267
+ while (true) {
268
+ // Build context - only spread params, NOT memory (explicit context passing)
269
+ const context = {
270
+ ...currentParams,
271
+ _steering: steeringContext,
272
+ _config: baseConfig
273
+ };
274
+
275
+ // Interpolate variables in prompt
276
+ const interpolatedPrompt = interpolatePrompt(prompt, context);
277
+
278
+ const model = config.model || 'fast';
279
+
280
+ const fullPrompt = buildPrompt(context, {
281
+ model,
282
+ prompt: interpolatedPrompt,
283
+ includeContext: config.includeContext !== 'false',
284
+ responseType: config.response
285
+ });
259
286
 
260
- const fullPrompt = buildPrompt(context, {
261
- model,
262
- prompt: interpolatedPrompt,
263
- includeContext: config.includeContext !== 'false',
264
- responseType: config.response
265
- });
287
+ await logAgentStart(runtime, name, fullPrompt);
266
288
 
267
- await logAgentStart(runtime, name, fullPrompt);
289
+ console.log(` Using model: ${model}`);
268
290
 
269
- console.log(` Using model: ${model}`);
291
+ response = await llm(context, {
292
+ model: model,
293
+ prompt: interpolatedPrompt,
294
+ includeContext: config.includeContext !== 'false',
295
+ responseType: config.response
296
+ });
270
297
 
271
- response = await llm(context, {
272
- model: model,
273
- prompt: interpolatedPrompt,
274
- includeContext: config.includeContext !== 'false',
275
- responseType: config.response
276
- });
298
+ // Parse output based on format
299
+ output = response.text;
300
+ if (config.format === 'json') {
301
+ try {
302
+ output = parseJSON(response.text);
303
+ } catch {
304
+ console.warn(` Warning: Failed to parse JSON output`);
305
+ }
306
+ }
277
307
 
278
- // Parse output based on format
279
- output = response.text;
280
- if (config.format === 'json') {
281
- try {
282
- output = parseJSON(response.text);
283
- } catch {
284
- console.warn(` Warning: Failed to parse JSON output`);
308
+ if (output && typeof output === 'object' && output._interaction) {
309
+ const interactionResponse = await handleInteraction(runtime, output._interaction, name);
310
+ currentParams = { ...params, userResponse: interactionResponse };
311
+ interactionDepth += 1;
312
+ if (interactionDepth > 5) {
313
+ throw new Error(`Agent ${name} exceeded maximum interaction depth`);
314
+ }
315
+ continue;
285
316
  }
317
+
318
+ break;
286
319
  }
287
320
 
288
- if (output && typeof output === 'object' && output._interaction) {
289
- const interactionResponse = await handleInteraction(runtime, output._interaction, name);
290
- currentParams = { ...params, userResponse: interactionResponse };
291
- interactionDepth += 1;
292
- if (interactionDepth > 5) {
293
- throw new Error(`Agent ${name} exceeded maximum interaction depth`);
321
+ // Check for interaction request
322
+ const parsedInteraction = parseInteractionRequest(response.text);
323
+ const structuredInteraction =
324
+ config.autoInteract !== 'false' && parsedInteraction.isInteraction;
325
+
326
+ // Check if agent returned an 'interact' object in its JSON response
327
+ const hasInteractKey = output && typeof output === 'object' && output.interact;
328
+
329
+ // Explicit interaction mode (format: interaction OR interaction: true)
330
+ // But only trigger if agent actually wants to interact (has interact key or parsed interaction)
331
+ const explicitInteraction =
332
+ config.format === 'interaction' ||
333
+ ((config.interaction === 'true' || (typeof config.interaction === 'string' && config.interaction.length > 0)) &&
334
+ (hasInteractKey || structuredInteraction));
335
+
336
+ if (explicitInteraction || structuredInteraction) {
337
+ // Use interact object if present, otherwise fall back to parsed/raw
338
+ const interactionData = hasInteractKey ? output.interact : (structuredInteraction ? parsedInteraction : null);
339
+
340
+ const slugRaw =
341
+ interactionData?.slug ||
342
+ (typeof config.interaction === 'string' && config.interaction !== 'true'
343
+ ? config.interaction
344
+ : null) ||
345
+ config.interactionSlug ||
346
+ config.interactionKey ||
347
+ name;
348
+
349
+ const slug = sanitizeSlug(slugRaw);
350
+ const targetKey = config.interactionKey || outputKey || slug;
351
+
352
+ // Build interaction object with full metadata
353
+ const interactionObj = hasInteractKey ? {
354
+ ...output.interact,
355
+ slug,
356
+ targetKey
357
+ } : {
358
+ slug,
359
+ targetKey,
360
+ content: structuredInteraction ? parsedInteraction.question : response.text
361
+ };
362
+
363
+ const userResponse = await handleInteraction(runtime, interactionObj, name);
364
+
365
+ // Return the user's response as the agent result
366
+ if (outputKey) {
367
+ return { [outputKey]: userResponse, _debug_prompt: response.fullPrompt };
294
368
  }
295
- continue;
296
- }
297
369
 
298
- break;
299
- }
370
+ return userResponse;
371
+ }
300
372
 
301
- // Check for interaction request
302
- const parsedInteraction = parseInteractionRequest(response.text);
303
- const structuredInteraction =
304
- config.autoInteract !== 'false' && parsedInteraction.isInteraction;
305
-
306
- // Check if agent returned an 'interact' object in its JSON response
307
- const hasInteractKey = output && typeof output === 'object' && output.interact;
308
-
309
- // Explicit interaction mode (format: interaction OR interaction: true)
310
- // But only trigger if agent actually wants to interact (has interact key or parsed interaction)
311
- const explicitInteraction =
312
- config.format === 'interaction' ||
313
- ((config.interaction === 'true' || (typeof config.interaction === 'string' && config.interaction.length > 0)) &&
314
- (hasInteractKey || structuredInteraction));
315
-
316
- if (explicitInteraction || structuredInteraction) {
317
- // Use interact object if present, otherwise fall back to parsed/raw
318
- const interactionData = hasInteractKey ? output.interact : (structuredInteraction ? parsedInteraction : null);
319
-
320
- const slugRaw =
321
- interactionData?.slug ||
322
- (typeof config.interaction === 'string' && config.interaction !== 'true'
323
- ? config.interaction
324
- : null) ||
325
- config.interactionSlug ||
326
- config.interactionKey ||
327
- name;
328
-
329
- const slug = sanitizeSlug(slugRaw);
330
- const targetKey = config.interactionKey || outputKey || slug;
331
-
332
- // Build interaction object with full metadata
333
- const interactionObj = hasInteractKey ? {
334
- ...output.interact,
335
- slug,
336
- targetKey
337
- } : {
338
- slug,
339
- targetKey,
340
- content: structuredInteraction ? parsedInteraction.question : response.text
341
- };
342
-
343
- const userResponse = await handleInteraction(runtime, interactionObj, name);
344
-
345
- // Return the user's response as the agent result
373
+ // Return result object
346
374
  if (outputKey) {
347
- return { [outputKey]: userResponse, _debug_prompt: response.fullPrompt };
375
+ return { [outputKey]: output, _debug_prompt: response.fullPrompt };
348
376
  }
349
377
 
350
- return userResponse;
351
- }
378
+ return output;
379
+ };
352
380
 
353
- // Return result object
354
- if (outputKey) {
355
- return { [outputKey]: output, _debug_prompt: response.fullPrompt };
381
+ // Execute with optional file change tracking
382
+ if (runtime.workflowConfig.fileTracking !== false) {
383
+ return withChangeTracking(runtime, name, executeMDAgentCore);
384
+ } else {
385
+ return executeMDAgentCore();
356
386
  }
357
-
358
- return output;
359
387
  }
360
388
 
361
389
  /**
@@ -12,6 +12,7 @@ import readline from 'readline';
12
12
  import { pathToFileURL } from 'url';
13
13
  import { createMemoryProxy } from './memory.js';
14
14
  import { RemoteClient } from '../remote/client.js';
15
+ import { DEFAULT_IGNORE } from '../file-tree.js';
15
16
 
16
17
  // Global runtime reference for agent() and memory access
17
18
  // stored on globalThis to ensure singleton access across different module instances (CLI vs local)
@@ -74,7 +75,12 @@ export class WorkflowRuntime {
74
75
  this.workflowConfig = {
75
76
  models: {},
76
77
  apiKeys: {},
77
- description: ''
78
+ description: '',
79
+ // File tracking defaults
80
+ projectRoot: path.resolve(workflowDir, '../..'),
81
+ fileTracking: true,
82
+ fileTrackingIgnore: DEFAULT_IGNORE,
83
+ fileTrackingKeepDeleted: false
78
84
  };
79
85
 
80
86
  // Load steering
@@ -317,7 +323,12 @@ export class WorkflowRuntime {
317
323
  this.workflowConfig = {
318
324
  models: cfg.models || {},
319
325
  apiKeys: cfg.apiKeys || {},
320
- description: cfg.description || ''
326
+ description: cfg.description || '',
327
+ // File tracking configuration
328
+ projectRoot: cfg.projectRoot || path.resolve(this.workflowDir, '../..'),
329
+ fileTracking: cfg.fileTracking ?? true,
330
+ fileTrackingIgnore: cfg.fileTrackingIgnore || DEFAULT_IGNORE,
331
+ fileTrackingKeepDeleted: cfg.fileTrackingKeepDeleted ?? false
321
332
  };
322
333
 
323
334
  // Import workflow module
@@ -0,0 +1,252 @@
1
+ /**
2
+ * File: /lib/runtime/track-changes.js
3
+ *
4
+ * Wraps agent execution with file change tracking.
5
+ * Captures baseline before agent runs, detects changes after,
6
+ * and updates memory.fileTree with the results.
7
+ */
8
+
9
+ import path from 'path';
10
+ import {
11
+ captureBaseline,
12
+ detectChanges,
13
+ normalizePath,
14
+ extractExportsFromFile,
15
+ DEFAULT_IGNORE
16
+ } from '../file-tree.js';
17
+
18
+ /**
19
+ * Wrap an async function with file change tracking.
20
+ * Captures baseline before execution, detects changes after,
21
+ * and updates the runtime's fileTree.
22
+ *
23
+ * @param {Object} runtime - The workflow runtime instance
24
+ * @param {string} agentName - Name of the agent (for attribution)
25
+ * @param {Function} fn - Async function to execute
26
+ * @returns {Promise<any>} - Result of the function
27
+ */
28
+ export async function withChangeTracking(runtime, agentName, fn) {
29
+ const projectRoot = runtime.workflowConfig.projectRoot;
30
+ const ignorePatterns = runtime.workflowConfig.fileTrackingIgnore || DEFAULT_IGNORE;
31
+
32
+ // Capture baseline before agent runs
33
+ const baseline = await captureBaseline(projectRoot, ignorePatterns);
34
+
35
+ // Run the agent
36
+ const result = await fn();
37
+
38
+ // Detect changes made during agent execution
39
+ const changes = await detectChanges(projectRoot, baseline, ignorePatterns);
40
+
41
+ // Update fileTree with detected changes
42
+ applyChangesToFileTree(runtime, changes, agentName);
43
+
44
+ // Merge _files annotations if present (preserves existing data unless explicitly overwritten)
45
+ if (result && typeof result === 'object' && Array.isArray(result._files)) {
46
+ mergeAnnotations(runtime, result._files);
47
+ }
48
+
49
+ return result;
50
+ }
51
+
52
+ /**
53
+ * Apply detected file changes to the runtime's fileTree.
54
+ */
55
+ function applyChangesToFileTree(runtime, changes, agentName) {
56
+ const now = new Date().toISOString();
57
+ const projectRoot = runtime.workflowConfig.projectRoot;
58
+
59
+ // Initialize fileTree if needed
60
+ if (!runtime._rawMemory.fileTree) {
61
+ runtime._rawMemory.fileTree = {};
62
+ }
63
+
64
+ // Handle created files
65
+ for (const filePath of changes.created) {
66
+ try {
67
+ const normalized = normalizePath(filePath, projectRoot);
68
+ runtime._rawMemory.fileTree[normalized] = {
69
+ path: normalized,
70
+ status: 'created',
71
+ createdBy: agentName,
72
+ lastModifiedBy: agentName,
73
+ createdAt: now,
74
+ updatedAt: now
75
+ };
76
+ } catch (e) {
77
+ // Skip files with invalid paths
78
+ console.warn(`[file-tree] Skipping invalid path: ${filePath} - ${e.message}`);
79
+ }
80
+ }
81
+
82
+ // Handle modified files
83
+ for (const filePath of changes.modified) {
84
+ try {
85
+ const normalized = normalizePath(filePath, projectRoot);
86
+ const existing = runtime._rawMemory.fileTree[normalized] || {};
87
+ runtime._rawMemory.fileTree[normalized] = {
88
+ ...existing,
89
+ path: normalized,
90
+ status: 'modified',
91
+ lastModifiedBy: agentName,
92
+ updatedAt: now,
93
+ createdAt: existing.createdAt || now,
94
+ createdBy: existing.createdBy || agentName
95
+ };
96
+ } catch (e) {
97
+ console.warn(`[file-tree] Skipping invalid path: ${filePath} - ${e.message}`);
98
+ }
99
+ }
100
+
101
+ // Handle renamed files (MVP: from/to pairs)
102
+ for (const { from, to } of changes.renamed) {
103
+ try {
104
+ const normalizedFrom = normalizePath(from, projectRoot);
105
+ const normalizedTo = normalizePath(to, projectRoot);
106
+ const existing = runtime._rawMemory.fileTree[normalizedFrom] || {};
107
+ delete runtime._rawMemory.fileTree[normalizedFrom];
108
+ runtime._rawMemory.fileTree[normalizedTo] = {
109
+ ...existing,
110
+ path: normalizedTo,
111
+ status: 'renamed',
112
+ renamedFrom: normalizedFrom,
113
+ lastModifiedBy: agentName,
114
+ updatedAt: now
115
+ };
116
+ } catch (e) {
117
+ console.warn(`[file-tree] Skipping invalid rename: ${from} -> ${to} - ${e.message}`);
118
+ }
119
+ }
120
+
121
+ // Handle deleted files
122
+ for (const filePath of changes.deleted) {
123
+ try {
124
+ const normalized = normalizePath(filePath, projectRoot);
125
+ if (runtime.workflowConfig.fileTrackingKeepDeleted) {
126
+ runtime._rawMemory.fileTree[normalized] = {
127
+ ...runtime._rawMemory.fileTree[normalized],
128
+ status: 'deleted',
129
+ deletedBy: agentName,
130
+ deletedAt: now
131
+ };
132
+ } else {
133
+ delete runtime._rawMemory.fileTree[normalized];
134
+ }
135
+ } catch (e) {
136
+ console.warn(`[file-tree] Skipping invalid path: ${filePath} - ${e.message}`);
137
+ }
138
+ }
139
+
140
+ // Trigger persistence
141
+ runtime.memory.fileTree = runtime._rawMemory.fileTree;
142
+ }
143
+
144
+ /**
145
+ * Merge _files annotations from agent result into fileTree.
146
+ * Only annotates files that were actually detected by change tracking.
147
+ * Preserves existing values unless explicitly overwritten.
148
+ */
149
+ function mergeAnnotations(runtime, files) {
150
+ const projectRoot = runtime.workflowConfig.projectRoot;
151
+
152
+ for (const file of files) {
153
+ if (!file.path) continue;
154
+
155
+ try {
156
+ const normalizedPath = normalizePath(file.path, projectRoot);
157
+ const existing = runtime._rawMemory.fileTree?.[normalizedPath];
158
+
159
+ if (!existing) continue; // Only annotate files that were actually detected
160
+
161
+ // Preserve existing values unless _files explicitly provides new ones
162
+ if (file.caption !== undefined) existing.caption = file.caption;
163
+ if (file.exports !== undefined) existing.exports = file.exports;
164
+ if (file.metadata !== undefined) {
165
+ existing.metadata = { ...existing.metadata, ...file.metadata };
166
+ }
167
+
168
+ // Trigger best-effort export extraction if requested
169
+ if (file.extractExports) {
170
+ const absolutePath = path.resolve(projectRoot, normalizedPath);
171
+ const exports = extractExportsFromFile(absolutePath);
172
+ if (exports) existing.exports = exports;
173
+ }
174
+ } catch (e) {
175
+ console.warn(`[file-tree] Skipping invalid annotation path: ${file.path} - ${e.message}`);
176
+ }
177
+ }
178
+
179
+ // Trigger persistence
180
+ runtime.memory.fileTree = runtime._rawMemory.fileTree;
181
+ }
182
+
183
+ /**
184
+ * Manually track a file in the fileTree.
185
+ * Used for files created outside of agent execution.
186
+ */
187
+ export function trackFile(runtime, relativePath, options = {}) {
188
+ const projectRoot = runtime.workflowConfig.projectRoot;
189
+ const normalized = normalizePath(relativePath, projectRoot);
190
+ const now = new Date().toISOString();
191
+ const agentName = options.agentName || 'manual';
192
+
193
+ // Initialize fileTree if needed
194
+ if (!runtime._rawMemory.fileTree) {
195
+ runtime._rawMemory.fileTree = {};
196
+ }
197
+
198
+ const existing = runtime._rawMemory.fileTree[normalized];
199
+
200
+ const entry = {
201
+ path: normalized,
202
+ status: existing?.status || 'created',
203
+ createdBy: existing?.createdBy || agentName,
204
+ lastModifiedBy: agentName,
205
+ createdAt: existing?.createdAt || now,
206
+ updatedAt: now
207
+ };
208
+
209
+ // Apply options
210
+ if (options.caption !== undefined) entry.caption = options.caption;
211
+ if (options.exports !== undefined) entry.exports = options.exports;
212
+ if (options.metadata !== undefined) {
213
+ entry.metadata = { ...existing?.metadata, ...options.metadata };
214
+ }
215
+
216
+ // Extract exports if requested
217
+ if (options.extractExports) {
218
+ const absolutePath = path.resolve(projectRoot, normalized);
219
+ const exports = extractExportsFromFile(absolutePath);
220
+ if (exports) entry.exports = exports;
221
+ }
222
+
223
+ runtime._rawMemory.fileTree[normalized] = entry;
224
+
225
+ // Trigger persistence
226
+ runtime.memory.fileTree = runtime._rawMemory.fileTree;
227
+
228
+ return entry;
229
+ }
230
+
231
+ /**
232
+ * Get the current fileTree.
233
+ */
234
+ export function getFileTree(runtime) {
235
+ return runtime._rawMemory.fileTree || {};
236
+ }
237
+
238
+ /**
239
+ * Remove a file from tracking.
240
+ */
241
+ export function untrackFile(runtime, relativePath) {
242
+ const projectRoot = runtime.workflowConfig.projectRoot;
243
+ const normalized = normalizePath(relativePath, projectRoot);
244
+
245
+ if (runtime._rawMemory.fileTree?.[normalized]) {
246
+ delete runtime._rawMemory.fileTree[normalized];
247
+ runtime.memory.fileTree = runtime._rawMemory.fileTree;
248
+ return true;
249
+ }
250
+
251
+ return false;
252
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "2.1.9",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "A workflow orchestrator for running agents and scripts in sequence with state management",
6
6
  "main": "lib/index.js",
@@ -9,5 +9,16 @@ export const config = {
9
9
  gemini: process.env.GEMINI_API_KEY,
10
10
  anthropic: process.env.ANTHROPIC_API_KEY,
11
11
  openai: process.env.OPENAI_API_KEY,
12
- }
12
+ },
13
+
14
+ // File tracking (all optional - shown with defaults)
15
+ // projectRoot: process.env.PROJECT_ROOT, // Defaults to ../.. from workflow
16
+ // fileTracking: true, // Enable/disable file tracking
17
+ // fileTrackingIgnore: [ // Glob patterns to ignore
18
+ // 'node_modules/**',
19
+ // '.git/**',
20
+ // 'dist/**',
21
+ // 'workflows/**'
22
+ // ],
23
+ // fileTrackingKeepDeleted: false // Keep deleted files in tree
13
24
  };
@@ -8,5 +8,16 @@ export const config = {
8
8
  gemini: process.env.GEMINI_API_KEY,
9
9
  anthropic: process.env.ANTHROPIC_API_KEY,
10
10
  openai: process.env.OPENAI_API_KEY,
11
- }
11
+ },
12
+
13
+ // File tracking (all optional - shown with defaults)
14
+ // projectRoot: process.env.PROJECT_ROOT, // Defaults to ../.. from workflow
15
+ // fileTracking: true, // Enable/disable file tracking
16
+ // fileTrackingIgnore: [ // Glob patterns to ignore
17
+ // 'node_modules/**',
18
+ // '.git/**',
19
+ // 'dist/**',
20
+ // 'workflows/**'
21
+ // ],
22
+ // fileTrackingKeepDeleted: false // Keep deleted files in tree
12
23
  };