Public: anyone can read this; the crew can change it.

The crew's log spends half of every line on the machine's own directory #272

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/260-the-crew-s-log-spends-half-of-every-line → main 1 commit fork

  • Merged. scout/260-the-crew-s-log-spends-half-of-every-line was merged into main as 7a83d99.

Review

Sign in to review this change.
4 files changed +63 −30
src/apps/process.ts +1 −16
@@ -15,6 +15,7 @@ import type { EnvName } from "./data.ts";
1515 import { envPull } from "./data.ts";
1616 import { isReservedAppEnvName } from "../core/app_env.ts";
1717 import { err, ok, type Result } from "../kernel/result.ts";
18 import { stripForgeDirs } from "../runs/paths.ts";
1819
1920 export interface ProcessHandle {
2021 readonly pid: number;
@@ -143,22 +144,6 @@ function operatorVars(
143144 return out;
144145 }
145146
146 /**
147 * The forge's own directories stripped from a line of output — the data
148 * directory, the app's id, the environment and the per-commit checkout are
149 * the forge's private layout, not the app's; a stack trace naming
150 * `dir/src/server.ts:1:11` reads as `src/server.ts:1:11` instead, the path
151 * the app's own source control knows. Applied where output is captured, not
152 * filtered — a line with no match passes through whole, so a message never
153 * loses its stack for the sake of hiding a directory.
154 */
155 function stripForgeDirs(dirs: readonly string[], line: string): string {
156 return dirs.reduce(
157 (out, dir) => out.split(`${dir}/`).join("").split(dir).join("."),
158 line,
159 );
160 }
161
162147 /**
163148 * Run a step to completion, streaming its output. A non-zero exit, a
164149 * timeout, or an abort is an Err with the reason a person would want.
src/crew/claude.ts +17 −13
@@ -13,6 +13,7 @@
1313 */
1414
1515 import { childEnv, pump } from "../apps/process.ts";
16 import { stripForgeDirs } from "../runs/paths.ts";
1617 import type { SandboxPolicy } from "./sandbox.ts";
1718
1819 export interface ClaudeInput {
@@ -209,7 +210,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
209210 return;
210211 }
211212 if (type === "assistant") {
212 for (const text of assistantLines(e)) input.log(text);
213 for (const text of assistantLines(e, input.cwd)) input.log(text);
213214 return;
214215 }
215216 if (type === "result") {
@@ -309,7 +310,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
309310 }
310311
311312 /** The lines a person wants from an assistant turn: its words and its tool calls. */
312 function assistantLines(e: Record<string, unknown>): string[] {
313 function assistantLines(e: Record<string, unknown>, cwd: string): string[] {
313314 const message = e["message"];
314315 if (typeof message !== "object" || message === null) return [];
315316 const content = (message as Record<string, unknown>)["content"];
@@ -321,14 +322,14 @@ function assistantLines(e: Record<string, unknown>): string[] {
321322 if (line.trim() !== "") out.push(line);
322323 } else if (block["type"] === "tool_use") {
323324 out.push(
324 `→ ${describeTool(String(block["name"] ?? "tool"), block["input"])}`,
325 `→ ${describeTool(String(block["name"] ?? "tool"), block["input"], cwd)}`,
325326 );
326327 }
327328 }
328329 return out;
329330 }
330331
331 function describeTool(name: string, input: unknown): string {
332 function describeTool(name: string, input: unknown, cwd: string): string {
332333 const args =
333334 typeof input === "object" && input !== null
334335 ? (input as Record<string, unknown>)
@@ -340,15 +341,18 @@ function describeTool(name: string, input: unknown): string {
340341 }
341342 return "";
342343 };
343 const detail = first([
344 "command",
345 "file_path",
346 "path",
347 "pattern",
348 "url",
349 "description",
350 "prompt",
351 ]);
344 const detail = stripForgeDirs(
345 [cwd],
346 first([
347 "command",
348 "file_path",
349 "path",
350 "pattern",
351 "url",
352 "description",
353 "prompt",
354 ]),
355 );
352356 const short = detail.length > 160 ? `${detail.slice(0, 157)}…` : detail;
353357 return short === "" ? name : `${name} ${short}`;
354358 }
src/runs/paths.ts added +20
@@ -0,0 +1,20 @@
1 /**
2 * The forge's own directories stripped from a line of output — the data
3 * directory, the app's id, the environment and the per-commit checkout are
4 * the forge's private layout, not the app's; a stack trace naming
5 * `dir/src/server.ts:1:11` reads as `src/server.ts:1:11` instead, the path
6 * the app's own source control knows. Applied where output is captured, not
7 * filtered — a line with no match passes through whole, so a message never
8 * loses its stack for the sake of hiding a directory.
9 *
10 * An empty `dir` is dropped rather than stripped: `"".split("dir").join(".")`
11 * would rewrite every character boundary of the line.
12 */
13 export function stripForgeDirs(dirs: readonly string[], line: string): string {
14 return dirs
15 .filter((dir) => dir !== "")
16 .reduce(
17 (out, dir) => out.split(`${dir}/`).join("").split(dir).join("."),
18 line,
19 );
20 }
test/crew.test.ts +25 −1
@@ -40,6 +40,25 @@ import { listRuns, type Run } from "../src/core/runs.ts";
4040 import { createUser, type User } from "../src/core/users.ts";
4141 import { serve, type Servers } from "../src/http/server.ts";
4242 import { readRunLog } from "../src/runs/log.ts";
43 import { stripForgeDirs } from "../src/runs/paths.ts";
44
45 describe("stripForgeDirs", () => {
46 test("strips a known directory, leaves the rest of the path", () => {
47 expect(stripForgeDirs(["/data/crew/bot/1/2"], "/data/crew/bot/1/2/src/a.ts")).toBe(
48 "src/a.ts",
49 );
50 });
51
52 test("a line with no match passes through whole", () => {
53 expect(stripForgeDirs(["/data/crew/bot/1/2"], "/usr/bin/ssh")).toBe(
54 "/usr/bin/ssh",
55 );
56 });
57
58 test("an empty directory is dropped, not used as a prefix", () => {
59 expect(stripForgeDirs([""], "/usr/bin/ssh")).toBe("/usr/bin/ssh");
60 });
61 });
4362
4463 describe("the crew, with a stand-in Claude Code", () => {
4564 let dataDir: string;
@@ -104,7 +123,7 @@ fi
104123 if grep -q "The check on your pull" "${promptFile}"; then echo "daffodils" >> CREW.md; fi
105124 echo "work by the crew $(date +%s%N)" >> CREW.md
106125 git add -A && git commit -qm "crew work" && git push -q -u origin HEAD
107 echo '{"type":"assistant","message":{"content":[{"type":"text","text":"Added CREW.md."},{"type":"tool_use","name":"Bash","input":{"command":"git push"}}]}}'
126 echo '{"type":"assistant","message":{"content":[{"type":"text","text":"Added CREW.md."},{"type":"tool_use","name":"Read","input":{"file_path":"'"$(pwd)"'/CREW.md"}},{"type":"tool_use","name":"Bash","input":{"command":"cat /etc/hosts"}},{"type":"tool_use","name":"Bash","input":{"command":"git push"}}]}}'
108127 echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW.md as asked.","total_cost_usd":0.01,"num_turns":3,"session_id":"fake-1","terminal_reason":"completed"}'
109128 `,
110129 );
@@ -245,6 +264,11 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
245264 ).toBe(pull.headOid);
246265 expect(pull.body).toContain("Added CREW.md as asked.");
247266 expect(pull.body).toContain(`Closes #${issueNumber}`);
267 // A tool line names the file the way the repository does — the run's
268 // workdir stripped off — while a path outside the workdir is untouched.
269 const log = readRunLog(dataDir, run.id).lines.join("\n");
270 expect(log).toContain("→ Read CREW.md");
271 expect(log).toContain("→ Bash cat /etc/hosts");
248272 // The run's credential is gone; the clone stays for the follow-up.
249273 const tokens = app.db
250274 .query<

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs