@chrok/pi-braid 0.3.0 → 0.3.2
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 +66 -1
- package/dist/command.js +184 -26
- package/dist/display.js +16 -1
- package/dist/index.js +7 -2
- package/dist/jobs.js +27 -1
- package/dist/review.js +64 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ It registers:
|
|
|
25
25
|
- `braid_update` / `braid_resume` — edit live definitions or release paused executions.
|
|
26
26
|
- `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
|
|
27
27
|
- `/braid [jobId]` — open a live flow panel in interactive Pi.
|
|
28
|
+
- `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
|
|
28
29
|
|
|
29
30
|
Submission and completion reminders use short session handles such as `job-1`.
|
|
30
31
|
Status, cancellation and the panel accept either that exact handle or the original
|
|
@@ -50,6 +51,44 @@ or switching/forking sessions aborts outstanding work and suppresses its
|
|
|
50
51
|
reminders. Job IDs cannot be retrieved after that lifecycle ends. They are not
|
|
51
52
|
persistent processes outside Pi.
|
|
52
53
|
|
|
54
|
+
## Review command
|
|
55
|
+
|
|
56
|
+
Run `/braid:review` to review current uncommitted changes (including staged, unstaged,
|
|
57
|
+
and non-ignored new files). With no uncommitted changes, it reviews the latest
|
|
58
|
+
commit. Supply a file, commit range, or focus with `/braid:review src/runtime.ts` or
|
|
59
|
+
`/braid:review main..HEAD, focus on cancellation`. Workers inspect the supplied target
|
|
60
|
+
with local tools; they report unavailable remote targets instead of inventing
|
|
61
|
+
their contents. Use `/braid:review review-only, src/runtime.ts` to request findings
|
|
62
|
+
without fixes.
|
|
63
|
+
|
|
64
|
+
Each round assigns three independent, read-only reviewers distinct angles based
|
|
65
|
+
on the change. Findings need concrete evidence and file/line references, with
|
|
66
|
+
P0/P1/P2 priority and a merge verdict. One writer synthesizes the feedback and
|
|
67
|
+
fixes only evidenced, in-scope P0/P1 problems in an isolated worktree, then runs
|
|
68
|
+
focused validation. Optional P2 polish is reported. A product, scope, or
|
|
69
|
+
architecture decision stops the loop for user input.
|
|
70
|
+
|
|
71
|
+
The graph enforces a fixed maximum of **three review rounds**, including the
|
|
72
|
+
first, and **two fix passes**. The final round reports remaining issues without
|
|
73
|
+
making further edits; reaching the cap does not mean the review is clean.
|
|
74
|
+
Only fixes that passed re-review and validation are integrated into the invoking
|
|
75
|
+
checkout, preserving existing user changes. Conflicting concurrent edits or
|
|
76
|
+
failed validation leave the fix checkpoint archived for recovery. Outside Git,
|
|
77
|
+
workers can only inspect files and report findings.
|
|
78
|
+
|
|
79
|
+
The command returns immediately with a job handle. Use `/braid [jobId]` to see
|
|
80
|
+
progress, `braid_status` for results, and `braid_cancel` to stop. The existing
|
|
81
|
+
completion reminder resumes the parent to report rounds, applied fixes,
|
|
82
|
+
validation, remaining findings, and the stop reason. Session shutdown cancels
|
|
83
|
+
the job. Cancellation or failure during integration can leave partial checkout
|
|
84
|
+
changes; Braid retains backup and checkpoint refs for recovery.
|
|
85
|
+
|
|
86
|
+
The review policy is adapted from pi-subagents'
|
|
87
|
+
[review-loop](https://github.com/nicobailon/pi-subagents/blob/main/prompts/review-loop.md)
|
|
88
|
+
and [parallel-review](https://github.com/nicobailon/pi-subagents/blob/main/prompts/parallel-review.md)
|
|
89
|
+
prompts. Invoking `/braid:review` authorizes the narrow fix passes described above;
|
|
90
|
+
an explicit review-only request disables them.
|
|
91
|
+
|
|
53
92
|
## Graph definitions
|
|
54
93
|
|
|
55
94
|
Each node has a unique `id` and one of these four shapes:
|
|
@@ -163,6 +202,21 @@ scheduler persistence across Pi reloads. Reminders alone do not pause execution.
|
|
|
163
202
|
|
|
164
203
|
## Live flow panel
|
|
165
204
|
|
|
205
|
+
In interactive Pi, background progress appears automatically **above the input
|
|
206
|
+
editor** as soon as a job is submitted; no command or polling is needed:
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
Braid job-1 · running · 2/5 done · review
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The widget shows node progress, active node IDs, paused gates, and failures.
|
|
213
|
+
It shows up to three active jobs, with an overflow count for the rest. When no
|
|
214
|
+
jobs are active, the newest job's final completed/failed/cancelled status stays
|
|
215
|
+
visible until another job is submitted or the session ends. Counts use the
|
|
216
|
+
latest state of each current node definition; failed and skipped nodes count as
|
|
217
|
+
done, and reruns or live edits can change progress and totals. RPC, JSON, and
|
|
218
|
+
print modes do not create a terminal widget.
|
|
219
|
+
|
|
166
220
|
Run `/braid` to open the newest job, or `/braid <jobId>` to open a specific job.
|
|
167
221
|
The bordered panel keeps the job header and keyboard controls visible while
|
|
168
222
|
you scroll the flow and event log. It refreshes as nodes start, finish, fail,
|
|
@@ -171,6 +225,17 @@ Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to
|
|
|
171
225
|
cancel the selected job, and Escape or `q` to close the panel. Closing the panel
|
|
172
226
|
leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
|
|
173
227
|
|
|
228
|
+
Run `/braid <jobId> <nodeId>` to open the panel on one node's latest invocation,
|
|
229
|
+
or `/braid <jobId> <executionId>` for an exact invocation such as an earlier loop
|
|
230
|
+
iteration. The node view refreshes live with its status, model, elapsed time,
|
|
231
|
+
token usage, context size, tool-call count, workspace, and full output or error.
|
|
232
|
+
Press Backspace to return to the whole graph; switching jobs also returns to it.
|
|
233
|
+
Tab completes job handles and then that job's node IDs.
|
|
234
|
+
IDs with leading/trailing whitespace, terminal controls, or a leading double
|
|
235
|
+
quote use JSON string syntax, such as `/braid job-1 " review "` or
|
|
236
|
+
`/braid job-1 "review\nstep"`. Tab inserts this form automatically and displays
|
|
237
|
+
control characters as escapes.
|
|
238
|
+
|
|
174
239
|
The panel renders a Mermaid flowchart, node states, elapsed times, context-token
|
|
175
240
|
estimates or provider-reported usage, context-window sizes, filesystem tool-call
|
|
176
241
|
counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
|
|
@@ -278,7 +343,7 @@ The extension loads its own compiled `dist/` and the Pi host dependencies.
|
|
|
278
343
|
`npm ci` at the repository root installs the pinned workspace development
|
|
279
344
|
environment, including a local link to core. Use
|
|
280
345
|
`npm install --workspace @chrok/pi-braid <dependency>` when updating Pi dependencies;
|
|
281
|
-
|
|
346
|
+
all packages share the root lockfile. The adapter uses the `grok-mermaid` terminal renderer for Mermaid flowcharts.
|
|
282
347
|
The local install is trusted code: Pi extensions execute with the process's full
|
|
283
348
|
permissions.
|
|
284
349
|
|
package/dist/command.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
2
|
-
import { renderGraphResult } from "./display.js";
|
|
2
|
+
import { renderGraphResult, renderNodeResult } from "./display.js";
|
|
3
3
|
// Match the overlay height exactly so Pi never clips the footer or bottom border.
|
|
4
4
|
const panelHeight = (rows) => Math.max(1, Math.floor(rows * 0.9));
|
|
5
5
|
const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
|
|
@@ -10,17 +10,19 @@ export class BraidPanel {
|
|
|
10
10
|
theme;
|
|
11
11
|
done;
|
|
12
12
|
selected;
|
|
13
|
+
focus;
|
|
13
14
|
offset = 0;
|
|
14
15
|
maxOffset = 0;
|
|
15
16
|
disposed = false;
|
|
16
17
|
unsubscribe;
|
|
17
18
|
ticker;
|
|
18
|
-
constructor(jobs, tui, theme, done, jobId) {
|
|
19
|
+
constructor(jobs, tui, theme, done, jobId, focus) {
|
|
19
20
|
this.jobs = jobs;
|
|
20
21
|
this.tui = tui;
|
|
21
22
|
this.theme = theme;
|
|
22
23
|
this.done = done;
|
|
23
24
|
this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined;
|
|
25
|
+
this.focus = focus;
|
|
24
26
|
this.unsubscribe = jobs.subscribe(() => this.refresh());
|
|
25
27
|
this.ticker = setInterval(() => this.refresh(), 1000);
|
|
26
28
|
this.ticker.unref();
|
|
@@ -60,8 +62,11 @@ export class BraidPanel {
|
|
|
60
62
|
: current?.status === "failed"
|
|
61
63
|
? "error"
|
|
62
64
|
: "muted";
|
|
65
|
+
const focusLabel = this.focus
|
|
66
|
+
? "nodeId" in this.focus ? `node ${plain(this.focus.nodeId)}` : `execution ${plain(this.focus.executionId)}`
|
|
67
|
+
: "";
|
|
63
68
|
const heading = current
|
|
64
|
-
? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}`
|
|
69
|
+
? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}${focusLabel ? ` · ${focusLabel}` : ""}`
|
|
65
70
|
: this.theme.fg("muted", "No background jobs");
|
|
66
71
|
// Reserve a body row and the close hint even in a very short terminal.
|
|
67
72
|
if (rows < 10) {
|
|
@@ -87,21 +92,23 @@ export class BraidPanel {
|
|
|
87
92
|
: "Jobs will appear here as soon as they are submitted.")),
|
|
88
93
|
rule("├", "┤"),
|
|
89
94
|
];
|
|
90
|
-
const content = current
|
|
91
|
-
?
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
? {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
95
|
+
const content = current && this.focus
|
|
96
|
+
? this.renderFocus(current, this.focus, contentWidth)
|
|
97
|
+
: current
|
|
98
|
+
? [
|
|
99
|
+
...(current.error
|
|
100
|
+
? [this.theme.fg("error", plain(current.error))]
|
|
101
|
+
: []),
|
|
102
|
+
...renderGraphResult({
|
|
103
|
+
...(current.result ?? current.live),
|
|
104
|
+
progress: current.live.progress,
|
|
105
|
+
...(current.workspaces ? { workspaces: current.workspaces } : {}),
|
|
106
|
+
...(current.fullOutputPath
|
|
107
|
+
? { fullOutputPath: current.fullOutputPath }
|
|
108
|
+
: {}),
|
|
109
|
+
}, true, false, this.theme).render(contentWidth),
|
|
110
|
+
]
|
|
111
|
+
: ["No Braid jobs in this session."];
|
|
105
112
|
const height = rows - header.length - 4;
|
|
106
113
|
this.maxOffset = Math.max(0, content.length - height);
|
|
107
114
|
this.offset = Math.min(this.offset, this.maxOffset);
|
|
@@ -109,11 +116,17 @@ export class BraidPanel {
|
|
|
109
116
|
while (body.length < height)
|
|
110
117
|
body.push("");
|
|
111
118
|
const range = `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`;
|
|
112
|
-
const help =
|
|
113
|
-
?
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
const help = this.focus
|
|
120
|
+
? contentWidth >= 72
|
|
121
|
+
? "⌫ graph ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close"
|
|
122
|
+
: contentWidth >= 42
|
|
123
|
+
? "⌫ graph · ↑/↓ scroll · c cancel · Esc close"
|
|
124
|
+
: "⌫ graph · Esc close"
|
|
125
|
+
: contentWidth >= 72
|
|
126
|
+
? "←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close"
|
|
127
|
+
: contentWidth >= 42
|
|
128
|
+
? "←/→ jobs · ↑/↓ scroll · c cancel · Esc close"
|
|
129
|
+
: "↑/↓ scroll · Esc close";
|
|
117
130
|
return [
|
|
118
131
|
...header,
|
|
119
132
|
...body.map(row),
|
|
@@ -123,6 +136,27 @@ export class BraidPanel {
|
|
|
123
136
|
rule("╰", "╯"),
|
|
124
137
|
];
|
|
125
138
|
}
|
|
139
|
+
/** A focused node can disappear after a live graph update; say so instead of failing. */
|
|
140
|
+
renderFocus(job, focus, width) {
|
|
141
|
+
let node;
|
|
142
|
+
try {
|
|
143
|
+
node = "nodeId" in focus
|
|
144
|
+
? this.jobs.getNode(job.jobId, focus.nodeId)
|
|
145
|
+
: this.jobs.getNode(job.jobId, undefined, focus.executionId);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [
|
|
149
|
+
this.theme.fg("warning", "This node or execution is no longer in the job's graph."),
|
|
150
|
+
this.theme.fg("muted", "Press Backspace to return to the graph."),
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
// Latest-node results omit the loop iteration; their execution record has it.
|
|
154
|
+
const executions = job.result?.executions ?? job.execution?.executions;
|
|
155
|
+
const iteration = node.iteration ??
|
|
156
|
+
(node.executionId ? executions?.[node.executionId]?.iteration : undefined);
|
|
157
|
+
return renderNodeResult(iteration === undefined ? node : { ...node, iteration }, true, this.theme, undefined, job.live.progress[node.id], job.live.observedAt)
|
|
158
|
+
.render(width);
|
|
159
|
+
}
|
|
126
160
|
handleInput(data) {
|
|
127
161
|
if (matchesKey(data, "escape") ||
|
|
128
162
|
data === "q" ||
|
|
@@ -136,6 +170,11 @@ export class BraidPanel {
|
|
|
136
170
|
if (matchesKey(data, "left") || matchesKey(data, "right")) {
|
|
137
171
|
const step = matchesKey(data, "right") ? 1 : -1;
|
|
138
172
|
this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId;
|
|
173
|
+
this.focus = undefined;
|
|
174
|
+
this.offset = 0;
|
|
175
|
+
}
|
|
176
|
+
else if (matchesKey(data, "backspace") && this.focus) {
|
|
177
|
+
this.focus = undefined;
|
|
139
178
|
this.offset = 0;
|
|
140
179
|
}
|
|
141
180
|
else if (matchesKey(data, "up"))
|
|
@@ -159,19 +198,76 @@ export class BraidPanel {
|
|
|
159
198
|
this.unsubscribe();
|
|
160
199
|
}
|
|
161
200
|
}
|
|
201
|
+
export function registerBraidWidget(pi, jobs) {
|
|
202
|
+
let dispose = () => { };
|
|
203
|
+
const clear = () => {
|
|
204
|
+
dispose();
|
|
205
|
+
dispose = () => { };
|
|
206
|
+
};
|
|
207
|
+
pi.on("session_start", (_event, ctx) => {
|
|
208
|
+
clear();
|
|
209
|
+
if (ctx.mode !== "tui")
|
|
210
|
+
return;
|
|
211
|
+
const refresh = () => {
|
|
212
|
+
if (!jobs.list().length) {
|
|
213
|
+
ctx.ui.setWidget("braid-status", undefined);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
ctx.ui.setWidget("braid-status", (_tui, theme) => ({
|
|
217
|
+
render(width) {
|
|
218
|
+
const all = jobs.list();
|
|
219
|
+
const active = all.filter(job => job.status === "running");
|
|
220
|
+
const selected = active.length ? active.slice(0, 3) : all.slice(0, 1);
|
|
221
|
+
const lines = selected.map(job => {
|
|
222
|
+
const progress = jobs.progress(job.jobId);
|
|
223
|
+
const status = job.status === "running" && progress.paused && !progress.running.length
|
|
224
|
+
? "paused" : job.status;
|
|
225
|
+
const color = status === "completed" ? "success"
|
|
226
|
+
: status === "failed" ? "error"
|
|
227
|
+
: status === "cancelled" ? "muted" : "warning";
|
|
228
|
+
const parts = [
|
|
229
|
+
theme.fg("accent", `Braid ${job.handle}`),
|
|
230
|
+
theme.fg(color, status),
|
|
231
|
+
`${progress.done}/${progress.total} done`,
|
|
232
|
+
];
|
|
233
|
+
if (progress.failed)
|
|
234
|
+
parts.push(theme.fg("error", `${progress.failed} failed`));
|
|
235
|
+
if (progress.paused && status !== "paused")
|
|
236
|
+
parts.push(theme.fg("warning", `${progress.paused} paused`));
|
|
237
|
+
parts.push(plain(progress.running.length ? progress.running.join(", ") : job.goal));
|
|
238
|
+
return truncateToWidth(parts.join(" · "), width, "…");
|
|
239
|
+
});
|
|
240
|
+
if (active.length > selected.length)
|
|
241
|
+
lines.push(truncateToWidth(theme.fg("muted", `Braid · +${active.length - selected.length} active jobs · /braid details`), width, "…"));
|
|
242
|
+
return lines;
|
|
243
|
+
},
|
|
244
|
+
invalidate() { },
|
|
245
|
+
}), { placement: "aboveEditor" });
|
|
246
|
+
};
|
|
247
|
+
const unsubscribe = jobs.subscribe(refresh);
|
|
248
|
+
dispose = () => {
|
|
249
|
+
unsubscribe();
|
|
250
|
+
ctx.ui.setWidget("braid-status", undefined);
|
|
251
|
+
};
|
|
252
|
+
refresh();
|
|
253
|
+
});
|
|
254
|
+
return clear;
|
|
255
|
+
}
|
|
162
256
|
export function registerBraidCommand(pi, jobs) {
|
|
163
257
|
pi.registerCommand("braid", {
|
|
164
|
-
description: "Open the live background-job flow panel: /braid [jobId]",
|
|
258
|
+
description: "Open the live background-job flow panel: /braid [jobId [nodeId|executionId]]",
|
|
259
|
+
getArgumentCompletions: (prefix) => braidCompletions(jobs, prefix),
|
|
165
260
|
handler: async (args, ctx) => {
|
|
166
261
|
if (ctx.mode !== "tui")
|
|
167
262
|
throw new Error("The Braid panel requires interactive Pi. Use braid_status for job status.");
|
|
168
|
-
const jobId = args.trim()
|
|
263
|
+
const [jobId, target] = splitArguments(args.trim());
|
|
169
264
|
if (jobId && !jobs.get(jobId))
|
|
170
265
|
throw jobs.unknownJob(jobId);
|
|
266
|
+
const focus = jobId && target ? resolveFocus(jobs, jobId, target) : undefined;
|
|
171
267
|
let panel;
|
|
172
268
|
try {
|
|
173
269
|
await ctx.ui.custom((tui, theme, _keys, done) => {
|
|
174
|
-
panel = new BraidPanel(jobs, tui, theme, done, jobId);
|
|
270
|
+
panel = new BraidPanel(jobs, tui, theme, done, jobId, focus);
|
|
175
271
|
return panel;
|
|
176
272
|
}, {
|
|
177
273
|
overlay: true,
|
|
@@ -188,3 +284,65 @@ export function registerBraidCommand(pi, jobs) {
|
|
|
188
284
|
},
|
|
189
285
|
});
|
|
190
286
|
}
|
|
287
|
+
/** Job IDs never contain whitespace; node IDs may, so the rest is one target. */
|
|
288
|
+
function splitArguments(text) {
|
|
289
|
+
const split = text.search(/\s/);
|
|
290
|
+
if (split < 0)
|
|
291
|
+
return [text || undefined, undefined];
|
|
292
|
+
const jobId = text.slice(0, split);
|
|
293
|
+
const target = text.slice(split).trim();
|
|
294
|
+
if (!target.startsWith('"'))
|
|
295
|
+
return [jobId, target || undefined];
|
|
296
|
+
try {
|
|
297
|
+
return [jobId, JSON.parse(target)];
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
throw new Error('A quoted Braid target must be a valid JSON string, for example " review ".');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** Quote IDs that would otherwise be trimmed, parsed as quotes, or rendered as controls. */
|
|
304
|
+
function formatTarget(id) {
|
|
305
|
+
if (id === id.trim() && !id.startsWith('"') && !/[\p{Cc}\u2028\u2029]/u.test(id))
|
|
306
|
+
return id;
|
|
307
|
+
return JSON.stringify(id).replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029");
|
|
308
|
+
}
|
|
309
|
+
/** Node IDs take precedence; anything else must be an exact execution ID. */
|
|
310
|
+
function resolveFocus(jobs, jobId, target) {
|
|
311
|
+
try {
|
|
312
|
+
jobs.getNode(jobId, target);
|
|
313
|
+
return { nodeId: target };
|
|
314
|
+
}
|
|
315
|
+
catch { }
|
|
316
|
+
try {
|
|
317
|
+
jobs.getNode(jobId, undefined, target);
|
|
318
|
+
return { executionId: target };
|
|
319
|
+
}
|
|
320
|
+
catch { }
|
|
321
|
+
const handle = jobs.get(jobId)?.handle ?? jobId;
|
|
322
|
+
throw new Error(`Unknown Braid node or execution "${plain(target)}" in ${handle}. Open /braid ${handle} to see its nodes.`);
|
|
323
|
+
}
|
|
324
|
+
/** Pi replaces the whole argument text, so node suggestions repeat the job handle. */
|
|
325
|
+
function braidCompletions(jobs, prefix) {
|
|
326
|
+
const text = prefix.trimStart();
|
|
327
|
+
const split = text.search(/\s/);
|
|
328
|
+
const jobPrefix = split < 0 ? text : text.slice(0, split);
|
|
329
|
+
if (split < 0) {
|
|
330
|
+
const items = jobs.list()
|
|
331
|
+
.filter((job) => job.handle.startsWith(jobPrefix))
|
|
332
|
+
.map((job) => ({ value: job.handle, label: job.handle, description: `${job.status} · ${plain(job.goal)}` }));
|
|
333
|
+
return items.length ? items : null;
|
|
334
|
+
}
|
|
335
|
+
const nodePrefix = text.slice(split).trimStart();
|
|
336
|
+
const job = jobs.get(jobPrefix);
|
|
337
|
+
if (!job)
|
|
338
|
+
return null;
|
|
339
|
+
const nodes = job.result?.nodes ?? job.execution?.nodes ?? job.live.nodes;
|
|
340
|
+
const items = Object.values(nodes)
|
|
341
|
+
.flatMap((node) => {
|
|
342
|
+
const target = formatTarget(node.id);
|
|
343
|
+
return node.id.startsWith(nodePrefix) || target.startsWith(nodePrefix)
|
|
344
|
+
? [{ value: `${job.handle} ${target}`, label: target, description: node.status }]
|
|
345
|
+
: [];
|
|
346
|
+
});
|
|
347
|
+
return items.length ? items : null;
|
|
348
|
+
}
|
package/dist/display.js
CHANGED
|
@@ -429,12 +429,27 @@ export function applyProgress(state, progress) {
|
|
|
429
429
|
});
|
|
430
430
|
}
|
|
431
431
|
/** Focused status reads must show the requested execution, including historical ones. */
|
|
432
|
-
export function renderNodeResult(node, expanded, theme, fullOutputPath) {
|
|
432
|
+
export function renderNodeResult(node, expanded, theme, fullOutputPath, progress, now = Date.now()) {
|
|
433
433
|
const failed = node.status === "failed";
|
|
434
434
|
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
435
|
+
const timing = [
|
|
436
|
+
...(node.model ? [`model ${compact(node.model, 60)}`] : []),
|
|
437
|
+
...(node.iteration ? [`iteration #${node.iteration}`] : []),
|
|
438
|
+
...(node.startedAt !== undefined || node.latencyMs !== undefined ? [`elapsed ${nodeElapsed(node, now)}`] : []),
|
|
439
|
+
...(node.usage ? [`${compactCount(node.usage.inputTokens)} in / ${compactCount(node.usage.outputTokens)} out tokens`] : []),
|
|
440
|
+
];
|
|
441
|
+
// Progress is tracked per node ID; only show it for the execution it describes.
|
|
442
|
+
const live = progress && (!progress.executionId || progress.executionId === node.executionId) ? progress : undefined;
|
|
443
|
+
const workspace = node.workspace;
|
|
444
|
+
// Cleaned-up worktrees are gone; their checkpoint ref is what can be recovered.
|
|
445
|
+
const workspaceActive = workspace && ["preparing", "ready", "failed"].includes(workspace.state);
|
|
446
|
+
const workspaceRef = workspaceActive ? workspace.worktreeRoot : workspace?.checkpointRef ?? workspace?.reason;
|
|
435
447
|
const lines = [
|
|
436
448
|
theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
|
|
437
449
|
...(node.executionId ? [theme.fg("dim", `execution: ${compact(node.executionId, 80)}`)] : []),
|
|
450
|
+
...(timing.length ? [theme.fg("muted", timing.join(" · "))] : []),
|
|
451
|
+
...(live ? [theme.fg("muted", `context ${live.contextSource === "estimate" ? "~" : ""}${compactCount(live.contextTokens)}/${live.contextWindow === undefined ? "—" : compactCount(live.contextWindow)} · ${live.toolCalls} tool calls in ${live.toolRounds} rounds · ${live.phase} phase`)] : []),
|
|
452
|
+
...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
|
|
438
453
|
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
439
454
|
...(node.error ? [theme.fg("error", `${node.error.code}: ${clean(node.error.message)}`)] : []),
|
|
440
455
|
...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,8 @@ import { join } from "node:path";
|
|
|
5
5
|
import { defineTool, truncateHead, } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { Text } from "@earendil-works/pi-tui";
|
|
7
7
|
import { BraidJobs } from "./jobs.js";
|
|
8
|
-
import { registerBraidCommand } from "./command.js";
|
|
8
|
+
import { registerBraidCommand, registerBraidWidget } from "./command.js";
|
|
9
|
+
import { registerReviewCommand } from "./review.js";
|
|
9
10
|
import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";
|
|
10
11
|
const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
|
|
11
12
|
const prompt = () => Type.Union([
|
|
@@ -224,8 +225,9 @@ export function createBraidTools(jobs) {
|
|
|
224
225
|
const selectedNode = job.selectedNode ?? (executionId
|
|
225
226
|
? state && Object.hasOwn(state.executions, executionId) ? state.executions[executionId] : undefined
|
|
226
227
|
: nodeId && state && Object.hasOwn(state.nodes, nodeId) ? state.nodes[nodeId] : undefined);
|
|
228
|
+
// Render at the read's observation time so a saved result never keeps counting.
|
|
227
229
|
if (selectedNode)
|
|
228
|
-
return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath);
|
|
230
|
+
return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, job.live.progress?.[selectedNode.id], job.live.observedAt);
|
|
229
231
|
if (executionId || nodeId)
|
|
230
232
|
return new Text(fallback || "Braid returned no node details", 0, 0);
|
|
231
233
|
if (job.error)
|
|
@@ -422,6 +424,8 @@ export default function braidExtension(pi) {
|
|
|
422
424
|
});
|
|
423
425
|
const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs);
|
|
424
426
|
registerBraidCommand(pi, jobs);
|
|
427
|
+
registerReviewCommand(pi, jobs);
|
|
428
|
+
const disposeWidget = registerBraidWidget(pi, jobs);
|
|
425
429
|
pi.registerTool(braidTool);
|
|
426
430
|
pi.registerTool(statusTool);
|
|
427
431
|
pi.registerTool(cancelTool);
|
|
@@ -430,6 +434,7 @@ export default function braidExtension(pi) {
|
|
|
430
434
|
pi.on("session_shutdown", () => {
|
|
431
435
|
pending.clear();
|
|
432
436
|
jobs.dispose();
|
|
437
|
+
disposeWidget();
|
|
433
438
|
});
|
|
434
439
|
pi.on("before_agent_start", (event) => ({
|
|
435
440
|
systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n\nFor the current user request, make this delegation choice before using read, grep, find, edit, write, or bash. Completion reminders refer to existing jobs: retrieve their results instead of submitting the same graph again.`,
|
package/dist/jobs.js
CHANGED
|
@@ -56,6 +56,7 @@ export class BraidJobs {
|
|
|
56
56
|
controller: new AbortController(),
|
|
57
57
|
done: Promise.resolve(),
|
|
58
58
|
usageClaimed: false,
|
|
59
|
+
runningExecutions: new Map(),
|
|
59
60
|
nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
|
|
60
61
|
};
|
|
61
62
|
this.jobs.set(job.jobId, job);
|
|
@@ -100,6 +101,16 @@ export class BraidJobs {
|
|
|
100
101
|
value: { ...event.workspace }, enumerable: true, configurable: true, writable: true,
|
|
101
102
|
});
|
|
102
103
|
}
|
|
104
|
+
// Definition edits can remove nodes whose executions are still running.
|
|
105
|
+
if (event.type === "node_started" && event.executionId) {
|
|
106
|
+
job.runningExecutions.set(event.executionId, event.nodeId);
|
|
107
|
+
}
|
|
108
|
+
else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) {
|
|
109
|
+
job.runningExecutions.delete(event.executionId);
|
|
110
|
+
}
|
|
111
|
+
else if (event.type === "graph_completed" || event.type === "graph_failed") {
|
|
112
|
+
job.runningExecutions.clear();
|
|
113
|
+
}
|
|
103
114
|
applyEvent(job.live, event);
|
|
104
115
|
if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) {
|
|
105
116
|
// The panel keeps short previews; node retrieval needs the complete
|
|
@@ -161,6 +172,7 @@ export class BraidJobs {
|
|
|
161
172
|
job.status = "failed";
|
|
162
173
|
job.live.status = "failed";
|
|
163
174
|
job.live.pausedExecutionIds = [];
|
|
175
|
+
job.runningExecutions.clear();
|
|
164
176
|
job.error = error instanceof Error ? error.message : String(error);
|
|
165
177
|
if (reports.length)
|
|
166
178
|
job.usage = sumPiUsage(reports);
|
|
@@ -186,7 +198,7 @@ export class BraidJobs {
|
|
|
186
198
|
const job = this.lookup(jobId);
|
|
187
199
|
if (!job)
|
|
188
200
|
return undefined;
|
|
189
|
-
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, ...snapshot } = job;
|
|
201
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, ...snapshot } = job;
|
|
190
202
|
const copy = structuredClone(snapshot);
|
|
191
203
|
if (job.run)
|
|
192
204
|
copy.execution = job.run.snapshot();
|
|
@@ -239,6 +251,20 @@ export class BraidJobs {
|
|
|
239
251
|
}))
|
|
240
252
|
.reverse();
|
|
241
253
|
}
|
|
254
|
+
/** Editor progress without copying outputs, transcripts, or execution snapshots. */
|
|
255
|
+
progress(jobId) {
|
|
256
|
+
const job = this.lookup(jobId);
|
|
257
|
+
if (!job)
|
|
258
|
+
return undefined;
|
|
259
|
+
const nodes = Object.values(job.live.nodes);
|
|
260
|
+
return {
|
|
261
|
+
total: nodes.length,
|
|
262
|
+
done: nodes.filter(node => node.status === "completed" || node.status === "failed" || node.status === "skipped").length,
|
|
263
|
+
failed: nodes.filter(node => node.status === "failed").length,
|
|
264
|
+
running: [...new Set(job.runningExecutions.values())],
|
|
265
|
+
paused: job.live.pausedExecutionIds?.length ?? 0,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
242
268
|
cancel(jobId) {
|
|
243
269
|
const job = this.lookup(jobId);
|
|
244
270
|
if (!job)
|
package/dist/review.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const maxRounds = 3;
|
|
2
|
+
export function registerReviewCommand(pi, jobs) {
|
|
3
|
+
pi.registerCommand("braid:review", {
|
|
4
|
+
description: "Parallel review/fix loop, at most 3 review rounds: /braid:review [target or focus]",
|
|
5
|
+
handler: async (args, ctx) => {
|
|
6
|
+
const target = args.trim() || "Current uncommitted changes; if there are none, the latest commit.";
|
|
7
|
+
const input = {
|
|
8
|
+
goal: `Review the following target, fix concrete in-scope defects, and re-review material fixes. Stop after at most ${maxRounds} review rounds. Respect an explicit review-only request.\n\nTarget or focus:\n${target}`,
|
|
9
|
+
promptTemplates: {
|
|
10
|
+
reviewer: `You are reviewer {{slot}}. Use the distinct angle assigned to you by scope.
|
|
11
|
+
Inspect the repository instructions, actual files, and target diff in your assigned snapshot. Do not rely on conversation history or edit files. Use scope's pinned original baseline and target endpoint for the original target diff, and separately compare its pinned initial job snapshot to YOUR workspace.snapshotCommit for cumulative loop fixes. Never substitute the current snapshot for the original target endpoint: historical ranges and latest-commit reviews exclude later commits and unrelated caller edits, even within target files. Inspect current files for fix validity, but attribute findings only to the original target diff or cumulative fix diff. Plain git diff may be empty because caller edits were captured in a commit.
|
|
12
|
+
Report only concrete current issues within the target, supported by source proof, a test/repro, or a contract contradiction. For diff reviews, require that the issue is caused or made reachable by the diff. Include file/line references, evidence, a suggested fix, and P0/P1/P2: P0 blocks merge, P1 should be fixed before release, P2 is report-only. Do not filter the first pass to blockers only or invent findings to fill your angle.
|
|
13
|
+
On follow-up rounds check whether earlier findings were resolved, whether the fixes introduced defects in their blast radius, and whether deferred notes still stand. You cannot run tests here; distinguish source inspection from executed validation.
|
|
14
|
+
If nothing qualifies, say exactly "No issues found." End with "Merge verdict: BLOCK", "Merge verdict: OK", or "Merge verdict: OK with notes".`,
|
|
15
|
+
},
|
|
16
|
+
nodes: [
|
|
17
|
+
{
|
|
18
|
+
type: "execute", id: "scope", workspace: "worktree", requireSuccess: true,
|
|
19
|
+
prompt: `Prepare this review round by inspecting the requested target and relevant repository instructions. You are the only fix writer.
|
|
20
|
+
On the FIRST round only, resolve the target without editing files. For the default target, compare workspace.baseCommit (the caller's original HEAD) to workspace.snapshotCommit, including captured staged/unstaged/non-ignored new files. If identical, review the latest original commit against its parent; for an initial/unborn commit inspect all captured files. Do not mistake a clean isolated worktree for an empty target. For an explicit commit/range target, resolve HEAD against workspace.baseCommit, not the detached snapshot HEAD, and pin all endpoints as commit hashes.
|
|
21
|
+
Pin the original baseline commit, original target endpoint commit, initial job snapshot commit (first-round workspace.snapshotCommit), and original target/file scope in the first handoff. For captured uncommitted changes the original target endpoint is the initial job snapshot; for latest-commit review it is workspace.baseCommit; for an explicit range it is the resolved upper endpoint. On later rounds retain all these exact values from the previous check handoff; never re-resolve HEAD-relative refs or switch target modes. Inspect two separate diffs: original baseline to original target endpoint, and initial job snapshot to current workspace.snapshotCommit for cumulative loop fixes. Do not review baseline-to-current-snapshot as the target: file filtering cannot exclude later commits or unrelated dirty edits in the same target file. Preserve those out-of-target edits and do not fix their defects.
|
|
22
|
+
On RETRY rounds, apply only the concrete, evidenced in-scope P0/P1 fixes synthesized by the previous check. Preserve all user edits and any review-only constraint. No optional polish, scope expansion, architecture decisions, or recursive agents. Use only your own worktree. Run focused validation, repair failures within scope, and report commands with exit codes, evidence, and limitations. If validation remains unsuccessful or no material fix was possible, say so explicitly; do not claim success.
|
|
23
|
+
Choose three distinct, concrete review angles for this change (usually correctness/regressions, tests/contracts, and simplicity; adapt for security, performance, UI/accessibility, or docs when relevant). Assign them to reviewers 1, 2, and 3. Carry forward cumulative fixes, changed files, validation results, unresolved/deferred findings, and current execution.iteration. If a requested target cannot be read with your tools, report that limitation instead of substituting another target. Outside Git, report findings without attempting fixes.`,
|
|
24
|
+
},
|
|
25
|
+
...[1, 2, 3].map(slot => ({
|
|
26
|
+
type: "execute", id: `review-${slot}`, workspace: "read-only", requireSuccess: true,
|
|
27
|
+
prompt: { template: "reviewer", variables: { slot: String(slot) } },
|
|
28
|
+
})),
|
|
29
|
+
{
|
|
30
|
+
type: "decision", id: "check", workspace: "read-only", requireSuccess: true, choices: ["retry", "done"],
|
|
31
|
+
prompt: `Synthesize scope and all three reviews. Verify and deduplicate findings against actual files in YOUR workspace.snapshotCommit, which includes scope's completed fixes; separate valid P0/P1 fixes worth doing now, P2 report-only notes, and feedback to ignore/defer with reasons. Do not blindly apply suggestions or add optional polish.
|
|
32
|
+
You are read-only and cannot edit files or run tests. If the target is unavailable, review-only was requested, nothing actionable remains, fixes are unavailable outside Git, or a fix requires an unapproved product/scope/architecture decision, call decide with done. Report the exact stop reason and unresolved findings.
|
|
33
|
+
If the previous fix pass failed validation or made no material progress, stop with done and prohibit integration of unvalidated or unreviewed fixes; do not repeatedly retry the same findings.
|
|
34
|
+
There are at most ${maxRounds} review rounds, including the first. If execution.iteration >= ${maxRounds}, call decide with done; distinguish round-limit exhaustion from a clean review. Never choose retry in the final round.
|
|
35
|
+
Otherwise choose retry only when concrete in-scope P0/P1 fixes are worth doing now. Give scope a narrow, deduplicated fix list with evidence so the next writer pass addresses only those findings before fresh reviewers inspect its checkpoint.
|
|
36
|
+
Your final handoff must retain the pinned original baseline, original target endpoint, initial job snapshot, and target/file scope unchanged, cumulative fixes and changed files from scope, commands and exit codes, validation evidence/limitations, unresolved and deferred findings, whether the cumulative fixes passed review and validation and may be integrated, rounds run, and stop reason. Prohibit integration when unresolved findings invalidate those fixes. Call decide exactly once, then give this handoff.`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: "integrate", id: "apply", requireSuccess: true,
|
|
40
|
+
prompt: `Read the final check handoff and inspect the cumulative diff in the merge source. Apply only fixes that passed follow-up review and focused validation; do not implement new fixes here. If review-only, no fixes exist, validation failed, or the handoff prohibits integration, leave the source files unchanged and mark the source discarded or archived as appropriate.
|
|
41
|
+
Use mergeSources.changes.baseCommit (the initial job snapshot, including caller edits) to checkpointRef as the patch baseline. Do not diff from original HEAD or replay pre-existing caller changes. Preserve staged, unstaged, untracked, and concurrent user edits. If new checkout edits conflict, archive the checkpoint and report the conflict; do not create an unreviewed resolution or overwrite user work. Call finish_merge exactly once with the source executionId and an accurate disposition after applying or archiving the changes.
|
|
42
|
+
Inspect the final diff and run or confirm focused validation without changing code. Summarize rounds run, fixes actually applied, validation, remaining/deferred findings, integration outcome, and why the loop stopped. Report round-limit or needs-decision exits honestly; never claim they are clean.`,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
edges: [
|
|
46
|
+
...[1, 2, 3].flatMap(slot => [
|
|
47
|
+
{ from: "scope", to: `review-${slot}` },
|
|
48
|
+
{ from: `review-${slot}`, to: "check" },
|
|
49
|
+
]),
|
|
50
|
+
{ from: "scope", to: "check" },
|
|
51
|
+
{ from: "check", to: "scope", choice: "retry", feedback: "review-loop" },
|
|
52
|
+
{ from: "check", to: "apply", choice: "done" },
|
|
53
|
+
],
|
|
54
|
+
loops: [{ id: "review-loop", entry: "scope", maxIterations: maxRounds }],
|
|
55
|
+
};
|
|
56
|
+
const job = jobs.start(input, { maxConcurrency: 3, maxExecutions: maxRounds * 5 + 1 }, ctx);
|
|
57
|
+
pi.sendMessage({
|
|
58
|
+
customType: "braid-review-started", display: true,
|
|
59
|
+
content: `Review started as Braid ${job.handle}: ${target}\nUp to ${maxRounds} parallel review rounds and ${maxRounds - 1} fix passes. Only reviewed fixes are integrated. Open /braid ${job.handle} for progress, or braid_cancel to stop. Wait for the completion reminder, then retrieve braid_status({"jobId":"${job.handle}"}) and summarize; do not start a replacement review loop.`,
|
|
60
|
+
details: { jobId: job.jobId, handle: job.handle },
|
|
61
|
+
}, { triggerTurn: false });
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrok/pi-braid",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Pi extension for Braid: background agent graphs, bounded loops, live updates, and a flow panel",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"grok-mermaid": "^0.2.2",
|
|
9
|
-
"@chrok/braid": "0.3.
|
|
9
|
+
"@chrok/braid": "0.3.2"
|
|
10
10
|
},
|
|
11
11
|
"peerDependencies": {
|
|
12
12
|
"@earendil-works/pi-ai": "*",
|