Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run-id>
npx ai-engineering-loop context diff-hunks --run <run-id> --include-ignored
npx ai-engineering-loop verification summarize --run <run-id>
npx ai-engineering-loop context compact --run <run-id>
```

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.
Expand Down
9 changes: 6 additions & 3 deletions bin/ai-engineering-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }));
Expand All @@ -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;
}
Expand Down Expand Up @@ -2071,6 +2073,7 @@ Commands:
--base <git-ref> base ref for diff-hunks; default HEAD
--query <term> query term for context query
--limit <n> 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 <dir> score host-generated JSON results
Expand Down
3 changes: 2 additions & 1 deletion core/runtime-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run-id>
npx ai-engineering-loop context diff-hunks --run <run-id> --include-ignored
npx ai-engineering-loop verification summarize --run <run-id>
npx ai-engineering-loop context compact --run <run-id>
```

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

Expand Down
111 changes: 101 additions & 10 deletions lib/smart-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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)]) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
},
Expand All @@ -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);
Expand All @@ -510,6 +597,7 @@ function assessSmallTaskFastPath(rootDir, { base = 'HEAD' } = {}) {
recommendedProfile: reasons.length === 0 ? 'lean' : 'standard',
limits: SMALL_TASK_LIMITS,
changedPaths,
ignoredPaths,
changedLines,
riskyPaths,
reasons
Expand All @@ -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
};
}
Expand All @@ -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,
Expand Down
21 changes: 19 additions & 2 deletions lib/studio-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}))
Expand Down
7 changes: 5 additions & 2 deletions studio/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) => `
<p class="${event.type === 'NODE_ACTIVITY' || event.type.includes('START') ? 'event-active' : ''}">
${escapeHtml(event.at.slice(11, 19))} · ${escapeHtml(event.type)} · ${escapeHtml(event.message)}
</p>
`).join('') || live.events.slice(-7).reverse().map((event) => `
<p>${escapeHtml(event.at.slice(11, 19))} · ${escapeHtml(event.type)}</p>
`).join('');
const eventsDetailNote = live.summary?.eventsTruncated
? '<p class="muted">Older workflow events are kept out of live polling; node detail and lifecycle stream load them on demand.</p>'
: '';
byId('events').innerHTML = liveEventsHtml + eventsDetailNote;
renderRunActivityDock(live, active, runtimeNode, lifecycle);
updateRuntimePresentation();
followActiveNode();
Expand Down
25 changes: 25 additions & 0 deletions tests/smart-context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Loading
Loading