agent-state-machine 2.1.8 → 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.
Files changed (28) hide show
  1. package/README.md +55 -5
  2. package/bin/cli.js +1 -140
  3. package/lib/config-utils.js +259 -0
  4. package/lib/file-tree.js +366 -0
  5. package/lib/index.js +109 -2
  6. package/lib/llm.js +35 -5
  7. package/lib/runtime/agent.js +146 -118
  8. package/lib/runtime/model-resolution.js +128 -0
  9. package/lib/runtime/runtime.js +13 -2
  10. package/lib/runtime/track-changes.js +252 -0
  11. package/package.json +1 -1
  12. package/templates/project-builder/agents/assumptions-clarifier.md +0 -8
  13. package/templates/project-builder/agents/code-reviewer.md +0 -8
  14. package/templates/project-builder/agents/code-writer.md +0 -10
  15. package/templates/project-builder/agents/requirements-clarifier.md +0 -7
  16. package/templates/project-builder/agents/roadmap-generator.md +0 -10
  17. package/templates/project-builder/agents/scope-clarifier.md +0 -6
  18. package/templates/project-builder/agents/security-clarifier.md +0 -9
  19. package/templates/project-builder/agents/security-reviewer.md +0 -12
  20. package/templates/project-builder/agents/task-planner.md +0 -10
  21. package/templates/project-builder/agents/test-planner.md +0 -9
  22. package/templates/project-builder/config.js +13 -1
  23. package/templates/starter/config.js +12 -1
  24. package/vercel-server/public/remote/assets/{index-Cunx4VJE.js → index-BOKpYANC.js} +32 -27
  25. package/vercel-server/public/remote/assets/index-DHL_iHQW.css +1 -0
  26. package/vercel-server/public/remote/index.html +2 -2
  27. package/vercel-server/ui/src/components/ContentCard.jsx +6 -6
  28. package/vercel-server/public/remote/assets/index-D02x57pS.css +0 -1
@@ -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
@@ -7,6 +7,8 @@ import path from 'path';
7
7
  import os from 'os';
8
8
  import { spawn, execSync } from 'child_process';
9
9
  import { createRequire } from 'module';
10
+ import { getCurrentRuntime } from './runtime/runtime.js';
11
+ import { resolveUnknownModel } from './runtime/model-resolution.js';
10
12
 
11
13
  const require = createRequire(import.meta.url);
12
14
 
@@ -160,6 +162,18 @@ export function buildPrompt(context, options) {
160
162
  }
161
163
  }
162
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
+
163
177
  return parts.join('\n');
164
178
  }
165
179
 
@@ -419,13 +433,29 @@ export async function llm(context, options) {
419
433
  const apiKeys = config.apiKeys || {};
420
434
 
421
435
  // Look up the model command/config
422
- const modelConfig = models[options.model];
436
+ let modelConfig = models[options.model];
423
437
 
424
438
  if (!modelConfig) {
425
- const available = Object.keys(models).join(', ');
426
- throw new Error(
427
- `Unknown model key: "${options.model}". Available models: ${available || 'none defined'}`
428
- );
439
+ const runtime = getCurrentRuntime();
440
+ if (runtime) {
441
+ const workflowDir = options.workflowDir || config.workflowDir || runtime.workflowDir;
442
+ if (!workflowDir) {
443
+ throw new Error(`Unknown model key: "${options.model}". Workflow directory is missing.`);
444
+ }
445
+ modelConfig = await resolveUnknownModel(options.model, config, workflowDir, {
446
+ availableCLIs: detectAvailableCLIs()
447
+ });
448
+ if (!config.models) {
449
+ config.models = models;
450
+ }
451
+ config.models[options.model] = modelConfig;
452
+ runtime.workflowConfig.models[options.model] = modelConfig;
453
+ } else {
454
+ const available = Object.keys(models).join(', ');
455
+ throw new Error(
456
+ `Unknown model key: "${options.model}". Available models: ${available || 'none defined'}`
457
+ );
458
+ }
429
459
  }
430
460
 
431
461
  // Build the full prompt