@a-t-h-i/bot-lobby 0.6.3 → 0.6.4
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.
- package/README.md +102 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +6 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/layout.ts +27 -8
- package/src/lobby/markdown.ts +36 -6
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +14 -36
- package/src/lobby/tabs/home.ts +112 -48
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +27 -5
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +47 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +8 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +6 -1
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +16 -9
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +4 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/workflow/workflow.ts +592 -42
package/src/schemas/task.ts
CHANGED
|
@@ -114,6 +114,21 @@ export interface TaskTriage {
|
|
|
114
114
|
at: string;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** What the working tree held when a task's agents started editing: its changed files, repository-relative, and the commit it stood on. */
|
|
118
|
+
export interface ChangeBaseline {
|
|
119
|
+
at: string;
|
|
120
|
+
files: string[];
|
|
121
|
+
/** HEAD when the first worker started: the QA gate reviews everything since, committed work included. */
|
|
122
|
+
head?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The user accepted a task's work without a QA pass. */
|
|
126
|
+
export interface QaWaiver {
|
|
127
|
+
at: string;
|
|
128
|
+
/** What QA still asked for when the user accepted. */
|
|
129
|
+
open: string[];
|
|
130
|
+
}
|
|
131
|
+
|
|
117
132
|
export interface Task {
|
|
118
133
|
id: string;
|
|
119
134
|
title: string;
|
|
@@ -145,6 +160,12 @@ export interface Task {
|
|
|
145
160
|
archivedAt?: string;
|
|
146
161
|
/** The classifier's read of the request, when it was on as the task started. */
|
|
147
162
|
triage?: TaskTriage;
|
|
163
|
+
/** Files already changed when the first worker started: the QA gate reads them as pre-existing, not as this task's work. */
|
|
164
|
+
baseline?: ChangeBaseline;
|
|
165
|
+
/** Review rounds the user granted past `workflow.maxReviewIterations`. */
|
|
166
|
+
extraReviewRounds?: number;
|
|
167
|
+
/** Set only by the user: the work is accepted without a QA pass, so the task may complete. */
|
|
168
|
+
qaWaiver?: QaWaiver;
|
|
148
169
|
}
|
|
149
170
|
|
|
150
171
|
export function createTask(
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task's time budget: the minutes the user set (and granted since), the
|
|
3
|
+
* work time spent, and the time each delegation was given. Work time counts
|
|
4
|
+
* while the oracle works on the task — its own turns, which include every
|
|
5
|
+
* agent it runs — and not while it waits on the user (between turns, and
|
|
6
|
+
* while a dialog is open). It lives beside the task's state as budget.json,
|
|
7
|
+
* written by the session that owns the task; every write reads the file
|
|
8
|
+
* afresh, so the clock and the engine never overwrite each other.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { taskDirFor } from "./persistence.ts";
|
|
13
|
+
import { readJsonCached } from "./file-cache.ts";
|
|
14
|
+
|
|
15
|
+
/** What one delegation was given. */
|
|
16
|
+
export interface Allotment {
|
|
17
|
+
/** The delegation: a worker run, the scouts, the researcher, the QA gate. */
|
|
18
|
+
id: string;
|
|
19
|
+
who: string;
|
|
20
|
+
what: string;
|
|
21
|
+
/** Minutes given, including any granted after it ran out. */
|
|
22
|
+
minutes: number;
|
|
23
|
+
granted: number;
|
|
24
|
+
startedAt: string;
|
|
25
|
+
endedAt?: string;
|
|
26
|
+
outcome?: "finished" | "out of time" | "stopped";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TaskBudget {
|
|
30
|
+
/** Minutes the user set. */
|
|
31
|
+
minutes: number;
|
|
32
|
+
/** Minutes the user granted on top since. */
|
|
33
|
+
granted: number;
|
|
34
|
+
/** Work time spent, in ms. */
|
|
35
|
+
usedMs: number;
|
|
36
|
+
allotments: Allotment[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const MAX_ALLOTMENTS = 40;
|
|
40
|
+
/** Least a worker is given; below this the budget counts as spent. */
|
|
41
|
+
export const MIN_ALLOT_MS = 3 * 60_000;
|
|
42
|
+
/** Least a scout batch, the researcher or the QA gate is given. */
|
|
43
|
+
export const MIN_READ_MS = 2 * 60_000;
|
|
44
|
+
/** Kept back for the QA gate until it passes: this share of the budget, at least `QA_RESERVE_MIN_MS`. */
|
|
45
|
+
export const QA_RESERVE_SHARE = 0.15;
|
|
46
|
+
export const QA_RESERVE_MIN_MS = 5 * 60_000;
|
|
47
|
+
/** Time an agent whose time is up has to write its report before it is stopped. */
|
|
48
|
+
export const REPORT_GRACE_MS = 2 * 60_000;
|
|
49
|
+
/** Longest budget accepted, in minutes. */
|
|
50
|
+
export const MAX_BUDGET_MINUTES = 24 * 60;
|
|
51
|
+
|
|
52
|
+
export function budgetPath(root: string, configDir: string, taskId: string): string {
|
|
53
|
+
return join(taskDirFor(root, configDir, taskId), "budget.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isBudget(value: unknown): value is TaskBudget {
|
|
57
|
+
const budget = value as Partial<TaskBudget> | undefined;
|
|
58
|
+
return Boolean(budget && typeof budget.minutes === "number" && typeof budget.usedMs === "number");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readBudget(root: string, configDir: string, taskId: string): TaskBudget | undefined {
|
|
62
|
+
try {
|
|
63
|
+
const value: unknown = JSON.parse(readFileSync(budgetPath(root, configDir, taskId), "utf8"));
|
|
64
|
+
if (!isBudget(value)) return undefined;
|
|
65
|
+
return { minutes: value.minutes, granted: value.granted ?? 0, usedMs: value.usedMs, allotments: Array.isArray(value.allotments) ? value.allotments : [] };
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeBudget(root: string, configDir: string, taskId: string, budget: TaskBudget): void {
|
|
72
|
+
const path = budgetPath(root, configDir, taskId);
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
writeFileSync(path, `${JSON.stringify({ ...budget, allotments: budget.allotments.slice(-MAX_ALLOTMENTS) }, null, 2)}\n`, "utf8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A task's budget as a display reads it every frame: parsed again only when the file changes. */
|
|
78
|
+
export function peekBudget(root: string, configDir: string, taskId: string): TaskBudget | undefined {
|
|
79
|
+
const value = readJsonCached(budgetPath(root, configDir, taskId), isBudget);
|
|
80
|
+
return value ? { minutes: value.minutes, granted: value.granted ?? 0, usedMs: value.usedMs, allotments: Array.isArray(value.allotments) ? value.allotments : [] } : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Work time spent of the whole, for the lobby's status box; undefined without a budget. */
|
|
84
|
+
export function budgetClock(root: string, configDir: string, taskId: string, now = Date.now()): { usedMs: number; totalMs: number } | undefined {
|
|
85
|
+
const budget = peekBudget(root, configDir, taskId);
|
|
86
|
+
return budget ? { usedMs: usedMs(taskId, budget, now), totalMs: (budget.minutes + budget.granted) * 60_000 } : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Change a task's budget in place (read afresh, written back); undefined when it has none. */
|
|
90
|
+
export function updateBudget(root: string, configDir: string, taskId: string, change: (budget: TaskBudget) => void): TaskBudget | undefined {
|
|
91
|
+
const budget = readBudget(root, configDir, taskId);
|
|
92
|
+
if (!budget) return undefined;
|
|
93
|
+
change(budget);
|
|
94
|
+
try {
|
|
95
|
+
writeBudget(root, configDir, taskId, budget);
|
|
96
|
+
} catch {
|
|
97
|
+
// A read-only tree must never fail a workflow step; the clock just stops being saved.
|
|
98
|
+
}
|
|
99
|
+
return budget;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Set (or, with 0, remove) a task's budget; time already spent is kept when it changes. */
|
|
103
|
+
export function setBudget(root: string, configDir: string, taskId: string, minutes: number): TaskBudget | undefined {
|
|
104
|
+
if (minutes <= 0) {
|
|
105
|
+
rmSync(budgetPath(root, configDir, taskId), { force: true });
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const current = readBudget(root, configDir, taskId);
|
|
109
|
+
const budget: TaskBudget = current ? { ...current, minutes, granted: 0 } : { minutes, granted: 0, usedMs: 0, allotments: [] };
|
|
110
|
+
writeBudget(root, configDir, taskId, budget);
|
|
111
|
+
return budget;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Minutes from `90`, `90m`, `90 min`, `1h`, `1.5h`, `1h30`, `1h30m` or `2 hours`; undefined for anything else. */
|
|
115
|
+
export function parseMinutes(text: string): number | undefined {
|
|
116
|
+
const match = /^\s*(?:(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hours?))?\s*(?:(\d+(?:\.\d+)?)\s*(?:m|min|mins|minutes?)?)?\s*$/i.exec(text);
|
|
117
|
+
if (!match || (!match[1] && !match[2])) return undefined;
|
|
118
|
+
const minutes = Math.round(Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0));
|
|
119
|
+
return minutes > 0 && minutes <= MAX_BUDGET_MINUTES ? minutes : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `34m`, `1h 05m`. */
|
|
123
|
+
export function formatMinutes(ms: number): string {
|
|
124
|
+
const minutes = Math.max(0, Math.round(ms / 60_000));
|
|
125
|
+
if (minutes < 60) return `${minutes}m`;
|
|
126
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------------------------ work clock */
|
|
130
|
+
|
|
131
|
+
interface Clock {
|
|
132
|
+
root: string;
|
|
133
|
+
configDir: string;
|
|
134
|
+
/** The oracle's turn on the task is running. */
|
|
135
|
+
running: boolean;
|
|
136
|
+
/** Dialogs open: the user is being asked, so the clock waits. */
|
|
137
|
+
waits: number;
|
|
138
|
+
/** Since when the current stretch counts, while it does. */
|
|
139
|
+
since?: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Work clocks of the tasks this process drives. */
|
|
143
|
+
const clocks = new Map<string, Clock>();
|
|
144
|
+
/** How often a counting clock is saved, so other windows see time pass. */
|
|
145
|
+
export const CLOCK_FLUSH_MS = 30_000;
|
|
146
|
+
let flusher: ReturnType<typeof setInterval> | undefined;
|
|
147
|
+
|
|
148
|
+
/** Fold the stretch that counted into the saved total, and start a new one if it still counts. */
|
|
149
|
+
function settle(taskId: string, clock: Clock, now = Date.now()): void {
|
|
150
|
+
if (clock.since !== undefined) {
|
|
151
|
+
const spent = Math.max(0, now - clock.since);
|
|
152
|
+
clock.since = undefined;
|
|
153
|
+
if (spent > 0) updateBudget(clock.root, clock.configDir, taskId, (budget) => { budget.usedMs += spent; });
|
|
154
|
+
}
|
|
155
|
+
if (clock.running && clock.waits === 0) clock.since = now;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ensureFlusher(): void {
|
|
159
|
+
if (flusher) return;
|
|
160
|
+
flusher = setInterval(() => {
|
|
161
|
+
for (const [taskId, clock] of clocks) if (clock.since !== undefined) settle(taskId, clock);
|
|
162
|
+
}, CLOCK_FLUSH_MS);
|
|
163
|
+
flusher.unref();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The oracle started a turn on a task: its clock runs, if the task has a budget. */
|
|
167
|
+
export function startClock(root: string, configDir: string, taskId: string): void {
|
|
168
|
+
if (!readBudget(root, configDir, taskId)) return;
|
|
169
|
+
const clock = clocks.get(taskId) ?? { root, configDir, running: false, waits: 0 };
|
|
170
|
+
clocks.set(taskId, clock);
|
|
171
|
+
clock.running = true;
|
|
172
|
+
settle(taskId, clock);
|
|
173
|
+
ensureFlusher();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The oracle's turn ended: the clock stops until the next one. */
|
|
177
|
+
export function stopClocks(): void {
|
|
178
|
+
for (const [taskId, clock] of clocks) {
|
|
179
|
+
clock.running = false;
|
|
180
|
+
settle(taskId, clock);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The user is being asked: every clock waits (nested dialogs each call this and `resumeClocks`). */
|
|
185
|
+
export function pauseClocks(): void {
|
|
186
|
+
for (const [taskId, clock] of clocks) {
|
|
187
|
+
clock.waits += 1;
|
|
188
|
+
settle(taskId, clock);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function resumeClocks(): void {
|
|
193
|
+
for (const [taskId, clock] of clocks) {
|
|
194
|
+
clock.waits = Math.max(0, clock.waits - 1);
|
|
195
|
+
settle(taskId, clock);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Run `ask` (a dialog) with every clock stopped: time spent waiting on the user is not the task's. */
|
|
200
|
+
export async function whileAsking<T>(ask: () => Promise<T>): Promise<T> {
|
|
201
|
+
pauseClocks();
|
|
202
|
+
try {
|
|
203
|
+
return await ask();
|
|
204
|
+
} finally {
|
|
205
|
+
resumeClocks();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Work time spent on a task so far, the running stretch included. */
|
|
210
|
+
export function usedMs(taskId: string, budget: TaskBudget, now = Date.now()): number {
|
|
211
|
+
const since = clocks.get(taskId)?.since;
|
|
212
|
+
return budget.usedMs + (since !== undefined ? Math.max(0, now - since) : 0);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Forget the clocks (tests; a session that ends). */
|
|
216
|
+
export function resetClocks(): void {
|
|
217
|
+
clocks.clear();
|
|
218
|
+
if (flusher) clearInterval(flusher);
|
|
219
|
+
flusher = undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* -------------------------------------------------------------- dividing */
|
|
223
|
+
|
|
224
|
+
/** Where a budget stands: all of it, spent, left, what is kept for the QA gate, and what the rest may use. */
|
|
225
|
+
export interface BudgetState {
|
|
226
|
+
totalMs: number;
|
|
227
|
+
usedMs: number;
|
|
228
|
+
leftMs: number;
|
|
229
|
+
reserveMs: number;
|
|
230
|
+
/** Left once the QA gate's reserve is kept back. */
|
|
231
|
+
windowMs: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function budgetState(taskId: string, budget: TaskBudget, qaPassed: boolean, now = Date.now()): BudgetState {
|
|
235
|
+
const totalMs = (budget.minutes + budget.granted) * 60_000;
|
|
236
|
+
const used = usedMs(taskId, budget, now);
|
|
237
|
+
const leftMs = Math.max(0, totalMs - used);
|
|
238
|
+
const reserveMs = qaPassed ? 0 : Math.min(leftMs, Math.max(QA_RESERVE_MIN_MS, totalMs * QA_RESERVE_SHARE));
|
|
239
|
+
return { totalMs, usedMs: used, leftMs, reserveMs, windowMs: Math.max(0, leftMs - reserveMs) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What a worker delegation is given: the minutes the oracle asked for (it
|
|
244
|
+
* divides by scope), or an even share of what is left before the QA gate's
|
|
245
|
+
* reserve across the plan's domains still to build; never more than that
|
|
246
|
+
* window. Undefined when the window is too small for any real work.
|
|
247
|
+
*/
|
|
248
|
+
export function workerAllotment(state: BudgetState, askedMinutes: number | undefined, openSlots: number): { ms: number; note?: string } | undefined {
|
|
249
|
+
if (state.windowMs < MIN_ALLOT_MS) return undefined;
|
|
250
|
+
const asked = askedMinutes && askedMinutes > 0 ? askedMinutes * 60_000 : undefined;
|
|
251
|
+
const wanted = asked ?? state.windowMs / Math.max(1, openSlots);
|
|
252
|
+
const ms = Math.round(Math.min(state.windowMs, Math.max(MIN_ALLOT_MS, wanted)));
|
|
253
|
+
const note = asked && asked > state.windowMs ? `asked for ${formatMinutes(asked)}, but only ${formatMinutes(state.windowMs)} are left before the QA gate's reserve` : undefined;
|
|
254
|
+
return { ms, ...(note ? { note } : {}) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** What a read-only batch (scouts, the researcher) is given: a share of what is left, within its configured limit. */
|
|
258
|
+
export function readerAllotment(state: BudgetState, share: number, limitMs: number): number | undefined {
|
|
259
|
+
if (state.leftMs < MIN_READ_MS) return undefined;
|
|
260
|
+
return Math.round(Math.min(limitMs, state.leftMs, Math.max(MIN_READ_MS, state.leftMs * share)));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** What the QA gate is given: its reserve at least, its configured limit if more is left, never more than is left. */
|
|
264
|
+
export function qaAllotment(state: BudgetState, limitMs: number): number | undefined {
|
|
265
|
+
if (state.leftMs < MIN_READ_MS) return undefined;
|
|
266
|
+
return Math.round(Math.min(state.leftMs, Math.max(state.reserveMs, limitMs)));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The line the oracle and `status` read: `Time budget: 90m (+15m granted) · 34m used · 71m left (14m kept for the QA gate).` */
|
|
270
|
+
export function budgetLine(budget: TaskBudget, state: BudgetState): string {
|
|
271
|
+
const granted = budget.granted > 0 ? ` (+${budget.granted}m granted)` : "";
|
|
272
|
+
const reserve = state.reserveMs > 0 ? ` (${formatMinutes(state.reserveMs)} kept for the QA gate)` : "";
|
|
273
|
+
return `Time budget: ${budget.minutes}m${granted} · ${formatMinutes(state.usedMs)} used · ${formatMinutes(state.leftMs)} left${reserve}.`;
|
|
274
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who changed what: a project-wide ledger of the files each quick fix and each
|
|
3
|
+
* task worker edited, read from their own `edit`/`write` tool calls (never
|
|
4
|
+
* from their reports). The QA gate and the Master see every changed file of
|
|
5
|
+
* the working tree against it, so a quick fix the user asked for, a planned
|
|
6
|
+
* step, work that was there before the task and an edit no agent made each
|
|
7
|
+
* read as what they are, instead of all looking like unplanned changes.
|
|
8
|
+
* Append-only JSON lines beside the metrics; best-effort like them.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
|
+
import type { Domain } from "../schemas/agent.ts";
|
|
13
|
+
import type { ChangeBaseline, Task } from "../schemas/task.ts";
|
|
14
|
+
import { dataRoot } from "./project.ts";
|
|
15
|
+
|
|
16
|
+
export type ChangeSource = "quickfix" | "worker";
|
|
17
|
+
|
|
18
|
+
export interface ChangeRecord {
|
|
19
|
+
source: ChangeSource;
|
|
20
|
+
/** The quick fix (`QF-3`) or the worker run that made the edits. */
|
|
21
|
+
id: string;
|
|
22
|
+
/** The task a worker ran for. */
|
|
23
|
+
taskId?: string;
|
|
24
|
+
domain?: Domain;
|
|
25
|
+
/** What it was asked to do, one line. */
|
|
26
|
+
what: string;
|
|
27
|
+
/** Absolute paths it edited with `edit`/`write`. */
|
|
28
|
+
files: string[];
|
|
29
|
+
startedAt: string;
|
|
30
|
+
finishedAt: string;
|
|
31
|
+
status: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Past this size the ledger keeps only its newer half; records older than any open task are not needed. */
|
|
35
|
+
export const MAX_LEDGER_BYTES = 512 * 1024;
|
|
36
|
+
|
|
37
|
+
const EDIT_TOOLS = new Set(["edit", "write"]);
|
|
38
|
+
|
|
39
|
+
export function ledgerPath(root: string, configDir: string): string {
|
|
40
|
+
return join(dataRoot(root, configDir), "changes.jsonl");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The absolute path an `edit`/`write` call changes, or undefined for any other call. */
|
|
44
|
+
export function editedFile(toolName: string, args: unknown, cwd: string): string | undefined {
|
|
45
|
+
if (!EDIT_TOOLS.has(toolName.trim().toLowerCase()) || !args || typeof args !== "object") return undefined;
|
|
46
|
+
const fields = args as Record<string, unknown>;
|
|
47
|
+
const raw = [fields.path, fields.file_path, fields.file].find((value): value is string => typeof value === "string" && value.trim().length > 0);
|
|
48
|
+
if (!raw) return undefined;
|
|
49
|
+
// pi's file tools accept `@path` as well as `path`.
|
|
50
|
+
const path = raw.trim().replace(/^@/, "");
|
|
51
|
+
return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Collects the files one run edits, in the order it first touched them. */
|
|
55
|
+
export class EditLog {
|
|
56
|
+
private readonly files = new Set<string>();
|
|
57
|
+
private readonly cwd: string;
|
|
58
|
+
|
|
59
|
+
constructor(cwd: string) {
|
|
60
|
+
// git names the repository by its real path (macOS: /private/var, not /var), so edits are recorded against it too.
|
|
61
|
+
let real = cwd;
|
|
62
|
+
try {
|
|
63
|
+
real = realpathSync(cwd);
|
|
64
|
+
} catch {
|
|
65
|
+
// A folder that cannot be resolved is recorded as given.
|
|
66
|
+
}
|
|
67
|
+
this.cwd = real;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Note a tool call; true when it edited a file not seen before. */
|
|
71
|
+
note(toolName: string, args: unknown): boolean {
|
|
72
|
+
const file = editedFile(toolName, args, this.cwd);
|
|
73
|
+
if (!file || this.files.has(file)) return false;
|
|
74
|
+
this.files.add(file);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
list(): string[] {
|
|
79
|
+
return [...this.files];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The files as a person reads them: relative to the folder the run worked in, when they are inside it. */
|
|
83
|
+
shown(): string[] {
|
|
84
|
+
return this.list().map((file) => {
|
|
85
|
+
const path = relative(this.cwd, file);
|
|
86
|
+
return path && !path.startsWith("..") && !isAbsolute(path) ? path : file;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function appendChange(root: string, configDir: string, record: ChangeRecord): void {
|
|
92
|
+
if (record.files.length === 0) return;
|
|
93
|
+
const path = ledgerPath(root, configDir);
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
97
|
+
if (statSync(path).size > MAX_LEDGER_BYTES) {
|
|
98
|
+
const lines = readFileSync(path, "utf8").split("\n").filter((line) => line.trim());
|
|
99
|
+
writeFileSync(path, `${lines.slice(Math.floor(lines.length / 2)).join("\n")}\n`, "utf8");
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Provenance is best-effort; a read-only tree must never fail a run.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isRecord(value: unknown): value is ChangeRecord {
|
|
107
|
+
if (!value || typeof value !== "object") return false;
|
|
108
|
+
const record = value as Partial<ChangeRecord>;
|
|
109
|
+
return (record.source === "quickfix" || record.source === "worker") && typeof record.id === "string" && Array.isArray(record.files) && typeof record.finishedAt === "string";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every record in the ledger finished at or after `since` (ISO), oldest first. */
|
|
113
|
+
export function readChanges(root: string, configDir: string, since?: string): ChangeRecord[] {
|
|
114
|
+
let text: string;
|
|
115
|
+
try {
|
|
116
|
+
text = readFileSync(ledgerPath(root, configDir), "utf8");
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const from = since ? Date.parse(since) : Number.NEGATIVE_INFINITY;
|
|
121
|
+
const records: ChangeRecord[] = [];
|
|
122
|
+
for (const line of text.split("\n")) {
|
|
123
|
+
if (!line.trim()) continue;
|
|
124
|
+
try {
|
|
125
|
+
const record: unknown = JSON.parse(line);
|
|
126
|
+
if (isRecord(record) && Date.parse(record.finishedAt) >= from) records.push(record);
|
|
127
|
+
} catch {
|
|
128
|
+
// A torn line (a crash mid-append) is skipped.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return records;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type Provenance = "planned" | "quickfix" | "other-task" | "pre-existing" | "unattributed";
|
|
135
|
+
|
|
136
|
+
export interface FileProvenance {
|
|
137
|
+
/** Repository-relative, as git reports it. */
|
|
138
|
+
path: string;
|
|
139
|
+
/** Every source that explains the change; `unattributed` alone when none does. */
|
|
140
|
+
kinds: Provenance[];
|
|
141
|
+
/** One clause per source: `planned: DEV — Add the login endpoint`. */
|
|
142
|
+
notes: string[];
|
|
143
|
+
/** The quick fixes that edited it. */
|
|
144
|
+
quickFixes: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DOMAIN_LABELS: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
|
|
148
|
+
|
|
149
|
+
function clip(text: string, max = 90): string {
|
|
150
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
151
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function clock(iso: string): string {
|
|
155
|
+
const date = new Date(iso);
|
|
156
|
+
return Number.isNaN(date.getTime()) ? "" : ` at ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Explain each changed file of the working tree (`changed`, relative to the
|
|
161
|
+
* repository at `top`) for `task`: edits by its own workers are planned; by a
|
|
162
|
+
* quick fix, the user's direct request; by another task's workers, that
|
|
163
|
+
* task's; changed before its agents started, pre-existing; anything else, no
|
|
164
|
+
* agent recorded it. `records` should reach back to the task's start.
|
|
165
|
+
*/
|
|
166
|
+
export function explainChanges(task: Pick<Task, "id" | "baseline">, changed: readonly string[], top: string, records: readonly ChangeRecord[]): FileProvenance[] {
|
|
167
|
+
const byFile = new Map<string, ChangeRecord[]>();
|
|
168
|
+
for (const record of records) {
|
|
169
|
+
for (const file of record.files) {
|
|
170
|
+
// git names files with forward slashes on every platform.
|
|
171
|
+
const key = relative(top, file).split("\\").join("/");
|
|
172
|
+
if (key.startsWith("..") || isAbsolute(key)) continue;
|
|
173
|
+
const list = byFile.get(key) ?? [];
|
|
174
|
+
list.push(record);
|
|
175
|
+
byFile.set(key, list);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const before = new Set(task.baseline?.files ?? []);
|
|
179
|
+
return changed.map((path) => {
|
|
180
|
+
const kinds: Provenance[] = [];
|
|
181
|
+
const notes: string[] = [];
|
|
182
|
+
const quickFixes: string[] = [];
|
|
183
|
+
const add = (kind: Provenance, note: string) => {
|
|
184
|
+
if (!kinds.includes(kind)) kinds.push(kind);
|
|
185
|
+
if (!notes.includes(note)) notes.push(note);
|
|
186
|
+
};
|
|
187
|
+
for (const record of byFile.get(path) ?? []) {
|
|
188
|
+
if (record.source === "quickfix") {
|
|
189
|
+
add("quickfix", `quick fix ${record.id}${clock(record.finishedAt)}, asked by the user: "${clip(record.what)}"`);
|
|
190
|
+
if (!quickFixes.includes(record.id)) quickFixes.push(record.id);
|
|
191
|
+
} else if (record.taskId === task.id) add("planned", `planned: ${DOMAIN_LABELS[record.domain ?? ""] ?? record.domain ?? "worker"} — ${clip(record.what)}`);
|
|
192
|
+
else add("other-task", `another task's worker (${record.taskId ?? "unknown task"}): ${clip(record.what)}`);
|
|
193
|
+
}
|
|
194
|
+
if (before.has(path)) add("pre-existing", "pre-existing: it already had uncommitted changes before this task's agents started");
|
|
195
|
+
if (kinds.length === 0) add("unattributed", "unattributed: no bot-lobby agent recorded editing it");
|
|
196
|
+
return { path, kinds, notes, quickFixes };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Most files the reviewer is told about one by one. */
|
|
201
|
+
export const MAX_PROVENANCE_LINES = 60;
|
|
202
|
+
|
|
203
|
+
/** The provenance as the reviewer reads it: one line per changed file, the unexplained ones first, the rest counted past `max`. */
|
|
204
|
+
export function provenanceLines(files: readonly FileProvenance[], max = MAX_PROVENANCE_LINES): string {
|
|
205
|
+
const order = (file: FileProvenance) => (file.kinds.includes("unattributed") ? 0 : file.kinds.includes("quickfix") ? 1 : 2);
|
|
206
|
+
const sorted = [...files].sort((a, b) => order(a) - order(b));
|
|
207
|
+
const lines = sorted.slice(0, max).map((file) => `- ${file.path} — ${file.notes.join("; ")}`);
|
|
208
|
+
const rest = sorted.slice(max);
|
|
209
|
+
if (rest.length > 0) lines.push(`- …and ${rest.length} more: ${provenanceSummary(rest)}`);
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A one-line count for the Master: `4 planned · 1 quick fix (QF-2) · 1 unattributed (src/x.ts)`. */
|
|
214
|
+
export function provenanceSummary(files: readonly FileProvenance[]): string {
|
|
215
|
+
const count = (kind: Provenance) => files.filter((file) => file.kinds.includes(kind));
|
|
216
|
+
const parts: string[] = [];
|
|
217
|
+
const planned = count("planned").length;
|
|
218
|
+
if (planned > 0) parts.push(`${planned} planned`);
|
|
219
|
+
const quick = count("quickfix");
|
|
220
|
+
if (quick.length > 0) {
|
|
221
|
+
const ids = [...new Set(quick.flatMap((file) => file.quickFixes))];
|
|
222
|
+
parts.push(`${quick.length} by quick fix (${ids.join(", ")})`);
|
|
223
|
+
}
|
|
224
|
+
const other = count("other-task").length;
|
|
225
|
+
if (other > 0) parts.push(`${other} by another task`);
|
|
226
|
+
const before = count("pre-existing").length;
|
|
227
|
+
if (before > 0) parts.push(`${before} pre-existing`);
|
|
228
|
+
const unknown = count("unattributed");
|
|
229
|
+
if (unknown.length > 0) parts.push(`${unknown.length} unattributed (${unknown.slice(0, 5).map((file) => file.path).join(", ")}${unknown.length > 5 ? ", …" : ""})`);
|
|
230
|
+
return parts.join(" · ");
|
|
231
|
+
}
|
package/src/text.ts
CHANGED
|
@@ -5,6 +5,13 @@ export function truncate(text: string, maxChars: number): string {
|
|
|
5
5
|
return `${text.slice(0, maxChars)}\n[...${text.length - maxChars} characters omitted]`;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** The end of `text` within a character budget (the newest entries of a log), marking how much was left out before it. */
|
|
9
|
+
export function tail(text: string, maxChars: number): string {
|
|
10
|
+
if (maxChars <= 0) return "";
|
|
11
|
+
if (text.length <= maxChars) return text;
|
|
12
|
+
return `[...${text.length - maxChars} earlier characters omitted]\n${text.slice(text.length - maxChars)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
/** Filler and imperative words that carry no topic signal in a request. */
|
|
9
16
|
const FILLER_WORDS = new Set([
|
|
10
17
|
"let's",
|
|
@@ -45,9 +52,28 @@ function fillerKey(word: string): string {
|
|
|
45
52
|
*/
|
|
46
53
|
export function shortTitle(request: string, maxWords = 3): string {
|
|
47
54
|
if (maxWords <= 0) return "";
|
|
48
|
-
const words = request
|
|
55
|
+
const words = titleText(request).split(/\s+/).filter(Boolean);
|
|
49
56
|
const content = words.filter((word) => !FILLER_WORDS.has(fillerKey(word)));
|
|
50
|
-
return (content.length > 0 ? content : words).slice(0, maxWords).join(" ");
|
|
57
|
+
return (content.length > 0 ? content : words).slice(0, maxWords).join(" ").replace(/[:;,]+$/, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Headings that only label a section (`### Objective`), not name the work. */
|
|
61
|
+
const LABEL_HEADING = /^#{1,6}\s*(objective|goal|goals|summary|overview|task|request|context|background|description|plan)\s*:?\s*$/i;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A request as words for a title: escaped line breaks (`\\n` written out by a
|
|
65
|
+
* model) read as breaks, label headings are skipped, and Markdown marks
|
|
66
|
+
* (`#`, `-`, `*`, `>`, backticks) are dropped.
|
|
67
|
+
*/
|
|
68
|
+
function titleText(request: string): string {
|
|
69
|
+
return request
|
|
70
|
+
.replace(/\\[nrt]/g, "\n")
|
|
71
|
+
.split("\n")
|
|
72
|
+
.filter((line) => !LABEL_HEADING.test(line.trim()))
|
|
73
|
+
.join(" ")
|
|
74
|
+
.replace(/[#*>`|]+/g, " ")
|
|
75
|
+
.replace(/(^|\s)[-+](?=\s|$)/g, " ")
|
|
76
|
+
.trim();
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
/** Compact duration such as "45s", "3m" or "2m 05s"; a non-finite input reads "0s". */
|