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
5 changes: 5 additions & 0 deletions engine/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"@types/bun": "^1.3.14",
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
Expand Down
6 changes: 5 additions & 1 deletion engine/src/agent.fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,14 @@ test('the source session file is never written — even when the fork is appende
fs.mkdtempSync(path.join(tmp, 'target-')),
sessionDir,
);
// Cast at the SDK boundary, not around our own types. pi's exported `Message` union is
// narrower than what `appendMessage` accepts at runtime — this shape is what the agent
// actually sends and what the assertions below verify the SDK does with it. The cast
// documents that mismatch rather than hiding a shape our code got wrong.
forked.appendMessage({
role: 'user',
content: [{ type: 'text', text: 'a question against the frozen snapshot' }],
});
} as Parameters<typeof forked.appendMessage>[0]);

expect(fs.readFileSync(source, 'utf8')).toBe(before); // byte-identical
expect(fs.readFileSync(forked.getSessionFile() as string, 'utf8')).toContain(
Expand Down
2 changes: 1 addition & 1 deletion engine/src/kild/attachment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe('concurrent attaches', () => {
path.join(home, 'attached', 'claims', 'kild-w', 'kild'),
'utf8',
);
expect(claim.trim()).toBe(winner);
expect(claim.trim()).toBe(winner as string);
expect(await findAttachment(winner as string)).toMatchObject({ kildId: 'kild-w' });
});

Expand Down
24 changes: 12 additions & 12 deletions engine/src/kild/git-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test('commits vs base come newest-first with per-commit stats', async () => {
await git(dir, ['add', '.']);
await commit(dir, 'trim a, add b');

const result = await reviewCommits(dir, 'main');
const result = await reviewCommits(dir, { base: 'main', source: 'explicit' });

expect(result.error).toBeUndefined();
expect(result.base).toBe('main');
Expand All @@ -93,21 +93,21 @@ test('commits vs base come newest-first with per-commit stats', async () => {

test('no commits ahead of base yields an empty list, no error', async () => {
const dir = await initRepo();
const result = await reviewCommits(dir, 'main');
const result = await reviewCommits(dir, { base: 'main', source: 'explicit' });
expect(result.error).toBeUndefined();
expect(result.commits).toEqual([]);
});

test('commits: a missing base ref is an error object, not a crash', async () => {
const dir = await initRepo();
const result = await reviewCommits(dir, 'does-not-exist');
const result = await reviewCommits(dir, { base: 'does-not-exist', source: 'explicit' });
expect(result.error).toBe('base ref not found: does-not-exist');
expect(result.commits).toEqual([]);
});

test('commits: a non-git directory is an error object, not a crash', async () => {
const dir = mkTmp('kild-git-review-nogit-');
const result = await reviewCommits(dir, 'main');
const result = await reviewCommits(dir, { base: 'main', source: 'explicit' });
expect(result.error).toBeDefined();
expect(result.commits).toEqual([]);
});
Expand All @@ -123,7 +123,7 @@ test('files combine committed, uncommitted, and untracked changes vs base', asyn
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\nedited\n'); // uncommitted edit
fs.writeFileSync(path.join(dir, 'untracked.txt'), 'u1\nu2\nu3\n'); // never added

const result = await reviewFiles(dir, 'main');
const result = await reviewFiles(dir, { base: 'main', source: 'explicit' });

expect(result.error).toBeUndefined();
const byPath = new Map(result.files.map((file) => [file.path, file]));
Expand Down Expand Up @@ -160,7 +160,7 @@ test('files report deletions and renames with the pre-rename path', async () =>
await git(dir, ['mv', 'README.md', 'RENAMED.md']);
await commit(dir, 'delete + rename');

const result = await reviewFiles(dir, 'main');
const result = await reviewFiles(dir, { base: 'main', source: 'explicit' });

expect(result.error).toBeUndefined();
const byPath = new Map(result.files.map((file) => [file.path, file]));
Expand All @@ -184,15 +184,15 @@ test("files never include the base's own advances (merge-base semantics)", async
await commit(dir, 'theirs');
await git(dir, ['checkout', 'feature']);

const result = await reviewFiles(dir, 'main');
const result = await reviewFiles(dir, { base: 'main', source: 'explicit' });

expect(result.error).toBeUndefined();
expect(result.files.map((file) => file.path)).toEqual(['mine.txt']);
});

test('files: a missing base ref is an error object, not a crash', async () => {
const dir = await initRepo();
const result = await reviewFiles(dir, 'does-not-exist');
const result = await reviewFiles(dir, { base: 'does-not-exist', source: 'explicit' });
expect(result.error).toBe('base ref not found: does-not-exist');
expect(result.files).toEqual([]);
});
Expand All @@ -207,7 +207,7 @@ test('diff returns one unified patch covering committed + working-tree changes',
await commit(dir, 'committed line');
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\ncommitted\nuncommitted\n');

const result = await reviewDiff(dir, 'main', 'README.md');
const result = await reviewDiff(dir, { base: 'main', source: 'explicit' }, 'README.md');

expect(result.error).toBeUndefined();
expect(result.truncated).toBe(false);
Expand All @@ -220,7 +220,7 @@ test('diff covers an untracked file via no-index', async () => {
const dir = await initRepo();
fs.writeFileSync(path.join(dir, 'fresh.txt'), 'brand new\n');

const result = await reviewDiff(dir, 'main', 'fresh.txt');
const result = await reviewDiff(dir, { base: 'main', source: 'explicit' }, 'fresh.txt');

expect(result.error).toBeUndefined();
expect(result.patch).toContain('+brand new');
Expand All @@ -229,7 +229,7 @@ test('diff covers an untracked file via no-index', async () => {
test('diff refuses a path git did not report (traversal guard)', async () => {
const dir = await initRepo();
for (const evil of ['../../etc/passwd', '/etc/passwd', 'nope.txt']) {
const result = await reviewDiff(dir, 'main', evil);
const result = await reviewDiff(dir, { base: 'main', source: 'explicit' }, evil);
expect(result.unknownPath).toBe(true);
expect(result.error).toContain('not reported by git');
expect(result.patch).toBe('');
Expand All @@ -240,7 +240,7 @@ test('diff larger than the cap is truncated and flagged', async () => {
const dir = await initRepo();
fs.writeFileSync(path.join(dir, 'big.txt'), 'x-line-of-payload\n'.repeat(20_000)); // ~360 KB

const result = await reviewDiff(dir, 'main', 'big.txt');
const result = await reviewDiff(dir, { base: 'main', source: 'explicit' }, 'big.txt');

expect(result.error).toBeUndefined();
expect(result.truncated).toBe(true);
Expand Down
31 changes: 23 additions & 8 deletions engine/src/kild/git-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';

import { resolveDefaultBase } from './worktree-status.ts';
import { type BaseSource, type ResolvedBase, resolveDefaultBase } from './worktree-status.ts';

/**
* Review intelligence — the git drill-down behind a review surface. Where
Expand Down Expand Up @@ -56,12 +56,18 @@ export interface ReviewFile {

export interface ReviewCommitsResult {
base: string;
/** Where {@link base} came from — see BaseSource. A commit count measured against a guessed
* base is not evidence of unlanded work, and a caller refusing on it must be able to say so. */
baseSource: BaseSource;
commits: ReviewCommit[];
error?: string; // any git failure captured here, NEVER thrown
}

export interface ReviewFilesResult {
base: string;
/** Where {@link base} came from — same contract as ReviewCommitsResult. Stats measured
* against a guessed base are not facts about anybody's work either. */
baseSource: BaseSource;
files: ReviewFile[];
error?: string; // any git failure captured here, NEVER thrown
}
Expand Down Expand Up @@ -221,9 +227,17 @@ export function parsePorcelainZ(stdout: string): {

/** Commits on the kild's branch that base doesn't have (`base..HEAD`), newest
* first, each with its own diff stats. Never throws — failures land in `error`. */
export async function reviewCommits(dir: string, base?: string): Promise<ReviewCommitsResult> {
const resolvedBase = base ?? (await resolveDefaultBase(dir));
const result: ReviewCommitsResult = { base: resolvedBase, commits: [] };
export async function reviewCommits(
dir: string,
base?: ResolvedBase,
): Promise<ReviewCommitsResult> {
const resolved = base ?? (await resolveDefaultBase(dir));
const resolvedBase = resolved.base;
const result: ReviewCommitsResult = {
base: resolvedBase,
baseSource: resolved.source,
commits: [],
};
const invalid = await verifyRepoAndBase(dir, resolvedBase);
if (invalid) {
result.error = invalid;
Expand Down Expand Up @@ -269,9 +283,10 @@ async function countLines(dir: string, file: string): Promise<number> {

/** Per-file diff stats vs base — committed (branch vs merge-base) and uncommitted
* (working tree, incl. untracked files) combined into one list. Never throws. */
export async function reviewFiles(dir: string, base?: string): Promise<ReviewFilesResult> {
const resolvedBase = base ?? (await resolveDefaultBase(dir));
const result: ReviewFilesResult = { base: resolvedBase, files: [] };
export async function reviewFiles(dir: string, base?: ResolvedBase): Promise<ReviewFilesResult> {
const resolved = base ?? (await resolveDefaultBase(dir));
const resolvedBase = resolved.base;
const result: ReviewFilesResult = { base: resolvedBase, baseSource: resolved.source, files: [] };
const invalid = await verifyRepoAndBase(dir, resolvedBase);
if (invalid) {
result.error = invalid;
Expand Down Expand Up @@ -358,7 +373,7 @@ async function noIndexDiff(dir: string, file: string): Promise<GitResult> {
* and no git/fs call ever receives the raw caller path otherwise. Never throws. */
export async function reviewDiff(
dir: string,
base: string | undefined,
base: ResolvedBase | undefined,
file: string,
): Promise<ReviewDiffResult> {
const files = await reviewFiles(dir, base);
Expand Down
3 changes: 2 additions & 1 deletion engine/src/kild/kild-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import { configuredCloseHook, configuredMemoryDir } from './config.ts';
import { type HookAgentSpawn, type KildCloseEvent, runCloseHook } from './hooks.ts';
import type { Kild } from './kild-types.ts';
import { storedBase } from './kild-types.ts';
import { appendKildLog, collectLedgerFacts, kildTranscriptPath } from './memory.ts';
import { worktreePath } from './worktree.ts';

Expand Down Expand Up @@ -47,7 +48,7 @@ export async function closeKild(kild: Kild, deps: CloseDeps): Promise<void> {
appendKildLog(
kild,
memoryDir,
await collectLedgerFacts(dir, kild.base, kild.landedSha, kild.landed),
await collectLedgerFacts(dir, storedBase(kild), kild.landedSha, kild.landed),
);
} catch (err) {
console.error(`kild: ledger append failed for '${kild.name}': ${errText(err)}`);
Expand Down
79 changes: 72 additions & 7 deletions engine/src/kild/kild-disposal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ test('a tree with ONLY untracked litter is disposable, and the litter is named',
repo,
dir: wt.path,
branch: 'kild/litter',
base: 'main',
base: { base: 'main', source: 'explicit' },
inUse: false,
});
expect(assessment.ok).toBe(true);
Expand All @@ -77,7 +77,7 @@ test('uncommitted changes to TRACKED files are not a refusal either', async () =
repo,
dir: wt.path,
branch: 'kild/tracked',
base: 'main',
base: { base: 'main', source: 'explicit' },
inUse: false,
});
expect(assessment.ok).toBe(true);
Expand All @@ -98,7 +98,7 @@ test('an undeterminable discard list says so, instead of reporting nothing lost'
repo,
dir: wt.path,
branch: 'kild/unreadable',
base: 'main',
base: { base: 'main', source: 'explicit' },
inUse: false,
force: true, // past the commits guard, which refuses `undetermined` on its own
});
Expand All @@ -119,7 +119,7 @@ test('a branch carrying commits base does not have is REFUSED, with the count an
repo,
dir: wt.path,
branch: 'kild/authored',
base: 'main',
base: { base: 'main', source: 'explicit' },
inUse: false,
});
expect(assessment.ok).toBe(false);
Expand All @@ -143,7 +143,7 @@ test('force overrides the authored refusal — the branch and its commits surviv
repo,
dir: wt.path,
branch: 'kild/forced',
base: 'main',
base: { base: 'main', source: 'explicit' },
inUse: false,
force: true,
});
Expand Down Expand Up @@ -183,7 +183,7 @@ test('a base that cannot be resolved is undetermined, and says force is the way
repo,
dir: wt.path,
branch: 'kild/nobase',
base: 'no-such-base',
base: { base: 'no-such-base', source: 'explicit' },
inUse: false,
});
expect(assessment).toMatchObject({ ok: false, code: 'undetermined' });
Expand All @@ -195,7 +195,7 @@ test('a base that cannot be resolved is undetermined, and says force is the way
repo,
dir: wt.path,
branch: 'kild/nobase',
base: 'no-such-base',
base: { base: 'no-such-base', source: 'explicit' },
inUse: false,
force: true,
}),
Expand All @@ -209,3 +209,68 @@ test('removal frees the tree and keeps the branch — disposal never deletes wor
expect(existsSync(wt.path)).toBe(false);
expect((await git('branch')).stdout).toContain('kild/reclaim');
});

test('a refusal built on a GUESSED base says so, and one on a chosen base does not', async () => {
// The measured failure: 116 trees held behind "carries 365 commits not in main". True, and
// useless — that repo's default is `dev`, so every tree was hundreds of commits ahead of a
// branch it had never forked from, by construction. The refusal named the base. What it
// could not say was that nobody had chosen it, and that cost four measurement passes and
// 5.4 GB. The count is only evidence of unlanded work if the base is.
const wt = await ensureWorktree(repo, 'guessed', 'main');
writeFileSync(path.join(wt.path, 'a.txt'), 'authored\n');
await gitIn(wt.path, 'add', '.');
await gitIn(wt.path, 'commit', '-q', '-m', 'real work');

// No `base` given, and this repo has no origin/HEAD → the literal fallback.
const guessed = await assessDisposal({
repo,
dir: wt.path,
branch: 'kild/guessed',
inUse: false,
});
expect(guessed.ok).toBe(false);
if (guessed.ok) return;
expect(guessed.baseSource).toBe('fallback');
expect(guessed.base).toBe('main');
expect(guessed.message).toContain('nothing configured a base');
expect(guessed.message).toContain('.kild/config.json');

// Named explicitly: the same refusal, with no hedge, because somebody chose the branch.
const chosen = await assessDisposal({
repo,
dir: wt.path,
branch: 'kild/guessed',
base: { base: 'main', source: 'explicit' },
inUse: false,
});
expect(chosen.ok).toBe(false);
if (chosen.ok) return;
expect(chosen.baseSource).toBe('explicit');
expect(chosen.message).not.toContain('this is a guess');
expect(chosen.message).toContain('carries 1 commit not in main');
});

test('a base cached in origin/HEAD is labelled as the cache it is', async () => {
// origin/HEAD is written at clone time and never refreshed, so it goes stale silently when
// the remote's default moves. Better than a literal guess; still not a fact anyone asserted.
await git('remote', 'add', 'origin', repo);
await git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
await git('update-ref', 'refs/remotes/origin/main', 'main');

const wt = await ensureWorktree(repo, 'cached', 'main');
writeFileSync(path.join(wt.path, 'a.txt'), 'authored\n');
await gitIn(wt.path, 'add', '.');
await gitIn(wt.path, 'commit', '-q', '-m', 'real work');

const assessment = await assessDisposal({
repo,
dir: wt.path,
branch: 'kild/cached',
inUse: false,
});
expect(assessment.ok).toBe(false);
if (assessment.ok) return;
expect(assessment.baseSource).toBe('origin-head');
expect(assessment.message).toContain('origin/HEAD');
expect(assessment.message).toContain('caches at clone time');
});
Loading
Loading