From 3e5bf2a2b39806849205033d32cd42680f145a80 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:12:56 +0530 Subject: [PATCH 001/118] fix build packaging --- scripts/copy-ui.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 scripts/copy-ui.js diff --git a/scripts/copy-ui.js b/scripts/copy-ui.js new file mode 100644 index 0000000..421fd37 --- /dev/null +++ b/scripts/copy-ui.js @@ -0,0 +1,22 @@ +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const source = path.join(root, 'src', 'ui'); +const destination = path.join(root, 'dist', 'ui'); + +if (!fs.existsSync(source)) { + console.error(`UI source directory not found: ${source}`); + process.exit(1); +} + +fs.rmSync(destination, { recursive: true, force: true }); +fs.mkdirSync(destination, { recursive: true }); +fs.cpSync(source, destination, { recursive: true }); + +if (!fs.existsSync(path.join(destination, 'index.html'))) { + console.error('UI packaging failed: dist/ui/index.html was not produced.'); + process.exit(1); +} + +console.log(`Copied UI assets: ${path.relative(root, source)} -> ${path.relative(root, destination)}`); From a6f84c06bed6a9bcbaed4655bf7318708f01d129 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:13:11 +0530 Subject: [PATCH 002/118] harden release scripts --- package.json | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 3bf2525..dfa052a 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,42 @@ { "name": "codebase-os", "version": "1.0.0", - "description": "Codebase Operating System — A foundational layer for intelligent software development", + "description": "Codebase Operating System — a verified software-change runtime for AI engineering", "author": "Dharantej Reddy Poduvu ", + "repository": { + "type": "git", + "url": "git+https://github.com/dharan1007/codebase--os.git" + }, + "homepage": "https://github.com/dharan1007/codebase--os#readme", + "bugs": { + "url": "https://github.com/dharan1007/codebase--os/issues" + }, "keywords": [ "ai", "agent", "automation", "codebase", - "engineering" + "engineering", + "verification" ], "main": "dist/cli/index.js", "bin": { "cos": "dist/cli/index.js" }, + "files": [ + "dist", + "README.md", + "LICENSE", + "SECURITY.md" + ], "scripts": { "build": "tsc --project tsconfig.json && node scripts/copy-ui.js", "dev": "ts-node --project tsconfig.json src/cli/index.ts", "start": "node dist/cli/index.js", "typecheck": "tsc --project tsconfig.json --noEmit", - "test": "jest --passWithNoTests" + "test": "node --test tests/*.test.cjs", + "verify": "npm run typecheck && npm run build && npm test", + "prepublishOnly": "npm run verify" }, "dependencies": { "@anthropic-ai/sdk": "^0.20.9", @@ -68,6 +85,6 @@ "typescript": "^5.3.3" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } } From b63caedf78a7979dd3fdd81adba6acddd4e07fd7 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:14:00 +0530 Subject: [PATCH 003/118] fix dependency-first planning --- src/core/ai/TopologicalPlanner.ts | 547 ++++++++++++++++++++---------- 1 file changed, 360 insertions(+), 187 deletions(-) diff --git a/src/core/ai/TopologicalPlanner.ts b/src/core/ai/TopologicalPlanner.ts index 3f3dd3b..a257cd6 100644 --- a/src/core/ai/TopologicalPlanner.ts +++ b/src/core/ai/TopologicalPlanner.ts @@ -1,5 +1,5 @@ import type { RelationshipGraph } from '../graph/RelationshipGraph.js'; -import type { GraphNode } from '../../types/index.js'; +import type { EdgeKind, GraphEdge } from '../../types/index.js'; import path from 'path'; export interface PlannedFile { @@ -25,34 +25,61 @@ export interface BlastRadiusReport { } /** - * TopologicalPlanner — the core differentiator of Codebase OS. + * Relationship kinds that express a dependency from source -> target. * - * Codex, Claude Code, and Cursor make file changes in arbitrary order. - * This engine computes the mathematically correct execution order using - * Kahn's topological sort over the persistent relationship graph. + * Deliberately excluded: + * - provides / exports: containment or publication, not execution dependencies + * - tests: a test is evidence for a target, not a prerequisite to edit it * - * Before the agent writes a single line: - * 1. Identify the root files involved in the task - * 2. BFS backward → find all dependents (will break if we don't update them) - * 3. BFS forward → find all dependencies (must be changed first) - * 4. Kahn's sort → execution order where leaf files (most depended-on) go first - * 5. Return a blast radius report with cross-layer warnings and cycle detection + * Keeping this explicit prevents containment/test edges from corrupting + * blast-radius traversal and topological ordering. + */ +const DEPENDENCY_EDGE_KINDS: ReadonlySet = new Set([ + 'imports', + 'calls', + 'extends', + 'implements', + 'uses_type', + 'reads_from', + 'writes_to', + 'depends_on', + 'references', + 'api_uses', + 'db_uses', + 'renders', +]); + +interface AffectedInfo { + depth: number; + reason: string; +} + +/** + * TopologicalPlanner computes a dependency-first file execution plan. + * + * Graph convention: + * source -> target means "source depends on target". + * + * For execution, that relationship is inverted into: + * target -> source + * + * before Kahn's algorithm is applied. This guarantees that a dependency is + * emitted before a consumer whenever the dependency subgraph is acyclic. */ export class TopologicalPlanner { constructor(private graph: RelationshipGraph, private rootDir: string) {} - /** - * Given a natural-language task string, find the most relevant root files - * and compute a topologically sorted execution plan. - */ - planFromTask(task: string): BlastRadiusReport { + planFromTask(task: string, maxDepthOverride?: number): BlastRadiusReport { const keywords = task .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .split(/\s+/) - .filter(w => w.length > 3 && !['this', 'that', 'with', 'from', 'make', 'change', 'update', 'refactor', 'fix', 'add', 'remove'].includes(w)); + .filter(w => w.length > 3 && ![ + 'this', 'that', 'with', 'from', 'make', 'change', 'update', + 'refactor', 'fix', 'add', 'remove', 'into', 'using', 'should', + ].includes(w)); - const candidateNodes = Array.from(this.graph.nodes.values()) + const candidateFiles = Array.from(this.graph.nodes.values()) .filter(n => n.kind === 'file' || n.kind === 'function' || n.kind === 'class' || n.kind === 'interface') .map(n => { let score = 0; @@ -63,244 +90,390 @@ export class TopologicalPlanner { else if (name.includes(kw)) score += 5; if (fp.includes(kw)) score += 3; } - return { node: n, score }; + return { filePath: n.filePath, score }; }) .filter(x => x.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, 5) - .map(x => x.node.filePath); - - const uniqueRoots = [...new Set(candidateNodes)]; - if (uniqueRoots.length === 0) { - return { - rootFiles: [], - affectedFiles: [], - layerBreakdown: {}, - crossLayerWarnings: [], - cycles: [], - totalFiles: 0, - executionPlan: [], - estimatedComplexity: 'low', - }; + .sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath)); + + const uniqueRoots: string[] = []; + const seen = new Set(); + for (const candidate of candidateFiles) { + if (seen.has(candidate.filePath)) continue; + seen.add(candidate.filePath); + uniqueRoots.push(candidate.filePath); + if (uniqueRoots.length >= 5) break; } - return this.planFromFiles(uniqueRoots); + if (uniqueRoots.length === 0) return this.emptyReport([]); + return this.planFromFiles(uniqueRoots, maxDepthOverride); } - /** - * Given specific file paths, compute the full blast radius and sorted plan. - */ - planFromFiles(rootFilePaths: string[]): BlastRadiusReport { - // Collect root node IDs + planFromFiles(rootFilePaths: string[], maxDepthOverride?: number): BlastRadiusReport { const rootNodeIds = new Set(); const rootFileSet = new Set(); - for (const fp of rootFilePaths) { - const abs = path.isAbsolute(fp) ? fp : path.resolve(this.rootDir, fp); - rootFileSet.add(abs); - const nodes = this.graph.getNodesByFile(abs); - for (const n of nodes) rootNodeIds.add(n.id); + for (const filePath of rootFilePaths) { + const absolute = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(this.rootDir, filePath); + rootFileSet.add(absolute); + for (const node of this.graph.getNodesByFile(absolute)) { + rootNodeIds.add(node.id); + } } if (rootNodeIds.size === 0) { return { - rootFiles: rootFilePaths, - affectedFiles: [], - layerBreakdown: {}, - crossLayerWarnings: [], - cycles: [], - totalFiles: 0, + ...this.emptyReport(rootFilePaths), executionPlan: rootFilePaths, - estimatedComplexity: 'low', }; } - // ADAPTIVE DEPTH: compute the BFS ceiling from the centrality of root nodes. - // A hub node (many dependents) must be traversed deeply — a leaf node is shallow. - // - // Formula: maxDepth = clamp(log2(maxDependents + 2) * 3, 4, 20) - // maxDependents=0 → depth 4 (leaf: shallow scan) - // maxDependents=10 → depth 10 (moderate hub) - // maxDependents=100 → depth 15 (major hub) - // maxDependents=500 → depth 20 (central infrastructure, full traversal) const maxDependents = Math.max( - ...Array.from(rootNodeIds).map(id => - (this.graph.reverseAdjacency.get(id) ?? new Set()).size - ), - 0 + ...Array.from(rootNodeIds, id => this.getDependentNodeIds(id).length), + 0, ); - const adaptiveDepth = Math.min(20, Math.max(4, Math.round(Math.log2(maxDependents + 2) * 3))); + const adaptiveDepth = this.resolveDepth(maxDependents, maxDepthOverride); - const affectedIds = new Map(); - - // Seed with roots + const affectedIds = new Map(); for (const id of rootNodeIds) { affectedIds.set(id, { depth: 0, reason: 'root' }); } - // Forward BFS: anything the root depends ON (we may need to update these first) - const fwdQueue: Array<{ id: string; depth: number }> = [...rootNodeIds].map(id => ({ id, depth: 1 })); - const fwdVisited = new Set(rootNodeIds); - while (fwdQueue.length > 0) { - const { id, depth } = fwdQueue.shift()!; - if (depth > adaptiveDepth) continue; - for (const dep of (this.graph.adjacency.get(id) ?? new Set())) { - if (!fwdVisited.has(dep)) { - fwdVisited.add(dep); - affectedIds.set(dep, { depth, reason: `dependency (depth ${depth}/${adaptiveDepth})` }); - fwdQueue.push({ id: dep, depth: depth + 1 }); - } - } - } - - // Backward BFS: anything that IMPORTS the root (will break without updates) - const bwdQueue: Array<{ id: string; depth: number }> = [...rootNodeIds].map(id => ({ id, depth: 1 })); - const bwdVisited = new Set(rootNodeIds); - while (bwdQueue.length > 0) { - const { id, depth } = bwdQueue.shift()!; - if (depth > adaptiveDepth) continue; - for (const dep of (this.graph.reverseAdjacency.get(id) ?? new Set())) { - if (!bwdVisited.has(dep)) { - bwdVisited.add(dep); - if (!affectedIds.has(dep)) { - affectedIds.set(dep, { depth, reason: `dependent (will break at depth ${depth}/${adaptiveDepth})` }); - } - bwdQueue.push({ id: dep, depth: depth + 1 }); - } - } - } - - // Topological sort via Kahn's algorithm - const topoOrder = this.kahnsSort([...affectedIds.keys()]); - - // Deduplicate by file, accumulate into PlannedFile list - const fileMap = new Map(); - let order = 1; - for (const nodeId of topoOrder) { - const node = this.graph.getNode(nodeId); - if (!node || fileMap.has(node.filePath)) continue; - const rel = path.relative(this.rootDir, node.filePath).replace(/\\/g, '/'); - const info = affectedIds.get(nodeId)!; - fileMap.set(node.filePath, { - filePath: node.filePath, - relativePath: rel, - layer: node.layer, - dependentCount: this.graph.reverseAdjacency.get(nodeId)?.size ?? 0, - dependencyCount: this.graph.adjacency.get(nodeId)?.size ?? 0, - executionOrder: order++, + this.walkDependencies(rootNodeIds, adaptiveDepth, affectedIds); + this.walkDependents(rootNodeIds, adaptiveDepth, affectedIds); + + const fileInfo = this.collapseAffectedNodesToFiles(affectedIds, rootFileSet); + const affectedFileSet = new Set(fileInfo.keys()); + const dependencyMap = this.buildFileDependencyMap(affectedFileSet); + const dependentMap = this.reverseFileDependencyMap(dependencyMap); + const { order: fileOrder, cyclicFiles } = this.topologicallySortFiles(dependencyMap); + + const files: PlannedFile[] = []; + let executionOrder = 1; + for (const filePath of fileOrder) { + const info = fileInfo.get(filePath); + if (!info) continue; + const representative = this.graph.getNodesByFile(filePath)[0]; + if (!representative) continue; + + files.push({ + filePath, + relativePath: path.relative(this.rootDir, filePath).replace(/\\/g, '/'), + layer: representative.layer, + dependentCount: dependentMap.get(filePath)?.size ?? 0, + dependencyCount: dependencyMap.get(filePath)?.size ?? 0, + executionOrder: executionOrder++, reason: info.reason, - isRoot: rootFileSet.has(node.filePath), + isRoot: rootFileSet.has(filePath), }); } - const files = [...fileMap.values()]; - - // Layer breakdown const layerBreakdown: Record = {}; - for (const f of files) { - layerBreakdown[f.layer] = (layerBreakdown[f.layer] ?? 0) + 1; - } - - // Cross-layer warnings — unexpected layer boundary crossings - const crossLayerSet = new Set(); - for (const edge of this.graph.edges.values()) { - if (!affectedIds.has(edge.sourceId) || !affectedIds.has(edge.targetId)) continue; - const src = this.graph.getNode(edge.sourceId); - const tgt = this.graph.getNode(edge.targetId); - if (!src || !tgt || src.layer === tgt.layer) continue; - crossLayerSet.add(`${src.name} (${src.layer}) -> ${tgt.name} (${tgt.layer})`); + for (const file of files) { + layerBreakdown[file.layer] = (layerBreakdown[file.layer] ?? 0) + 1; } - // Cycle detection - const cycles = this.detectCycles([...affectedIds.keys()]); - - const complexity = files.length >= 20 ? 'high' : files.length >= 8 ? 'medium' : 'low'; + const crossLayerWarnings = this.buildCrossLayerWarnings(affectedFileSet); + const cycles = this.describeCycles(dependencyMap, cyclicFiles); + const complexity: BlastRadiusReport['estimatedComplexity'] = + files.length >= 20 ? 'high' : files.length >= 8 ? 'medium' : 'low'; return { rootFiles: rootFilePaths, affectedFiles: files, layerBreakdown, - crossLayerWarnings: [...crossLayerSet].slice(0, 10), - cycles: cycles.slice(0, 5), + crossLayerWarnings, + cycles, totalFiles: files.length, executionPlan: files.map(f => f.relativePath), estimatedComplexity: complexity, }; } + private walkDependencies( + roots: Set, + maxDepth: number, + affected: Map, + ): void { + const queue = Array.from(roots, id => ({ id, depth: 1 })); + const visited = new Set(roots); + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.depth > maxDepth) continue; + + for (const dependencyId of this.getDependencyNodeIds(current.id)) { + if (visited.has(dependencyId)) continue; + visited.add(dependencyId); + this.setAffectedIfBetter( + affected, + dependencyId, + current.depth, + `dependency (depth ${current.depth}/${maxDepth})`, + ); + queue.push({ id: dependencyId, depth: current.depth + 1 }); + } + } + } + + private walkDependents( + roots: Set, + maxDepth: number, + affected: Map, + ): void { + const queue = Array.from(roots, id => ({ id, depth: 1 })); + const visited = new Set(roots); + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.depth > maxDepth) continue; + + for (const dependentId of this.getDependentNodeIds(current.id)) { + if (visited.has(dependentId)) continue; + visited.add(dependentId); + this.setAffectedIfBetter( + affected, + dependentId, + current.depth, + `dependent (depth ${current.depth}/${maxDepth})`, + ); + queue.push({ id: dependentId, depth: current.depth + 1 }); + } + } + } + + private setAffectedIfBetter( + affected: Map, + nodeId: string, + depth: number, + reason: string, + ): void { + const existing = affected.get(nodeId); + if (!existing || depth < existing.depth) { + affected.set(nodeId, { depth, reason }); + } + } + + private collapseAffectedNodesToFiles( + affectedIds: Map, + rootFileSet: Set, + ): Map { + const files = new Map(); + + for (const [nodeId, info] of affectedIds) { + const node = this.graph.getNode(nodeId); + if (!node) continue; + const absolute = path.resolve(node.filePath); + const normalizedInfo = rootFileSet.has(absolute) + ? { depth: 0, reason: 'root' } + : info; + const existing = files.get(absolute); + if (!existing || normalizedInfo.depth < existing.depth) { + files.set(absolute, normalizedInfo); + } + } + + return files; + } + /** - * Kahn's algorithm — O(V+E) topological sort. - * Produces deterministic ordering where nodes with zero in-degree come first - * (i.e., foundational files that nothing imports — change these first). + * Returns file -> dependencies. Each source file depends on every target + * file reached through a dependency-bearing edge. */ - private kahnsSort(nodeIds: string[]): string[] { - const idSet = new Set(nodeIds); - const inDegree = new Map(nodeIds.map(id => [id, 0])); - const adj = new Map(nodeIds.map(id => [id, []])); - - for (const id of nodeIds) { - for (const dep of (this.graph.adjacency.get(id) ?? new Set())) { - if (idSet.has(dep)) { - adj.get(id)!.push(dep); - inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1); - } + private buildFileDependencyMap(fileSet: Set): Map> { + const dependencyMap = new Map>(); + for (const filePath of fileSet) dependencyMap.set(filePath, new Set()); + + for (const edge of this.graph.edges.values()) { + if (!this.isDependencyEdge(edge)) continue; + const source = this.graph.getNode(edge.sourceId); + const target = this.graph.getNode(edge.targetId); + if (!source || !target) continue; + + const sourceFile = path.resolve(source.filePath); + const targetFile = path.resolve(target.filePath); + if (sourceFile === targetFile) continue; + if (!fileSet.has(sourceFile) || !fileSet.has(targetFile)) continue; + + dependencyMap.get(sourceFile)!.add(targetFile); + } + + return dependencyMap; + } + + private reverseFileDependencyMap( + dependencyMap: Map>, + ): Map> { + const reverse = new Map>(); + for (const filePath of dependencyMap.keys()) reverse.set(filePath, new Set()); + + for (const [consumer, dependencies] of dependencyMap) { + for (const dependency of dependencies) { + reverse.get(dependency)?.add(consumer); } } + return reverse; + } + + /** + * Kahn sort on file dependencies. + * + * dependencyMap is consumer -> dependency, so the scheduling graph is + * inverted to dependency -> consumer before in-degrees are computed. + */ + private topologicallySortFiles( + dependencyMap: Map>, + ): { order: string[]; cyclicFiles: Set } { + const inDegree = new Map(); + const dependents = new Map>(); + + for (const filePath of dependencyMap.keys()) { + inDegree.set(filePath, 0); + dependents.set(filePath, new Set()); + } - const queue: string[] = []; - for (const [id, deg] of inDegree) { - if (deg === 0) queue.push(id); + for (const [consumer, dependencies] of dependencyMap) { + for (const dependency of dependencies) { + if (!dependencyMap.has(dependency)) continue; + dependents.get(dependency)!.add(consumer); + inDegree.set(consumer, (inDegree.get(consumer) ?? 0) + 1); + } } - const result: string[] = []; + const queue = Array.from(inDegree.entries()) + .filter(([, degree]) => degree === 0) + .map(([filePath]) => filePath) + .sort(); + const order: string[] = []; + while (queue.length > 0) { const current = queue.shift()!; - result.push(current); - for (const neighbor of (adj.get(current) ?? [])) { - const newDeg = (inDegree.get(neighbor) ?? 1) - 1; - inDegree.set(neighbor, newDeg); - if (newDeg === 0) queue.push(neighbor); + order.push(current); + + const nextDependents = Array.from(dependents.get(current) ?? []).sort(); + for (const dependent of nextDependents) { + const nextDegree = (inDegree.get(dependent) ?? 1) - 1; + inDegree.set(dependent, nextDegree); + if (nextDegree === 0) { + queue.push(dependent); + queue.sort(); + } } } - // Append cycle participants (couldn't be sorted) - for (const id of nodeIds) { - if (!result.includes(id)) result.push(id); + const cyclicFiles = new Set(); + for (const [filePath, degree] of inDegree) { + if (degree > 0) cyclicFiles.add(filePath); } - return result; + // Cycles do not have a valid total topological order. Append the affected + // members deterministically and surface them in `cycles` for review. + for (const filePath of Array.from(cyclicFiles).sort()) { + if (!order.includes(filePath)) order.push(filePath); + } + + return { order, cyclicFiles }; } - private detectCycles(nodeIds: string[]): string[] { - const idSet = new Set(nodeIds); + private describeCycles( + dependencyMap: Map>, + cyclicFiles: Set, + ): string[] { + if (cyclicFiles.size === 0) return []; + const cycles: string[] = []; const visited = new Set(); const stack = new Set(); - const pathArr: string[] = []; + const chain: string[] = []; - const dfs = (id: string): void => { + const dfs = (filePath: string): void => { if (cycles.length >= 5) return; - visited.add(id); - stack.add(id); - pathArr.push(id); - for (const neighbor of (this.graph.adjacency.get(id) ?? new Set())) { - if (!idSet.has(neighbor)) continue; - if (!visited.has(neighbor)) dfs(neighbor); - else if (stack.has(neighbor)) { - const start = pathArr.indexOf(neighbor); - if (start !== -1) { - const names = pathArr.slice(start).map(nid => this.graph.getNode(nid)?.name ?? nid); - cycles.push(names.join(' -> ')); + visited.add(filePath); + stack.add(filePath); + chain.push(filePath); + + for (const dependency of dependencyMap.get(filePath) ?? []) { + if (!cyclicFiles.has(dependency)) continue; + if (!visited.has(dependency)) { + dfs(dependency); + } else if (stack.has(dependency)) { + const index = chain.indexOf(dependency); + if (index >= 0) { + const members = chain.slice(index) + .concat(dependency) + .map(p => path.relative(this.rootDir, p).replace(/\\/g, '/')); + const text = members.join(' -> '); + if (!cycles.includes(text)) cycles.push(text); } } } - pathArr.pop(); - stack.delete(id); + + chain.pop(); + stack.delete(filePath); }; - for (const id of nodeIds) { - if (!visited.has(id)) dfs(id); + for (const filePath of Array.from(cyclicFiles).sort()) { + if (!visited.has(filePath)) dfs(filePath); } + return cycles; } + + private buildCrossLayerWarnings(fileSet: Set): string[] { + const warnings = new Set(); + + for (const edge of this.graph.edges.values()) { + if (!this.isDependencyEdge(edge)) continue; + const source = this.graph.getNode(edge.sourceId); + const target = this.graph.getNode(edge.targetId); + if (!source || !target || source.layer === target.layer) continue; + + const sourceFile = path.resolve(source.filePath); + const targetFile = path.resolve(target.filePath); + if (!fileSet.has(sourceFile) || !fileSet.has(targetFile)) continue; + + warnings.add( + `${source.name} (${source.layer}) -> ${target.name} (${target.layer}) [${edge.kind}]`, + ); + } + + return Array.from(warnings).slice(0, 10); + } + + private getDependencyNodeIds(nodeId: string): string[] { + return this.graph.getOutgoingEdges(nodeId) + .filter(edge => this.isDependencyEdge(edge)) + .map(edge => edge.targetId); + } + + private getDependentNodeIds(nodeId: string): string[] { + return this.graph.getIncomingEdges(nodeId) + .filter(edge => this.isDependencyEdge(edge)) + .map(edge => edge.sourceId); + } + + private isDependencyEdge(edge: GraphEdge): boolean { + return DEPENDENCY_EDGE_KINDS.has(edge.kind); + } + + private resolveDepth(maxDependents: number, override?: number): number { + if (override !== undefined && Number.isFinite(override)) { + return Math.min(50, Math.max(1, Math.trunc(override))); + } + return Math.min(20, Math.max(4, Math.round(Math.log2(maxDependents + 2) * 3))); + } + + private emptyReport(rootFiles: string[]): BlastRadiusReport { + return { + rootFiles, + affectedFiles: [], + layerBreakdown: {}, + crossLayerWarnings: [], + cycles: [], + totalFiles: 0, + executionPlan: [], + estimatedComplexity: 'low', + }; + } } From dd5079de7e700bd5d16572bb54a2f015be4edaea Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:14:17 +0530 Subject: [PATCH 004/118] test dependency planning --- tests/topological-planner.test.cjs | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/topological-planner.test.cjs diff --git a/tests/topological-planner.test.cjs b/tests/topological-planner.test.cjs new file mode 100644 index 0000000..308637f --- /dev/null +++ b/tests/topological-planner.test.cjs @@ -0,0 +1,123 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { TopologicalPlanner } = require('../dist/core/ai/TopologicalPlanner.js'); + +function node(id, filePath, kind = 'file', layer = 'backend') { + return { + id, + kind, + name: path.basename(filePath), + filePath, + layer, + language: 'typescript', + metadata: {}, + hash: id, + createdAt: 0, + updatedAt: 0, + }; +} + +function edge(id, kind, sourceId, targetId) { + return { + id, + kind, + sourceId, + targetId, + weight: 1, + metadata: {}, + createdAt: 0, + }; +} + +function createGraph(nodes, edges) { + const nodeMap = new Map(nodes.map(n => [n.id, n])); + const edgeMap = new Map(edges.map(e => [e.id, e])); + return { + nodes: nodeMap, + edges: edgeMap, + adjacency: new Map(), + reverseAdjacency: new Map(), + getNode(id) { + return nodeMap.get(id); + }, + getNodesByFile(filePath) { + const normalized = path.resolve(filePath); + return [...nodeMap.values()].filter(n => path.resolve(n.filePath) === normalized); + }, + getOutgoingEdges(nodeId) { + return [...edgeMap.values()].filter(e => e.sourceId === nodeId); + }, + getIncomingEdges(nodeId) { + return [...edgeMap.values()].filter(e => e.targetId === nodeId); + }, + }; +} + +test('orders imported dependencies before consumers', () => { + const root = path.resolve('/repo'); + const repository = path.join(root, 'Repository.ts'); + const service = path.join(root, 'Service.ts'); + const controller = path.join(root, 'Controller.ts'); + + const graph = createGraph( + [ + node('repository', repository), + node('service', service), + node('controller', controller), + ], + [ + edge('service-repository', 'imports', 'service', 'repository'), + edge('controller-service', 'imports', 'controller', 'service'), + ], + ); + + const planner = new TopologicalPlanner(graph, root); + const report = planner.planFromFiles([controller], 10); + + assert.deepEqual(report.executionPlan, [ + 'Repository.ts', + 'Service.ts', + 'Controller.ts', + ]); + assert.equal(report.cycles.length, 0); +}); + +test('does not treat provides edges as dependency ordering edges', () => { + const root = path.resolve('/repo'); + const source = path.join(root, 'module.ts'); + const fileNode = node('file', source, 'file'); + const functionNode = node('fn', source, 'function'); + + const graph = createGraph( + [fileNode, functionNode], + [edge('provides', 'provides', 'file', 'fn')], + ); + + const planner = new TopologicalPlanner(graph, root); + const report = planner.planFromFiles([source], 10); + + assert.equal(report.totalFiles, 1); + assert.deepEqual(report.executionPlan, ['module.ts']); + assert.equal(report.cycles.length, 0); +}); + +test('surfaces dependency cycles instead of presenting them as valid topology', () => { + const root = path.resolve('/repo'); + const a = path.join(root, 'a.ts'); + const b = path.join(root, 'b.ts'); + + const graph = createGraph( + [node('a', a), node('b', b)], + [ + edge('a-b', 'imports', 'a', 'b'), + edge('b-a', 'imports', 'b', 'a'), + ], + ); + + const planner = new TopologicalPlanner(graph, root); + const report = planner.planFromFiles([a], 10); + + assert.equal(report.totalFiles, 2); + assert.ok(report.cycles.length > 0); +}); From d5badd34e37af7f976db35d520cf3698669cb44a Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:15:05 +0530 Subject: [PATCH 005/118] make file mutations fail closed --- src/core/ai/tools/localTools.ts | 296 ++++++++++++++++++++++---------- 1 file changed, 206 insertions(+), 90 deletions(-) diff --git a/src/core/ai/tools/localTools.ts b/src/core/ai/tools/localTools.ts index 1c9b21f..a11ad20 100644 --- a/src/core/ai/tools/localTools.ts +++ b/src/core/ai/tools/localTools.ts @@ -1,5 +1,8 @@ import fs from 'fs'; import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; +import { spawnSync } from 'child_process'; export interface ToolResult { success: boolean; @@ -8,20 +11,95 @@ export interface ToolResult { isStreaming?: boolean; } -/** Validates that a resolved path is within the project rootDir sandbox */ -function assertWithinRoot(resolved: string, rootDir: string, label: string): void { +function isWithin(candidate: string, root: string): boolean { + return candidate === root || candidate.startsWith(root + path.sep); +} + +/** + * Resolves a path inside the project sandbox and defends against both lexical + * `../` escapes and symlink escapes. For a path that does not exist yet, the + * nearest existing ancestor is realpath-checked. + */ +function resolveWithinRoot(filePath: string, rootDir: string, label: string): string { const rootResolved = path.resolve(rootDir); - const normalResolved = path.resolve(resolved); - if (!normalResolved.startsWith(rootResolved + path.sep) && normalResolved !== rootResolved) { + const candidate = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(rootResolved, filePath); + + if (!isWithin(candidate, rootResolved)) { throw new Error(`Path sandbox violation: "${label}" resolves outside project root`); } + + const rootReal = fs.realpathSync(rootResolved); + if (fs.existsSync(candidate)) { + const candidateReal = fs.realpathSync(candidate); + if (!isWithin(candidateReal, rootReal)) { + throw new Error(`Path sandbox violation: "${label}" escapes project root through a symlink`); + } + return candidate; + } + + let ancestor = path.dirname(candidate); + while (!fs.existsSync(ancestor)) { + const parent = path.dirname(ancestor); + if (parent === ancestor) break; + ancestor = parent; + } + + if (fs.existsSync(ancestor)) { + const ancestorReal = fs.realpathSync(ancestor); + if (!isWithin(ancestorReal, rootReal)) { + throw new Error(`Path sandbox violation: parent of "${label}" escapes project root through a symlink`); + } + } + + return candidate; +} + +function sha256(content: string): string { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function countPatchLines(diff: string): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const line of diff.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) added++; + if (line.startsWith('-') && !line.startsWith('---')) removed++; + } + return { added, removed }; +} + +/** + * Converts an LLM-produced single-file hunk stream into a canonical patch whose + * path is controlled by Codebase OS, not by model output. + */ +function canonicalizeSingleFilePatch(filePath: string, unifiedDiff: string, rootDir: string): string { + const hunkIndex = unifiedDiff.search(/^@@/m); + if (hunkIndex < 0) { + throw new Error('patch_file rejected: no unified-diff hunk header (@@ ... @@) was found'); + } + + const body = unifiedDiff.slice(hunkIndex).trimEnd(); + if (/^diff --git /m.test(body) || /^---\s+/m.test(body) || /^\+\+\+\s+/m.test(body)) { + throw new Error('patch_file rejected: multi-file or nested file headers are not allowed'); + } + if (/^(rename from|rename to|new file mode|deleted file mode) /m.test(body)) { + throw new Error('patch_file rejected: rename/create/delete directives are not allowed in patch_file'); + } + + const resolved = resolveWithinRoot(filePath, rootDir, filePath); + const relative = path.relative(path.resolve(rootDir), resolved).replace(/\\/g, '/'); + if (!relative || relative.startsWith('../')) { + throw new Error(`patch_file rejected: invalid target path ${filePath}`); + } + + return `--- a/${relative}\n+++ b/${relative}\n${body}\n`; } -/** Reads file content for the AI agent */ +/** Reads file content for the AI agent. */ export async function readFileTool(filePath: string, rootDir: string): Promise { try { - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(rootDir, filePath); - if (!fs.existsSync(resolved)) { + const resolved = resolveWithinRoot(filePath, rootDir, filePath); + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { return { success: false, output: '', error: `File not found: ${filePath}` }; } const content = fs.readFileSync(resolved, 'utf8'); @@ -32,21 +110,27 @@ export async function readFileTool(filePath: string, rootDir: string): Promise { try { - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(rootDir, filePath); - assertWithinRoot(resolved, rootDir, filePath); + const resolved = resolveWithinRoot(filePath, rootDir, filePath); if (!content || content.trim().length === 0) { return { success: false, output: '', error: `write_file rejected: content is empty for ${filePath}` }; } + if (fs.existsSync(resolved)) { + return { + success: false, + output: '', + error: `write_file rejected: ${filePath} already exists. Read it and use patch_file so the change is context-validated.`, + }; + } + const dir = path.dirname(resolved); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const isNew = !fs.existsSync(resolved); - fs.writeFileSync(resolved, content, 'utf8'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(resolved, content, { encoding: 'utf8', flag: 'wx' }); return { success: true, - output: `${isNew ? 'Created' : 'Overwrote'}: ${path.relative(rootDir, resolved)} (${content.split('\n').length} lines)`, + output: `Created: ${path.relative(rootDir, resolved)} (${content.split('\n').length} lines, sha256=${sha256(content).slice(0, 12)})`, }; } catch (err) { return { success: false, output: '', error: String(err) }; @@ -54,111 +138,136 @@ export async function writeFileTool(filePath: string, content: string, rootDir: } /** - * Applies a unified diff patch to an existing file. - * This is the correct method for modifying existing files. - * Avoids full-file hallucination by operating on precise hunks only. + * Applies a single-file unified diff transactionally through `git apply`. + * + * `git apply --check` validates every context/removal line against the current + * file before any write occurs. A stale or hallucinated patch therefore fails + * closed instead of splicing at an approximate line number. */ export async function patchFileTool(filePath: string, unifiedDiff: string, rootDir: string): Promise { + let tempPatch: string | null = null; try { - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(rootDir, filePath); - assertWithinRoot(resolved, rootDir, filePath); - + const resolved = resolveWithinRoot(filePath, rootDir, filePath); if (!unifiedDiff || unifiedDiff.trim().length === 0) { return { success: false, output: '', error: `patch_file rejected: diff is empty for ${filePath}` }; } - - if (!fs.existsSync(resolved)) { + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { return { success: false, output: '', error: `File not found for patching: ${filePath}. Use write_file to create new files.` }; } + const gitProbe = spawnSync('git', ['--version'], { encoding: 'utf8', shell: false }); + if (gitProbe.status !== 0) { + return { + success: false, + output: '', + error: 'patch_file requires Git so patches can be context-validated with `git apply --check`. Install Git and retry.', + }; + } + + const canonicalPatch = canonicalizeSingleFilePatch(filePath, unifiedDiff, rootDir); const original = fs.readFileSync(resolved, 'utf8'); - const originalLines = original.split('\n'); - const result: string[] = [...originalLines]; - let offset = 0; - let totalAdded = 0; - let totalRemoved = 0; - - const diffLines = unifiedDiff.split('\n'); - let i = 0; - - // Skip file header lines (--- and +++) - while (i < diffLines.length && (diffLines[i]!.startsWith('---') || diffLines[i]!.startsWith('+++'))) i++; - - const hunkRegex = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; - - while (i < diffLines.length) { - const line = diffLines[i]!; - const hunkMatch = line.match(hunkRegex); - if (!hunkMatch) { i++; continue; } - - const oldStart = parseInt(hunkMatch[1]!, 10) - 1; // convert to 0-indexed - i++; - - const removals: string[] = []; - const additions: string[] = []; - - while (i < diffLines.length && !diffLines[i]!.match(hunkRegex)) { - const hunkLine = diffLines[i]!; - if (hunkLine.startsWith('-')) { - removals.push(hunkLine.slice(1)); - } else if (hunkLine.startsWith('+')) { - additions.push(hunkLine.slice(1)); - } - // context lines (space prefix) are intentionally skipped — they don't change content - i++; - } + const beforeHash = sha256(original); + + tempPatch = path.join( + os.tmpdir(), + `codebase-os-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.patch`, + ); + fs.writeFileSync(tempPatch, canonicalPatch, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + + const commonArgs = ['apply', '--recount', '--whitespace=nowarn']; + const check = spawnSync('git', [...commonArgs, '--check', tempPatch], { + cwd: path.resolve(rootDir), + encoding: 'utf8', + shell: false, + }); + if (check.status !== 0) { + const detail = (check.stderr || check.stdout || 'patch context did not match').trim(); + return { + success: false, + output: '', + error: `patch_file rejected before write: ${detail}`, + }; + } - const insertAt = oldStart + offset; - result.splice(insertAt, removals.length, ...additions); - offset += additions.length - removals.length; - totalAdded += additions.length; - totalRemoved += removals.length; + // Protect against a concurrent edit between preflight and apply. + const currentHash = sha256(fs.readFileSync(resolved, 'utf8')); + if (currentHash !== beforeHash) { + return { + success: false, + output: '', + error: `patch_file rejected: ${filePath} changed after patch validation; re-read the file and regenerate the patch.`, + }; } - fs.writeFileSync(resolved, result.join('\n'), 'utf8'); - const rel = path.relative(rootDir, resolved); + const apply = spawnSync('git', [...commonArgs, tempPatch], { + cwd: path.resolve(rootDir), + encoding: 'utf8', + shell: false, + }); + if (apply.status !== 0) { + const detail = (apply.stderr || apply.stdout || 'git apply failed').trim(); + return { success: false, output: '', error: `patch_file failed without confirmation of a valid write: ${detail}` }; + } + + const updated = fs.readFileSync(resolved, 'utf8'); + const afterHash = sha256(updated); + if (afterHash === beforeHash) { + return { success: false, output: '', error: 'patch_file produced no content change' }; + } + + const { added, removed } = countPatchLines(canonicalPatch); return { success: true, - output: `Patched: ${rel} (+${totalAdded} -${totalRemoved} lines)`, + output: + `Patched: ${path.relative(rootDir, resolved)} (+${added} -${removed} lines, ` + + `sha256 ${beforeHash.slice(0, 12)} -> ${afterHash.slice(0, 12)})`, }; } catch (err) { return { success: false, output: '', error: String(err) }; + } finally { + if (tempPatch) { + try { fs.unlinkSync(tempPatch); } catch { /* best effort */ } + } } } -/** Deletes a file as directed by the AI agent */ +/** Deletes a file or directory inside the project sandbox. */ export async function deleteFileTool(filePath: string, rootDir: string): Promise { try { - const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(rootDir, filePath); - assertWithinRoot(resolved, rootDir, filePath); + const resolved = resolveWithinRoot(filePath, rootDir, filePath); if (!fs.existsSync(resolved)) { return { success: false, output: '', error: `File not found: ${filePath}` }; } - const stats = fs.statSync(resolved); + if (path.resolve(resolved) === path.resolve(rootDir)) { + return { success: false, output: '', error: 'Refusing to delete the project root.' }; + } + + const stats = fs.lstatSync(resolved); if (stats.isDirectory()) { - fs.rmSync(resolved, { recursive: true, force: true }); + fs.rmSync(resolved, { recursive: true, force: false }); return { success: true, output: `Deleted directory: ${path.relative(rootDir, resolved)}` }; - } else { - fs.unlinkSync(resolved); - return { success: true, output: `Deleted file: ${path.relative(rootDir, resolved)}` }; } + + fs.unlinkSync(resolved); + return { success: true, output: `Deleted file: ${path.relative(rootDir, resolved)}` }; } catch (err) { return { success: false, output: '', error: String(err) }; } } -/** Moves or Renames a file/directory */ +/** Moves or renames a file/directory within the project sandbox. */ export async function moveFileTool(oldPath: string, newPath: string, rootDir: string): Promise { try { - const resolvedOld = path.isAbsolute(oldPath) ? oldPath : path.resolve(rootDir, oldPath); - const resolvedNew = path.isAbsolute(newPath) ? newPath : path.resolve(rootDir, newPath); - assertWithinRoot(resolvedOld, rootDir, oldPath); - assertWithinRoot(resolvedNew, rootDir, newPath); + const resolvedOld = resolveWithinRoot(oldPath, rootDir, oldPath); + const resolvedNew = resolveWithinRoot(newPath, rootDir, newPath); if (!fs.existsSync(resolvedOld)) { return { success: false, output: '', error: `Source not found: ${oldPath}` }; } - const dir = path.dirname(resolvedNew); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + if (fs.existsSync(resolvedNew)) { + return { success: false, output: '', error: `Destination already exists: ${newPath}` }; + } + + fs.mkdirSync(path.dirname(resolvedNew), { recursive: true }); fs.renameSync(resolvedOld, resolvedNew); return { success: true, @@ -169,26 +278,33 @@ export async function moveFileTool(oldPath: string, newPath: string, rootDir: st } } -/** Lists files in a directory for the AI agent */ +/** Lists files in a directory for the AI agent. */ export async function listFilesTool(dirPath: string, rootDir: string): Promise { try { - const resolved = path.isAbsolute(dirPath) ? dirPath : path.resolve(rootDir, dirPath); + const resolved = resolveWithinRoot(dirPath, rootDir, dirPath); if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { return { success: false, output: '', error: `Not a directory: ${dirPath}` }; } + const files: string[] = []; - const walk = (dir: string, depth: number) => { - if (depth > 3) return; + const walk = (dir: string, depth: number): void => { + if (depth > 3 || files.length >= 150) return; const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const e of entries) { - if (['node_modules', '.git', 'dist', '.cos'].includes(e.name)) continue; - const full = path.join(dir, e.name); - files.push(`${e.isDirectory() ? '[DIR] ' : '[FILE] '}${path.relative(resolved, full)}`); - if (e.isDirectory()) walk(full, depth + 1); + for (const entry of entries) { + if (files.length >= 150) return; + if (['node_modules', '.git', 'dist', '.cos'].includes(entry.name)) continue; + + const full = path.join(dir, entry.name); + const display = path.relative(resolved, full); + files.push(`${entry.isDirectory() ? '[DIR] ' : entry.isSymbolicLink() ? '[LINK] ' : '[FILE] '}${display}`); + + // Never follow symlinked directories during recursive discovery. + if (entry.isDirectory() && !entry.isSymbolicLink()) walk(full, depth + 1); } }; + walk(resolved, 0); - return { success: true, output: files.slice(0, 150).join('\n') }; + return { success: true, output: files.join('\n') }; } catch (err) { return { success: false, output: '', error: String(err) }; } From 80f23d50c3eaa9956dd5c738f5474a57f7fd791a Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:15:19 +0530 Subject: [PATCH 006/118] test transactional patches --- tests/local-tools.test.cjs | 74 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/local-tools.test.cjs diff --git a/tests/local-tools.test.cjs b/tests/local-tools.test.cjs new file mode 100644 index 0000000..8906c01 --- /dev/null +++ b/tests/local-tools.test.cjs @@ -0,0 +1,74 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { + patchFileTool, + readFileTool, + writeFileTool, +} = require('../dist/core/ai/tools/localTools.js'); + +function makeRepo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cos-local-tools-')); + const init = spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' }); + if (init.status !== 0) throw new Error(init.stderr || 'git init failed'); + return root; +} + +test('patch_file applies only when current context matches', async t => { + const root = makeRepo(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const target = path.join(root, 'example.ts'); + fs.writeFileSync(target, 'const a = 1;\nconst b = 2;\n', 'utf8'); + + const result = await patchFileTool( + 'example.ts', + '@@ -1,2 +1,2 @@\n const a = 1;\n-const b = 2;\n+const b = 3;', + root, + ); + + assert.equal(result.success, true, result.error); + assert.equal(fs.readFileSync(target, 'utf8'), 'const a = 1;\nconst b = 3;\n'); +}); + +test('patch_file rejects stale context without modifying the file', async t => { + const root = makeRepo(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const target = path.join(root, 'example.ts'); + const original = 'const a = 9;\nconst b = 2;\n'; + fs.writeFileSync(target, original, 'utf8'); + + const result = await patchFileTool( + 'example.ts', + '@@ -1,2 +1,2 @@\n const a = 1;\n-const b = 2;\n+const b = 3;', + root, + ); + + assert.equal(result.success, false); + assert.equal(fs.readFileSync(target, 'utf8'), original); +}); + +test('read_file rejects paths outside the project root', async t => { + const root = makeRepo(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const result = await readFileTool(path.resolve(root, '..', 'outside.txt'), root); + assert.equal(result.success, false); + assert.match(result.error || '', /sandbox violation/i); +}); + +test('write_file cannot overwrite an existing file', async t => { + const root = makeRepo(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const target = path.join(root, 'existing.ts'); + fs.writeFileSync(target, 'original\n', 'utf8'); + + const result = await writeFileTool('existing.ts', 'replacement\n', root); + assert.equal(result.success, false); + assert.equal(fs.readFileSync(target, 'utf8'), 'original\n'); +}); From e333d77777d7419be87fad7d8cb8b4be97f774c9 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:15:45 +0530 Subject: [PATCH 007/118] make validation fail closed --- src/utils/validation.ts | 110 +++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 52 deletions(-) diff --git a/src/utils/validation.ts b/src/utils/validation.ts index ddf66ea..9f0c302 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -48,25 +48,25 @@ export class TypeScriptValidator { validateFile(filePath: string, content: string): ValidationResult { try { - let sf: SourceFile | undefined = this.project.getSourceFile(filePath); - if (sf) { - sf.replaceWithText(content); + let sourceFile: SourceFile | undefined = this.project.getSourceFile(filePath); + if (sourceFile) { + sourceFile.replaceWithText(content); } else { - sf = this.project.createSourceFile(filePath, content, { overwrite: true }); + sourceFile = this.project.createSourceFile(filePath, content, { overwrite: true }); } - const diagnostics: Diagnostic[] = sf.getPreEmitDiagnostics(); + const diagnostics: Diagnostic[] = sourceFile.getPreEmitDiagnostics(); const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; - for (const diag of diagnostics) { - const start = diag.getStart(); - const sourceFile = diag.getSourceFile(); + for (const diagnostic of diagnostics) { + const start = diagnostic.getStart(); + const diagnosticSourceFile = diagnostic.getSourceFile(); let line = 0; let column = 0; - if (start !== undefined && sourceFile) { - const pos = sourceFile.getLineAndColumnAtPos(start); + if (start !== undefined && diagnosticSourceFile) { + const pos = diagnosticSourceFile.getLineAndColumnAtPos(start); line = pos.line; column = pos.column; } @@ -75,27 +75,40 @@ export class TypeScriptValidator { file: filePath, line, column, - message: diag.getMessageText().toString(), - code: diag.getCode(), + message: diagnostic.getMessageText().toString(), + code: diagnostic.getCode(), }; - if (diag.getCategory() === DiagnosticCategory.Error) { + if (diagnostic.getCategory() === DiagnosticCategory.Error) { errors.push(entry); - } else if (diag.getCategory() === DiagnosticCategory.Warning) { + } else if (diagnostic.getCategory() === DiagnosticCategory.Warning) { warnings.push(entry); } } return { valid: errors.length === 0, errors, warnings }; } catch (err) { - logger.debug('TypeScript validation error', { error: String(err), file: filePath }); - return { valid: true, errors: [], warnings: [] }; + const message = `Validator execution failed: ${String(err)}`; + logger.warn('TypeScript validation failed closed', { error: String(err), file: filePath }); + return { + valid: false, + errors: [{ + file: filePath, + line: 0, + column: 0, + message, + // A synthetic compiler-range code keeps callers that gate on + // low-numbered TS diagnostics from accidentally ignoring this. + code: 1999, + }], + warnings: [], + }; } } validateSyntax(content: string, filePath: string): boolean { const result = this.validateFile(filePath, content); - return result.errors.filter(e => e.code < 2000).length === 0; + return result.valid && result.errors.filter(e => e.code < 2000).length === 0; } } @@ -113,31 +126,29 @@ export function validateSchema(data: any, schema: any): boolean { return Object.keys(schema).every(key => key in data); } +/** + * Removes a single outer Markdown code fence when a provider ignored the + * raw-code-only instruction. Internal fences are preserved. + */ export function sanitizeAIOutput(raw: string): string { - return raw.trim(); + const trimmed = raw.trim(); + const fenced = trimmed.match(/^```(?:[\w.+-]+)?\s*\n([\s\S]*?)\n```$/); + return fenced ? fenced[1]!.trim() : trimmed; } -/** - * [ARCHITECTURAL HARDENING]: Fuzzy JSON Search Engine - * This implementation is physically incapable of failing just because - * the AI added conversational filler (e.g. "Sure, here is the JSON:"). - * It uses a sliding-window bracket matcher to find the first valid JSON object. - */ +/** Extracts the first parseable JSON object/array from provider output. */ export function extractJSONFromAIOutput(raw: string): any { const content = raw.trim(); - - // Attempt 1: Standard parse - try { return JSON.parse(content); } catch { } - // Attempt 2: Search for JSON blocks in backticks + try { return JSON.parse(content); } catch { /* continue */ } + const fenceMatches = [...content.matchAll(/```(?:json)?\n?([\s\S]*?)```/g)]; for (const match of fenceMatches) { try { - return JSON.parse(match[1].trim()); - } catch { } + return JSON.parse(match[1]!.trim()); + } catch { /* continue */ } } - // Attempt 3: Sliding window bracket matching (Deep Search) const firstBrace = content.indexOf('{'); const lastBrace = content.lastIndexOf('}'); const firstBracket = content.indexOf('['); @@ -149,16 +160,14 @@ export function extractJSONFromAIOutput(raw: string): any { if (start !== -1 && end !== -1 && end > start) { const candidate = content.substring(start, end + 1); try { - // Basic "AI Self-Repair": remove trailing commas before parsing const repaired = candidate.replace(/,(\s*[\]\}])/g, '$1'); return JSON.parse(repaired); } catch { - // Last resort: try the raw candidate - try { return JSON.parse(candidate); } catch { } + try { return JSON.parse(candidate); } catch { /* continue */ } } } - throw new Error('No valid JSON structure found in AI response after deep extraction.'); + throw new Error('No valid JSON structure found in AI response.'); } // ─── Agent Action Schema ────────────────────────────────────────────────────── @@ -174,7 +183,6 @@ const AgentToolEnum = z.enum([ 'search_code', 'find_references', 'pause_and_ask', - 'spawn_sub_agent', 'finish', ]); @@ -187,10 +195,6 @@ export const AgentActionSchema = z.object({ export type AgentActionValidated = z.infer; -/** - * Validates a raw parsed agent action against the schema and enforces path safety. - * Throws with a clear repair message on failure so the agent can self-correct. - */ export function validateAgentAction(raw: unknown, rootDir: string): AgentActionValidated { const result = AgentActionSchema.safeParse(raw); if (!result.success) { @@ -199,36 +203,38 @@ export function validateAgentAction(raw: unknown, rootDir: string): AgentActionV } const action = result.data; - - // Path sandbox: reject absolute paths or paths escaping rootDir - const pathArg = action.args['path'] || action.args['oldPath'] || action.args['newPath']; - if (pathArg) { + const pathArgs = [ + action.args['path'], + action.args['oldPath'], + action.args['newPath'], + action.args['dir'], + ].filter((value): value is string => Boolean(value)); + + for (const pathArg of pathArgs) { if (path.isAbsolute(pathArg)) { throw new Error( `[PATH SANDBOX VIOLATION]: "${pathArg}" is an absolute path. ` + - `You MUST use paths relative to the project root. Correct the path and retry.` + 'Use paths relative to the project root.', ); } const resolved = path.resolve(rootDir, pathArg); const rootResolved = path.resolve(rootDir); if (!resolved.startsWith(rootResolved + path.sep) && resolved !== rootResolved) { throw new Error( - `[PATH SANDBOX VIOLATION]: "${pathArg}" escapes the project root. ` + - `All file paths must be relative and within the project. Correct the path and retry.` + `[PATH SANDBOX VIOLATION]: "${pathArg}" escapes the project root.`, ); } } - // Content presence: write_file and patch_file must have non-empty content/diff if (action.tool === 'write_file' && (!action.args['content'] || action.args['content'].trim().length === 0)) { - throw new Error('[CONTENT VIOLATION]: write_file requires a non-empty "content" argument. Provide the full file content.'); + throw new Error('[CONTENT VIOLATION]: write_file requires non-empty "content".'); } if (action.tool === 'patch_file' && (!action.args['diff'] || action.args['diff'].trim().length === 0)) { - throw new Error('[CONTENT VIOLATION]: patch_file requires a non-empty "diff" argument in unified diff format.'); + throw new Error('[CONTENT VIOLATION]: patch_file requires a non-empty unified "diff".'); } if (action.tool === 'finish' && (!action.args['summary'] || action.args['summary'].trim().length === 0)) { - throw new Error('[CONTENT VIOLATION]: finish requires a non-empty "summary" argument describing what was accomplished.'); + throw new Error('[CONTENT VIOLATION]: finish requires a non-empty "summary".'); } return action; -} \ No newline at end of file +} From 51753c4c14bbe41b0d4b8357e4e59756cd49a6b2 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:15:54 +0530 Subject: [PATCH 008/118] fix incremental scan semantics --- src/cli/commands/scan.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index 088085f..38f5efa 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -6,8 +6,8 @@ import { logger } from '../../utils/logger.js'; export function scanCommand(): Command { return new Command('scan') - .description('Scan the project and build the relationship graph') - .option('-f, --force', 'Force a full rescan', false) + .description('Scan the project and build or refresh the relationship graph') + .option('-f, --force', 'Force a full rescan instead of hash-based incremental analysis', false) .action(async (opts: any) => { try { const ctx = await loadContext(); @@ -16,34 +16,30 @@ export function scanCommand(): Command { const { config, graph, db, aiProvider } = ctx; const scanner = new ProjectScanner(config.rootDir, graph, config, db, aiProvider); - console.log(chalk.bold('Starting project scan...')); - const result = await scanner.scanProject(opts.force); + const incremental = !opts.force; + console.log(chalk.bold(`Starting ${incremental ? 'incremental' : 'full'} project scan...`)); + const result = await scanner.scanProject(incremental); if (result.errors.length > 0) { console.log(chalk.yellow(`\nScan completed with ${result.errors.length} errors.`)); - if (result.errors.length <= 10) { - result.errors.forEach(e => { - console.log(chalk.gray(` - ${e.file}: ${e.error}`)); - }); - } else { - console.log(chalk.gray(` (Showing first 10 errors. Check logs for details)`)); - result.errors.slice(0, 10).forEach(e => { - console.log(chalk.gray(` - ${e.file}: ${e.error}`)); - }); + result.errors.slice(0, 10).forEach(e => { + console.log(chalk.gray(` - ${e.file}: ${e.error}`)); + }); + if (result.errors.length > 10) { + console.log(chalk.gray(' (Showing first 10 errors. Check logs for details.)')); } } else { - console.log(chalk.green('\nScan completed successfully!')); + console.log(chalk.green('\nScan completed successfully.')); } console.log(chalk.gray(`Analyzed ${result.analyzedFiles}/${result.totalFiles} files`)); console.log(chalk.gray(`Nodes created: ${result.nodesCreated}`)); console.log(chalk.gray(`Edges created: ${result.edgesCreated}`)); console.log(chalk.gray(`Duration: ${result.durationMs}ms`)); - } catch (err) { logger.error('Scan command failed', { error: String(err) }); console.error(chalk.red('\nScan failed:'), String(err)); - process.exit(1); + process.exitCode = 1; } }); } From 36f9aba6739f7cd4513150d9c93071af9d98ad3f Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:16:12 +0530 Subject: [PATCH 009/118] fix model-specific provider caching --- src/core/ai/ProviderRegistry.ts | 35 +++++++++++++-------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/src/core/ai/ProviderRegistry.ts b/src/core/ai/ProviderRegistry.ts index 77dcf1f..b3e449f 100644 --- a/src/core/ai/ProviderRegistry.ts +++ b/src/core/ai/ProviderRegistry.ts @@ -1,4 +1,4 @@ -import type { AIProvider, AIProviderKind, ProjectConfig } from '../../types/index.js'; +import type { AIProvider, AIProviderKind } from '../../types/index.js'; import { OpenAIProvider } from './providers/OpenAIProvider.js'; import { AnthropicProvider } from './providers/AnthropicProvider.js'; import { GeminiProvider } from './providers/GeminiProvider.js'; @@ -8,12 +8,11 @@ import { logger } from '../../utils/logger.js'; import { ModelRegistry } from './ModelRegistry.js'; /** - * ProviderRegistry — Singleton registry for AI Provider instances. + * Singleton provider registry. * - * CRITICAL FIX: The previous architecture instantiated new Providers (and thus new RateLimiters) - * per request. This destroyed the "Leaky Bucket" state, leading to 429 errors. - * - * This Registry ensures ONE instance per provider exists, preserving RPM/TPM state. + * Instances are shared per provider + credential + model so rate-limit state is + * preserved without accidentally reusing a provider object configured for a + * different model. */ export class ProviderRegistry { private static instance: ProviderRegistry; @@ -28,19 +27,16 @@ export class ProviderRegistry { return ProviderRegistry.instance; } - /** - * Retrieves or creates a singleton provider instance. - */ getProvider(kind: AIProviderKind, apiKey?: string, model?: string): AIProvider { - const key = `${kind}:${apiKey || 'default'}`; - - if (this.providers.has(key)) { - return this.providers.get(key)!; - } - const resolvedModel = model || ModelRegistry.resolve('reasoning-high', kind); - let provider: AIProvider; + // Do not include the raw credential in logs, but including it in this + // in-process key keeps separately configured credentials isolated. + const registryKey = `${kind}:${apiKey || 'default'}:${resolvedModel}`; + + const existing = this.providers.get(registryKey); + if (existing) return existing; + let provider: AIProvider; switch (kind) { case 'openai': provider = new OpenAIProvider(apiKey || process.env['OPENAI_API_KEY'] || '', resolvedModel); @@ -61,14 +57,11 @@ export class ProviderRegistry { throw new Error(`Unsupported provider kind: ${kind}`); } - this.providers.set(key, provider); - logger.info(`ProviderRegistry: Initialized singleton for ${kind}`, { model: resolvedModel }); + this.providers.set(registryKey, provider); + logger.info(`ProviderRegistry: initialized ${kind}`, { model: resolvedModel }); return provider; } - /** - * Clears the registry (useful for testing or session reset). - */ reset(): void { this.providers.clear(); } From e8623473c50c6225901ede5b354be9a94af504d1 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:16:29 +0530 Subject: [PATCH 010/118] unify orchestrator state database --- src/core/ai/AIProviderFactory.ts | 51 ++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/core/ai/AIProviderFactory.ts b/src/core/ai/AIProviderFactory.ts index 597c421..b2a0480 100644 --- a/src/core/ai/AIProviderFactory.ts +++ b/src/core/ai/AIProviderFactory.ts @@ -1,3 +1,4 @@ +import path from 'path'; import type { AIProvider, AIProviderKind, ProjectConfig } from '../../types/index.js'; import { AIOrchestrator } from '../orchestrator/AIOrchestrator.js'; import { ModelRouter } from '../orchestrator/ModelRouter.js'; @@ -9,27 +10,21 @@ import { EmbeddingIndex } from '../context/EmbeddingIndex.js'; import { GraphStore } from '../../storage/GraphStore.js'; import { ResourceMonitor } from '../orchestrator/ResourceMonitor.js'; import { ProviderRegistry } from './ProviderRegistry.js'; -import { ModelRegistry } from './ModelRegistry.js'; export class AIProviderFactory { static create(config: ProjectConfig): AIProvider { - const providerKind = config.ai.provider; - const model = config.ai.model; - - // Use the Singleton Registry to ensure shared state (Limits/Queue) - const registry = ProviderRegistry.getInstance(); - const provider = registry.getProvider( - providerKind as AIProviderKind, + const providerKind = config.ai.provider as AIProviderKind; + const provider = ProviderRegistry.getInstance().getProvider( + providerKind, this.getApiKey(providerKind), - model + config.ai.model, ); - return AIProviderFactory.wrapWithOrchestrator(provider, config); + return this.wrapWithOrchestrator(provider, config); } static createRaw(kind: AIProviderKind, model: string): AIProvider { - const apiKey = this.getApiKey(kind); - return ProviderRegistry.getInstance().getProvider(kind, apiKey, model); + return ProviderRegistry.getInstance().getProvider(kind, this.getApiKey(kind), model); } private static getApiKey(kind: string): string | undefined { @@ -43,23 +38,32 @@ export class AIProviderFactory { } private static wrapWithOrchestrator(provider: AIProvider, config: ProjectConfig): AIProvider { - const db = new Database(config.rootDir); + // AppContext persists project state in /.cos/cos.db. The previous + // implementation opened /cos.db here, splitting embeddings/cache + // and graph context from the CLI's authoritative state database. + const dataDir = path.join(config.rootDir, '.cos'); + const db = new Database(dataDir); + const store = new GraphStore(db); + const graph = new RelationshipGraph(store); + graph.load(); + const resourceMonitor = new ResourceMonitor(db); const router = new ModelRouter(config, db, resourceMonitor); - + const index = new EmbeddingIndex(db, provider); + const orchestrator = new AIOrchestrator(config, { router, cache: new ResponseCache(db), - contextBuilder: new ContextBuilder(new EmbeddingIndex(db, provider), new RelationshipGraph(new GraphStore(db))) + contextBuilder: new ContextBuilder(index, graph), }); return { kind: provider.kind, - execute: (req) => orchestrator.execute(req), + execute: req => orchestrator.execute(req), isAvailable: () => provider.isAvailable(), - embed: provider.embed ? (text) => provider.embed!(text) : undefined, - batchEmbed: provider.batchEmbed ? (texts) => provider.batchEmbed!(texts) : undefined, - listModels: provider.listModels ? () => provider.listModels!() : undefined + embed: provider.embed ? text => provider.embed!(text) : undefined, + batchEmbed: provider.batchEmbed ? texts => provider.batchEmbed!(texts) : undefined, + listModels: provider.listModels ? () => provider.listModels!() : undefined, }; } @@ -67,16 +71,19 @@ export class AIProviderFactory { const kinds: AIProviderKind[] = ['openai', 'anthropic', 'gemini', 'openrouter', 'ollama']; const available: AIProviderKind[] = []; - await Promise.all(kinds.map(async (kind) => { + await Promise.all(kinds.map(async kind => { try { const key = this.getApiKey(kind); if (kind === 'ollama' || (key && key.length > 0)) { const provider = ProviderRegistry.getInstance().getProvider(kind, key); if (await provider.isAvailable()) available.push(kind); } - } catch { /* skip */ } + } catch { + // Provider discovery is best effort; individual failures should + // not prevent other configured providers from being discovered. + } })); return available; } -} \ No newline at end of file +} From 57e9671842cb0b49fc092bf1d6ced4746d9fe642 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:16:49 +0530 Subject: [PATCH 011/118] fix persistent failure memory --- src/core/context/SessionMemory.ts | 135 +++++++++++++++--------------- 1 file changed, 67 insertions(+), 68 deletions(-) diff --git a/src/core/context/SessionMemory.ts b/src/core/context/SessionMemory.ts index d5f15df..58b9616 100644 --- a/src/core/context/SessionMemory.ts +++ b/src/core/context/SessionMemory.ts @@ -17,27 +17,14 @@ export interface ProjectMemory { } /** - * SessionMemory — the second major differentiator of Codebase OS. - * - * Claude Code, Codex, and Cursor start every session completely blank. - * They have NO memory of what was done in previous sessions. - * - * SessionMemory reads the persistent SQLite change_records table and - * reconstructs a structured "project memory" context block that is - * injected into the agent's initial prompt at the start of every run. - * - * This gives Codebase OS genuine multi-session intelligence: - * - What files have been most frequently modified - * - What zones of the codebase keep generating failures (and why) - * - What was accomplished in the last N sessions - * - What the agent should NOT repeat (known failure patterns) + * Reconstructs project-scoped engineering memory from durable change and + * failure records. This is evidence-backed memory, not raw chat history. */ export class SessionMemory { constructor(private db: Database, private rootDir: string) {} load(lastNSessions = 5): ProjectMemory { try { - // Query recent change records, grouped by session let rows: any[] = []; try { rows = this.db.prepare(` @@ -48,61 +35,67 @@ export class SessionMemory { LIMIT 300 `).all() as any[]; } catch { - // Table might not exist yet on a fresh project return this.empty(); } - if (rows.length === 0) return this.empty(); - - // Group by session const sessionMap = new Map(); + const fileFreq = new Map(); + for (const row of rows) { const relPath = path.relative(this.rootDir, row.file_path).replace(/\\/g, '/'); - if (!sessionMap.has(row.session_id)) { - sessionMap.set(row.session_id, { - sessionId: row.session_id, - filesModified: [], - changeCount: 0, - appliedAt: row.applied_at, - }); - } - const s = sessionMap.get(row.session_id)!; - if (!s.filesModified.includes(relPath)) s.filesModified.push(relPath); - s.changeCount++; + const session = sessionMap.get(row.session_id) ?? { + sessionId: row.session_id, + filesModified: [], + changeCount: 0, + appliedAt: row.applied_at, + }; + if (!session.filesModified.includes(relPath)) session.filesModified.push(relPath); + session.changeCount++; + session.appliedAt = Math.max(session.appliedAt, row.applied_at); + sessionMap.set(row.session_id, session); + fileFreq.set(relPath, (fileFreq.get(relPath) ?? 0) + 1); } const pastSessions = [...sessionMap.values()] .sort((a, b) => b.appliedAt - a.appliedAt) - .slice(0, lastNSessions); + .slice(0, Math.max(0, lastNSessions)); - // File modification frequency — "hot files" - const fileFreq = new Map(); - for (const row of rows) { - const rel = path.relative(this.rootDir, row.file_path).replace(/\\/g, '/'); - fileFreq.set(rel, (fileFreq.get(rel) ?? 0) + 1); - } const hotFiles = [...fileFreq.entries()] - .sort((a, b) => b[1] - a[1]) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, 8) .map(([file, changeCount]) => ({ file, changeCount })); - // Recurring failures from failure_log let recurringFailureFiles: ProjectMemory['recurringFailureFiles'] = []; try { + // failure_snapshots uses camelCase `filePath` and stores a + // frequency counter; the previous query targeted a nonexistent + // failure_log table and silently erased this memory signal. const failures = this.db.prepare(` - SELECT file_path, COUNT(*) as failureCount, MAX(message) as lastError - FROM failure_log - GROUP BY file_path - HAVING failureCount >= 2 + SELECT + fs.filePath AS file_path, + SUM(COALESCE(fs.frequency, 1)) AS failureCount, + ( + SELECT latest.message + FROM failure_snapshots latest + WHERE latest.filePath = fs.filePath + ORDER BY latest.timestamp DESC + LIMIT 1 + ) AS lastError + FROM failure_snapshots fs + GROUP BY fs.filePath + HAVING SUM(COALESCE(fs.frequency, 1)) >= 2 ORDER BY failureCount DESC LIMIT 6 `).all() as any[]; - recurringFailureFiles = failures.map(f => ({ - file: path.relative(this.rootDir, f.file_path).replace(/\\/g, '/'), - failureCount: f.failureCount, - lastError: (f.lastError ?? '').toString().slice(0, 100), + + recurringFailureFiles = failures.map(failure => ({ + file: path.relative(this.rootDir, failure.file_path).replace(/\\/g, '/'), + failureCount: Number(failure.failureCount) || 0, + lastError: String(failure.lastError ?? '').slice(0, 160), })); - } catch { /* table may not exist */ } + } catch { + recurringFailureFiles = []; + } const memory: ProjectMemory = { pastSessions, @@ -113,47 +106,53 @@ export class SessionMemory { }; memory.formatted = this.format(memory); return memory; - } catch { return this.empty(); } } private empty(): ProjectMemory { - return { pastSessions: [], totalChanges: 0, hotFiles: [], recurringFailureFiles: [], formatted: '' }; + return { + pastSessions: [], + totalChanges: 0, + hotFiles: [], + recurringFailureFiles: [], + formatted: '', + }; } - private format(m: ProjectMemory): string { - if (m.totalChanges === 0) return ''; + private format(memory: ProjectMemory): string { + if (memory.totalChanges === 0 && memory.recurringFailureFiles.length === 0) return ''; const lines: string[] = [ - '=== PROJECT MEMORY (persistent across sessions) ===', - `Total changes recorded: ${m.totalChanges}`, + '=== PROJECT MEMORY (durable engineering evidence) ===', + `Recorded successful changes: ${memory.totalChanges}`, '', ]; - if (m.pastSessions.length > 0) { - lines.push('Recent sessions (most recent first):'); - for (const s of m.pastSessions) { - const date = new Date(s.appliedAt).toISOString().slice(0, 16).replace('T', ' '); - const fileList = s.filesModified.slice(0, 4).join(', ') + (s.filesModified.length > 4 ? ` +${s.filesModified.length - 4} more` : ''); - lines.push(` [${date}] ${s.changeCount} changes | ${fileList}`); + if (memory.pastSessions.length > 0) { + lines.push('Recent recorded sessions:'); + for (const session of memory.pastSessions) { + const date = new Date(session.appliedAt).toISOString().slice(0, 16).replace('T', ' '); + const fileList = session.filesModified.slice(0, 4).join(', ') + + (session.filesModified.length > 4 ? ` +${session.filesModified.length - 4} more` : ''); + lines.push(` [${date}] ${session.changeCount} changes | ${fileList}`); } lines.push(''); } - if (m.hotFiles.length > 0) { - lines.push('Hot files (modified most frequently — approach with care):'); - for (const f of m.hotFiles.slice(0, 5)) { - lines.push(` ${f.file} (${f.changeCount}x)`); + if (memory.hotFiles.length > 0) { + lines.push('Frequently modified files:'); + for (const file of memory.hotFiles.slice(0, 5)) { + lines.push(` ${file.file} (${file.changeCount}x)`); } lines.push(''); } - if (m.recurringFailureFiles.length > 0) { - lines.push('Recurring failure zones (do NOT repeat these mistakes):'); - for (const f of m.recurringFailureFiles) { - lines.push(` ${f.file} (${f.failureCount} failures): ${f.lastError}`); + if (memory.recurringFailureFiles.length > 0) { + lines.push('Recurring failure zones:'); + for (const failure of memory.recurringFailureFiles) { + lines.push(` ${failure.file} (${failure.failureCount} failures): ${failure.lastError}`); } lines.push(''); } From 7f8545513e4cf538d18a9129dc499aec9f99d4be Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:18:14 +0530 Subject: [PATCH 012/118] require evidence before agent success --- src/core/ai/AgentLoop.ts | 543 +++++++++++++++++++++++++-------------- 1 file changed, 354 insertions(+), 189 deletions(-) diff --git a/src/core/ai/AgentLoop.ts b/src/core/ai/AgentLoop.ts index 871962d..7c172a4 100644 --- a/src/core/ai/AgentLoop.ts +++ b/src/core/ai/AgentLoop.ts @@ -32,10 +32,10 @@ import { SessionMemory } from '../context/SessionMemory.js'; import { CognitiveState } from '../context/CognitiveState.js'; import path from 'path'; import fs from 'fs'; +import { v4 as uuidv4 } from 'uuid'; import { AgentController, type AgentBudget } from './AgentController.js'; import { ContextManager } from '../context/ContextManager.js'; import { ModelRegistry } from './ModelRegistry.js'; -import { RequestQueue } from '../orchestrator/RequestQueue.js'; import { WatchdogService } from '../orchestrator/WatchdogService.js'; import { withTimeout } from '../../utils/TimeoutWrapper.js'; @@ -47,7 +47,7 @@ export interface AgentState { } export interface AgentAction { - tool: 'read_file' | 'write_file' | 'patch_file' | 'delete_file' | 'move_file' | 'list_files' | 'run_shell' | 'search_code' | 'find_references' | 'pause_and_ask' | 'spawn_sub_agent' | 'finish'; + tool: 'read_file' | 'write_file' | 'patch_file' | 'delete_file' | 'move_file' | 'list_files' | 'run_shell' | 'search_code' | 'find_references' | 'pause_and_ask' | 'finish'; args: Record; reasoning: string; tasklist?: string[]; @@ -61,15 +61,22 @@ export interface AgentStep { export interface AgentResult { success: boolean; + verified: boolean; steps: AgentStep[]; summary: string; filesWritten: string[]; totalSteps: number; tasklist: string[]; + verificationCommands: string[]; outageDetected?: boolean; quotaReached?: boolean; } +interface AgentMessage { + role: 'user' | 'assistant'; + content: string; +} + export class AgentLoop { private maxSteps = 60; private steps: AgentStep[] = []; @@ -83,16 +90,18 @@ export class AgentLoop { private fileModifications = new Map(); private actionRepetition = new Map(); private filesReadThisSession = new Set(); - private startTime: number = 0; + private startTime = 0; private failureManager: FailureManager; private rootCaseAnalyzer: RootCauseAnalyzer; - private cognitiveState!: CognitiveState; - private controller!: AgentController; - private contextManager!: ContextManager; + private cognitiveState: CognitiveState; + private controller: AgentController; + private contextManager: ContextManager; + private changeHistory: ChangeHistory; + private currentTask = ''; + private lastMutationStep = 0; + private lastVerificationStep = 0; + private verificationCommands: string[] = []; - // Context budget constants - // We keep seed + last RECENT_WINDOW raw messages. - // Older messages beyond this are only available via CognitiveState summary. private static readonly SEED_MESSAGES = 1; private static readonly RECENT_WINDOW = 12; @@ -116,19 +125,18 @@ export class AgentLoop { this.cognitiveState = new CognitiveState(sessionId, db, provider); this.cognitiveState.restore(); - const history = new ChangeHistory(db); + this.changeHistory = new ChangeHistory(db); const gitManager = new GitManager(rootDir); - this.failureManager = failureIntelligence?.manager || new FailureManager(db, history, failureStore); + this.failureManager = failureIntelligence?.manager || new FailureManager(db, this.changeHistory, failureStore); this.rootCaseAnalyzer = failureIntelligence?.rca || new RootCauseAnalyzer(provider, gitManager, graph); - // Initialize regulation components const budget: AgentBudget = { maxSteps: 60, - maxTokens: 500000, // 500k token session budget - maxCost: 2.0 // $2.00 hard cap per session + maxTokens: 500000, + maxCost: 2.0, }; this.controller = new AgentController(budget); - + const modelId = ModelRegistry.resolve('reasoning-high', provider.kind as any); this.contextManager = new ContextManager(modelId); } @@ -140,101 +148,124 @@ export class AgentLoop { onStep?: (step: number, action: any, result: any, tasklist: string[], diff?: string) => Promise | void; initialSteps?: AgentStep[]; initialFiles?: string[]; - initialMessages?: any[]; + initialMessages?: AgentMessage[]; } = {} ): Promise { + this.currentTask = task; if (options.maxSteps) this.maxSteps = options.maxSteps; const onStep = options.onStep; this.startTime = Date.now(); - // Register with Watchdog WatchdogService.getInstance().register(this.sessionId); - this.steps = options.initialSteps || []; - this.filesWritten = options.initialFiles || []; - const messages: Array<{ role: 'user' | 'assistant'; content: string }> = options.initialMessages || []; + this.steps = options.initialSteps ?? []; + this.filesWritten = options.initialFiles ?? []; + const messages: AgentMessage[] = options.initialMessages ? [...options.initialMessages] : []; + this.restoreExecutionEvidenceFromSteps(); if (messages.length === 0) { const bootstrapContext = await this.buildBootstrapContext(task); const isDesignTask = /ui|style|css|aesthetic|design|layout|frontend/i.test(task); - const designGems = isDesignTask ? `\n\n[DESIGN GUIDELINES]:\n${PromptTemplates.designPrinciples()}` : ''; + const designGuidance = isDesignTask ? `\n\n[DESIGN GUIDELINES]:\n${PromptTemplates.designPrinciples()}` : ''; const seedPrompt = `TASK: ${task}\n\n` + `[CODEBASE CONTEXT — read these files before making any changes]:\n${bootstrapContext}\n\n` + - designGems + - `RULE: For any EXISTING file, emit a patch_file action with a unified diff. ` + - `For NEW files, emit a write_file action with full content. ` + - `Begin with a read_file or list_files action to confirm your understanding.`; + designGuidance + + `RULE: For any EXISTING file, emit patch_file with a unified diff. ` + + `For NEW files, emit write_file with full content. ` + + `After the final mutation, run an appropriate successful build/test/typecheck/lint command before requesting finish. ` + + `Begin with read_file or list_files to confirm your understanding.`; messages.push({ role: 'user', content: seedPrompt }); } let stepCount = this.steps.length; - let lastSummary = 'Agent paused.'; + let lastSummary = 'Agent stopped before verified completion.'; + let completed = false; while (stepCount < this.maxSteps) { try { this.controller.checkpoint(); - // Pulse Watchdog at the start of every step WatchdogService.getInstance().pulse(this.sessionId, 'EXECUTING'); } catch (err: any) { - logger.error(`[AgentLoop] Budget halted execution: ${err.message}`); + lastSummary = `Budget halted execution: ${err.message}`; + logger.error(`[AgentLoop] ${lastSummary}`); break; } - + stepCount++; - // ── COGNITIVE STATE ────────────────────────────────────────────── const compressibleMessages = messages.slice( AgentLoop.SEED_MESSAGES, - Math.max(AgentLoop.SEED_MESSAGES, messages.length - AgentLoop.RECENT_WINDOW) + Math.max(AgentLoop.SEED_MESSAGES, messages.length - AgentLoop.RECENT_WINDOW), ); const cognitiveHeader = await this.cognitiveState.tick( - stepCount, compressibleMessages, task, - (summary: string) => logger.debug('CognitiveState compressed', { summaryLen: summary.length }) + stepCount, + compressibleMessages, + task, + summary => logger.debug('CognitiveState compressed', { summaryLen: summary.length }), ); - // ── CONTEXT REGULATION [NEW] ──────────────────────────────────── - // Instead of a lossy splice, we use the ContextManager to fit within model constraints - messages.push({ role: 'user', content: cognitiveHeader }); // Inject summary - const regulatedMessages = this.contextManager.regulate(messages as any); - + // Do not append the generated cognitive header to durable conversation + // history every step; inject it only into this request to avoid summary + // headers recursively bloating future context. + const regulatedMessages = this.contextManager.regulate([ + ...messages, + { role: 'user', content: cognitiveHeader }, + ] as any); + let response = ''; let orchestratorAttempts = 0; - const MAX_ORCHESTRATOR_ATTEMPTS = 3; + const maxOrchestratorAttempts = 3; - while (orchestratorAttempts < MAX_ORCHESTRATOR_ATTEMPTS) { + while (orchestratorAttempts < maxOrchestratorAttempts) { try { const systemPrompt = PromptTemplates.agentSystemPrompt(this.rootDir); - const results = await this.provider.execute({ + const providerResult = await this.provider.execute({ taskType: 'reasoning', priority: 'high', context: regulatedMessages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n\n'), systemPrompt, maxTokens: 4000, }); - - response = results.content; - this.controller.recordUsage(results.usage.totalTokens, 0); + + response = providerResult.content; + this.controller.recordUsage(providerResult.usage.totalTokens, 0); messages.push({ role: 'assistant', content: response }); - break; // Success! + break; } catch (err: any) { orchestratorAttempts++; - const isQuota = err.message?.includes('Quota') || err.message?.includes('429'); - - if (orchestratorAttempts >= MAX_ORCHESTRATOR_ATTEMPTS) { - if (isQuota) return this.finalize(lastSummary, stepCount, false, messages, true); - logger.error('[AgentLoop] Orchestrator exhausted all fallbacks and retries.', { error: String(err) }); - return this.finalize(lastSummary, stepCount, true, messages); + const errorText = String(err?.message ?? err); + const isQuota = /quota|429|rate.?limit/i.test(errorText); + + if (orchestratorAttempts >= maxOrchestratorAttempts) { + if (isQuota) { + return this.finalize( + `Provider quota/rate limit interrupted the task: ${errorText}`, + stepCount, + false, + messages, + false, + true, + ); + } + logger.error('[AgentLoop] Provider execution exhausted retries.', { error: errorText }); + return this.finalize( + `Provider execution failed: ${errorText}`, + stepCount, + false, + messages, + true, + false, + ); } const delay = isQuota ? 10000 : 3000; - logger.warn(`[AgentLoop] Cloud congestion. Attempt ${orchestratorAttempts}/${MAX_ORCHESTRATOR_ATTEMPTS}. Waiting ${delay/1000}s...`); - await new Promise(r => setTimeout(r, delay)); + logger.warn(`[AgentLoop] Provider retry ${orchestratorAttempts}/${maxOrchestratorAttempts} after ${delay}ms.`); + await new Promise(resolve => setTimeout(resolve, delay)); } } - // Parse + validate action with Zod let action: AgentAction; try { const raw = extractJSONFromAIOutput(response); @@ -245,50 +276,57 @@ export class AgentLoop { role: 'user', content: `[AGENT CORRECTION REQUIRED]: ${err.message}\n` + - `Output ONLY valid JSON matching: ` + - `{ "tool": "", "args": { ... }, "reasoning": "...", "tasklist": [...] }\n` + - `Valid tools: read_file, write_file, patch_file, delete_file, move_file, list_files, run_shell, search_code, find_references, pause_and_ask, finish`, + `Output ONLY valid JSON matching ` + + `{ "tool": "", "args": { ... }, "reasoning": "...", "tasklist": [...] }.\n` + + `Valid tools: read_file, write_file, patch_file, delete_file, move_file, list_files, run_shell, search_code, find_references, pause_and_ask, finish.`, }); continue; } - // ── STAGNATION & REPETITION DETECTION ──────────────────────────── const actionKey = `${action.tool}:${JSON.stringify(action.args)}`; - const actionCount = (this.actionRepetition.get(actionKey) || 0) + 1; + const actionCount = (this.actionRepetition.get(actionKey) ?? 0) + 1; this.actionRepetition.set(actionKey, actionCount); - if (actionCount >= 3) { messages.push({ role: 'user', content: - `[STAGNATION ALERT]: You have called "${action.tool}" with these exact arguments ${actionCount} times. ` + - `You are stuck in a reasoning loop. DO NOT repeat the same tool call. ` + - `If you are stuck, read a different file, use search_code, or use pause_and_ask for manual guidance.`, + `[STAGNATION ALERT]: "${action.tool}" was repeated with identical arguments ${actionCount} times. ` + + `Do not repeat it. Read different evidence, search the codebase, or ask for input.`, }); - this.actionRepetition.set(actionKey, 0); // Reset for next cycle + this.actionRepetition.set(actionKey, 0); continue; } if (action.tool === 'finish') { lastSummary = action.args['summary'] ?? 'Task completed.'; + if (this.filesWritten.length > 0 && this.lastVerificationStep < this.lastMutationStep) { + messages.push({ + role: 'user', + content: + '[VERIFICATION REQUIRED]: Code changed after the most recent successful verification. ' + + 'Run an appropriate build, test, typecheck, lint, or language-specific verification command. ' + + 'The runtime will not mark this task successful until that evidence exists.', + }); + this.saveCheckpoint(messages); + continue; + } + completed = true; break; } - // Decision engine for destructive operations let allowed = true; if (['write_file', 'patch_file', 'delete_file', 'run_shell'].includes(action.tool)) { const targetPath = action.args['path'] || action.args['oldPath'] || action.args['command'] || ''; if (action.tool === 'write_file' || action.tool === 'patch_file') { - const modCount = (this.fileModifications.get(targetPath) || 0) + 1; + const modCount = (this.fileModifications.get(targetPath) ?? 0) + 1; this.fileModifications.set(targetPath, modCount); if (modCount >= 4) { messages.push({ role: 'user', content: - `[CONVERGENCE ALARM]: You have modified "${targetPath}" ${modCount} times. ` + - `Your approach is oscillating. Stop. Re-read the file, identify the root cause, ` + - `and use a different strategy or call pause_and_ask.`, + `[CONVERGENCE ALARM]: "${targetPath}" has been modified ${modCount} times. ` + + 'Re-read it and change strategy before modifying it again.', }); this.saveCheckpoint(messages); continue; @@ -298,53 +336,54 @@ export class AgentLoop { let diffLines = 0; let newContent: string | undefined; if (action.tool === 'patch_file') { - const diff = action.args['diff'] || ''; - diffLines = diff.split('\n').filter(l => l.startsWith('+') || l.startsWith('-')).length; + diffLines = (action.args['diff'] ?? '').split('\n') + .filter(line => (line.startsWith('+') && !line.startsWith('+++')) || (line.startsWith('-') && !line.startsWith('---'))) + .length; } else if (action.tool === 'write_file') { - newContent = action.args['content'] || ''; + newContent = action.args['content'] ?? ''; diffLines = newContent.split('\n').length; } - // Derive real confidence from agent behavior — no more hardcoded 0.8 - const modCount = this.fileModifications.get(targetPath) || 0; - const hasReadFile = this.filesReadThisSession.has(targetPath); - const confidence = DecisionEngine.deriveConfidence(hasReadFile, modCount, stepCount); - + const modCount = this.fileModifications.get(targetPath) ?? 0; + const confidence = DecisionEngine.deriveConfidence( + this.filesReadThisSession.has(targetPath), + modCount, + stepCount, + ); const evaluation = this.decisionEngine.evaluate( - action.tool, targetPath, diffLines, confidence, newContent + action.tool, + targetPath, + diffLines, + confidence, + newContent, ); allowed = await this.decisionEngine.enforce(action.tool, targetPath, evaluation); } if (!allowed) { - messages.push({ role: 'user', content: 'Action denied by safety guard. Replan your approach.' }); + messages.push({ role: 'user', content: 'Action denied by safety guard. Re-plan your approach.' }); this.saveCheckpoint(messages); continue; } - // Execute the tool let result: ToolResult; let diffOutput: string | undefined; try { - // Wrap tool execution with safety timeout (90s default) result = await withTimeout( () => this.executeTool(action, onStep, stepCount, this.tasklist), 90000, - `Tool:${action.tool}` + `Tool:${action.tool}`, ); - // Capture diff for write operations to show in UI/CLI - if (result.success && (action.tool === 'write_file' || action.tool === 'patch_file')) { - diffOutput = action.tool === 'patch_file' - ? action.args['diff'] - : undefined; + if (result.success && action.tool === 'patch_file') { + diffOutput = action.args['diff']; } if (!result.success) { const report = await this.failureManager.handleFailure( 'runtime_crash', action.args['path'] || 'unknown', - result.error || 'Unknown tool failure' + result.error || 'Unknown tool failure', ); if (report.isRecurring) { const rcaReport = await this.rootCaseAnalyzer.analyze({ @@ -361,19 +400,23 @@ export class AgentLoop { content: `[ROOT CAUSE ANALYSIS]: ${rcaReport.primaryCause}\n` + `[SYSTEMIC HYPOTHESES]:\n` + - rcaReport.hypotheses.map(h => `- ${h.description} (Confidence: ${h.confidence})`).join('\n') + - `\n\nRe-plan using these systemic insights.`, + rcaReport.hypotheses.map(h => `- ${h.description} (confidence ${h.confidence})`).join('\n') + + '\n\nRe-plan using this evidence.', }); } } } catch (err: any) { - await this.failureManager.handleFailure('runtime_crash', action.args['path'] || 'unknown', err.message); - result = { success: false, output: '', error: err.message }; + await this.failureManager.handleFailure( + 'runtime_crash', + action.args['path'] || 'unknown', + String(err?.message ?? err), + ); + result = { success: false, output: '', error: String(err?.message ?? err) }; } this.steps.push({ step: stepCount, action, result }); + this.updateExecutionEvidence(stepCount, action, result); - // Track files read/modified in both the local set AND the CognitiveState if (action.tool === 'read_file' && result.success && action.args['path']) { this.filesReadThisSession.add(action.args['path']); this.cognitiveState.recordFileRead(action.args['path']); @@ -381,18 +424,19 @@ export class AgentLoop { if ((action.tool === 'write_file' || action.tool === 'patch_file') && result.success && action.args['path']) { this.cognitiveState.recordFileModified(action.args['path']); } - // Persist cognitive state to SQLite every step so crash recovery works this.cognitiveState.persist(); if (onStep) await onStep(stepCount, action, result, this.tasklist, diffOutput); - - // Emit step to dashboard this.localServer.emitStep({ step: stepCount, action, result }); const agentState: AgentState = { - filesRead: [...new Set(this.steps.filter(s => s.action.tool === 'read_file').map(s => s.action.args['path'] ?? ''))], + filesRead: [...new Set(this.steps + .filter(step => step.action.tool === 'read_file') + .map(step => step.action.args['path'] ?? ''))], filesModified: this.filesWritten, - testsStatus: 'unknown', + testsStatus: this.lastVerificationStep >= this.lastMutationStep && this.lastVerificationStep > 0 + ? 'pass' + : 'unknown', errorsRemaining: 0, }; @@ -400,168 +444,243 @@ export class AgentLoop { `[TOOL RESULT — Step ${stepCount}]\n` + `Tool: ${action.tool} | Target: ${action.args['path'] || action.args['command'] || action.args['dir'] || '(none)'}\n` + `Status: ${result.success ? 'SUCCESS' : 'FAILED'}\n` + - `Output: ${(result.output || result.error || 'empty').slice(0, 600)}\n\n` + + `Output: ${(result.output || result.error || 'empty').slice(0, 800)}\n\n` + `Files read so far: [${agentState.filesRead.slice(-5).join(', ')}]\n` + `Files modified so far: [${agentState.filesModified.join(', ')}]\n` + - `Determine your next action.`; + `Verification status: ${agentState.testsStatus}.\n` + + 'Determine the next action.'; messages.push({ role: 'user', content: toolMsg }); this.saveCheckpoint(messages); - await new Promise(r => setTimeout(r, 800)); + await new Promise(resolve => setTimeout(resolve, 250)); } - WatchdogService.getInstance().unregister(this.sessionId); - return this.finalize(lastSummary, stepCount, false, messages); + if (!completed && stepCount >= this.maxSteps) { + lastSummary = `Maximum step budget (${this.maxSteps}) reached before verified completion.`; + } + + return this.finalize(lastSummary, stepCount, completed, messages); } - /** - * Builds a rich bootstrap context by: - * 1. Loading persistent session memory from SQLite (cross-session intelligence) - * 2. Running the TopologicalPlanner to compute a blast radius execution plan - * 3. Reading the top 5 most relevant file contents (120 lines each) - * 4. Including the directory structure for orientation - * - * This replaces the broken 3-word phrase searchCodeTool discovery. - * No other coding agent does this — they all start blind every session. - */ private async buildBootstrapContext(task: string): Promise { const sections: string[] = []; - // 1. Session memory — what happened in previous sessions try { const memory = new SessionMemory(this.db, this.rootDir); - const m = memory.load(5); - if (m.formatted) { - sections.push(m.formatted); - } - } catch { /* fresh project, no history */ } + const loaded = memory.load(5); + if (loaded.formatted) sections.push(loaded.formatted); + } catch { + // Fresh projects legitimately have no durable memory yet. + } - // 2. Topological blast radius — which files will be affected and in what order if (this.graph.nodes.size > 0) { try { const planner = new TopologicalPlanner(this.graph, this.rootDir); const report = planner.planFromTask(task); if (report.totalFiles > 0) { const planLines = [ - '=== TOPOLOGICAL EXECUTION PLAN ===', + '=== DEPENDENCY-FIRST EXECUTION PLAN ===', `Blast radius: ${report.totalFiles} files across ${Object.keys(report.layerBreakdown).join(', ')} layers.`, - 'Execute in this order (dependencies first):', - ...report.affectedFiles.map(f => - ` [${f.executionOrder}] ${f.relativePath} [${f.layer}]${f.isRoot ? ' (ROOT)' : ''}${f.dependentCount >= 5 ? ` hub(${f.dependentCount} dependents)` : ''}` + 'Use this dependency order unless new evidence requires re-planning:', + ...report.affectedFiles.map(file => + ` [${file.executionOrder}] ${file.relativePath} [${file.layer}]` + + `${file.isRoot ? ' (ROOT)' : ''}` + + `${file.dependentCount >= 5 ? ` hub(${file.dependentCount} dependents)` : ''}`, ), ]; if (report.crossLayerWarnings.length > 0) { planLines.push('', 'Architecture warnings:'); - for (const w of report.crossLayerWarnings) { - planLines.push(` [!] ${w}`); - } + for (const warning of report.crossLayerWarnings) planLines.push(` [!] ${warning}`); } if (report.cycles.length > 0) { - planLines.push('', 'Circular dependencies detected:'); - for (const c of report.cycles) { - planLines.push(` [cycle] ${c}`); - } + planLines.push('', 'Dependency cycles require explicit handling:'); + for (const cycle of report.cycles) planLines.push(` [cycle] ${cycle}`); } planLines.push('=== END PLAN ==='); sections.push(planLines.join('\n')); } - } catch { /* graph might be disconnected */ } + } catch { + // A disconnected/partial graph should degrade discovery, not crash the agent. + } } - // 3. Read top relevant file contents const hubFiles = this.getHubFiles(task); const fileSnippets: string[] = []; - for (const relPath of hubFiles.slice(0, 5)) { - const absPath = path.resolve(this.rootDir, relPath); + for (const relativePath of hubFiles.slice(0, 5)) { + const absolutePath = path.resolve(this.rootDir, relativePath); try { - const content = fs.readFileSync(absPath, 'utf8'); - const snippet = content.split('\n').slice(0, 120).join('\n'); - fileSnippets.push(`=== ${relPath} ===\n${snippet}`); - } catch { /* file may have been deleted */ } + const content = fs.readFileSync(absolutePath, 'utf8'); + fileSnippets.push( + `=== ${relativePath} ===\n${content.split('\n').slice(0, 120).join('\n')}`, + ); + } catch { + // File may have been removed since the graph snapshot. + } } if (fileSnippets.length > 0) { - sections.push('=== RELEVANT FILE CONTENTS ==='); - sections.push(fileSnippets.join('\n\n---\n\n')); - sections.push('=== END FILE CONTENTS ==='); + sections.push(`=== RELEVANT FILE CONTENTS ===\n${fileSnippets.join('\n\n---\n\n')}\n=== END FILE CONTENTS ===`); } - // 4. Directory structure const dirResult = await listFilesTool('.', this.rootDir); - const dirTree = dirResult.output.split('\n').slice(0, 50).join('\n'); + const dirTree = dirResult.success ? dirResult.output.split('\n').slice(0, 50).join('\n') : '(structure unavailable)'; sections.push(`=== PROJECT STRUCTURE ===\n${dirTree}\n=== END STRUCTURE ===`); return sections.join('\n\n'); } - private analyzeImpact(filePath: string): string { - try { - const nodes = this.graph.getNodesByFile(filePath); - if (nodes.length === 0) return ''; - const dependents = nodes.flatMap(n => this.graph.getDirectDependents(n.id)); - if (dependents.length === 0) return ''; - const impactList = dependents.slice(0, 10).map(d => `- ${d.name} (${d.filePath})`).join('\n'); - return `Modifying "${filePath}" potentially impacts:\n${impactList}\nEnsure these files remain consistent.`; - } catch { - return ''; - } - } - private getHubFiles(task: string): string[] { - if (!this.graph || this.graph.nodes.size === 0) return []; - const kw = task.toLowerCase().split(/\s+/).filter(w => w.length > 3); + if (this.graph.nodes.size === 0) return []; + const keywords = task.toLowerCase().split(/\s+/).filter(word => word.length > 3); const nodes = Array.from(this.graph.nodes.values()) - .filter(n => kw.some(k => n.name.toLowerCase().includes(k) || n.filePath.toLowerCase().includes(k))) + .filter(node => keywords.some(keyword => + node.name.toLowerCase().includes(keyword) || node.filePath.toLowerCase().includes(keyword), + )) .sort((a, b) => { - const score = (node: any) => { - const deps = Array.from(this.graph.reverseAdjacency.get(node.id) || []); - return deps.length; - }; + const score = (node: any) => this.graph.getIncomingEdges(node.id).length; return score(b) - score(a); }); - return nodes.slice(0, 8).map(n => path.relative(this.rootDir, n.filePath)); + + const seen = new Set(); + const files: string[] = []; + for (const node of nodes) { + const relative = path.relative(this.rootDir, node.filePath); + if (seen.has(relative)) continue; + seen.add(relative); + files.push(relative); + if (files.length >= 8) break; + } + return files; } - private saveCheckpoint(messages: any[]) { + private saveCheckpoint(messages: AgentMessage[], status: 'in_progress' | 'paused' = 'in_progress'): void { this.checkpointManager.save({ id: this.sessionId, sessionId: this.sessionId, taskType: 'agent', - status: 'in_progress', - plan: [{ id: 'agent-main', kind: 'refactor', description: '', targetFile: '.', context: '', constraints: [], expectedOutput: '', priority: 1 }], + status, + plan: [{ + id: 'agent-main', + kind: 'refactor', + description: this.currentTask, + targetFile: '.', + context: '', + constraints: [], + expectedOutput: '', + priority: 1, + }], results: [], - metadata: { steps: this.steps, filesWritten: this.filesWritten, messages }, + metadata: { + task: this.currentTask, + steps: this.steps, + filesWritten: this.filesWritten, + messages, + lastMutationStep: this.lastMutationStep, + lastVerificationStep: this.lastVerificationStep, + verificationCommands: this.verificationCommands, + }, updatedAt: Date.now(), }); } private async finalize( - lastSummary: string, + summary: string, stepCount: number, + completed: boolean, + messages: AgentMessage[] = [], outageDetected = false, - messages: any[] = [], - quotaReached = false + quotaReached = false, ): Promise { + WatchdogService.getInstance().unregister(this.sessionId); this.localServer.stop(); + + const verified = this.filesWritten.length === 0 || + (this.lastVerificationStep >= this.lastMutationStep && this.lastVerificationStep > 0); + const success = completed && verified && !outageDetected && !quotaReached; + const result: AgentResult = { - success: true, - summary: lastSummary, + success, + verified, + summary, steps: this.steps, filesWritten: this.filesWritten, totalSteps: stepCount, tasklist: this.tasklist, + verificationCommands: [...this.verificationCommands], outageDetected, quotaReached, }; - const tokensUsed = messages.reduce((acc, m) => acc + (m.content.length / 4), 0); - this.evalTracker.trackSession(this.sessionId, 'code', this.startTime, result, tokensUsed, this.provider.kind, 'agent-loop-model'); + + if (success) { + this.checkpointManager.markFinished(this.sessionId); + } else { + this.saveCheckpoint(messages, 'paused'); + } + + const tokensUsed = messages.reduce((acc, message) => acc + message.content.length / 4, 0); + this.evalTracker.trackSession( + this.sessionId, + 'code', + this.startTime, + result, + tokensUsed, + this.provider.kind, + 'agent-loop-model', + ); return result; } + private updateExecutionEvidence(step: number, action: AgentAction, result: ToolResult): void { + if (!result.success) return; + + if (['write_file', 'patch_file', 'delete_file', 'move_file'].includes(action.tool)) { + this.lastMutationStep = step; + } + + if (action.tool === 'run_shell') { + const command = action.args['command'] ?? ''; + if (this.isVerificationCommand(command)) { + this.lastVerificationStep = step; + this.verificationCommands.push(command); + } + } + } + + private restoreExecutionEvidenceFromSteps(): void { + this.lastMutationStep = 0; + this.lastVerificationStep = 0; + this.verificationCommands = []; + this.filesReadThisSession.clear(); + + for (const step of this.steps) { + if (!step.result.success) continue; + if (step.action.tool === 'read_file' && step.action.args['path']) { + this.filesReadThisSession.add(step.action.args['path']); + } + this.updateExecutionEvidence(step.step, step.action, step.result); + } + } + + private isVerificationCommand(command: string): boolean { + const normalized = command.trim().toLowerCase(); + const patterns = [ + /^npm\s+(test|run\s+(test|build|typecheck|lint|check|verify))(\s|$)/, + /^(pnpm|yarn|bun)\s+(test|build|typecheck|lint|check|verify)(\s|$)/, + /^npx\s+(tsc|eslint|jest|vitest)(\s|$)/, + /^(pytest|python\s+-m\s+pytest)(\s|$)/, + /^go\s+test(\s|$)/, + /^cargo\s+(test|check|clippy)(\s|$)/, + /^(mvn|gradle)\s+.*\b(test|check|verify|build)\b/, + /^dotnet\s+(test|build)(\s|$)/, + /^(swift|flutter|dart)\s+test(\s|$)/, + ]; + return patterns.some(pattern => pattern.test(normalized)); + } + private async executeTool( action: AgentAction, onStep: any, stepCount: number, - tasklist: string[] + tasklist: string[], ): Promise { try { switch (action.tool) { @@ -569,25 +688,41 @@ export class AgentLoop { return await readFileTool(action.args['path'] ?? '', this.rootDir); case 'write_file': { - const r = await writeFileTool(action.args['path'] ?? '', action.args['content'] ?? '', this.rootDir); - if (r.success) this.filesWritten.push(action.args['path'] ?? ''); - return r; + const target = action.args['path'] ?? ''; + const absolute = path.resolve(this.rootDir, target); + const result = await writeFileTool(target, action.args['content'] ?? '', this.rootDir); + if (result.success) { + if (!this.filesWritten.includes(target)) this.filesWritten.push(target); + const updated = fs.readFileSync(absolute, 'utf8'); + this.recordChange(stepCount, target, '', updated); + } + return result; } case 'patch_file': { - const r = await patchFileTool(action.args['path'] ?? '', action.args['diff'] ?? '', this.rootDir); - if (r.success) { - const p = action.args['path'] ?? ''; - if (!this.filesWritten.includes(p)) this.filesWritten.push(p); + const target = action.args['path'] ?? ''; + const absolute = path.resolve(this.rootDir, target); + let original = ''; + try { original = fs.readFileSync(absolute, 'utf8'); } catch { /* tool will return a precise error */ } + + const result = await patchFileTool(target, action.args['diff'] ?? '', this.rootDir); + if (result.success) { + if (!this.filesWritten.includes(target)) this.filesWritten.push(target); + const updated = fs.readFileSync(absolute, 'utf8'); + this.recordChange(stepCount, target, original, updated); } - return r; + return result; } case 'delete_file': return await deleteFileTool(action.args['path'] ?? '', this.rootDir); case 'move_file': - return await moveFileTool(action.args['oldPath'] ?? '', action.args['newPath'] ?? '', this.rootDir); + return await moveFileTool( + action.args['oldPath'] ?? '', + action.args['newPath'] ?? '', + this.rootDir, + ); case 'list_files': return await listFilesTool(action.args['dir'] ?? '.', this.rootDir); @@ -599,9 +734,16 @@ export class AgentLoop { return await findReferencesTool(action.args['symbol'] ?? '', this.rootDir); case 'run_shell': - return await this.sandboxManager.execute(action.args['command'] ?? '', false, (chunk) => { - onStep?.(stepCount, action, { success: true, output: chunk, isStreaming: true }, tasklist); - }); + return await this.sandboxManager.execute( + action.args['command'] ?? '', + false, + chunk => onStep?.( + stepCount, + action, + { success: true, output: chunk, isStreaming: true }, + tasklist, + ), + ); default: return { success: false, output: '', error: `Unknown tool: ${action.tool}` }; @@ -610,4 +752,27 @@ export class AgentLoop { return { success: false, output: '', error: String(err) }; } } + + private recordChange(stepCount: number, relativePath: string, original: string, updated: string): void { + if (original === updated) return; + const absolutePath = path.resolve(this.rootDir, relativePath); + const diff = computeDiff(original, updated, relativePath).raw; + + this.changeHistory.record({ + id: uuidv4(), + sessionId: this.sessionId, + taskId: `agent-step-${stepCount}`, + filePath: absolutePath, + originalContent: original, + updatedContent: updated, + diff, + appliedAt: Date.now(), + provider: this.provider.kind, + // This field historically represented model confidence. For direct + // file-tool transactions, 1 means only that the write was confirmed, + // not that the code is semantically correct; semantic completion is + // separately gated by verification evidence. + confidence: 1, + }); + } } From 18cf401938dee9770abf9d30aeebc4b4eb2b1b84 Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:18:36 +0530 Subject: [PATCH 013/118] restore full agent checkpoints --- src/cli/commands/continue.ts | 59 ++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/src/cli/commands/continue.ts b/src/cli/commands/continue.ts index 7c63e05..9de14cb 100644 --- a/src/cli/commands/continue.ts +++ b/src/cli/commands/continue.ts @@ -13,12 +13,12 @@ import { ChangeHistory } from '../../storage/ChangeHistory.js'; export function continueCommand(): Command { return new Command('continue') - .description('Resume the last interrupted AI task from its last checkpoint') + .description('Resume the last interrupted AI task from its durable checkpoint') .action(async () => { const ctx = await loadContext(); if (!ctx) return; - const { config, db, sessionId, graph, store } = ctx; + const { config, db, graph, store } = ctx; const checkpointManager = new CheckpointManager(db); const checkpoint = checkpointManager.getLatest(); @@ -31,42 +31,57 @@ export function continueCommand(): Command { console.log(chalk.gray('─'.repeat(40))); console.log(` Task Type: ${chalk.cyan(checkpoint.taskType.toUpperCase())}`); console.log(` Session: ${chalk.gray(checkpoint.sessionId)}`); + console.log(` Status: ${chalk.gray(checkpoint.status)}`); console.log(` Updated: ${new Date(checkpoint.updatedAt).toLocaleString()}`); console.log(''); const monitor = new ResourceMonitor(db); const modelRouter = new ModelRouter(config, db, monitor); - const provider = modelRouter.getProviderForTask('code'); + const provider = modelRouter.getProviderForTask('reasoning'); if (checkpoint.taskType === 'agent') { const agent = new AgentLoop(provider, config.rootDir, db, checkpoint.sessionId, graph, store); - const task = checkpoint.plan[0]?.description ?? 'Unknown task'; - const steps = checkpoint.metadata.steps ?? []; - const files = checkpoint.metadata.filesWritten ?? []; - + const task = checkpoint.metadata.task || checkpoint.plan[0]?.description; + if (!task || typeof task !== 'string' || task.trim().length === 0) { + console.log(chalk.red('Checkpoint is missing the original task description; refusing an unsafe blind resume.')); + return; + } + + const steps = Array.isArray(checkpoint.metadata.steps) ? checkpoint.metadata.steps : []; + const files = Array.isArray(checkpoint.metadata.filesWritten) ? checkpoint.metadata.filesWritten : []; + const messages = Array.isArray(checkpoint.metadata.messages) ? checkpoint.metadata.messages : []; + console.log(chalk.yellow(`Resuming autonomous agent from step ${steps.length + 1}...`)); - + const spinner = ora('Agent is working...').start(); const result = await agent.run(task, { - onStep: async (step: number, action: any, toolResult: any) => { - spinner.start(`Agent working... (step ${step}: ${action.tool})`); + onStep: async (step: number, action: any) => { + spinner.text = `Agent working... (step ${step}: ${action.tool})`; }, initialSteps: steps, - initialFiles: files + initialFiles: files, + initialMessages: messages, }); - + spinner.stop(); console.log(chalk.bold('\nAgent Summary')); console.log(chalk.gray('─'.repeat(40))); - console.log(` ${result.success ? chalk.green('Completed') : chalk.yellow('Partial')} — ${result.totalSteps} step(s) taken`); + console.log(` ${result.success ? chalk.green('Verified completion') : chalk.yellow('Still incomplete')} — ${result.totalSteps} step(s)`); + console.log(` Verified: ${result.verified ? chalk.green('yes') : chalk.yellow('no')}`); console.log(` ${result.summary}`); - checkpointManager.markFinished(checkpoint.id); + if (result.verificationCommands.length > 0) { + console.log(chalk.gray(` Evidence: ${result.verificationCommands.join(' | ')}`)); + } + // AgentLoop owns checkpoint status. Do not mark a partial resume + // finished merely because this command returned. } else { const history = new ChangeHistory(db); const executor = new SelfHealingExecutor(provider, config, history, checkpoint.sessionId, db); - - console.log(chalk.yellow(`Resuming plan execution: ${checkpoint.results.length} / ${checkpoint.plan.length} tasks completed.`)); - + + console.log(chalk.yellow( + `Resuming plan execution: ${checkpoint.results.length} / ${checkpoint.plan.length} tasks completed.`, + )); + const spinners = new Map(); const healResult = await executor.executeAndHeal( checkpoint.plan, @@ -81,11 +96,17 @@ export function continueCommand(): Command { spinners.get(label)?.fail(`Failed: ${detail ?? 'error'}`); } }, - checkpoint.results + checkpoint.results, ); console.log(RichFormatter.formatExecutionTable(healResult.finalResults)); - checkpointManager.markFinished(checkpoint.id); + const complete = healResult.finalResults.length >= checkpoint.plan.length && + healResult.finalResults.every(result => result.success); + if (complete) { + checkpointManager.markFinished(checkpoint.id); + } else { + console.log(chalk.yellow('Plan remains checkpointed because one or more tasks are not verified successful.')); + } } console.log(''); From 3055929e2789fe90caae0f88b6a4fed0a042c6ef Mon Sep 17 00:00:00 2001 From: dharan <150376358+dharan1007@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:19:53 +0530 Subject: [PATCH 014/118] make scans durable and graph-consistent --- src/core/scanner/ProjectScanner.ts | 634 +++++++++++++++-------------- 1 file changed, 329 insertions(+), 305 deletions(-) diff --git a/src/core/scanner/ProjectScanner.ts b/src/core/scanner/ProjectScanner.ts index 0caf57a..7af0ab4 100644 --- a/src/core/scanner/ProjectScanner.ts +++ b/src/core/scanner/ProjectScanner.ts @@ -1,54 +1,21 @@ -/** - * ProjectScanner — Streaming, Checkpointed, Prompt-Injection-Hardened Scanner. - * - * CRITICAL FAILURES FIXED: - * - * 1. MEMORY OOM (was: fast-glob → full array → all files in heap simultaneously) - * FIX: Stream-based discovery. We never hold more than STREAM_WINDOW files in - * memory at once. Heap footprint is O(STREAM_WINDOW), not O(total files). - * - * 2. TRANSACTION FRAGILITY (was: one giant transaction wrapping the entire scan) - * FIX: Per-window checkpointed transactions. If scan dies at file 99,999 of - * 100,000, a resume picks up from the last committed checkpoint, not from zero. - * - * 3. PROMPT INJECTION (was: raw code comments fed directly to LLM context) - * FIX: All content heading to the embedding index is scrubbed for embedded - * AI instructions ("/* AI:", "