vectorforce 0.0.0-stage → 0.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 (37) hide show
  1. package/.agents/agents/AGENTS.md +12 -0
  2. package/.agents/agents/vectorforce-architect.md +35 -0
  3. package/.agents/agents/vectorforce-auditor.md +25 -0
  4. package/.agents/agents/vectorforce-samples-builder.md +30 -0
  5. package/.agents/mcp_config.json +11 -0
  6. package/.codex/config.toml +5 -0
  7. package/AGENTS.md +115 -0
  8. package/LICENSE +21 -0
  9. package/README.md +198 -2
  10. package/bin/vf.js +1159 -0
  11. package/codex.json +14 -0
  12. package/commandcode.json +11 -0
  13. package/musecode.json +14 -0
  14. package/package.json +40 -4
  15. package/src/core/errors.js +45 -0
  16. package/src/core/hash.js +56 -0
  17. package/src/core/types.js +194 -0
  18. package/src/index.js +17 -0
  19. package/src/mcp/harness-config.js +546 -0
  20. package/src/mcp/mcp-server.js +507 -0
  21. package/src/operations/briefing-service.js +313 -0
  22. package/src/operations/checkpoint-service.js +120 -0
  23. package/src/operations/handoff-service.js +343 -0
  24. package/src/operations/project-service.js +132 -0
  25. package/src/operations/reasoning-service.js +356 -0
  26. package/src/policy/authorizer.js +147 -0
  27. package/src/providers/code-index-provider.js +285 -0
  28. package/src/providers/document-provider.js +105 -0
  29. package/src/providers/git-provider.js +98 -0
  30. package/src/providers/llm-provider.js +425 -0
  31. package/src/storage/sqlite-store.js +387 -0
  32. package/src/studio/server.js +764 -0
  33. package/studio/dist/assets/index-C0_eVpV1.js +9 -0
  34. package/studio/dist/assets/index-CxTK-2ty.css +2 -0
  35. package/studio/dist/favicon.svg +1 -0
  36. package/studio/dist/icons.svg +24 -0
  37. package/studio/dist/index.html +17 -0
@@ -0,0 +1,285 @@
1
+ import { readdirSync, readFileSync, statSync, realpathSync } from 'node:fs';
2
+ import { join, extname, posix } from 'node:path';
3
+ import { sha256 } from '../core/hash.js';
4
+ import { Authorizer } from '../policy/authorizer.js';
5
+
6
+ export const DEFAULT_MAX_FILES = 2000;
7
+ export const DEFAULT_MAX_FILE_BYTES = 200000;
8
+ export const SNIPPET_LINES = 40;
9
+ export const SNIPPET_MAX_CHARS = 4000;
10
+
11
+ const SKIP_DIRS = new Set([
12
+ '.git', 'node_modules', 'dist', 'build', 'coverage', '.vectorforce',
13
+ '.commandcode', '.agents', 'tmp', '.next', '.cache', 'out', 'vendor', 'target'
14
+ ]);
15
+
16
+ const SKIP_FILE_PATTERNS = [
17
+ /^\.env($|\.)/i,
18
+ /\.pem$/i,
19
+ /\.key$/i,
20
+ /\.p12$/i,
21
+ /\.pfx$/i,
22
+ /credentials/i,
23
+ /secret/i,
24
+ /^id_(rsa|dsa|ecdsa|ed25519)/i,
25
+ /^\.npmrc$/i
26
+ ];
27
+
28
+ const CODE_EXTENSIONS = new Set([
29
+ '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.py', '.go', '.rs',
30
+ '.java', '.rb', '.sql', '.sh', '.md', '.json', '.yaml', '.yml'
31
+ ]);
32
+
33
+ const LANGUAGE_BY_EXT = {
34
+ '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript',
35
+ '.ts': 'typescript', '.tsx': 'typescript', '.py': 'python', '.go': 'go', '.rs': 'rust',
36
+ '.java': 'java', '.rb': 'ruby', '.sql': 'sql', '.sh': 'shell', '.md': 'markdown',
37
+ '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml'
38
+ };
39
+
40
+ const RESOLUTION_SUFFIXES = [
41
+ '', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
42
+ '/index.js', '/index.ts', '/index.jsx', '/index.tsx'
43
+ ];
44
+
45
+ const STOPWORDS = new Set([
46
+ 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'into', 'when', 'then', 'than',
47
+ 'are', 'was', 'were', 'will', 'should', 'must', 'add', 'use', 'via', 'per', 'not',
48
+ 'but', 'you', 'your', 'our', 'its', 'has', 'have', 'had', 'can', 'may', 'all', 'any',
49
+ 'new', 'old', 'out', 'one', 'two', 'task', 'code', 'work'
50
+ ]);
51
+
52
+ function uniqueMatches(content, regex, group = 1) {
53
+ const seen = new Set();
54
+ let match;
55
+ regex.lastIndex = 0;
56
+ while ((match = regex.exec(content)) !== null) {
57
+ const value = match[group];
58
+ if (value) seen.add(value);
59
+ }
60
+ return Array.from(seen);
61
+ }
62
+
63
+ /**
64
+ * Deterministic, dependency-free index of a workspace's source tree.
65
+ * Extracts a structural skeleton (files, symbols, import edges) that a language
66
+ * model can reason over. Never follows symlinks and never escapes the workspace.
67
+ */
68
+ export class CodeIndexProvider {
69
+ /**
70
+ * Build a structural index of the workspace.
71
+ * @param {string} canonicalWorkspace
72
+ * @param {{ maxFiles?: number, maxFileBytes?: number }} [opts]
73
+ * @returns {{ files: Array<object>, edges: Array<object>, truncated: boolean, gaps: Array<object> }}
74
+ */
75
+ static build(canonicalWorkspace, opts = {}) {
76
+ const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
77
+ const maxFileBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
78
+ const root = realpathSync(canonicalWorkspace);
79
+
80
+ const files = [];
81
+ const gaps = [];
82
+ let truncated = false;
83
+
84
+ const walk = (dir, relDir) => {
85
+ let entries;
86
+ try {
87
+ entries = readdirSync(dir, { withFileTypes: true });
88
+ } catch (err) {
89
+ gaps.push({ type: 'code', target: relDir || '.', reason: `readdir failed: ${err.message}` });
90
+ return;
91
+ }
92
+ entries.sort((a, b) => a.name.localeCompare(b.name));
93
+
94
+ for (const entry of entries) {
95
+ if (files.length >= maxFiles) {
96
+ truncated = true;
97
+ return;
98
+ }
99
+ const name = entry.name;
100
+ const abs = join(dir, name);
101
+ const rel = relDir ? `${relDir}/${name}` : name;
102
+
103
+ if (entry.isSymbolicLink()) continue;
104
+
105
+ if (entry.isDirectory()) {
106
+ if (SKIP_DIRS.has(name)) continue;
107
+ walk(abs, rel);
108
+ continue;
109
+ }
110
+ if (!entry.isFile()) continue;
111
+ if (CodeIndexProvider._isSecretPath(name, rel)) continue;
112
+
113
+ const ext = extname(name).toLowerCase();
114
+ if (!CODE_EXTENSIONS.has(ext)) continue;
115
+ if (!Authorizer.isWithinWorkspace(root, abs)) continue;
116
+
117
+ let st;
118
+ try {
119
+ st = statSync(abs);
120
+ } catch {
121
+ continue;
122
+ }
123
+ if (st.size > maxFileBytes) {
124
+ gaps.push({ type: 'code', target: rel, reason: `Skipped: ${st.size} bytes exceeds ${maxFileBytes} byte limit` });
125
+ continue;
126
+ }
127
+
128
+ let content;
129
+ try {
130
+ content = readFileSync(abs, 'utf8');
131
+ } catch (err) {
132
+ gaps.push({ type: 'code', target: rel, reason: `read failed: ${err.message}` });
133
+ continue;
134
+ }
135
+ if (content.includes('\u0000')) continue;
136
+
137
+ const language = LANGUAGE_BY_EXT[ext] || ext.slice(1);
138
+ files.push({
139
+ path: rel,
140
+ size: st.size,
141
+ sha256: sha256(content),
142
+ language,
143
+ symbols: CodeIndexProvider.extractSymbols(content, language),
144
+ snippet: CodeIndexProvider._snippet(content)
145
+ });
146
+ }
147
+ };
148
+
149
+ walk(root, '');
150
+
151
+ return {
152
+ files,
153
+ edges: CodeIndexProvider._buildEdges(files),
154
+ truncated,
155
+ gaps
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Extract a structural symbol skeleton using deterministic regex heuristics.
161
+ * @param {string} content
162
+ * @param {string} language
163
+ * @returns {{ exports: string[], imports: string[], functions: string[], classes: string[] }}
164
+ */
165
+ static extractSymbols(content, language) {
166
+ const result = { exports: [], imports: [], functions: [], classes: [] };
167
+
168
+ if (language === 'javascript' || language === 'typescript') {
169
+ result.imports = uniqueMatches(content, /(?:import\s+(?:[^'"]*?\s+from\s+)?|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g);
170
+ result.exports = uniqueMatches(content, /\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g);
171
+ result.exports.push(...uniqueMatches(content, /\bmodule\.exports\s*=\s*([A-Za-z_$][\w$]*)/g));
172
+ result.functions = uniqueMatches(content, /\bfunction\s+([A-Za-z_$][\w$]*)/g);
173
+ result.functions.push(...uniqueMatches(content, /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/g));
174
+ result.classes = uniqueMatches(content, /\bclass\s+([A-Za-z_$][\w$]*)/g);
175
+ } else if (language === 'python') {
176
+ result.imports = uniqueMatches(content, /^\s*(?:import|from)\s+([\w.]+)/gm);
177
+ result.functions = uniqueMatches(content, /^\s*def\s+([A-Za-z_]\w*)/gm);
178
+ result.classes = uniqueMatches(content, /^\s*class\s+([A-Za-z_]\w*)/gm);
179
+ result.exports = result.functions.slice();
180
+ }
181
+
182
+ for (const key of Object.keys(result)) {
183
+ result[key] = Array.from(new Set(result[key])).slice(0, 50);
184
+ }
185
+ return result;
186
+ }
187
+
188
+ /**
189
+ * Select the most relevant files for a reasoning task, deterministically.
190
+ * Ranking: changed-since-checkpoint > token match in path/symbols > entry points.
191
+ * @param {object} index result of build()
192
+ * @param {{ queryTokens?: string[], changedFiles?: string[], limit?: number, byteBudget?: number }} [opts]
193
+ * @returns {{ files: Array<object>, usedBytes: number }}
194
+ */
195
+ static selectRelevantFiles(index, opts = {}) {
196
+ const limit = opts.limit ?? 12;
197
+ const byteBudget = opts.byteBudget ?? 48000;
198
+ const tokenSet = new Set((opts.queryTokens || []).map(t => String(t).toLowerCase()).filter(Boolean));
199
+ const changed = new Set(opts.changedFiles || []);
200
+ const entryPoints = new Set([
201
+ 'package.json', 'readme.md', 'index.js', 'index.ts', 'src/index.js', 'src/index.ts',
202
+ 'main.js', 'main.py', 'app.py', 'app.js', 'app.ts', 'pyproject.toml'
203
+ ]);
204
+
205
+ const scored = index.files.map(file => {
206
+ let score = 0;
207
+ const hay = `${file.path} ${file.symbols.exports.join(' ')} ${file.symbols.functions.join(' ')} ${file.symbols.classes.join(' ')}`.toLowerCase();
208
+ for (const token of tokenSet) {
209
+ if (token.length >= 3 && hay.includes(token)) score += 2;
210
+ }
211
+ if (changed.has(file.path)) score += 5;
212
+ if (entryPoints.has(file.path.toLowerCase())) score += 3;
213
+ return { file, score };
214
+ });
215
+
216
+ scored.sort((a, b) => b.score - a.score || a.file.path.localeCompare(b.file.path));
217
+
218
+ const selected = [];
219
+ let usedBytes = 0;
220
+ for (const { file, score } of scored) {
221
+ if (selected.length >= limit) break;
222
+ const size = file.snippet.length;
223
+ if (usedBytes + size > byteBudget) continue;
224
+ selected.push({
225
+ path: file.path,
226
+ language: file.language,
227
+ symbols: file.symbols,
228
+ snippet: file.snippet,
229
+ changed: changed.has(file.path),
230
+ score
231
+ });
232
+ usedBytes += size;
233
+ }
234
+
235
+ return { files: selected, usedBytes };
236
+ }
237
+
238
+ /**
239
+ * Tokenize free text into relevance keywords (lowercased, dedupliced, stopwords removed).
240
+ * @param {...string} texts
241
+ * @returns {string[]}
242
+ */
243
+ static tokenize(...texts) {
244
+ const tokens = new Set();
245
+ for (const text of texts) {
246
+ if (!text) continue;
247
+ for (const raw of String(text).toLowerCase().split(/[^a-z0-9_]+/)) {
248
+ if (raw.length >= 3 && !STOPWORDS.has(raw)) tokens.add(raw);
249
+ }
250
+ }
251
+ return Array.from(tokens);
252
+ }
253
+
254
+ static _isSecretPath(name, rel) {
255
+ return SKIP_FILE_PATTERNS.some(re => re.test(name) || re.test(rel));
256
+ }
257
+
258
+ static _snippet(content) {
259
+ let snippet = content.split('\n').slice(0, SNIPPET_LINES).join('\n');
260
+ if (snippet.length > SNIPPET_MAX_CHARS) snippet = snippet.slice(0, SNIPPET_MAX_CHARS);
261
+ return snippet;
262
+ }
263
+
264
+ static _buildEdges(files) {
265
+ const known = new Set(files.map(f => f.path));
266
+ const edges = [];
267
+ const seen = new Set();
268
+
269
+ for (const file of files) {
270
+ const dir = posix.dirname(file.path);
271
+ for (const spec of file.symbols.imports) {
272
+ if (!spec.startsWith('.')) continue;
273
+ const base = posix.normalize(posix.join(dir, spec));
274
+ const hit = RESOLUTION_SUFFIXES.map(suffix => base + suffix).find(candidate => known.has(candidate));
275
+ if (!hit || hit === file.path) continue;
276
+ const key = `${file.path}->${hit}`;
277
+ if (seen.has(key)) continue;
278
+ seen.add(key);
279
+ edges.push({ from: file.path, to: hit });
280
+ }
281
+ }
282
+
283
+ return edges;
284
+ }
285
+ }
@@ -0,0 +1,105 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { Authorizer, MAX_FILE_BYTES, MAX_TOTAL_DOCUMENT_BYTES } from '../policy/authorizer.js';
3
+ import { sha256 } from '../core/hash.js';
4
+ import { ErrorCode, VectorForceError } from '../core/errors.js';
5
+
6
+ export class DocumentProvider {
7
+ /**
8
+ * Collect allowlisted documents safely.
9
+ * @param {string} canonicalWorkspace
10
+ * @param {string[]} requestedPaths Relative paths
11
+ * @param {string[]} allowedPaths Project allowlist
12
+ * @returns {{ documents: Array<object>, gaps: Array<object> }}
13
+ */
14
+ static collect(canonicalWorkspace, requestedPaths = [], allowedPaths = []) {
15
+ if (!requestedPaths || requestedPaths.length === 0) {
16
+ return { documents: [], gaps: [] };
17
+ }
18
+
19
+ if (requestedPaths.length > 8) {
20
+ throw new VectorForceError(
21
+ ErrorCode.POLICY_DENIED,
22
+ `Too many documents requested: ${requestedPaths.length}. Maximum is 8.`
23
+ );
24
+ }
25
+
26
+ // 1. Check authorization against allowlist
27
+ Authorizer.authorizeDocumentSelection(requestedPaths, allowedPaths);
28
+
29
+ // 2. Canonicalize path order (sort uniquely)
30
+ const uniqueSorted = Array.from(new Set(requestedPaths)).sort();
31
+
32
+ const documents = [];
33
+ const gaps = [];
34
+ let totalBytes = 0;
35
+
36
+ for (const relPath of uniqueSorted) {
37
+ const fullPath = Authorizer.validateDocumentPath(canonicalWorkspace, relPath);
38
+
39
+ // Check existence and stat
40
+ let st;
41
+ try {
42
+ st = statSync(fullPath);
43
+ } catch {
44
+ gaps.push({
45
+ type: 'document',
46
+ target: relPath,
47
+ reason: 'File does not exist or is inaccessible'
48
+ });
49
+ continue;
50
+ }
51
+
52
+ if (st.size > MAX_FILE_BYTES) {
53
+ throw new VectorForceError(
54
+ ErrorCode.DOCUMENT_TOO_LARGE,
55
+ `Document '${relPath}' exceeds maximum file size (${st.size} bytes > ${MAX_FILE_BYTES} bytes limit)`
56
+ );
57
+ }
58
+
59
+ if (totalBytes + st.size > MAX_TOTAL_DOCUMENT_BYTES) {
60
+ throw new VectorForceError(
61
+ ErrorCode.TOTAL_DOCUMENTS_TOO_LARGE,
62
+ `Total document size exceeds limit (${totalBytes + st.size} bytes > ${MAX_TOTAL_DOCUMENT_BYTES} bytes limit)`
63
+ );
64
+ }
65
+
66
+ // Read buffer
67
+ let buffer;
68
+ try {
69
+ buffer = readFileSync(fullPath);
70
+ } catch (err) {
71
+ gaps.push({
72
+ type: 'document',
73
+ target: relPath,
74
+ reason: `Read failed: ${err.message}`
75
+ });
76
+ continue;
77
+ }
78
+
79
+ // Check for NUL byte
80
+ if (buffer.includes(0x00)) {
81
+ throw new VectorForceError(
82
+ ErrorCode.INVALID_DOCUMENT_CONTENT,
83
+ `Document '${relPath}' contains invalid binary or NUL bytes`
84
+ );
85
+ }
86
+
87
+ const contentStr = buffer.toString('utf8');
88
+ const hash = sha256(buffer);
89
+ const collectedAt = new Date().toISOString();
90
+
91
+ documents.push({
92
+ path: relPath,
93
+ sha256: hash,
94
+ size_bytes: st.size,
95
+ collected_at: collectedAt,
96
+ content: contentStr,
97
+ untrusted_content: true
98
+ });
99
+
100
+ totalBytes += st.size;
101
+ }
102
+
103
+ return { documents, gaps };
104
+ }
105
+ }
@@ -0,0 +1,98 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ export class GitProvider {
4
+ /**
5
+ * Collect read-only Git status and HEAD commit.
6
+ * Never mutates source or calls network commands.
7
+ * @param {string} workspacePath
8
+ * @returns {{ git: object|null, gap: object|null }}
9
+ */
10
+ static collect(workspacePath) {
11
+ try {
12
+ // 1. Commit hash
13
+ const commitHash = execFileSync('git', ['rev-parse', 'HEAD'], {
14
+ cwd: workspacePath,
15
+ encoding: 'utf8',
16
+ timeout: 3000,
17
+ stdio: ['ignore', 'pipe', 'ignore']
18
+ }).trim();
19
+
20
+ // 2. Branch name
21
+ let branch = 'HEAD';
22
+ try {
23
+ branch = execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], {
24
+ cwd: workspacePath,
25
+ encoding: 'utf8',
26
+ timeout: 2000,
27
+ stdio: ['ignore', 'pipe', 'ignore']
28
+ }).trim();
29
+ } catch {
30
+ // Detached HEAD or initial commit
31
+ }
32
+
33
+ // 3. Status porcelain (check if dirty)
34
+ const statusOutput = execFileSync('git', ['status', '--porcelain'], {
35
+ cwd: workspacePath,
36
+ encoding: 'utf8',
37
+ timeout: 3000,
38
+ stdio: ['ignore', 'pipe', 'ignore']
39
+ }).trim();
40
+
41
+ const isDirty = statusOutput.length > 0;
42
+ const collectedAt = new Date().toISOString();
43
+
44
+ return {
45
+ git: {
46
+ commit_hash: commitHash,
47
+ branch,
48
+ is_dirty: isDirty,
49
+ collected_at: collectedAt
50
+ },
51
+ gap: null
52
+ };
53
+ } catch (err) {
54
+ return {
55
+ git: null,
56
+ gap: {
57
+ type: 'git',
58
+ target: 'workspace',
59
+ reason: `Git metadata unavailable: ${err.message || 'Not a git repository or git command failed'}`
60
+ }
61
+ };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * List workspace-relative files changed since a given commit (read-only).
67
+ * Deleted files are excluded so callers only reason over files that still exist.
68
+ * @param {string} workspacePath
69
+ * @param {string} [sinceCommit] 40-hex commit hash; when omitted, uses working-tree changes only
70
+ * @returns {{ files: string[], gap: object|null }}
71
+ */
72
+ static collectChangedFiles(workspacePath, sinceCommit = null) {
73
+ try {
74
+ const args = ['diff', '--name-only', '--diff-filter=d'];
75
+ if (sinceCommit) args.push(sinceCommit);
76
+ args.push('--');
77
+
78
+ const output = execFileSync('git', args, {
79
+ cwd: workspacePath,
80
+ encoding: 'utf8',
81
+ timeout: 3000,
82
+ stdio: ['ignore', 'pipe', 'ignore']
83
+ }).trim();
84
+
85
+ const files = output.length > 0 ? output.split('\n').map(f => f.trim()).filter(Boolean) : [];
86
+ return { files, gap: null };
87
+ } catch (err) {
88
+ return {
89
+ files: [],
90
+ gap: {
91
+ type: 'git',
92
+ target: 'changed-files',
93
+ reason: `Git changed-file listing unavailable: ${err.message || 'Not a git repository or git command failed'}`
94
+ }
95
+ };
96
+ }
97
+ }
98
+ }