diff --git a/README.md b/README.md index 1b9aaa0..a412fd2 100644 --- a/README.md +++ b/README.md @@ -178,10 +178,13 @@ npx ai-engineering-loop context query createStudioRun npx ai-engineering-loop context related lib/studio-server.js npx ai-engineering-loop context fast-path --json npx ai-engineering-loop context diff-hunks --run +npx ai-engineering-loop context diff-hunks --run --include-ignored npx ai-engineering-loop verification summarize --run npx ai-engineering-loop context compact --run ``` +By default, smart context skips high-volume review inputs such as CSS, generated bundles, lockfiles, build output, and paths listed in `.aelcontextignore`. Use `--include-ignored` when the task is explicitly about those files. + ## For people who want the controls The friendly workflow sits on top of a complete local CLI. You can inspect recipes, budgets, evidence, nodes, gates, policies, and handoffs whenever you need to. diff --git a/bin/ai-engineering-loop.js b/bin/ai-engineering-loop.js index 1170130..9f21fb1 100755 --- a/bin/ai-engineering-loop.js +++ b/bin/ai-engineering-loop.js @@ -1380,10 +1380,11 @@ function handleContext() { const query = argValue('--query'); const limit = Number(argValue('--limit') || 10); const json = process.argv.includes('--json'); + const includeIgnored = process.argv.includes('--include-ignored'); const files = commandFiles(4, ['--run', '--profile', '--base', '--query', '--limit']); try { if (stage === 'index') { - const result = buildContextIndex(CWD, { files }); + const result = buildContextIndex(CWD, { files, includeIgnored }); const summary = smartContextSummary(result); if (json) { console.log(JSON.stringify({ ok: true, ...summary })); @@ -1417,7 +1418,7 @@ function handleContext() { return; } if (stage === 'fast-path') { - const result = assessSmallTaskFastPath(CWD, { base }); + const result = assessSmallTaskFastPath(CWD, { base, includeIgnored }); if (json) { console.log(JSON.stringify({ ok: true, ...result })); } else { @@ -1441,7 +1442,7 @@ function handleContext() { return; } if (stage === 'diff-hunks' || stage === 'diff-pack') { - const result = createDiffHunkPack(CWD, { runId, base, profile }); + const result = createDiffHunkPack(CWD, { runId, base, profile, includeIgnored }); const summary = smartContextSummary(result); if (json) { console.log(JSON.stringify({ ok: true, ...summary })); @@ -1451,6 +1452,7 @@ function handleContext() { console.log(`- Review profile: ${summary.reviewProfile}`); console.log(`- Files: ${summary.files}, estimated tokens: ${summary.estimatedTokens}`); console.log(`- Unresolved findings: ${summary.unresolvedFindings}, truncated: ${summary.truncated}`); + if (summary.ignoredPaths) console.log(`- Ignored by context policy: ${summary.ignoredPaths}`); } return; } @@ -2071,6 +2073,7 @@ Commands: --base base ref for diff-hunks; default HEAD --query query term for context query --limit bound query/related result count + --include-ignored include CSS, generated, lockfile, and .aelcontextignore paths --json print only pack metadata; never print packed content eval Validate the 20-case production evaluation catalog --results score host-generated JSON results diff --git a/core/runtime-safety.md b/core/runtime-safety.md index 7378b25..8fae05b 100644 --- a/core/runtime-safety.md +++ b/core/runtime-safety.md @@ -68,11 +68,12 @@ npx ai-engineering-loop context query createStudioRun npx ai-engineering-loop context related lib/studio-server.js npx ai-engineering-loop context fast-path --json npx ai-engineering-loop context diff-hunks --run +npx ai-engineering-loop context diff-hunks --run --include-ignored npx ai-engineering-loop verification summarize --run npx ai-engineering-loop context compact --run ``` -The index stores file summaries and hashes, never file bodies, and reuses per-hash summaries when files are unchanged. `query` and `related` return metadata only so agents can choose the right files before reading source. `fast-path` keeps low-risk small diffs on lean review and names the reason when a task must use the standard path. The diff-hunk pack stores bounded changed hunks, file summaries, token estimates, and unresolved blocking findings from the previous ledger. The verification summary stores command outcomes, counts, and short failure excerpts without raw stdout. `context compact` writes `run-summary.md`, `run-summary.json`, and `open-findings.json` so iteration 2+ reviewers and Judge start from compact artifacts and request full source or logs only when the hunk or summary cannot prove or disprove a finding. +The index stores file summaries and hashes, never file bodies, and reuses per-hash summaries when files are unchanged. `query` and `related` return metadata only so agents can choose the right files before reading source. `fast-path` keeps low-risk small diffs on lean review and names the reason when a task must use the standard path. The diff-hunk pack stores bounded changed hunks, file summaries, token estimates, unresolved blocking findings from the previous ledger, and a count of paths skipped by context policy. Smart context skips CSS, generated bundles, lockfiles, build output, and `.aelcontextignore` matches by default; pass `--include-ignored` only when those files are the task. The verification summary stores command outcomes, counts, and short failure excerpts without raw stdout. `context compact` writes `run-summary.md`, `run-summary.json`, and `open-findings.json` so iteration 2+ reviewers and Judge start from compact artifacts and request full source or logs only when the hunk or summary cannot prove or disprove a finding. ## Unattended Maker isolation diff --git a/lib/smart-context.js b/lib/smart-context.js index 35f4d04..3cd4571 100644 --- a/lib/smart-context.js +++ b/lib/smart-context.js @@ -42,6 +42,24 @@ const SMALL_TASK_HIGH_RISK_PATTERNS = Object.freeze([ /(^|\/)(package-lock|pnpm-lock|yarn\.lock)$/i ]); +const DEFAULT_CONTEXT_IGNORE_PATTERNS = Object.freeze([ + '*.css', + '*.map', + '*.min.js', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lockb', + 'dist/', + 'build/', + 'coverage/', + 'node_modules/', + 'studio/styles.css', + 'studio/assets/', + 'docs/images/', + '.ai-engineering-loop/' +]); + function smartContextError(message, code = 'SMART_CONTEXT_ERROR') { const err = new Error(message); err.code = code; @@ -68,12 +86,63 @@ function git(rootDir, args) { }); } -function listTrackedFiles(rootDir) { +function normalizeRepoPath(relativePath) { + return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\/+/, ''); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function globToRegExp(pattern) { + const parts = String(pattern).split('*').map(escapeRegExp); + return new RegExp(`^${parts.join('.*')}$`); +} + +function readContextIgnorePatterns(rootDir) { + const ignorePath = path.join(rootDir, '.aelcontextignore'); + if (!fs.existsSync(ignorePath)) return []; + return fs.readFileSync(ignorePath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +} + +function matchesContextIgnorePattern(relativePath, pattern) { + const candidate = normalizeRepoPath(relativePath); + const normalizedPattern = normalizeRepoPath(pattern); + if (!normalizedPattern) return false; + if (normalizedPattern.endsWith('/')) { + return candidate === normalizedPattern.slice(0, -1) || candidate.startsWith(normalizedPattern); + } + if (!normalizedPattern.includes('/')) { + const basename = path.posix.basename(candidate); + return normalizedPattern.includes('*') + ? globToRegExp(normalizedPattern).test(basename) + : basename === normalizedPattern; + } + return normalizedPattern.includes('*') + ? globToRegExp(normalizedPattern).test(candidate) + : candidate === normalizedPattern; +} + +function contextIgnorePatterns(rootDir) { + return [...DEFAULT_CONTEXT_IGNORE_PATTERNS, ...readContextIgnorePatterns(rootDir)]; +} + +function isReviewContextIgnored(rootDir, relativePath, { includeIgnored = false } = {}) { + if (includeIgnored) return false; + const candidate = normalizeRepoPath(relativePath); + return contextIgnorePatterns(rootDir).some((pattern) => matchesContextIgnorePattern(candidate, pattern)); +} + +function listTrackedFiles(rootDir, { includeIgnored = false } = {}) { return git(rootDir, ['ls-files']) .split('\n') .map((line) => line.trim()) .filter(Boolean) - .filter((relativePath) => !isSensitivePath(relativePath)); + .filter((relativePath) => !isSensitivePath(relativePath)) + .filter((relativePath) => !isReviewContextIgnored(rootDir, relativePath, { includeIgnored })); } function languageFor(relativePath) { @@ -163,11 +232,13 @@ function writeCachedSummary(rootDir, summary) { }); } -function buildContextIndex(rootDir, { files } = {}) { +function buildContextIndex(rootDir, { files, includeIgnored = false } = {}) { ensurePrivateRuntimeIgnores(rootDir); - const candidates = (files && files.length ? files : listTrackedFiles(rootDir)) + const hasExplicitFiles = files && files.length; + const candidates = (hasExplicitFiles ? files : listTrackedFiles(rootDir, { includeIgnored })) .map((file) => String(file).trim()) - .filter(Boolean); + .filter(Boolean) + .filter((file) => hasExplicitFiles || !isReviewContextIgnored(rootDir, file, { includeIgnored })); const summaries = []; const cache = { hits: 0, misses: 0 }; for (const candidate of [...new Set(candidates)]) { @@ -402,14 +473,19 @@ function boundedHunks(file, limits, remainingBytes) { return { hunks, includedBytes, truncated }; } -function createDiffHunkPack(rootDir, { runId, base = 'HEAD', profile } = {}) { +function createDiffHunkPack(rootDir, { runId, base = 'HEAD', profile, includeIgnored = false } = {}) { const run = resolveRun(rootDir, runId); const policy = loadPolicy(rootDir); const reviewProfile = normalizeReviewProfile(profile || policy.reviewProfile); const limits = DIFF_PACK_LIMITS[reviewProfile]; const contextLimits = CONTEXT_PROFILE_LIMITS[reviewProfile]['devil-advocate']; const diff = git(rootDir, ['diff', '--no-ext-diff', '--unified=3', base, '--']); - const allParsedFiles = parseDiff(diff).filter((file) => !isSensitivePath(file.path)); + const parsedDiffFiles = parseDiff(diff).filter((file) => !isSensitivePath(file.path)); + const ignoredPaths = parsedDiffFiles + .filter((file) => isReviewContextIgnored(rootDir, file.path, { includeIgnored })) + .map((file) => file.path); + const allParsedFiles = parsedDiffFiles + .filter((file) => !isReviewContextIgnored(rootDir, file.path, { includeIgnored })); const parsedFiles = allParsedFiles.slice(0, limits.maxFiles); const totalFilesTruncated = allParsedFiles.length > parsedFiles.length; const unresolved = unresolvedFindings(rootDir, run.runId); @@ -455,6 +531,7 @@ function createDiffHunkPack(rootDir, { runId, base = 'HEAD', profile } = {}) { previousDiffHash: run.artifacts?.diff?.sha256 || null, currentDiffHash: sha256(diff), changedPaths, + ignoredPaths, unresolvedFindingIds: unresolved.map((finding) => finding.id), focusPaths }, @@ -477,16 +554,26 @@ function changedLineCount(diff) { return count; } -function assessSmallTaskFastPath(rootDir, { base = 'HEAD' } = {}) { +function assessSmallTaskFastPath(rootDir, { base = 'HEAD', includeIgnored = false } = {}) { const diff = git(rootDir, ['diff', '--no-ext-diff', '--unified=0', base, '--']); - const files = parseDiff(diff).filter((file) => !isSensitivePath(file.path)); - const untrackedPaths = git(rootDir, ['ls-files', '--others', '--exclude-standard']) + const parsedFiles = parseDiff(diff).filter((file) => !isSensitivePath(file.path)); + const ignoredDiffPaths = parsedFiles + .filter((file) => isReviewContextIgnored(rootDir, file.path, { includeIgnored })) + .map((file) => file.path); + const files = parsedFiles + .filter((file) => !isReviewContextIgnored(rootDir, file.path, { includeIgnored })); + const allUntrackedPaths = git(rootDir, ['ls-files', '--others', '--exclude-standard']) .split('\n') .map((line) => line.trim()) .filter(Boolean) .filter((filePath) => !filePath.startsWith('.ai-engineering-loop/')) .filter((filePath) => !isSensitivePath(filePath)); + const ignoredUntrackedPaths = allUntrackedPaths + .filter((filePath) => isReviewContextIgnored(rootDir, filePath, { includeIgnored })); + const untrackedPaths = allUntrackedPaths + .filter((filePath) => !isReviewContextIgnored(rootDir, filePath, { includeIgnored })); const changedPaths = [...new Set([...files.map((file) => file.path), ...untrackedPaths])]; + const ignoredPaths = [...new Set([...ignoredDiffPaths, ...ignoredUntrackedPaths])]; const untrackedLines = untrackedPaths.reduce((sum, filePath) => { try { const file = resolveSafeRepoFile(rootDir, filePath); @@ -510,6 +597,7 @@ function assessSmallTaskFastPath(rootDir, { base = 'HEAD' } = {}) { recommendedProfile: reasons.length === 0 ? 'lean' : 'standard', limits: SMALL_TASK_LIMITS, changedPaths, + ignoredPaths, changedLines, riskyPaths, reasons @@ -528,6 +616,7 @@ function smartContextSummary(result) { estimatedTokens: result.pack.estimatedTokens, truncated: result.pack.truncated, unresolvedFindings: result.pack.unresolvedFindings.length, + ignoredPaths: result.pack.reviewDelta?.ignoredPaths?.length || 0, reviewBudget: result.pack.reviewBudget }; } @@ -540,11 +629,13 @@ function smartContextSummary(result) { module.exports = { DIFF_PACK_LIMITS, + DEFAULT_CONTEXT_IGNORE_PATTERNS, REVIEW_BUDGETS, SMALL_TASK_LIMITS, assessSmallTaskFastPath, buildContextIndex, createDiffHunkPack, + isReviewContextIgnored, queryContextIndex, relatedContextIndex, parseDiff, diff --git a/lib/studio-server.js b/lib/studio-server.js index b81c6ed..3aafad6 100644 --- a/lib/studio-server.js +++ b/lib/studio-server.js @@ -54,6 +54,7 @@ const { const MAX_BODY = 256 * 1024; const ASSET_DIR = path.join(__dirname, '..', 'studio'); +const LIVE_WORKFLOW_EVENT_LIMIT = 12; function json(response, status, value) { response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); @@ -108,11 +109,27 @@ function liveSnapshot(rootDir) { const run = getCurrentRun(rootDir); if (!run?.workflow) return null; const workflow = workflowStatus(rootDir, { runId: run.runId }); + const eventTail = workflow.events.slice(-LIVE_WORKFLOW_EVENT_LIMIT); + const edges = edgesFromPlan(workflow.plan, workflow.state); return redactedValue({ run, + summary: { + runId: run.runId, + state: run.state, + nodeCount: workflow.plan.nodes.length, + edgeCount: edges.length, + workflowEventCount: workflow.events.length, + workflowEventLimit: LIVE_WORKFLOW_EVENT_LIMIT, + eventsTruncated: workflow.events.length > eventTail.length, + details: { + nodeIo: `/api/runs/${encodeURIComponent(run.runId)}/nodes/{nodeId}`, + artifacts: `/api/runs/${encodeURIComponent(run.runId)}/artifacts?path={path}`, + lifecycle: `/api/runs/${encodeURIComponent(run.runId)}/lifecycle?after={sequence}` + } + }, plan: workflow.plan, nodes: workflow.state.nodes, - edges: edgesFromPlan(workflow.plan, workflow.state), + edges, loopGroups: workflow.state.loopGroups, journey: (() => { try { @@ -128,7 +145,7 @@ function liveSnapshot(rootDir) { } })(), budget: budgetStatus(rootDir, { runId: run.runId }), - events: workflow.events.slice(-50).map(({ initialNodes, changes, ...event }) => ({ + events: eventTail.map(({ initialNodes, changes, ...event }) => ({ ...event, changedNodes: (changes || []).map((change) => change.nodeId) })) diff --git a/studio/app.js b/studio/app.js index 3791303..67c486a 100644 --- a/studio/app.js +++ b/studio/app.js @@ -2077,7 +2077,6 @@ function renderLive() { (active ? runtimeNode.activity || `${runtimeNode.status} · attempt ${runtimeNode.attempts}` : `Final state: ${live.run.state}`); const journeyStatuses = new Set(['pending', 'active', 'complete']); const degradedSources = live.journey?.degradedSources || []; - byId('run-journey').dataset.degraded = degradedSources.join(', '); byId('run-journey').innerHTML = (live.journey?.steps || []).map((step) => { const status = String(step?.status || '').toLowerCase(); const safeStatus = journeyStatuses.has(status) ? status : 'unknown'; @@ -2104,13 +2103,17 @@ function renderLive() { const limit = live.budget.perRunTokenLimit; byId('meter').style.width = `${Math.min(100, limit ? (spent / limit) * 100 : 0)}%`; byId('budget').textContent = `${spent.toLocaleString()} / ${limit.toLocaleString()} TOKENS`; - byId('events').innerHTML = lifecycleEvents.slice(-7).reverse().map((event) => ` + const liveEventsHtml = lifecycleEvents.slice(-7).reverse().map((event) => `

${escapeHtml(event.at.slice(11, 19))} · ${escapeHtml(event.type)} · ${escapeHtml(event.message)}

`).join('') || live.events.slice(-7).reverse().map((event) => `

${escapeHtml(event.at.slice(11, 19))} · ${escapeHtml(event.type)}

`).join(''); + const eventsDetailNote = live.summary?.eventsTruncated + ? '

Older workflow events are kept out of live polling; node detail and lifecycle stream load them on demand.

' + : ''; + byId('events').innerHTML = liveEventsHtml + eventsDetailNote; renderRunActivityDock(live, active, runtimeNode, lifecycle); updateRuntimePresentation(); followActiveNode(); diff --git a/tests/smart-context.test.js b/tests/smart-context.test.js index 913e141..833cf22 100644 --- a/tests/smart-context.test.js +++ b/tests/smart-context.test.js @@ -116,6 +116,31 @@ test('diff hunk pack sends bounded hunks and file summaries instead of whole fil assert.doesNotMatch(serialized, /api_key = "sk-test/); }); +test('context ignore skips CSS, generated files, and opt-in paths unless explicitly included', () => { + const root = tempRepo(); + fs.mkdirSync(path.join(root, 'studio'), { recursive: true }); + fs.mkdirSync(path.join(root, 'generated'), { recursive: true }); + fs.writeFileSync(path.join(root, 'src', 'app.js'), 'module.exports = 1;\n'); + fs.writeFileSync(path.join(root, 'studio', 'styles.css'), '.node { animation: glow 1s; }\n'); + fs.writeFileSync(path.join(root, 'generated', 'schema.js'), 'module.exports = "large";\n'); + fs.writeFileSync(path.join(root, '.aelcontextignore'), 'generated/\n'); + commitAll(root); + fs.writeFileSync(path.join(root, 'src', 'app.js'), 'module.exports = 2;\n'); + fs.writeFileSync(path.join(root, 'studio', 'styles.css'), '.node { animation: none; }\n'); + fs.writeFileSync(path.join(root, 'generated', 'schema.js'), 'module.exports = "changed";\n'); + + const lean = createDiffHunkPack(root, { runId: 'run-001', profile: 'lean' }); + const full = createDiffHunkPack(root, { runId: 'run-001', profile: 'lean', includeIgnored: true }); + const index = buildContextIndex(root); + const explicitIndex = buildContextIndex(root, { files: ['studio/styles.css'] }); + + assert.deepStrictEqual(lean.pack.files.map((file) => file.path), ['src/app.js']); + assert.deepStrictEqual(lean.pack.reviewDelta.ignoredPaths.sort(), ['generated/schema.js', 'studio/styles.css']); + assert.deepStrictEqual(full.pack.files.map((file) => file.path).sort(), ['generated/schema.js', 'src/app.js', 'studio/styles.css']); + assert.ok(!index.index.files.some((file) => file.path === 'studio/styles.css')); + assert.strictEqual(explicitIndex.index.files[0].path, 'studio/styles.css'); +}); + test('diff hunk pack uses review profile limits and records unresolved finding context', () => { const root = tempRepo(); updatePolicy(root, { reviewProfile: 'standard' }); diff --git a/tests/studio-server.test.js b/tests/studio-server.test.js index 99b8993..1484823 100644 --- a/tests/studio-server.test.js +++ b/tests/studio-server.test.js @@ -280,6 +280,15 @@ test('Studio creates, freezes, executes, and inspects a named local run through const execution = await executed.json(); assert.equal(execution.execution.dispatch, 'LOCAL_RUNTIME_ONLY'); assert.equal(execution.execution.externalDispatch, false); + const liveResponse = await fetch(`${base}/api/live`, { headers }); + assert.equal(liveResponse.status, 200); + const live = (await liveResponse.json()).live; + assert.equal(live.summary.runId, run.runId); + assert.equal(live.summary.workflowEventLimit, 12); + assert.equal(live.summary.eventsTruncated, live.summary.workflowEventCount > live.events.length); + assert.ok(live.events.length <= 12); + assert.equal(live.summary.details.nodeIo, `/api/runs/${run.runId}/nodes/{nodeId}`); + assert.equal(live.summary.details.lifecycle, `/api/runs/${run.runId}/lifecycle?after={sequence}`); const reopened = await fetch(`${base}/api/runs/${run.runId}/goal/unfreeze`, { method: 'POST',