pi-bro 0.21.0 → 0.22.0
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/CHANGELOG.md +53 -0
- package/README.md +80 -37
- package/backend.ts +55 -42
- package/bro.ts +166 -1359
- package/config-ui.ts +325 -0
- package/docs/README.md +2 -0
- package/docs/guided-review.md +131 -0
- package/docs/pig-compatibility.md +1 -0
- package/package.json +13 -4
- package/prompt.ts +19 -4
- package/review-modal.ts +1272 -0
- package/review-source.ts +296 -0
- package/review-ui.ts +519 -0
- package/review.ts +586 -0
- package/settings.ts +505 -0
- package/sources.ts +367 -0
- package/util.ts +26 -0
package/config-ui.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Component,
|
|
3
|
+
type SelectItem,
|
|
4
|
+
type SettingItem,
|
|
5
|
+
Container,
|
|
6
|
+
Input,
|
|
7
|
+
SelectList,
|
|
8
|
+
SettingsList,
|
|
9
|
+
Text,
|
|
10
|
+
matchesKey,
|
|
11
|
+
truncateToWidth,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import {
|
|
15
|
+
type ExtensionCommandContext,
|
|
16
|
+
getSelectListTheme,
|
|
17
|
+
getSettingsListTheme,
|
|
18
|
+
} from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import {
|
|
20
|
+
type AgyModelFamily,
|
|
21
|
+
type BroSettings,
|
|
22
|
+
type Capability,
|
|
23
|
+
type ExternalBackend,
|
|
24
|
+
CAPABILITIES,
|
|
25
|
+
CAPABILITY_LABELS,
|
|
26
|
+
EXTERNAL_BACKENDS,
|
|
27
|
+
applyEffortChange,
|
|
28
|
+
applyModelChange,
|
|
29
|
+
capabilityBackend,
|
|
30
|
+
capabilityOverride,
|
|
31
|
+
capabilityPair,
|
|
32
|
+
effortDisplay,
|
|
33
|
+
resolveModelEffort,
|
|
34
|
+
withCapabilityOverride,
|
|
35
|
+
} from "./settings.ts";
|
|
36
|
+
import { BRO_MODES, parseBroMode } from "./prompt.ts";
|
|
37
|
+
import { errorMessage } from "./util.ts";
|
|
38
|
+
|
|
39
|
+
export type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
40
|
+
export type TuiLike = {
|
|
41
|
+
readonly mode: "regular" | "fullscreen";
|
|
42
|
+
readonly terminal?: { rows?: number; columns?: number; write?: (data: string) => void };
|
|
43
|
+
requestRender(): void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const SHOW_TURNS_PRESETS = [1, 2, 3, 5, 8];
|
|
47
|
+
|
|
48
|
+
function showTurnsValues(current: number): string[] {
|
|
49
|
+
return [...new Set([...SHOW_TURNS_PRESETS, current])].sort((a, b) => a - b).map(String);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Testable core: takes settings/catalog/persist as plain arguments so smoke tests can drive
|
|
53
|
+
// the exact interaction (submenus, cancel, cycling, save failure) without a real Agy process
|
|
54
|
+
// or settings file. showBroConfigModal in bro.ts wires this to the real ctx/pi/filesystem.
|
|
55
|
+
export function createConfigModal(
|
|
56
|
+
initialSettings: BroSettings,
|
|
57
|
+
families: AgyModelFamily[],
|
|
58
|
+
persistSettings: (settings: BroSettings) => Promise<void>,
|
|
59
|
+
): (tui: TuiLike, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
60
|
+
return (tui, theme, _keybindings, done) => {
|
|
61
|
+
let settings = initialSettings;
|
|
62
|
+
// The last settings actually confirmed on disk. A failed save reverts `settings` (and the
|
|
63
|
+
// whole displayed row set) back to this, so the screen never shows state that doesn't exist.
|
|
64
|
+
let savedSettings = initialSettings;
|
|
65
|
+
// Only one persistSettings call is ever in flight. A change that arrives while one is
|
|
66
|
+
// already running is coalesced into `queued` (overwriting any earlier queued change) rather
|
|
67
|
+
// than firing a second concurrent write — this is what keeps writes serialized and makes
|
|
68
|
+
// sure the on-disk file always converges on the latest intent instead of a stale one that
|
|
69
|
+
// happened to finish last.
|
|
70
|
+
let saving = false;
|
|
71
|
+
let queued: BroSettings | undefined;
|
|
72
|
+
// Esc while a save is in flight must not close past an unshown result: it requests a close
|
|
73
|
+
// that only actually happens once the in-flight (and any coalesced) save has settled, and
|
|
74
|
+
// only if it succeeded — a failure cancels the pending close so its notice stays visible.
|
|
75
|
+
let closeRequested = false;
|
|
76
|
+
|
|
77
|
+
const modelPicker = (current: string, pickerDone: (value?: string) => void, capability?: Capability) => {
|
|
78
|
+
const defaultResolved = resolveModelEffort({ model: settings.model, effort: settings.effort }, families);
|
|
79
|
+
const options: SelectItem[] = [
|
|
80
|
+
...(capability ? [{ value: "__default__", label: `Default (${defaultResolved.family?.label ?? settings.model})` }] : []),
|
|
81
|
+
...families.map((family) => ({
|
|
82
|
+
value: family.id,
|
|
83
|
+
label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
|
|
84
|
+
})),
|
|
85
|
+
// Alternative CLI entries stay last so existing Agy keyboard navigation is unaffected.
|
|
86
|
+
...Object.values(EXTERNAL_BACKENDS).flatMap((meta) =>
|
|
87
|
+
meta.models.map((model) => ({
|
|
88
|
+
value: `${meta.prefix}${model.id}`,
|
|
89
|
+
label: `${model.label} · ${meta.name}`,
|
|
90
|
+
})),
|
|
91
|
+
),
|
|
92
|
+
];
|
|
93
|
+
for (const meta of Object.values(EXTERNAL_BACKENDS)) {
|
|
94
|
+
options.push({ value: `__${meta.name}_custom__`, label: `${meta.label} · custom model ID…` });
|
|
95
|
+
}
|
|
96
|
+
const input = new Input();
|
|
97
|
+
let enteringBackend: ExternalBackend | undefined;
|
|
98
|
+
input.onSubmit = (value) => {
|
|
99
|
+
if (value.trim() && enteringBackend) {
|
|
100
|
+
const meta = EXTERNAL_BACKENDS[enteringBackend];
|
|
101
|
+
pickerDone(`${meta.prefix}${value.trim()}`);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
|
|
105
|
+
const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
|
|
106
|
+
picker.setSelectedIndex(Math.max(0, selectedIndex));
|
|
107
|
+
picker.onSelect = (item) => {
|
|
108
|
+
const custom = (Object.keys(EXTERNAL_BACKENDS) as ExternalBackend[]).find((b) => item.value === `__${b}_custom__`);
|
|
109
|
+
if (custom) { enteringBackend = custom; tui.requestRender(); }
|
|
110
|
+
else pickerDone(item.value);
|
|
111
|
+
};
|
|
112
|
+
picker.onCancel = () => pickerDone();
|
|
113
|
+
const customLabel = () => enteringBackend ? EXTERNAL_BACKENDS[enteringBackend].label : "";
|
|
114
|
+
return {
|
|
115
|
+
render: (width: number) => enteringBackend ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
|
|
116
|
+
invalidate: () => { picker.invalidate(); input.invalidate(); },
|
|
117
|
+
handleInput: (data: string) => {
|
|
118
|
+
if (enteringBackend && matchesKey(data, "escape")) pickerDone();
|
|
119
|
+
else if (enteringBackend) input.handleInput(data);
|
|
120
|
+
else picker.handleInput(data);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const modelItem: SettingItem = { id: "model", label: "Default model", currentValue: settings.model, submenu: modelPicker };
|
|
126
|
+
const effortItem: SettingItem = { id: "effort", label: "Default effort", currentValue: "" };
|
|
127
|
+
const modeItem: SettingItem = { id: "mode", label: "Explain mode", currentValue: settings.mode, values: [...BRO_MODES] };
|
|
128
|
+
const showTurnsItem: SettingItem = {
|
|
129
|
+
id: "showTurns",
|
|
130
|
+
label: "Show turns",
|
|
131
|
+
currentValue: String(settings.showTurns),
|
|
132
|
+
values: showTurnsValues(settings.showTurns),
|
|
133
|
+
};
|
|
134
|
+
const capabilityItems = Object.fromEntries(
|
|
135
|
+
CAPABILITIES.map((capability) => [
|
|
136
|
+
capability,
|
|
137
|
+
{
|
|
138
|
+
model: {
|
|
139
|
+
id: `${capability}Model`,
|
|
140
|
+
label: `${CAPABILITY_LABELS[capability]} model`,
|
|
141
|
+
currentValue: "Default",
|
|
142
|
+
submenu: (current: string, pickerDone: (value?: string) => void) => modelPicker(current, pickerDone, capability),
|
|
143
|
+
} as SettingItem,
|
|
144
|
+
effort: {
|
|
145
|
+
id: `${capability}Effort`,
|
|
146
|
+
label: `${CAPABILITY_LABELS[capability]} effort`,
|
|
147
|
+
currentValue: "",
|
|
148
|
+
} as SettingItem,
|
|
149
|
+
},
|
|
150
|
+
]),
|
|
151
|
+
) as Record<Capability, { model: SettingItem; effort: SettingItem }>;
|
|
152
|
+
|
|
153
|
+
function refresh(): void {
|
|
154
|
+
const defaultBackend = settings.backend ?? "agy";
|
|
155
|
+
const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
|
|
156
|
+
modelItem.currentValue =
|
|
157
|
+
defaultBackend in EXTERNAL_BACKENDS
|
|
158
|
+
? `${EXTERNAL_BACKENDS[defaultBackend as ExternalBackend].prefix}${settings.model}`
|
|
159
|
+
: (def.family?.id ?? settings.model);
|
|
160
|
+
effortItem.currentValue = effortDisplay(def, defaultBackend);
|
|
161
|
+
effortItem.values =
|
|
162
|
+
defaultBackend in EXTERNAL_BACKENDS
|
|
163
|
+
? ["default", ...EXTERNAL_BACKENDS[defaultBackend as ExternalBackend].efforts]
|
|
164
|
+
: def.family?.efforts.length
|
|
165
|
+
? [...def.family.efforts]
|
|
166
|
+
: undefined;
|
|
167
|
+
modeItem.currentValue = settings.mode;
|
|
168
|
+
showTurnsItem.currentValue = String(settings.showTurns);
|
|
169
|
+
showTurnsItem.values = showTurnsValues(settings.showTurns);
|
|
170
|
+
|
|
171
|
+
for (const capability of CAPABILITIES) {
|
|
172
|
+
const override = capabilityOverride(settings, capability);
|
|
173
|
+
const backend = capabilityBackend(settings, capability);
|
|
174
|
+
const resolved = resolveModelEffort(capabilityPair(settings, capability), families);
|
|
175
|
+
const rows = capabilityItems[capability];
|
|
176
|
+
rows.model.currentValue = !override
|
|
177
|
+
? "Default"
|
|
178
|
+
: backend in EXTERNAL_BACKENDS
|
|
179
|
+
? `${EXTERNAL_BACKENDS[backend as ExternalBackend].prefix}${override.model}`
|
|
180
|
+
: (resolved.family?.id ?? override.model);
|
|
181
|
+
rows.effort.currentValue = effortDisplay(resolved, backend);
|
|
182
|
+
rows.effort.values =
|
|
183
|
+
backend in EXTERNAL_BACKENDS
|
|
184
|
+
? ["default", ...EXTERNAL_BACKENDS[backend as ExternalBackend].efforts]
|
|
185
|
+
: resolved.family?.efforts.length
|
|
186
|
+
? [...resolved.family.efforts]
|
|
187
|
+
: undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
refresh();
|
|
191
|
+
|
|
192
|
+
const items: SettingItem[] = [
|
|
193
|
+
modelItem,
|
|
194
|
+
effortItem,
|
|
195
|
+
modeItem,
|
|
196
|
+
showTurnsItem,
|
|
197
|
+
...CAPABILITIES.flatMap((capability) => [capabilityItems[capability].model, capabilityItems[capability].effort]),
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const noticeText = new Text("");
|
|
201
|
+
|
|
202
|
+
function runSave(toSave: BroSettings): void {
|
|
203
|
+
saving = true;
|
|
204
|
+
void persistSettings(toSave)
|
|
205
|
+
.then(() => {
|
|
206
|
+
savedSettings = toSave;
|
|
207
|
+
noticeText.setText("");
|
|
208
|
+
})
|
|
209
|
+
.catch((error: unknown) => {
|
|
210
|
+
// Restore the last state that is actually on disk: showing the failed, unsaved
|
|
211
|
+
// value would let the screen claim a setting that doesn't really exist.
|
|
212
|
+
settings = savedSettings;
|
|
213
|
+
queued = undefined;
|
|
214
|
+
closeRequested = false;
|
|
215
|
+
refresh();
|
|
216
|
+
noticeText.setText(theme.fg("warning", `Could not save settings: ${errorMessage(error)}. Reverted to the last saved settings.`));
|
|
217
|
+
})
|
|
218
|
+
.finally(() => {
|
|
219
|
+
saving = false;
|
|
220
|
+
tui.requestRender();
|
|
221
|
+
if (queued !== undefined) {
|
|
222
|
+
const next = queued;
|
|
223
|
+
queued = undefined;
|
|
224
|
+
runSave(next);
|
|
225
|
+
} else if (closeRequested) {
|
|
226
|
+
closeRequested = false;
|
|
227
|
+
done(undefined);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function persist(toSave: BroSettings): void {
|
|
233
|
+
if (saving) {
|
|
234
|
+
queued = toSave;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
runSave(toSave);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const onChange = (id: string, newValue: string) => {
|
|
241
|
+
if (id === "model") {
|
|
242
|
+
const current = { backend: settings.backend, model: settings.model, effort: settings.effort };
|
|
243
|
+
const next = applyModelChange(current, newValue, families);
|
|
244
|
+
if (!next) return;
|
|
245
|
+
if (next.backend === undefined) {
|
|
246
|
+
const { backend: _dropped, ...rest } = settings;
|
|
247
|
+
settings = { ...rest, model: next.model, effort: next.effort };
|
|
248
|
+
} else {
|
|
249
|
+
settings = { ...settings, backend: next.backend, model: next.model, effort: next.effort };
|
|
250
|
+
}
|
|
251
|
+
} else if (id === "effort") {
|
|
252
|
+
const current = { backend: settings.backend, model: settings.model, effort: settings.effort };
|
|
253
|
+
const next = applyEffortChange(current, newValue, families);
|
|
254
|
+
if (!next) return;
|
|
255
|
+
settings = { ...settings, model: next.model, effort: next.effort };
|
|
256
|
+
} else if (id === "mode") {
|
|
257
|
+
const mode = parseBroMode(newValue);
|
|
258
|
+
if (!mode) return;
|
|
259
|
+
settings = { ...settings, mode };
|
|
260
|
+
} else if (id === "showTurns") {
|
|
261
|
+
const turns = Number(newValue);
|
|
262
|
+
if (!Number.isInteger(turns) || turns < 1) return;
|
|
263
|
+
settings = { ...settings, showTurns: turns };
|
|
264
|
+
} else {
|
|
265
|
+
const capability = CAPABILITIES.find((item) => id === `${item}Model` || id === `${item}Effort`);
|
|
266
|
+
if (!capability) return;
|
|
267
|
+
if (id === `${capability}Model`) {
|
|
268
|
+
if (newValue === "__default__") {
|
|
269
|
+
settings = withCapabilityOverride(settings, capability, undefined);
|
|
270
|
+
} else {
|
|
271
|
+
const current = capabilityPair(settings, capability);
|
|
272
|
+
const next = applyModelChange(current, newValue, families);
|
|
273
|
+
if (!next) return;
|
|
274
|
+
settings = withCapabilityOverride(settings, capability, next);
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
const existing = capabilityOverride(settings, capability);
|
|
278
|
+
const current = existing ?? {
|
|
279
|
+
backend: capabilityBackend(settings, capability) === "agy" ? undefined : capabilityBackend(settings, capability),
|
|
280
|
+
model: settings.model,
|
|
281
|
+
effort: capabilityPair(settings, capability).effort,
|
|
282
|
+
};
|
|
283
|
+
const next = applyEffortChange(current, newValue, families);
|
|
284
|
+
if (!next) return;
|
|
285
|
+
settings = withCapabilityOverride(settings, capability, next);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
refresh();
|
|
289
|
+
tui.requestRender();
|
|
290
|
+
persist(settings);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const requestClose = () => {
|
|
294
|
+
if (saving) {
|
|
295
|
+
closeRequested = true;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
done(undefined);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const settingsList = new SettingsList(items, Math.min(items.length + 2, 18), getSettingsListTheme(), onChange, requestClose);
|
|
302
|
+
const container = new Container();
|
|
303
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Bro · config"))));
|
|
304
|
+
container.addChild(new Text(theme.fg("dim", "Shared defaults, with optional overrides per capability")));
|
|
305
|
+
container.addChild(settingsList);
|
|
306
|
+
container.addChild(noticeText);
|
|
307
|
+
container.addChild(new Text(theme.fg("dim", "↑/↓ navigate · Enter select/change · Esc back/close")));
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
render: (w: number) => {
|
|
311
|
+
const inner = Math.max(1, w - 4);
|
|
312
|
+
const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
|
|
313
|
+
return [border("┌", "┐"), ...container.render(inner).map(line => {
|
|
314
|
+
const text = truncateToWidth(line, inner, "");
|
|
315
|
+
return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
|
|
316
|
+
}), border("└", "┘")];
|
|
317
|
+
},
|
|
318
|
+
invalidate: () => container.invalidate(),
|
|
319
|
+
handleInput: (data: string) => {
|
|
320
|
+
settingsList.handleInput?.(data);
|
|
321
|
+
tui.requestRender();
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
}
|
package/docs/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# pi-bro docs
|
|
2
2
|
|
|
3
3
|
- [README](../README.md) — the user guide (authoritative for behavior).
|
|
4
|
+
- [guided-review.md](guided-review.md) — current Guided Review behavior, optional controls, access/storage and limits.
|
|
4
5
|
- [DEVELOPMENT.md](DEVELOPMENT.md) — code map, settings and BTW continuation model, invariants, test commands.
|
|
5
6
|
- [TESTING.md](TESTING.md) — the manual end-to-end checklist across backends.
|
|
7
|
+
- [dev/README.md](../dev/README.md) — manual review launcher and profile/call precautions (not shipped).
|
|
6
8
|
- [plans/](plans/README.md) — historical design notes and plans; not the current spec.
|
|
7
9
|
- [images/](images/) — screenshots used by the README.
|
|
8
10
|
- [benchmark/README.md](../benchmark/README.md) — the manual, live prompt benchmark.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Guided Review
|
|
2
|
+
|
|
3
|
+
**Understand and evaluate a PR, without turning your main Pi conversation into a review transcript.**
|
|
4
|
+
|
|
5
|
+
The normal journey is: **open a PR → read and investigate → leave when satisfied.** Bro automatically prepares both an explanation and a quality assessment. Asking questions is optional. You do not need to copy findings, mark progress or submit feedback to finish.
|
|
6
|
+
|
|
7
|
+
## Open a review
|
|
8
|
+
|
|
9
|
+
Requirements: local Pi interactive mode, Git, authenticated GitHub CLI (`gh`), and Claude or Muse configured for review. Run `gh auth status` to check GitHub authentication; `/bro doctor` checks Bro's selected backend setup but does not prove GitHub access or model connectivity.
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
/bro guided-review https://github.com/OWNER/REPO/pull/123
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
A URL works even when your current workspace belongs to a different repository. A PR number alone uses the current GitHub repository:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
/bro guided-review 123
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A **new** review captures the PR's base/head commits outside your working tree and automatically makes one model request. Subsequent questions each make another request. Calls use your selected backend CLI's account and billing—not Pi's model account. Bro does not automatically retry a failed generation or switch backends.
|
|
22
|
+
|
|
23
|
+
For fullscreen mouse scrolling, start Pi with:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pi --tui-mode fullscreen
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Opening the **same PR again** restores its saved review without another model call. Bare `/bro guided-review` lists saved reviews; `/bro guided-review resume` remains an alias for that list.
|
|
30
|
+
|
|
31
|
+
**Reopening keeps the captured code.** It does not fetch a new comparison when the author pushes commits. Opening by URL/number checks current metadata and reports newer source, but continues the saved capture. The saved-review picker does not check freshness. Reviewing newer revisions is not implemented.
|
|
32
|
+
|
|
33
|
+
## Read and investigate
|
|
34
|
+
|
|
35
|
+
Contents is on the left; reading and contextual actions are on the right. Narrow terminals show one pane at a time. The supported minimum is 36×18; smaller terminals show resize guidance with a working exit.
|
|
36
|
+
|
|
37
|
+
| Destination | What you get |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Overview | Purpose, main concerns and overall assessment |
|
|
40
|
+
| Topics | Short explanations with captured code and related findings |
|
|
41
|
+
| Findings | Ranked concerns, author questions, suggested fixes/checks and evidence |
|
|
42
|
+
| Across the change | Findings that do not belong to one topic, when present |
|
|
43
|
+
| Coverage and limitations | Reported inspection, uncited files, omissions and unverified execution |
|
|
44
|
+
| Changed files | The full changed-file list and one actual diff at a time |
|
|
45
|
+
| Your questions | Private discussions and unsent drafts, including earlier context |
|
|
46
|
+
|
|
47
|
+
A finding is a proposal, not a proven defect or an applied patch. **No findings is not approval.** Source blocks and diffs come from captured Git objects, not model quotations. Valid citations establish locations; they do not certify the reasoning or complete inspection. Coverage paths and access explanations are model-reported. Suggested tests have not been independently executed by Bro.
|
|
48
|
+
|
|
49
|
+
The preparation prompt investigates the whole change and relevant unchanged callers/tests before organizing topics. It supplies the first 100,000 diff characters, with an omission marker for larger changes; the backend can inspect the captured checkout beyond that seed. Follow-up questions include at most the latest 12 relevant turns and disclose older omitted discussion. A saved guide without an assessment is explicitly labelled unassessed. Prototype formats are not supported; unreadable files are left unchanged.
|
|
50
|
+
|
|
51
|
+
The generation header shows observable application stages and elapsed time—not a percentage or invented model checklist.
|
|
52
|
+
|
|
53
|
+
### Controls
|
|
54
|
+
|
|
55
|
+
| Control | Behavior |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| ↑ / ↓ | Select entries or scroll the focused reading area |
|
|
58
|
+
| Page Up / Page Down | Page the focused pane |
|
|
59
|
+
| Enter | Read a destination, open a list item, or focus contextual actions |
|
|
60
|
+
| Enter on a highlighted action | Activate it |
|
|
61
|
+
| Tab / Shift+Tab | Move focus in screen order |
|
|
62
|
+
| Esc | Return to where you came from; from Contents, close |
|
|
63
|
+
| Close | Save automatically and leave |
|
|
64
|
+
| Stop and close | Stop active work, await cancellation, save partial work, then leave |
|
|
65
|
+
|
|
66
|
+
In fullscreen mode, the wheel scrolls the pane under the pointer without moving its list selection or keyboard focus. Reading an active answer pauses following; deliberately returning to the bottom resumes it. In regular terminal mode, the wheel belongs to terminal scrollback.
|
|
67
|
+
|
|
68
|
+
## Ask privately, if needed
|
|
69
|
+
|
|
70
|
+
Choose Ask from a topic, finding, file or Overview. Type in the composer below the discussion. **Enter in the composer sends the question**; leaving without sending retains the draft. Stop response cancels an answer. Stop preparation/regeneration cancels the corresponding guide request.
|
|
71
|
+
|
|
72
|
+
Back restores the originating screen. Failed/stopped questions offer Edit and resend; it restores the question only if the current draft is empty. Resending is explicit and makes a new call. Nothing enters the main Pi conversation or is posted to GitHub.
|
|
73
|
+
|
|
74
|
+
Capture fetches only the head and GitHub-reported merge-base trees (depth 1). Source/diff/evidence remain available offline; ancestry, log and blame history are not captured. Fetch has a ten-minute bound; metadata and other commands have two-minute bounds. Very large trees can still exceed time/output/disk limits; retries reuse the managed store rather than intentionally deleting it.
|
|
75
|
+
|
|
76
|
+
## Optional conveniences
|
|
77
|
+
|
|
78
|
+
### Your work is remembered
|
|
79
|
+
|
|
80
|
+
There is no separate Save step. Bro saves the guide, questions, drafts and reading position automatically. Close waits for a successful save. If saving fails, the window stays open with a readable error and Retry saving; fix the reported filesystem problem and retry. Do not force-quit if unsaved work matters.
|
|
81
|
+
|
|
82
|
+
### Copy finding
|
|
83
|
+
|
|
84
|
+
Copy finding copies a finding's text, evidence references and suggested fix/check to the **system clipboard**. Nothing is submitted, inserted into Pi or applied to source. Your OS or clipboard manager may retain the copied text. Remote clipboard forwarding depends on your host and terminal; a failure is shown in the review.
|
|
85
|
+
|
|
86
|
+
### Regenerate guide
|
|
87
|
+
|
|
88
|
+
Use Overview → Regenerate guide only when you want a fresh explanation/assessment. Confirmation identifies the captured revision and the model call. **It reuses the same captured source; it is not an update fetch.** Successful output replaces one current guide. Failure/cancellation keeps the current guide. Questions and nonempty drafts retain their original context under Your questions; there is no guide-version picker. A replacement that fails to save remains in memory with retry controls.
|
|
89
|
+
|
|
90
|
+
## Access, privacy and storage
|
|
91
|
+
|
|
92
|
+
Review uses **file-only inspection**, not full-access execution. Claude runs with `--restricted`, Read/Grep/Glob only, `dontAsk`, safe mode and no MCP; Muse disables write, shell and web tools, foreign personal context and native session logs, without trusting repository rules. No model-run project tests, commands, Git or network tools are available. Unsupported/older CLI flags fail visibly; there is no full-access fallback.
|
|
93
|
+
|
|
94
|
+
Agy, Grok and Codex are not available for review generation/questions: supported controls do not establish equivalent shell-free inspection (Codex read-only sandbox still executes commands). Set the **review** override to Claude or Muse in `/bro config`; shared defaults and other features are unchanged. Saved reviews remain readable without a supported model backend. Doctor shows the review override requirement as information; it does not make an otherwise healthy installation fail.
|
|
95
|
+
|
|
96
|
+
These controls reduce malicious-code execution/write risk, but repository content can still manipulate the model's conclusions. Captured content, questions and answers go to the provider; do not use review as a secrets boundary or approval certificate. CLI enforcement and managed host policies remain the backend's responsibility.
|
|
97
|
+
|
|
98
|
+
PR identity/description, diff, relevant saved discussion and your Bro preferences are sent to the selected backend. It can read the captured source. Main-session conversation is not automatically attached. Each request reconstructs context; review does not resume BTW/native sessions. Claude/Muse disable native session persistence where their CLIs support it; backend/provider retention policies still apply.
|
|
99
|
+
|
|
100
|
+
Records, private text and captured source remain under:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/bro-reviews/
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The implementation uses the host's `getAgentDir()`. Records are `<review-id>.json`; captured source is under `source/<review-id>/`, including preserved `.incomplete-*` recovery directories. Failed/corrupt records are reported without overwriting them. There is no automatic cleanup or cross-machine sync. Source and recovery directories can grow. To remove one review manually, first close it in every Pi process and back up anything needed. Find its JSON record by matching `snapshot.target.repository` and `snapshot.target.number` (for example, search the JSON files for `"repository":"OWNER/REPO"`, then check the PR number). The filename without `.json` is its review ID; its source directory has that same ID under `source/`. Remove only that record and matching source directory. Do not delete the whole `bro-reviews/` directory, `bro-settings.json`, or another review's data. No deletion command is provided.
|
|
107
|
+
|
|
108
|
+
One open writer per review is supported within a Pi process. Concurrent editing of the same review from separate processes is unsupported.
|
|
109
|
+
|
|
110
|
+
## If something goes wrong
|
|
111
|
+
|
|
112
|
+
| Problem | What to do |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| GitHub unavailable when reopening | Use bare `/bro guided-review` to choose a saved review; the picker does not fetch |
|
|
115
|
+
| Unsupported review backend | Choose Claude or Muse for review in `/bro config`; no automatic switching |
|
|
116
|
+
| Git/gh authentication or fetch error | Read the acquisition error; check Git/`gh auth status`; retry explicitly |
|
|
117
|
+
| Invalid/failed preparation | Changed files remain available; use Prepare guide to retry when ready for another call |
|
|
118
|
+
| Failed regeneration | Current guide is kept; retry only if you want another call |
|
|
119
|
+
| Unavailable evidence | Treat it as unavailable; re-enter its topic/finding to retry transient reads |
|
|
120
|
+
| Binary/directory evidence | Not presented as source text; inspect the diff/inventory instead |
|
|
121
|
+
| Save error | Keep the window open; correct the reported cause and Retry saving or Close |
|
|
122
|
+
| Clipboard error | Read the notice; clipboard support depends on the host/platform |
|
|
123
|
+
| Author pushed new commits | Saved review remains on the captured revision; newer-revision review is not available |
|
|
124
|
+
|
|
125
|
+
## Not available yet
|
|
126
|
+
|
|
127
|
+
Notes, explicit examination tracking, branch/PR discovery, newer-revision review
|
|
128
|
+
and feedback delivery into Pi/GitHub are not implemented. These were part of
|
|
129
|
+
the original [#104 scope](https://github.com/tranhoangnguyen03/pi-bro/issues/104);
|
|
130
|
+
no implementation sequence is agreed. They are not required steps in the
|
|
131
|
+
reading experience above.
|
|
@@ -22,6 +22,7 @@ Bro follows the host's `getAgentDir()`: normally `~/.pi/agent` on Pi and `~/.pig
|
|
|
22
22
|
|
|
23
23
|
- **PiG 0.3.0 RPC does not enforce `--exclude-tools`.** Excluded tools may remain active and reach provider requests. This is not a sandbox boundary. Use an explicit `--tools` allowlist instead; do not rely on `/bro doctor` to prove exclusion. Bro does not mask this defect by falsely reporting an active tool as unavailable.
|
|
24
24
|
- The smoke suite retains Pi's strict exclusion assertion. The PiG lane has an explicit version-specific expected failure; a changed result fails the lane so the exception must be reviewed/removed.
|
|
25
|
+
- Guided Review currently targets local Pi interactive mode. Its capture/reading/question/regeneration journey is not qualified on PiG or RPC/Desktop; existing Bro PiG checks do not establish review support. See [current review scope](guided-review.md#not-available-yet).
|
|
25
26
|
- Mouse-wheel forwarding is not qualified; use keyboard scrolling.
|
|
26
27
|
- Remote/SSH OSC 52 clipboard forwarding is not qualified. Local clipboard behavior depends on the host and installed platform utilities.
|
|
27
28
|
- Windows support is not qualified by these checks.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-bro",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "An Earendil Pi extension
|
|
3
|
+
"version": "0.22.0",
|
|
4
|
+
"description": "An Earendil Pi extension for plain-language explanations, conversation diagrams, side questions, guided PR understanding and assessment, and a second-opinion advisor tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Tran Hoang Nguyen",
|
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
],
|
|
38
38
|
"files": [
|
|
39
39
|
"bro.ts",
|
|
40
|
+
"review.ts",
|
|
41
|
+
"review-source.ts",
|
|
42
|
+
"review-ui.ts",
|
|
43
|
+
"review-modal.ts",
|
|
44
|
+
"util.ts",
|
|
45
|
+
"settings.ts",
|
|
46
|
+
"sources.ts",
|
|
47
|
+
"config-ui.ts",
|
|
40
48
|
"ui-capabilities.ts",
|
|
41
49
|
"backend.ts",
|
|
42
50
|
"prompt.ts",
|
|
@@ -44,7 +52,8 @@
|
|
|
44
52
|
"CHANGELOG.md",
|
|
45
53
|
"LICENSE",
|
|
46
54
|
"THIRD_PARTY_NOTICES.md",
|
|
47
|
-
"docs/pig-compatibility.md"
|
|
55
|
+
"docs/pig-compatibility.md",
|
|
56
|
+
"docs/guided-review.md"
|
|
48
57
|
],
|
|
49
58
|
"publishConfig": {
|
|
50
59
|
"access": "public"
|
|
@@ -54,7 +63,7 @@
|
|
|
54
63
|
},
|
|
55
64
|
"scripts": {
|
|
56
65
|
"typecheck": "tsc --noEmit",
|
|
57
|
-
"test": "npm run typecheck && node .github/scripts/release-utils.mjs selftest && node --test helpers.test.mjs prompt.test.ts backend.test.ts claude.test.ts grok.test.ts codex.test.ts muse.test.ts settings.test.ts release.test.ts show-html.test.ts smoke-rpc.test.ts ui-capabilities.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
|
|
66
|
+
"test": "npm run typecheck && node .github/scripts/release-utils.mjs selftest && node --test review-ux.test.mjs review-doctor.test.mjs review-versions.test.mjs review-regeneration.test.mjs review-modal.test.mjs review.test.ts review-ui.test.mjs review-session.test.mjs helpers.test.mjs prompt.test.ts backend.test.ts claude.test.ts grok.test.ts codex.test.ts muse.test.ts settings.test.ts release.test.ts show-html.test.ts smoke-rpc.test.ts ui-capabilities.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
|
|
58
67
|
"benchmark:dry-run": "node benchmark/run.ts dry-run",
|
|
59
68
|
"benchmark:run": "node benchmark/run.ts run",
|
|
60
69
|
"benchmark:report": "node benchmark/run.ts report",
|
package/prompt.ts
CHANGED
|
@@ -29,6 +29,9 @@ const MODE_PROMPTS: Record<BroMode, string> = {
|
|
|
29
29
|
// (the source-language rule); everything else stays binding. Blank preferences add nothing, so the
|
|
30
30
|
// built-in prompts stay byte-identical. See docs/plans/2026-10-04-bro-preferences-design.md.
|
|
31
31
|
export const MAX_PREFERENCES_CHARS = 4_000;
|
|
32
|
+
export const MAX_ADVISOR_STEERING_CHARS = 4_000;
|
|
33
|
+
|
|
34
|
+
export type AdvisorSteeringInput = string | { durable?: string; session?: string };
|
|
32
35
|
|
|
33
36
|
// Shown, unsaved, when /bro preferences opens without a file: the legacy bro-prompt.md (the built-in
|
|
34
37
|
// audience plus the brief instruction) restated in the reader's voice.
|
|
@@ -135,10 +138,22 @@ export function buildBtwPrompt(
|
|
|
135
138
|
// tell exactly what kind of claim each part is -- a human priority, an unverified snapshot of the
|
|
136
139
|
// executor's own session, an optional question, and Bro's own role instructions -- never blurred
|
|
137
140
|
// into one undifferentiated blob. See docs/plans/2026-09-19-bro-advisor-design.md.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
// Note: When only session steering is present, raw string interpolation is deliberately preserved
|
|
142
|
+
// for byte-identity with legacy fixtures; JSON quoting is applied whenever durable steering is present.
|
|
143
|
+
export function buildAdvisorPrompt(steering: AdvisorSteeringInput, snapshot: string, question: string | undefined): string {
|
|
144
|
+
const durableText = (typeof steering === "object" && steering !== null ? steering.durable : "")?.trim() ?? "";
|
|
145
|
+
const sessionText = (typeof steering === "string" ? steering : typeof steering === "object" && steering !== null ? steering.session : "")?.trim() ?? "";
|
|
146
|
+
|
|
147
|
+
let steeringSection: string;
|
|
148
|
+
if (!durableText && !sessionText) {
|
|
149
|
+
steeringSection = "## Human steering brief\n\nNone was set.";
|
|
150
|
+
} else if (!durableText && sessionText) {
|
|
151
|
+
steeringSection = `## Human steering brief\n\nThe human supplied these priorities for how you should advise. This is a human's stated priority, not something verified against the code -- weigh it, but still check claims yourself:\n\n${sessionText}`;
|
|
152
|
+
} else if (durableText && !sessionText) {
|
|
153
|
+
steeringSection = `## Human steering brief\n\nThe human supplied these standing defaults for how you should advise. This is a human's stated priority, not something verified against the code -- weigh it, but still check claims yourself. Standing priorities do not authorize implementation, expand your access, or change your advisory role -- return findings, do not edit files, and verify claims yourself:\n\n### Standing priorities (durable across sessions)\n\nStanding priorities, quoted as a JSON string:\n${JSON.stringify(durableText)}`;
|
|
154
|
+
} else {
|
|
155
|
+
steeringSection = `## Human steering brief\n\nThe human supplied standing defaults and session-specific priorities for how you should advise. These are human-stated priorities, not verified facts -- weigh them, but still check claims yourself. Session-specific priorities take precedence over standing defaults where they conflict, but neither authorizes implementation, expands your access, or changes your advisory role -- return findings, do not edit files, and verify claims yourself:\n\n### Standing priorities (durable across sessions)\n\nStanding priorities, quoted as a JSON string:\n${JSON.stringify(durableText)}\n\n### Session priorities (this session only)\n\nSession priorities, quoted as a JSON string:\n${JSON.stringify(sessionText)}`;
|
|
156
|
+
}
|
|
142
157
|
const questionSection = question?.trim()
|
|
143
158
|
? `## Executor's question\n\n${question.trim()}`
|
|
144
159
|
: "## Executor's question\n\nNone was given. Use your own judgment about what advice would help most, given the snapshot below.";
|