@a-t-h-i/bot-lobby 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +180 -17
  2. package/package.json +12 -6
  3. package/prompts/backend.md +3 -1
  4. package/prompts/designer.md +24 -1
  5. package/prompts/master.md +98 -19
  6. package/prompts/quickfix.md +16 -3
  7. package/prompts/researcher.md +8 -2
  8. package/prompts/reviewer.md +42 -0
  9. package/prompts/worker.md +7 -0
  10. package/src/agents/backend.ts +2 -2
  11. package/src/agents/designer.ts +2 -2
  12. package/src/ask/dialog.ts +167 -0
  13. package/src/ask/image.ts +202 -0
  14. package/src/ask/png.ts +179 -0
  15. package/src/ask/relay.ts +89 -0
  16. package/src/ask/state.ts +160 -0
  17. package/src/ask/tool.ts +126 -0
  18. package/src/ask/types.ts +55 -0
  19. package/src/ask/view.ts +159 -0
  20. package/src/classifier/triage.ts +24 -7
  21. package/src/execution/agent-runner.ts +103 -11
  22. package/src/execution/git.ts +111 -14
  23. package/src/execution/pi-runner.ts +164 -11
  24. package/src/index.ts +9 -0
  25. package/src/lobby/ask.ts +10 -120
  26. package/src/lobby/feed.ts +6 -1
  27. package/src/lobby/layout.ts +27 -8
  28. package/src/lobby/markdown.ts +36 -6
  29. package/src/lobby/planner.ts +1 -1
  30. package/src/lobby/quickfix.ts +50 -3
  31. package/src/lobby/runtime.ts +41 -40
  32. package/src/lobby/tabs/home.ts +112 -48
  33. package/src/lobby/tabs/issues.ts +4 -3
  34. package/src/lobby/tabs/plan.ts +4 -4
  35. package/src/lobby/tabs/quickfix.ts +8 -2
  36. package/src/lobby/tabs/tasks.ts +16 -5
  37. package/src/lobby/theme.ts +30 -0
  38. package/src/lobby/view.ts +36 -5
  39. package/src/master/decisions.ts +10 -2
  40. package/src/master/master.ts +41 -4
  41. package/src/master/research.ts +5 -2
  42. package/src/pi/commands.ts +108 -13
  43. package/src/pi/events.ts +48 -10
  44. package/src/pi/quiet.ts +22 -4
  45. package/src/pi/route.ts +180 -0
  46. package/src/pi/start-task.ts +56 -4
  47. package/src/pi/tools.ts +32 -9
  48. package/src/pi/ui.ts +6 -1
  49. package/src/pi/zen-metrics.ts +13 -3
  50. package/src/pi/zen.ts +16 -9
  51. package/src/roles/reviewer.ts +23 -4
  52. package/src/roles/worker.ts +28 -2
  53. package/src/schemas/configuration.ts +15 -0
  54. package/src/schemas/findings.ts +14 -0
  55. package/src/schemas/task.ts +52 -0
  56. package/src/state/budget.ts +274 -0
  57. package/src/state/changes.ts +231 -0
  58. package/src/text.ts +28 -2
  59. package/src/web/extract.ts +332 -0
  60. package/src/web/fetch.ts +232 -0
  61. package/src/web/html.ts +183 -0
  62. package/src/web/read.ts +113 -0
  63. package/src/web/search.ts +202 -0
  64. package/src/web/tools.ts +279 -0
  65. package/src/workflow/track.ts +436 -0
  66. package/src/workflow/workflow.ts +734 -46
@@ -48,6 +48,15 @@ function sizeQuestion(subject: string) {
48
48
  return score(`How big is the change ${subject} asks for, judged by what it would take to build and verify in this repository?`, SIZE_LEVELS);
49
49
  }
50
50
 
51
+ /** Whether one agent can just do it: the quick fix or the team. */
52
+ function soloQuestion(subject: string) {
53
+ return noul(
54
+ `Could one engineer do ${subject} alone, right away — in one file or one area of the code — with no contract to agree between frontend and backend, no unfamiliar codebase to survey first, no risky change (security, data, payments, migrations) and no decision the user must make first?`,
55
+ "Yes: one person can just do it now, even if it is a rich piece of work in one place.",
56
+ "No: it needs a team — several areas, a survey of the codebase, a plan to agree, or review before it lands.",
57
+ );
58
+ }
59
+
51
60
  export function triageRequest(input: TriageInput): SystemOneRequest {
52
61
  const questions: SystemOneRequest["questions"] = {
53
62
  size: sizeQuestion("`request`"),
@@ -62,6 +71,7 @@ export function triageRequest(input: TriageInput): SystemOneRequest {
62
71
  "No: a competent engineer could build it as written, deciding details from the codebase.",
63
72
  ),
64
73
  kind: choice("What kind of work does `request` ask for?", TRIAGE_KINDS),
74
+ solo: soloQuestion("`request`"),
65
75
  };
66
76
  for (const { domain, owns } of input.domains) {
67
77
  questions[`domain_${domain}`] = noul(`Does building \`request\` require changes in this area: ${owns}?`);
@@ -85,12 +95,14 @@ export async function triageTask(classifier: Classifier, input: TriageInput, sig
85
95
  if (probability !== undefined) domains[domain] = probability;
86
96
  }
87
97
  const kind = choiceOf(result.answers, "kind");
98
+ const solo = yesOf(result.answers, "solo");
88
99
  return {
89
100
  size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!,
90
101
  sizeConfidence: size.confidence,
91
102
  domains,
92
103
  research: yesOf(result.answers, "needs_research") ?? 0,
93
104
  ambiguous: yesOf(result.answers, "ambiguous") ?? 0,
105
+ ...(solo !== undefined ? { solo } : {}),
94
106
  ...(kind ? { kind: kind.choice, kindProbability: kind.probability } : {}),
95
107
  at: new Date().toISOString(),
96
108
  };
@@ -131,20 +143,25 @@ export async function triageWithContext(classifier: Classifier, scope: FileScope
131
143
  return files.length > 0 ? { ...triage, likelyFiles: files } : triage;
132
144
  }
133
145
 
134
- /** The size of a quick fix prompt, or undefined when the classifier is off or fails. */
135
- export async function quickFixSize(classifier: Classifier, prompt: string, signal?: AbortSignal): Promise<{ size: TriageSize; confidence: number } | undefined> {
146
+ /**
147
+ * The size of a quick fix prompt, and how likely one engineer can do it alone,
148
+ * or undefined when the classifier is off or fails. A large prompt is held as
149
+ * a task unless it is still one engineer's work (a rich page in one file).
150
+ */
151
+ export async function quickFixSize(classifier: Classifier, prompt: string, signal?: AbortSignal): Promise<{ size: TriageSize; confidence: number; solo?: number } | undefined> {
136
152
  if (!classifier.enabled("triage")) return undefined;
137
- const largeAt = classifier.config.thresholds.quickFixLargeAt;
138
- const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`") } }, {
153
+ const { quickFixLargeAt: largeAt, quickFixAt } = classifier.config.thresholds;
154
+ const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`"), solo: soloQuestion("`prompt`") } }, {
139
155
  ...(signal ? { signal } : {}),
140
156
  saved: (answers) => {
141
157
  const sized = scoreOf(answers, "size");
142
- return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt ? 1 : 0;
158
+ return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt && (yesOf(answers, "solo") ?? 0) < quickFixAt ? 1 : 0;
143
159
  },
144
160
  });
145
161
  const size = scoreOf(result?.answers, "size");
146
162
  if (!size) return undefined;
147
- return { size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!, confidence: size.confidence };
163
+ const solo = yesOf(result?.answers, "solo");
164
+ return { size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!, confidence: size.confidence, ...(solo !== undefined ? { solo } : {}) };
148
165
  }
149
166
 
150
167
  const HINT = 0.5;
@@ -155,7 +172,7 @@ export function suggestedPath(triage: TaskTriage): string {
155
172
  if (triage.ambiguous >= HINT) return "clarify first: the request as written probably misses a decision.";
156
173
  const smallish = (triage.size === "trivial" || triage.size === "small") && triage.sizeConfidence >= 0.6;
157
174
  const steps: string[] = [];
158
- if (smallish && touched.length === 1) steps.push(`single-domain shortcut (${touched[0]}): skip the scout round and the proposal ceremony, state the short plan and delegate`);
175
+ if (smallish && touched.length === 1) steps.push(`fast track (${touched[0]}): no scouts, proposal or plan; delegate straight away`);
159
176
  else if (touched.length > 0) steps.push(`scout only ${touched.join(", ")}`);
160
177
  if (triage.research >= HINT) steps.push("summon the researcher for the outside facts");
161
178
  return steps.length > 0 ? `${steps.join("; ")}.` : "no strong signal; decide from the request.";
@@ -4,7 +4,11 @@ import { roleSpec } from "../roles/registry.ts";
4
4
  import { compilePrompt } from "../prompts/compiler.ts";
5
5
  import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
6
6
  import { shortDuration, truncate } from "../text.ts";
7
- import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "./pi-runner.ts";
7
+ import { EditLog } from "../state/changes.ts";
8
+ import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
9
+ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
10
+ import { ASK_ENV } from "../ask/relay.ts";
11
+ import { ASK_TOOL } from "../ask/types.ts";
8
12
 
9
13
  export interface AgentContext {
10
14
  task: string;
@@ -58,8 +62,49 @@ export interface AgentRequest {
58
62
  /** The classifier routed this run down from this configured profile (receipts and metrics show it). */
59
63
  routedFrom?: string;
60
64
  route?: string;
65
+ /** Under a task time budget: the run's allotted time, which replaces `timeoutMs`. */
66
+ time?: AgentTime;
67
+ /** Lets the agent ask the user (ask_user_question, relayed through the master); its clocks wait for the answers. */
68
+ onAsk?: RelayAsk;
61
69
  }
62
70
 
71
+ /**
72
+ * A run under a task time budget. The agent is told its minutes up front,
73
+ * warned at 75%, and asked to stop and report when they run out. A worker
74
+ * then reports where it left off and how much more it needs; `onTimeUp`
75
+ * (the engine asking the user) grants more, and the same agent carries on.
76
+ */
77
+ export interface AgentTime {
78
+ /** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
79
+ endsAt: number;
80
+ /** The allotment, as the agent and the lobby see it. */
81
+ allotMs: number;
82
+ /** What the agent is told about its time, in its context. */
83
+ note?: string;
84
+ /** Workers only: decide on more time once it has reported where it left off; resolve with ms, 0 to stop it. */
85
+ onTimeUp?: (run: AgentRun, report: string) => Promise<number>;
86
+ }
87
+
88
+ /** The warning at 75% of an allotment. */
89
+ export function headsUpMessage(leftMs: number, allotMs: number): string {
90
+ return `bot-lobby: about ${formatMinutes(leftMs)} of your ${formatMinutes(allotMs)} for this step are left. Finish what you have started and make sure it works; leave nice-to-haves.`;
91
+ }
92
+
93
+ /** The steer when an allotment is spent: a worker says where it left off and what more it needs; any other role reports what it has. */
94
+ export function timeUpMessage(allotMs: number, canAskForMore: boolean): string {
95
+ return canAskForMore
96
+ ? `bot-lobby: your ${formatMinutes(allotMs)} for this step are up. Stop here: finish or revert the edit in progress so every file is consistent, start nothing new, and write your report now in the required format, adding \`## Left Off\` (what you were doing and what is still to do) and \`## More Time\` (how many more minutes you need, and why). The user decides whether you get more time to finish.`
97
+ : `bot-lobby: your ${formatMinutes(allotMs)} are up. Stop now and write your report in the required format with what you have.`;
98
+ }
99
+
100
+ /** The follow-up that sends an agent on once the user gave it more time. */
101
+ export function carryOnMessage(extraMs: number): string {
102
+ return `bot-lobby: the user gave you ${formatMinutes(extraMs)} more. Carry on from where you left off and finish the step, then write your final report in the required format (leave out Left Off and More Time if you finish).`;
103
+ }
104
+
105
+ /** Least time an attempt starts with; a retry with less than this left is not started. */
106
+ const MIN_ATTEMPT_MS = 60_000;
107
+
63
108
  /** The watchdog fields of an AgentRequest, taken from the workflow config. */
64
109
  export function watchdogOptions(workflow: {
65
110
  maxAgentRetries: number;
@@ -91,13 +136,18 @@ function retryable(run: AgentRun): boolean {
91
136
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
92
137
  const startedAt = new Date().toISOString();
93
138
  const attempts = Math.max(1, (request.retries ?? 0) + 1);
139
+ // A failed attempt may have edited files before the retry: the run owns every edit.
140
+ const edits = new EditLog(request.cwd);
94
141
  let last: AgentRun | undefined;
95
142
  for (let attempt = 1; attempt <= attempts; attempt++) {
96
- last = await runAgentOnce(request, run, attempt, startedAt);
143
+ last = await runAgentOnce(request, run, attempt, startedAt, edits);
97
144
  request.onAttemptEnd?.(last);
98
145
  if (!retryable(last)) break;
146
+ // Under a budget a retry only uses what is left of the allotment.
147
+ if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
99
148
  }
100
- return last!;
149
+ const edited = edits.list();
150
+ return edited.length > 0 ? { ...last!, edited } : last!;
101
151
  }
102
152
 
103
153
  /** Abort every in-flight subagent (session shutdown, user cancel). */
@@ -120,31 +170,36 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
120
170
  ...(request.thinking ? { thinking: request.thinking } : {}),
121
171
  ...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
122
172
  ...(request.route ? { route: request.route } : {}),
173
+ ...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
123
174
  ...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
124
175
  };
125
176
  }
126
177
 
127
178
  function toolsFor(request: AgentRequest): readonly string[] | undefined {
128
179
  const tools = roleSpec(request.role).tools;
129
- if (!request.extraTools?.length) return tools;
130
- return [...(tools ?? []), ...request.extraTools];
180
+ const extra = [...(request.extraTools ?? []), ...(request.onAsk ? [ASK_TOOL] : [])];
181
+ if (extra.length === 0) return tools;
182
+ return [...(tools ?? []), ...extra];
131
183
  }
132
184
 
133
185
  /**
134
186
  * Run one domain/role agent in an isolated pi process. The role's tool
135
187
  * allowlist comes from its spec, so read-only roles cannot modify anything.
136
188
  */
137
- async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string): Promise<AgentRun> {
189
+ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog): Promise<AgentRun> {
138
190
  const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${Date.now().toString(36)}`, startedAt, attempt);
139
191
  request.onUpdate?.(base);
140
- const live = createLiveRun(base, request);
192
+ const live = createLiveRun(base, request, edits);
141
193
 
142
194
  const controller = new AbortController();
143
195
  activeControllers.add(controller);
144
196
  const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
145
197
  try {
146
198
  const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
147
- const wrapUp = request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
199
+ const time = request.time;
200
+ const left = time ? Math.max(MIN_ATTEMPT_MS, time.endsAt - Date.now()) : 0;
201
+ const wrapUp = time ? 0 : request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
202
+ const onTimeUp = time?.onTimeUp;
148
203
  const result = await runPiAgent({
149
204
  cwd: request.cwd,
150
205
  task: request.instruction,
@@ -152,9 +207,29 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
152
207
  tools: toolsFor(request),
153
208
  model: request.model,
154
209
  thinking: request.thinking,
155
- timeoutMs: request.timeoutMs,
210
+ timeoutMs: time ? left + REPORT_GRACE_MS : request.timeoutMs,
211
+ ...(time ? {
212
+ time: {
213
+ upAtMs: left,
214
+ headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
215
+ headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
216
+ upMessage: timeUpMessage(time.allotMs, Boolean(onTimeUp)),
217
+ graceMs: REPORT_GRACE_MS,
218
+ ...(onTimeUp ? {
219
+ onTimeUp: async (report: string) => {
220
+ const extraMs = await onTimeUp(live.current(), report);
221
+ if (extraMs > 0) {
222
+ time.endsAt = Date.now() + extraMs;
223
+ live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
224
+ }
225
+ return { extraMs, message: carryOnMessage(extraMs) };
226
+ },
227
+ } : {}),
228
+ },
229
+ } : {}),
156
230
  signal,
157
- env: request.env,
231
+ ...(request.onAsk ? { onAsk: request.onAsk } : {}),
232
+ env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
158
233
  stallTimeoutMs: request.stallTimeoutMs,
159
234
  toolStallTimeoutMs: request.toolStallTimeoutMs,
160
235
  wrapUpAtMs: wrapUp,
@@ -174,6 +249,8 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
174
249
  model: result.model ?? current.model,
175
250
  ...(result.stalled ? { stalled: true } : {}),
176
251
  ...(result.wrappedUp ? { wrappedUp: true } : {}),
252
+ ...(result.timeUp ? { timeUp: true } : {}),
253
+ ...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
177
254
  note: undefined,
178
255
  noteKind: undefined,
179
256
  };
@@ -198,7 +275,7 @@ const THROTTLE_MS = 2000;
198
275
  * `onUpdate`: activity changes, retries and notes immediately, counters and
199
276
  * heartbeats at most every couple of seconds.
200
277
  */
201
- function createLiveRun(base: AgentRun, request: AgentRequest) {
278
+ function createLiveRun(base: AgentRun, request: AgentRequest, edits: EditLog) {
202
279
  let state: AgentRun = base;
203
280
  let lastEmit = 0;
204
281
  const emit = (patch: Partial<AgentRun>, force: boolean) => {
@@ -212,6 +289,7 @@ function createLiveRun(base: AgentRun, request: AgentRequest) {
212
289
  const onEvent = (event: PiStreamEvent) => {
213
290
  switch (event.type) {
214
291
  case "tool_execution_start": {
292
+ edits.note(event.toolName, event.args);
215
293
  const activity = activityWord(event.toolName);
216
294
  const detail = activityDetail(event.toolName, event.args);
217
295
  const step = describeToolCall(event.toolName, event.args);
@@ -240,6 +318,20 @@ function createLiveRun(base: AgentRun, request: AgentRequest) {
240
318
  case "wrap_up":
241
319
  emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
242
320
  return;
321
+ case "time_up":
322
+ emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
323
+ return;
324
+ case "extended":
325
+ emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
326
+ return;
327
+ case "asking":
328
+ emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
329
+ return;
330
+ case "answered":
331
+ // Its time waited with it.
332
+ if (request.time) request.time.endsAt += event.waitedMs;
333
+ emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
334
+ return;
243
335
  case "usage": {
244
336
  const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
245
337
  const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };
@@ -1,4 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { promisify } from "node:util";
3
5
  import { truncate } from "../text.ts";
4
6
 
@@ -10,33 +12,128 @@ async function git(cwd: string, args: string[]): Promise<string> {
10
12
  return stdout.trim();
11
13
  }
12
14
 
13
- /** `git diff HEAD`, falling back to index+worktree when HEAD does not exist yet. */
14
- async function diffAgainstHead(cwd: string): Promise<string> {
15
+ /** `git diff <base>` (HEAD by default), falling back to index+worktree when HEAD does not exist yet. */
16
+ async function diffAgainst(cwd: string, base: string, extra: readonly string[] = [], paths: readonly string[] = []): Promise<string> {
15
17
  try {
16
- return await git(cwd, ["diff", "HEAD"]);
18
+ return await git(cwd, ["diff", ...extra, base, "--", ...paths]);
17
19
  } catch {
18
- const [unstaged, staged] = await Promise.all([git(cwd, ["diff"]), git(cwd, ["diff", "--cached"])]);
20
+ const [unstaged, staged] = await Promise.all([git(cwd, ["diff", ...extra, "--", ...paths]), git(cwd, ["diff", ...extra, "--cached", "--", ...paths])]);
19
21
  return [unstaged, staged].filter((part) => part.length > 0).join("\n");
20
22
  }
21
23
  }
22
24
 
25
+ /** The commit HEAD points at, or undefined (no commits yet, or no git). */
26
+ export async function headCommit(cwd: string): Promise<string | undefined> {
27
+ try {
28
+ return (await git(cwd, ["rev-parse", "--verify", "HEAD"])) || undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ /** The newest commit on HEAD made before `iso` (where a task started), or undefined. */
35
+ export async function commitBefore(cwd: string, iso: string): Promise<string | undefined> {
36
+ try {
37
+ return (await git(cwd, ["rev-list", "-1", `--before=${iso}`, "HEAD"])) || undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ export interface DiffOptions {
44
+ /** Bound on the whole text. */
45
+ limitChars?: number;
46
+ /** The commit to compare with (where the task started), so work already committed still shows; HEAD when absent. */
47
+ base?: string;
48
+ /** Paths (relative to `cwd`) left out: bot-lobby's own records. */
49
+ exclude?: readonly string[];
50
+ }
51
+
52
+ /** Longest new file shown whole; past it (or binary) only its name is listed. */
53
+ const MAX_NEW_FILE_CHARS = 4000;
54
+ const MAX_NEW_FILE_BYTES = 200 * 1024;
55
+
23
56
  /**
24
- * Repository evidence for a reviewer: working-tree status plus the diff against
25
- * HEAD. Returns an explanatory string instead of throwing when git is unusable
26
- * so a review can still proceed on other evidence.
57
+ * Repository evidence for a reviewer: working-tree status, the files changed
58
+ * since `base` (listed first, so a long diff never hides which files moved),
59
+ * the diff itself, and the content of new files (which `git diff` leaves
60
+ * out). Returns an explanatory string instead of throwing when git is
61
+ * unusable so a review can still proceed on other evidence.
27
62
  */
28
- export async function readRepositoryDiff(cwd: string, limitChars = 20_000): Promise<string> {
63
+ export async function readRepositoryDiff(cwd: string, options: DiffOptions = {}): Promise<string> {
64
+ const limit = options.limitChars ?? 20_000;
65
+ const paths = options.exclude?.length ? [".", ...options.exclude.map((path) => `:(exclude)${path}`)] : [];
66
+ const base = options.base ?? "HEAD";
67
+ const label = options.base ? `since ${options.base.slice(0, 7)}, where the task started (committed work included)` : "HEAD";
29
68
  try {
30
- const [status, diff] = await Promise.all([
31
- git(cwd, ["status", "--porcelain"]),
32
- diffAgainstHead(cwd),
69
+ const [status, stat, diff, untracked] = await Promise.all([
70
+ git(cwd, ["status", "--porcelain", "--", ...paths]),
71
+ diffAgainst(cwd, base, ["--stat"], paths),
72
+ diffAgainst(cwd, base, [], paths),
73
+ git(cwd, ["ls-files", "--others", "--exclude-standard", "--", ...paths]),
33
74
  ]);
34
- const parts = [
75
+ const head = [
35
76
  status ? `Status:\n${status}` : "Status: clean working tree",
36
- diff ? `Diff (HEAD):\n${diff}` : "Diff (HEAD): none",
77
+ stat ? `Changed files (${label}):\n${stat}` : "",
78
+ ].filter(Boolean).join("\n\n");
79
+ const fresh = untracked ? newFiles(cwd, untracked.split("\n").filter(Boolean)) : "";
80
+ const room = Math.max(0, limit - head.length);
81
+ // The diff and the new files share what is left; new files get at least a third when there are any.
82
+ const diffRoom = fresh ? Math.max(Math.floor(room * 0.66), room - fresh.length) : room;
83
+ const parts = [
84
+ head,
85
+ diff ? `Diff (${label}):\n${truncate(diff, diffRoom)}` : `Diff (${label}): none`,
86
+ fresh ? `New files (untracked):\n${truncate(fresh, Math.max(0, room - Math.min(diff.length, diffRoom)))}` : "",
37
87
  ];
38
- return truncate(parts.join("\n\n"), limitChars);
88
+ return truncate(parts.filter(Boolean).join("\n\n"), limit + 200);
39
89
  } catch (error) {
40
90
  return `Unable to read git state: ${(error as Error).message}`;
41
91
  }
42
92
  }
93
+
94
+ /** New files as a reviewer reads them: each whole when it is small text, otherwise just named. */
95
+ function newFiles(cwd: string, files: readonly string[]): string {
96
+ return files.map((file) => {
97
+ try {
98
+ const path = join(cwd, file);
99
+ if (statSync(path).size > MAX_NEW_FILE_BYTES) return `--- ${file} (large; read it directly)`;
100
+ const text = readFileSync(path, "utf8");
101
+ if (text.includes("\0")) return `--- ${file} (binary)`;
102
+ return `--- ${file}\n${truncate(text, MAX_NEW_FILE_CHARS)}`;
103
+ } catch {
104
+ return `--- ${file} (unreadable)`;
105
+ }
106
+ }).join("\n\n");
107
+ }
108
+
109
+ /** Most changed files one look reports; the rest are summarised by the diff. */
110
+ export const MAX_CHANGED_FILES = 400;
111
+
112
+ /**
113
+ * The repository's top folder and every file changed, relative to it: what
114
+ * `git status` reports (renames by their new path, untracked files one by
115
+ * one), plus what was committed since `base`; undefined when git is unusable.
116
+ */
117
+ export async function changedFiles(cwd: string, base?: string): Promise<{ top: string; files: string[] } | undefined> {
118
+ try {
119
+ const [top, status, committed] = await Promise.all([
120
+ git(cwd, ["rev-parse", "--show-toplevel"]),
121
+ run("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd, maxBuffer: MAX_BUFFER }),
122
+ // Work committed since the task started is still the task's to review.
123
+ base ? run("git", ["diff", "--name-only", "-z", base], { cwd, maxBuffer: MAX_BUFFER }).then((result) => result.stdout, () => "") : Promise.resolve(""),
124
+ ]);
125
+ const entries = status.stdout.split("\0");
126
+ const files = new Set<string>();
127
+ for (let index = 0; index < entries.length && files.size < MAX_CHANGED_FILES; index += 1) {
128
+ const entry = entries[index]!;
129
+ if (entry.length < 4) continue;
130
+ files.add(entry.slice(3));
131
+ // A rename or copy is followed by the path it came from.
132
+ if (/[RC]/.test(entry.slice(0, 2))) index += 1;
133
+ }
134
+ for (const file of committed.split("\0")) if (file && files.size < MAX_CHANGED_FILES) files.add(file);
135
+ return { top, files: [...files] };
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }