Skip to content

Commit 438f581

Browse files
committed
fix(miner): derive sdk changed files from git
1 parent bda42f7 commit 438f581

4 files changed

Lines changed: 282 additions & 43 deletions

File tree

packages/gittensory-engine/src/miner/agent-sdk-driver.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
// matcher, #2343's stated attachment point) and this driver forwards them verbatim onto the `query()` options, so
99
// house-rule enforcement can intercept every tool call before execution without this module knowing the rules.
1010

11+
import { execFile } from "node:child_process";
12+
import { promisify } from "node:util";
13+
1114
import { redactSecrets } from "../subprocess-env.js";
1215
import type {
1316
CodingAgentDriver,
@@ -40,8 +43,7 @@ export type AgentSdkQueryFn = (input: {
4043
options: AgentSdkQueryOptions;
4144
}) => AsyncIterable<Record<string, unknown>>;
4245

43-
/** Tool names whose successful use means a file in the working directory changed. */
44-
const FILE_EDIT_TOOL_NAMES = new Set(["Edit", "Write", "NotebookEdit"]);
46+
const execFileAsync = promisify(execFile);
4547

4648
/** Ceiling for any redacted free text surfaced on the result (error detail, summary) — one named place. */
4749
const MAX_REDACTED_TEXT_LENGTH = 500;
@@ -66,12 +68,30 @@ export type CreateAgentSdkDriverOptions = {
6668
query?: AgentSdkQueryFn | undefined;
6769
/** Forwarded verbatim to the SDK session — the #2343 `PreToolUse` interception point. */
6870
hooks?: AgentSdkHooks | undefined;
71+
/** Injected changed-file enumerator; defaults to git diff over the worktree. */
72+
listChangedFiles?: ((cwd: string) => Promise<string[]>) | undefined;
6973
};
7074

7175
function asRecord(value: unknown): Record<string, unknown> | null {
7276
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
7377
}
7478

79+
async function listWorktreeChangedFiles(cwd: string): Promise<string[]> {
80+
const [tracked, untracked] = await Promise.all([
81+
execFileAsync("git", ["-C", cwd, "diff", "--name-only", "HEAD", "--"]),
82+
execFileAsync("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard"]),
83+
]);
84+
return Array.from(
85+
new Set(
86+
[tracked.stdout, untracked.stdout]
87+
.join("\n")
88+
.split(/\r?\n/)
89+
.map((file) => file.trim())
90+
.filter(Boolean),
91+
),
92+
);
93+
}
94+
7595
/** Fold one assistant message's content blocks into the transcript/changed-file accumulators. */
7696
function foldAssistantMessage(
7797
message: Record<string, unknown>,
@@ -87,9 +107,7 @@ function foldAssistantMessage(
87107
transcript.push(block.text);
88108
} else if (block.type === "tool_use" && typeof block.name === "string") {
89109
const filePath = asRecord(block.input)?.file_path;
90-
if (FILE_EDIT_TOOL_NAMES.has(block.name) && typeof filePath === "string") {
91-
changedFiles.add(filePath);
92-
}
110+
if (typeof filePath === "string") changedFiles.add(filePath);
93111
}
94112
}
95113
}
@@ -105,6 +123,7 @@ export function createAgentSdkCodingAgentDriver(
105123
options: CreateAgentSdkDriverOptions = {},
106124
): CodingAgentDriver {
107125
const query = options.query ?? defaultQuery;
126+
const listChangedFiles = options.listChangedFiles ?? listWorktreeChangedFiles;
108127

109128
return {
110129
async run(task: CodingAgentDriverTask): Promise<CodingAgentDriverResult> {
@@ -173,10 +192,26 @@ export function createAgentSdkCodingAgentDriver(
173192
};
174193
}
175194

195+
let worktreeChangedFiles: string[];
196+
try {
197+
worktreeChangedFiles = await listChangedFiles(task.workingDirectory);
198+
} catch (error) {
199+
const detail = redactSecrets(error instanceof Error ? error.message : String(error)).slice(0, MAX_REDACTED_TEXT_LENGTH);
200+
return {
201+
ok: false,
202+
changedFiles: [],
203+
summary: "agent sdk changed-file enumeration failed",
204+
transcript,
205+
turnsUsed,
206+
error: `agent_sdk_changed_files_unavailable: ${detail}`,
207+
};
208+
}
209+
210+
const allChangedFiles = Array.from(new Set([...changedFiles, ...worktreeChangedFiles]));
176211
return {
177212
ok: true,
178-
changedFiles: [...changedFiles],
179-
summary: resultText.slice(0, MAX_REDACTED_TEXT_LENGTH) || `coding agent completed with ${changedFiles.size} changed file(s)`,
213+
changedFiles: allChangedFiles,
214+
summary: resultText.slice(0, MAX_REDACTED_TEXT_LENGTH) || `coding agent completed with ${allChangedFiles.length} changed file(s)`,
180215
transcript,
181216
turnsUsed,
182217
};

packages/gittensory-engine/test/agent-sdk-driver.test.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
33
import {
44
createAgentSdkCodingAgentDriver,
55
type AgentSdkQueryFn,
6+
type CreateAgentSdkDriverOptions,
67
type CodingAgentDriverTask,
78
} from "../dist/index.js";
89

@@ -35,10 +36,14 @@ function queryYielding(
3536
};
3637
}
3738

39+
function driverWith(options: CreateAgentSdkDriverOptions) {
40+
return createAgentSdkCodingAgentDriver({ listChangedFiles: async () => [], ...options });
41+
}
42+
3843
test("success: session options, tool-use changed-file tracking, transcript, turn count", async () => {
3944
const captured: { input?: Parameters<AgentSdkQueryFn>[0] } = {};
4045
const hooks = { PreToolUse: [{ hooks: ["policy-callback"] }] };
41-
const driver = createAgentSdkCodingAgentDriver({
46+
const driver = driverWith({
4247
query: queryYielding(
4348
[
4449
assistantMessage({ type: "text", text: "editing now" }),
@@ -74,8 +79,59 @@ test("success: session options, tool-use changed-file tracking, transcript, turn
7479
assert.equal(captured.input!.options.hooks, hooks);
7580
});
7681

82+
test("success derives changed files from the worktree after untracked mutating tools", async () => {
83+
const driver = driverWith({
84+
query: queryYielding([
85+
assistantMessage({ type: "tool_use", name: "Bash", input: { command: "node mutate.js" } }),
86+
{ type: "result", subtype: "success", is_error: false, num_turns: 2, result: "mutated" },
87+
]),
88+
listChangedFiles: async (cwd) => {
89+
assert.equal(cwd, task.workingDirectory);
90+
return ["packages/gittensory-engine/src/vulnerable.ts"];
91+
},
92+
});
93+
94+
const result = await driver.run(task);
95+
96+
assert.equal(result.ok, true);
97+
assert.deepEqual(result.changedFiles, ["packages/gittensory-engine/src/vulnerable.ts"]);
98+
});
99+
100+
test("success fails closed when changed-file enumeration is unavailable", async () => {
101+
const driver = driverWith({
102+
query: queryYielding([
103+
{ type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" },
104+
]),
105+
listChangedFiles: async () => {
106+
throw new Error("not a git worktree");
107+
},
108+
});
109+
110+
const result = await driver.run(task);
111+
112+
assert.equal(result.ok, false);
113+
assert.deepEqual(result.changedFiles, []);
114+
assert.match(result.error!, /agent_sdk_changed_files_unavailable: not a git worktree/);
115+
});
116+
117+
test("success stringifies a non-Error changed-file enumeration failure", async () => {
118+
const driver = driverWith({
119+
query: queryYielding([
120+
{ type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" },
121+
]),
122+
listChangedFiles: async () => {
123+
throw "git unavailable";
124+
},
125+
});
126+
127+
const result = await driver.run(task);
128+
129+
assert.equal(result.ok, false);
130+
assert.equal(result.error, "agent_sdk_changed_files_unavailable: git unavailable");
131+
});
132+
77133
test("non-success result subtype maps to a structured failure with the subtype as the error", async () => {
78-
const driver = createAgentSdkCodingAgentDriver({
134+
const driver = driverWith({
79135
query: queryYielding([
80136
assistantMessage({ type: "tool_use", name: "Edit", input: { file_path: "src/a.ts" } }),
81137
{ type: "result", subtype: "error_max_turns", is_error: true, num_turns: 6 },
@@ -90,7 +146,7 @@ test("non-success result subtype maps to a structured failure with the subtype a
90146
});
91147

92148
test("a success-subtype result that still flags is_error is treated as a failure", async () => {
93-
const driver = createAgentSdkCodingAgentDriver({
149+
const driver = driverWith({
94150
query: queryYielding([
95151
{ type: "result", subtype: "success", is_error: true, num_turns: 1, result: "refused" },
96152
]),
@@ -101,7 +157,7 @@ test("a success-subtype result that still flags is_error is treated as a failure
101157
});
102158

103159
test("stream ending without a result frame is a protocol failure, not a silent success", async () => {
104-
const driver = createAgentSdkCodingAgentDriver({
160+
const driver = driverWith({
105161
query: queryYielding([assistantMessage({ type: "text", text: "started..." })]),
106162
});
107163
const result = await driver.run(task);
@@ -111,7 +167,7 @@ test("stream ending without a result frame is a protocol failure, not a silent s
111167
});
112168

113169
test("a throw mid-stream returns a redacted structured failure and never propagates", async () => {
114-
const driver = createAgentSdkCodingAgentDriver({
170+
const driver = driverWith({
115171
query: () =>
116172
(async function* (): AsyncGenerator<Record<string, unknown>> {
117173
yield assistantMessage({ type: "text", text: "before the crash" });
@@ -127,7 +183,7 @@ test("a throw mid-stream returns a redacted structured failure and never propaga
127183
});
128184

129185
test("secret shapes in the result text are redacted from summary and transcript", async () => {
130-
const driver = createAgentSdkCodingAgentDriver({
186+
const driver = driverWith({
131187
query: queryYielding([
132188
{
133189
type: "result",
@@ -146,7 +202,7 @@ test("secret shapes in the result text are redacted from summary and transcript"
146202
});
147203

148204
test("malformed frames (no content array, non-object blocks, missing file_path) are skipped defensively", async () => {
149-
const driver = createAgentSdkCodingAgentDriver({
205+
const driver = driverWith({
150206
query: queryYielding([
151207
{ type: "assistant" },
152208
{ type: "assistant", message: { content: "not-an-array" } },
@@ -167,7 +223,7 @@ test("malformed frames (no content array, non-object blocks, missing file_path)
167223
});
168224

169225
test("names a result frame with no usable subtype 'unknown'", async () => {
170-
const driver = createAgentSdkCodingAgentDriver({
226+
const driver = driverWith({
171227
query: queryYielding([{ type: "result", is_error: true }]),
172228
});
173229
const result = await driver.run(task);

0 commit comments

Comments
 (0)