@chrok/pi-braid 0.3.1 → 0.3.3
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 +97 -1
- package/dist/command.js +346 -39
- package/dist/display.js +165 -18
- package/dist/index.js +23 -4
- package/dist/jobs.js +58 -3
- package/dist/review.js +64 -0
- package/dist/runner.js +90 -21
- package/dist/session-view.js +112 -0
- package/dist/transcript.js +100 -0
- package/package.json +2 -2
package/dist/display.js
CHANGED
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
import { render as renderMermaid } from "grok-mermaid";
|
|
2
|
-
import { Text, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Text, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatActivityStatus, } from "@chrok/braid";
|
|
3
4
|
export const MAX_VISIBLE_EVENTS = 80;
|
|
4
5
|
export const MAX_VISIBLE_NODES = 80;
|
|
5
6
|
class FixedLines {
|
|
6
7
|
lines;
|
|
7
8
|
chartStart;
|
|
8
9
|
chartLength;
|
|
9
|
-
|
|
10
|
+
fallback;
|
|
11
|
+
focusMarks;
|
|
12
|
+
/**
|
|
13
|
+
* A too-wide chart is cropped to the terminal width around the first
|
|
14
|
+
* `focusMarks` entry drawn in it (the selected node, then a running node),
|
|
15
|
+
* or from its left edge. `fallback` (a node list while selecting) appears when
|
|
16
|
+
* there is no chart, or when the selected node cannot be found in it.
|
|
17
|
+
*/
|
|
18
|
+
constructor(lines, chartStart = lines.length, chartLength = 0, fallback = [], focusMarks = []) {
|
|
10
19
|
this.lines = lines;
|
|
11
20
|
this.chartStart = chartStart;
|
|
12
21
|
this.chartLength = chartLength;
|
|
22
|
+
this.fallback = fallback;
|
|
23
|
+
this.focusMarks = focusMarks;
|
|
13
24
|
}
|
|
14
25
|
render(width) {
|
|
15
26
|
const chart = this.lines.slice(this.chartStart, this.chartStart + this.chartLength);
|
|
16
|
-
|
|
27
|
+
const fallback = this.fallback.map((line) => truncateToWidth(line, width, ""));
|
|
28
|
+
if (chart.length === 0 && fallback.length === 0)
|
|
17
29
|
return this.lines.map((line) => truncateToWidth(line, width, ""));
|
|
18
|
-
const chartWidth = Math.max(...chart.map((line) => visibleWidth(line)));
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
const chartWidth = chart.length ? Math.max(...chart.map((line) => visibleWidth(line))) : 0;
|
|
31
|
+
let chartOutput = chart;
|
|
32
|
+
if (!chart.length)
|
|
33
|
+
chartOutput = fallback;
|
|
34
|
+
else if (chartWidth > width) {
|
|
35
|
+
const focus = this.focus(chart);
|
|
36
|
+
const start = focus === undefined ? 0 : Math.max(0, Math.min(chartWidth - width, focus.column - Math.floor(width / 2)));
|
|
37
|
+
chartOutput = [
|
|
38
|
+
truncateToWidth(`[chart ${start + 1}–${start + width} of ${chartWidth} cols${focus ? ` · ${focus.label}` : ""}]`, width, ""),
|
|
39
|
+
...chart.map((line) => sliceByColumn(line, start, width, true)),
|
|
40
|
+
// The first mark is the selection; list nodes if it is not drawn.
|
|
41
|
+
...(this.focusMarks.length && focus?.mark !== this.focusMarks[0].mark ? fallback : []),
|
|
23
42
|
];
|
|
43
|
+
}
|
|
24
44
|
const output = [];
|
|
25
45
|
let chartInserted = false;
|
|
26
46
|
for (let index = 0; index < this.lines.length; index++) {
|
|
@@ -37,6 +57,22 @@ class FixedLines {
|
|
|
37
57
|
output.push(...chartOutput);
|
|
38
58
|
return output;
|
|
39
59
|
}
|
|
60
|
+
/** The first focus mark drawn in the chart and its centre column. */
|
|
61
|
+
focus(chart) {
|
|
62
|
+
for (const { mark, label } of this.focusMarks) {
|
|
63
|
+
for (const line of chart) {
|
|
64
|
+
const plain = stripTerminalSequences(line);
|
|
65
|
+
const index = plain.indexOf(mark);
|
|
66
|
+
if (index < 0)
|
|
67
|
+
continue;
|
|
68
|
+
// Centre on the label: from the mark to the node's right border, when drawn.
|
|
69
|
+
const border = plain.indexOf("│", index);
|
|
70
|
+
const end = border < 0 ? index : border;
|
|
71
|
+
return { mark, label, column: Math.floor((visibleWidth(plain.slice(0, index)) + visibleWidth(plain.slice(0, end))) / 2) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
40
76
|
invalidate() { }
|
|
41
77
|
}
|
|
42
78
|
function record(value) {
|
|
@@ -121,7 +157,9 @@ function nodeElapsed(node, now) {
|
|
|
121
157
|
}
|
|
122
158
|
return "—";
|
|
123
159
|
}
|
|
124
|
-
|
|
160
|
+
/** Replaces the selected node's one-column status icon, so selection never moves the layout. */
|
|
161
|
+
export const SELECTED_NODE_MARK = "◆";
|
|
162
|
+
function mermaidLabelLines(id, result, selected = false) {
|
|
125
163
|
const progress = "progress" in result ? result.progress?.[id] : undefined;
|
|
126
164
|
const node = result.nodes[id];
|
|
127
165
|
if (!node)
|
|
@@ -140,7 +178,7 @@ function mermaidLabelLines(id, result) {
|
|
|
140
178
|
const now = "observedAt" in result ? result.observedAt : Date.now();
|
|
141
179
|
const iteration = "executions" in result ? result.executions[node.executionId ?? ""]?.iteration : result.iterations?.[id];
|
|
142
180
|
const lines = [
|
|
143
|
-
`${icon} ${compact(id, 24)}${iteration ? ` #${iteration}` : ""}`,
|
|
181
|
+
`${selected ? SELECTED_NODE_MARK + icon.slice(1) : icon} ${compact(id, 24)}${iteration ? ` #${iteration}` : ""}`,
|
|
144
182
|
`${status}${decision} · ${nodeElapsed(node, now)}`,
|
|
145
183
|
];
|
|
146
184
|
if (progress) {
|
|
@@ -152,7 +190,7 @@ function mermaidLabelLines(id, result) {
|
|
|
152
190
|
}
|
|
153
191
|
return lines;
|
|
154
192
|
}
|
|
155
|
-
function mermaidSource(result) {
|
|
193
|
+
function mermaidSource(result, selected) {
|
|
156
194
|
const parts = graphParts(result);
|
|
157
195
|
const ids = Object.keys(parts.nodes);
|
|
158
196
|
const names = new Map(ids.map((id, index) => [id, `n${index}`]));
|
|
@@ -173,15 +211,22 @@ function mermaidSource(result) {
|
|
|
173
211
|
for (const id of ids) {
|
|
174
212
|
const shape = parts.nodeTypes[id] === "decision" ? "{" : "[";
|
|
175
213
|
const close = parts.nodeTypes[id] === "decision" ? "}" : "]";
|
|
176
|
-
lines.push(` ${names.get(id)}${shape}"${mermaidLabelLines(id, result).map(escapeMermaidText).join("<br/>")}"${close}`);
|
|
214
|
+
lines.push(` ${names.get(id)}${shape}"${mermaidLabelLines(id, result, id === selected).map(escapeMermaidText).join("<br/>")}"${close}`);
|
|
177
215
|
}
|
|
178
216
|
for (const edge of parts.edges) {
|
|
179
217
|
lines.push(` ${names.get(edge.from)} -->${edge.choice ? `|${escapeMermaidText(edge.choice)}|` : ""} ${names.get(edge.to)}`);
|
|
180
218
|
}
|
|
181
219
|
return lines.join("\n");
|
|
182
220
|
}
|
|
183
|
-
function mermaidLines(result, theme) {
|
|
184
|
-
|
|
221
|
+
function mermaidLines(result, theme, selected) {
|
|
222
|
+
let art;
|
|
223
|
+
try {
|
|
224
|
+
art = renderMermaid(mermaidSource(result, selected));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// The chart is a view of state already listed elsewhere; never fail the panel for it.
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
185
230
|
if (!art)
|
|
186
231
|
return [];
|
|
187
232
|
return art.styled.map((row) => row
|
|
@@ -190,6 +235,8 @@ function mermaidLines(result, theme) {
|
|
|
190
235
|
case "border":
|
|
191
236
|
return theme.fg("borderMuted", span.text);
|
|
192
237
|
case "text":
|
|
238
|
+
if (selected !== undefined && span.text.includes(SELECTED_NODE_MARK))
|
|
239
|
+
return theme.bg("selectedBg", theme.bold(theme.fg("warning", span.text)));
|
|
193
240
|
return span.text.includes("▶ ACTIVE")
|
|
194
241
|
? theme.bg("selectedBg", theme.bold(theme.fg("accent", span.text)))
|
|
195
242
|
: theme.fg("text", span.text);
|
|
@@ -428,23 +475,111 @@ export function applyProgress(state, progress) {
|
|
|
428
475
|
writable: true,
|
|
429
476
|
});
|
|
430
477
|
}
|
|
478
|
+
export const COLLAPSED_ACTIVITY_ENTRIES = 6;
|
|
479
|
+
/** Live phase, waits, and chronological history; absence of activity is shown, not judged. */
|
|
480
|
+
export function renderActivity(activity, expanded, theme, now, part = "all") {
|
|
481
|
+
const ago = (at) => elapsed(Math.max(0, now - at));
|
|
482
|
+
if (part === "history")
|
|
483
|
+
return renderActivity(activity, expanded, theme, now).slice(renderActivity(activity, expanded, theme, now, "summary").length);
|
|
484
|
+
const lines = [theme.fg(activity.finishedAt === undefined ? "accent" : "muted", `activity: ${compact(formatActivityStatus(activity, now), 200)}`)];
|
|
485
|
+
const model = activity.model;
|
|
486
|
+
if (model && model.completedAt === undefined) {
|
|
487
|
+
lines.push(theme.fg("muted", model.firstStreamAt === undefined
|
|
488
|
+
? ` model request #${model.round} sent ${ago(model.startedAt)} ago · no stream events received yet`
|
|
489
|
+
: ` model request #${model.round} sent ${ago(model.startedAt)} ago · ${model.streamEvents} stream events, ${compactCount(model.receivedChars)} chars · last ${ago(model.lastStreamAt)} ago`));
|
|
490
|
+
if (model.tail)
|
|
491
|
+
lines.push(theme.fg("dim", ` receiving ${model.receiving === "tool_call" ? `${compact(model.toolName ?? "tool call", 40)} arguments` : model.receiving ?? "output"}: …${compact(model.tail, 160)}`));
|
|
492
|
+
}
|
|
493
|
+
for (const tool of activity.tools) {
|
|
494
|
+
lines.push(theme.fg("warning", ` tool ${compact(tool.name, 40)} running ${ago(tool.startedAt)} · ${tool.lastOutputAt === undefined ? "no output yet" : `last output ${ago(tool.lastOutputAt)} ago`}`));
|
|
495
|
+
lines.push(theme.fg("dim", ` args: ${compact(tool.arguments, 200)}`));
|
|
496
|
+
if (tool.outputTail)
|
|
497
|
+
lines.push(theme.fg("dim", ` output: …${compact(tool.outputTail, 200)}`));
|
|
498
|
+
}
|
|
499
|
+
for (const limitation of activity.limitations)
|
|
500
|
+
lines.push(theme.fg("muted", ` unavailable: ${compact(limitation, 200)}`));
|
|
501
|
+
if (part === "summary")
|
|
502
|
+
return lines;
|
|
503
|
+
const visible = expanded ? activity.entries : activity.entries.slice(-COLLAPSED_ACTIVITY_ENTRIES);
|
|
504
|
+
const hidden = activity.droppedEntries + activity.entries.length - visible.length;
|
|
505
|
+
lines.push(theme.fg("dim", `history · ${activity.sequence} events${hidden ? ` · ${hidden} earlier ${expanded ? "trimmed" : "hidden (expand for more)"}` : ""}:`));
|
|
506
|
+
for (const entry of visible) {
|
|
507
|
+
const color = entry.isError ? "error" : entry.kind === "tool_call" ? "accent" : entry.kind === "assistant" ? "text" : "muted";
|
|
508
|
+
lines.push(theme.fg(color, ` ${`+${elapsed(Math.max(0, entry.timestamp - activity.startedAt))}`.padStart(7)} ${compact(entry.summary, 200)}`));
|
|
509
|
+
// Show a detail preview only when the summary line does not already contain it.
|
|
510
|
+
if (expanded && entry.detail && entry.kind !== "workspace" && !compact(entry.summary, 400).includes(compact(entry.detail, 400))) {
|
|
511
|
+
lines.push(theme.fg("dim", ` ${compact(entry.detail, 300)}${entry.detailLength ? ` (${compactCount(entry.detailLength)} chars)` : ""}`));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return lines;
|
|
515
|
+
}
|
|
431
516
|
/** Focused status reads must show the requested execution, including historical ones. */
|
|
432
|
-
export function renderNodeResult(node, expanded, theme, fullOutputPath) {
|
|
517
|
+
export function renderNodeResult(node, expanded, theme, fullOutputPath, progress, now = Date.now(), activity) {
|
|
433
518
|
const failed = node.status === "failed";
|
|
434
519
|
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
520
|
+
const timing = [
|
|
521
|
+
...(node.model ? [`model ${compact(node.model, 60)}`] : []),
|
|
522
|
+
...(node.iteration ? [`iteration #${node.iteration}`] : []),
|
|
523
|
+
...(node.startedAt !== undefined || node.latencyMs !== undefined ? [`elapsed ${nodeElapsed(node, now)}`] : []),
|
|
524
|
+
...(node.usage ? [`${compactCount(node.usage.inputTokens)} in / ${compactCount(node.usage.outputTokens)} out tokens`] : []),
|
|
525
|
+
];
|
|
526
|
+
// Progress is tracked per node ID; only show it for the execution it describes.
|
|
527
|
+
const live = progress && (!progress.executionId || progress.executionId === node.executionId) ? progress : undefined;
|
|
528
|
+
const workspace = node.workspace;
|
|
529
|
+
// Cleaned-up worktrees are gone; their checkpoint ref is what can be recovered.
|
|
530
|
+
const workspaceActive = workspace && ["preparing", "ready", "failed"].includes(workspace.state);
|
|
531
|
+
const workspaceRef = workspaceActive ? workspace.worktreeRoot : workspace?.checkpointRef ?? workspace?.reason;
|
|
435
532
|
const lines = [
|
|
436
533
|
theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
|
|
437
534
|
...(node.executionId ? [theme.fg("dim", `execution: ${compact(node.executionId, 80)}`)] : []),
|
|
535
|
+
...(timing.length ? [theme.fg("muted", timing.join(" · "))] : []),
|
|
536
|
+
...(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`)] : []),
|
|
537
|
+
...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
|
|
438
538
|
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
439
539
|
...(node.error ? [theme.fg("error", `${node.error.code}: ${clean(node.error.message)}`)] : []),
|
|
440
540
|
...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
|
|
541
|
+
// Activity describes one execution; never mix histories from repeated visits.
|
|
542
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, expanded, theme, now) : []),
|
|
441
543
|
...(node.output ? [expanded ? clean(node.output) : compact(node.output, 400)] : []),
|
|
442
544
|
...(!expanded && node.output && (node.output.length > 400 || node.output.includes("\n")) ? [theme.fg("dim", "Expand for full node output")] : []),
|
|
443
545
|
...(fullOutputPath ? [theme.fg("dim", `full node result: ${compact(fullOutputPath, 240)}`)] : []),
|
|
444
546
|
];
|
|
445
547
|
return new Text(lines.join("\n"), 0, 0);
|
|
446
548
|
}
|
|
447
|
-
|
|
549
|
+
/** Status, timing, and live waits above a node's session transcript. */
|
|
550
|
+
export function renderSessionHeader(node, theme, now, progress, activity) {
|
|
551
|
+
const failed = node.status === "failed";
|
|
552
|
+
const live = progress && (!progress.executionId || progress.executionId === node.executionId) ? progress : undefined;
|
|
553
|
+
const facts = [
|
|
554
|
+
...(node.executionId ? [compact(node.executionId, 80)] : []),
|
|
555
|
+
...(node.iteration ? [`iteration #${node.iteration}`] : []),
|
|
556
|
+
...(node.model ? [compact(node.model, 60)] : []),
|
|
557
|
+
...(node.startedAt !== undefined || node.latencyMs !== undefined ? [`elapsed ${nodeElapsed(node, now)}`] : []),
|
|
558
|
+
...(node.usage ? [`${compactCount(node.usage.inputTokens)} in / ${compactCount(node.usage.outputTokens)} out tokens`] : []),
|
|
559
|
+
];
|
|
560
|
+
return [
|
|
561
|
+
theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : node.status === "running" ? "▶" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
|
|
562
|
+
...(facts.length ? [theme.fg("muted", facts.join(" · "))] : []),
|
|
563
|
+
...(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`)] : []),
|
|
564
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, false, theme, now, "summary") : []),
|
|
565
|
+
...(node.error ? [theme.fg("error", `${node.error.code}: ${compact(node.error.message, 300)}`)] : []),
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
/** What a node view shows below its pinned header when no live session is retained. */
|
|
569
|
+
export function renderNodeDetails(node, theme, now, activity) {
|
|
570
|
+
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
571
|
+
const workspace = node.workspace;
|
|
572
|
+
const workspaceActive = workspace && ["preparing", "ready", "failed"].includes(workspace.state);
|
|
573
|
+
const workspaceRef = workspaceActive ? workspace.worktreeRoot : workspace?.checkpointRef ?? workspace?.reason;
|
|
574
|
+
return [
|
|
575
|
+
...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
|
|
576
|
+
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
577
|
+
...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
|
|
578
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, true, theme, now, "history") : []),
|
|
579
|
+
...(node.output ? ["", ...clean(node.output).split("\n")] : []),
|
|
580
|
+
];
|
|
581
|
+
}
|
|
582
|
+
export function renderGraphResult(result, expanded, isPartial, theme, fallback = "", isError = false, selectedNodeId) {
|
|
448
583
|
if (isError || !result?.nodes) {
|
|
449
584
|
const message = fallback ||
|
|
450
585
|
(isPartial ? "Braid is running…" : "Braid returned no execution details");
|
|
@@ -480,8 +615,17 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
|
|
|
480
615
|
if ("executions" in result)
|
|
481
616
|
lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision}`));
|
|
482
617
|
const chartStart = lines.length;
|
|
483
|
-
const chart = mermaidLines(result, theme);
|
|
618
|
+
const chart = mermaidLines(result, theme, selectedNodeId);
|
|
484
619
|
lines.push(...chart);
|
|
620
|
+
// Selection must stay usable when the chart cannot be drawn or does not fit.
|
|
621
|
+
const nodeList = selectedNodeId === undefined ? [] : [
|
|
622
|
+
theme.fg("dim", chart.length ? "nodes (selected node not found in the flowchart):" : "nodes (flowchart unavailable):"),
|
|
623
|
+
...Object.keys(graphParts(result).nodes).map(id => {
|
|
624
|
+
const node = result.nodes[id];
|
|
625
|
+
const text = `${id === selectedNodeId ? `${SELECTED_NODE_MARK} ` : " "}${compact(id, 60)} · ${node?.status ?? "pending"}`;
|
|
626
|
+
return id === selectedNodeId ? theme.bg("selectedBg", theme.bold(theme.fg("warning", text))) : theme.fg("muted", text);
|
|
627
|
+
}),
|
|
628
|
+
];
|
|
485
629
|
const workspaces = Object.values(result.workspaces ?? {}).filter(workspace => workspace.worktreeRoot);
|
|
486
630
|
if (workspaces.length) {
|
|
487
631
|
const active = workspaces.filter(workspace => ["preparing", "ready", "failed"].includes(workspace.state)).length;
|
|
@@ -497,5 +641,8 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
|
|
|
497
641
|
lines.push(...renderEventLog(result.events ?? [], true, theme));
|
|
498
642
|
if ("fullOutputPath" in result && result.fullOutputPath)
|
|
499
643
|
lines.push(theme.fg("dim", `full result/log: ${compact(result.fullOutputPath, 240)}`));
|
|
500
|
-
return new FixedLines(lines, chartStart, chart.length
|
|
644
|
+
return new FixedLines(lines, chartStart, chart.length, nodeList, [
|
|
645
|
+
...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: "selected node" }]),
|
|
646
|
+
{ mark: "▶ ACTIVE", label: "running node" },
|
|
647
|
+
]);
|
|
501
648
|
}
|
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([
|
|
@@ -107,6 +108,8 @@ function isJobSnapshot(value) {
|
|
|
107
108
|
["running", "completed", "failed", "cancelled"].includes(job.status ?? "") &&
|
|
108
109
|
!!job.live && typeof job.live.nodes === "object" && job.live.nodes !== null;
|
|
109
110
|
}
|
|
111
|
+
/** Focused reads return the latest activity entries; activityBefore pages earlier ones. */
|
|
112
|
+
export const STATUS_ACTIVITY_ENTRIES = 40;
|
|
110
113
|
function executionControl(job) {
|
|
111
114
|
return job.execution && {
|
|
112
115
|
status: job.execution.status,
|
|
@@ -201,11 +204,12 @@ export function createBraidTools(jobs) {
|
|
|
201
204
|
jobId: Type.Optional(text("Exact session handle (e.g. job-1) or UUID; omit all IDs to list jobs.")),
|
|
202
205
|
executionId: Type.Optional(text("Exact execution ID from braid_status or a reminder; requires jobId. Takes precedence over nodeId; if both are provided they must match.")),
|
|
203
206
|
nodeId: Type.Optional(text("Exact node ID; requires jobId. Retrieve this node's latest full output/error even while the job is running.")),
|
|
207
|
+
activityBefore: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: `Focused reads include activity: the current phase, model/tool waits, and the latest ${STATUS_ACTIVITY_ENTRIES} history entries. Pass an entry sequence to page earlier entries.` })),
|
|
204
208
|
}, { additionalProperties: false });
|
|
205
209
|
const statusTool = defineTool({
|
|
206
210
|
name: "braid_status",
|
|
207
211
|
label: "Braid status",
|
|
208
|
-
description: "Retrieve a background Braid job's status, node progress, and final results by exact session handle (e.g. job-1) or UUID in jobId. Add executionId for an exact execution, or nodeId for the latest instance, including intermediate results while the job runs. All job reads include current execution.revision and execution.pausedExecutionIds for live control; node.revision is the historical invocation revision. Large results include a path to the full JSON, including while running. For usage totals use usage (Pi provider accounting, including rounds from failed nodes), or piUsage in the saved final result. Prefer the short handle from submission/reminders. Omit all IDs to list jobs in this session. Completion reminders arrive automatically; avoid repeated polling.",
|
|
212
|
+
description: "Retrieve a background Braid job's status, node progress, and final results by exact session handle (e.g. job-1) or UUID in jobId. Add executionId for an exact execution, or nodeId for the latest instance, including intermediate results while the job runs. Focused reads include live activity: phase, time in phase and since the last observed signal, model request/stream state, running tools, and recent history. Lack of observed activity is a diagnostic signal, not proof of a stall. All job reads include current execution.revision and execution.pausedExecutionIds for live control; node.revision is the historical invocation revision. Large results include a path to the full JSON, including while running. For usage totals use usage (Pi provider accounting, including rounds from failed nodes), or piUsage in the saved final result. Prefer the short handle from submission/reminders. Omit all IDs to list jobs in this session. Completion reminders arrive automatically; avoid repeated polling.",
|
|
209
213
|
parameters: statusParameters,
|
|
210
214
|
renderResult(result, options, theme, ctx) {
|
|
211
215
|
const job = result.details;
|
|
@@ -224,8 +228,9 @@ export function createBraidTools(jobs) {
|
|
|
224
228
|
const selectedNode = job.selectedNode ?? (executionId
|
|
225
229
|
? state && Object.hasOwn(state.executions, executionId) ? state.executions[executionId] : undefined
|
|
226
230
|
: nodeId && state && Object.hasOwn(state.nodes, nodeId) ? state.nodes[nodeId] : undefined);
|
|
231
|
+
// Render at the read's observation time so a saved result never keeps counting.
|
|
227
232
|
if (selectedNode)
|
|
228
|
-
return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath);
|
|
233
|
+
return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, job.live.progress?.[selectedNode.id], job.live.observedAt, job.selectedActivity);
|
|
229
234
|
if (executionId || nodeId)
|
|
230
235
|
return new Text(fallback || "Braid returned no node details", 0, 0);
|
|
231
236
|
if (job.error)
|
|
@@ -240,6 +245,8 @@ export function createBraidTools(jobs) {
|
|
|
240
245
|
async execute(_id, params) {
|
|
241
246
|
if ((params.nodeId !== undefined || params.executionId !== undefined) && !params.jobId)
|
|
242
247
|
throw new Error("nodeId/executionId requires jobId");
|
|
248
|
+
if (params.activityBefore !== undefined && params.nodeId === undefined && params.executionId === undefined)
|
|
249
|
+
throw new Error("activityBefore requires nodeId or executionId");
|
|
243
250
|
if (!params.jobId)
|
|
244
251
|
return {
|
|
245
252
|
content: [{ type: "text", text: JSON.stringify(jobs.list()) }],
|
|
@@ -251,9 +258,18 @@ export function createBraidTools(jobs) {
|
|
|
251
258
|
if (params.nodeId !== undefined || params.executionId !== undefined) {
|
|
252
259
|
const node = jobs.getNode(params.jobId, params.nodeId, params.executionId);
|
|
253
260
|
const { id, executionId, status, error, output, ...nodeDetails } = node;
|
|
261
|
+
const activity = executionId ? jobs.getActivity(params.jobId, executionId, {
|
|
262
|
+
limit: STATUS_ACTIVITY_ENTRIES, ...(params.activityBefore === undefined ? {} : { before: params.activityBefore }),
|
|
263
|
+
}) : undefined;
|
|
264
|
+
// While running, activity is the useful part; afterwards the output is,
|
|
265
|
+
// so it comes first and history cannot push it out of the preview.
|
|
266
|
+
const running = activity && activity.finishedAt === undefined;
|
|
267
|
+
const live = activity ? { observedAt: job.live.observedAt, activity } : {};
|
|
254
268
|
const full = JSON.stringify({
|
|
255
269
|
jobId: job.jobId, handle: job.handle, status: job.status, execution: executionControl(job),
|
|
270
|
+
...(running ? live : {}),
|
|
256
271
|
node: { id, executionId, status, error, output, ...nodeDetails },
|
|
272
|
+
...(running ? {} : live),
|
|
257
273
|
}, null, 2);
|
|
258
274
|
const preview = truncateHead(full);
|
|
259
275
|
let suffix = "";
|
|
@@ -267,7 +283,7 @@ export function createBraidTools(jobs) {
|
|
|
267
283
|
}
|
|
268
284
|
// Focused reads do not claim the whole job's usage; final job retrieval does.
|
|
269
285
|
return { content: [{ type: "text", text: preview.content + suffix }], details: {
|
|
270
|
-
...job, selectedNode: node, ...(nodeOutputPath ? { nodeOutputPath } : {}),
|
|
286
|
+
...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(nodeOutputPath ? { nodeOutputPath } : {}),
|
|
271
287
|
} };
|
|
272
288
|
}
|
|
273
289
|
const { jobId, handle, status, error, usage: providerUsage, fullOutputPath, execution, ...snapshot } = job;
|
|
@@ -422,6 +438,8 @@ export default function braidExtension(pi) {
|
|
|
422
438
|
});
|
|
423
439
|
const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs);
|
|
424
440
|
registerBraidCommand(pi, jobs);
|
|
441
|
+
registerReviewCommand(pi, jobs);
|
|
442
|
+
const disposeWidget = registerBraidWidget(pi, jobs);
|
|
425
443
|
pi.registerTool(braidTool);
|
|
426
444
|
pi.registerTool(statusTool);
|
|
427
445
|
pi.registerTool(cancelTool);
|
|
@@ -430,6 +448,7 @@ export default function braidExtension(pi) {
|
|
|
430
448
|
pi.on("session_shutdown", () => {
|
|
431
449
|
pending.clear();
|
|
432
450
|
jobs.dispose();
|
|
451
|
+
disposeWidget();
|
|
433
452
|
});
|
|
434
453
|
pi.on("before_agent_start", (event) => ({
|
|
435
454
|
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
|
@@ -3,9 +3,10 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
5
5
|
import { truncateHead, } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { startBraid, validateGraph, } from "@chrok/braid";
|
|
6
|
+
import { ExecutionActivityTracker, startBraid, validateGraph, } from "@chrok/braid";
|
|
7
7
|
import { applyEvent, applyProgress, createLiveState, } from "./display.js";
|
|
8
8
|
import { createPiRunner, sumPiUsage } from "./runner.js";
|
|
9
|
+
import { PiTranscripts } from "./transcript.js";
|
|
9
10
|
/** Jobs belong to one extension/session lifetime, independently of foreground turns. */
|
|
10
11
|
export class BraidJobs {
|
|
11
12
|
onFinished;
|
|
@@ -56,6 +57,9 @@ export class BraidJobs {
|
|
|
56
57
|
controller: new AbortController(),
|
|
57
58
|
done: Promise.resolve(),
|
|
58
59
|
usageClaimed: false,
|
|
60
|
+
runningExecutions: new Map(),
|
|
61
|
+
activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
|
|
62
|
+
transcripts: new PiTranscripts(() => this.changed()),
|
|
59
63
|
nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
|
|
60
64
|
};
|
|
61
65
|
this.jobs.set(job.jobId, job);
|
|
@@ -78,6 +82,8 @@ export class BraidJobs {
|
|
|
78
82
|
runner: (() => {
|
|
79
83
|
const invoke = createPiRunner(registry, {
|
|
80
84
|
cwd,
|
|
85
|
+
activity: job.activity,
|
|
86
|
+
transcripts: job.transcripts,
|
|
81
87
|
maxToolRounds: options.maxToolRounds ?? Infinity,
|
|
82
88
|
maxToolCalls: options.maxToolCalls ?? Infinity,
|
|
83
89
|
onUsage: (usage) => {
|
|
@@ -91,15 +97,35 @@ export class BraidJobs {
|
|
|
91
97
|
this.changed();
|
|
92
98
|
},
|
|
93
99
|
});
|
|
94
|
-
return async (request) => {
|
|
100
|
+
return async (request) => {
|
|
101
|
+
try {
|
|
102
|
+
await nextTurn();
|
|
103
|
+
return await invoke(request);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
job.activity.workerFinished(request.execution.executionId);
|
|
107
|
+
job.transcripts.finish(request.execution.executionId, request.signal.aborted ? "Execution was stopped before this step finished" : undefined);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
95
110
|
})(),
|
|
96
111
|
onEvent: (event) => {
|
|
112
|
+
job.activity.observe(event);
|
|
97
113
|
if (event.type === "workspace_updated") {
|
|
98
114
|
job.workspaces ??= {};
|
|
99
115
|
Object.defineProperty(job.workspaces, event.workspace.executionId ?? event.workspace.nodeId, {
|
|
100
116
|
value: { ...event.workspace }, enumerable: true, configurable: true, writable: true,
|
|
101
117
|
});
|
|
102
118
|
}
|
|
119
|
+
// Definition edits can remove nodes whose executions are still running.
|
|
120
|
+
if (event.type === "node_started" && event.executionId) {
|
|
121
|
+
job.runningExecutions.set(event.executionId, event.nodeId);
|
|
122
|
+
}
|
|
123
|
+
else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) {
|
|
124
|
+
job.runningExecutions.delete(event.executionId);
|
|
125
|
+
}
|
|
126
|
+
else if (event.type === "graph_completed" || event.type === "graph_failed") {
|
|
127
|
+
job.runningExecutions.clear();
|
|
128
|
+
}
|
|
103
129
|
applyEvent(job.live, event);
|
|
104
130
|
if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) {
|
|
105
131
|
// The panel keeps short previews; node retrieval needs the complete
|
|
@@ -161,6 +187,7 @@ export class BraidJobs {
|
|
|
161
187
|
job.status = "failed";
|
|
162
188
|
job.live.status = "failed";
|
|
163
189
|
job.live.pausedExecutionIds = [];
|
|
190
|
+
job.runningExecutions.clear();
|
|
164
191
|
job.error = error instanceof Error ? error.message : String(error);
|
|
165
192
|
if (reports.length)
|
|
166
193
|
job.usage = sumPiUsage(reports);
|
|
@@ -186,7 +213,7 @@ export class BraidJobs {
|
|
|
186
213
|
const job = this.lookup(jobId);
|
|
187
214
|
if (!job)
|
|
188
215
|
return undefined;
|
|
189
|
-
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, ...snapshot } = job;
|
|
216
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, ...snapshot } = job;
|
|
190
217
|
const copy = structuredClone(snapshot);
|
|
191
218
|
if (job.run)
|
|
192
219
|
copy.execution = job.run.snapshot();
|
|
@@ -208,6 +235,20 @@ export class BraidJobs {
|
|
|
208
235
|
throw new Error(`Unknown Braid node or execution in ${job.handle}. Use braid_status with only jobId to list executions.`);
|
|
209
236
|
return structuredClone(node);
|
|
210
237
|
}
|
|
238
|
+
/** Live activity for one exact execution; undefined until it is first observed. */
|
|
239
|
+
getActivity(jobId, executionId, page) {
|
|
240
|
+
const job = this.lookup(jobId);
|
|
241
|
+
if (!job)
|
|
242
|
+
throw this.unknownJob(jobId);
|
|
243
|
+
return job.activity.get(executionId, page);
|
|
244
|
+
}
|
|
245
|
+
/** The live conversation for one execution; read-only, and absent once evicted. */
|
|
246
|
+
getTranscript(jobId, executionId) {
|
|
247
|
+
const job = this.lookup(jobId);
|
|
248
|
+
if (!job)
|
|
249
|
+
throw this.unknownJob(jobId);
|
|
250
|
+
return job.transcripts.get(executionId);
|
|
251
|
+
}
|
|
211
252
|
update(jobId, patch) {
|
|
212
253
|
const job = this.lookup(jobId);
|
|
213
254
|
if (!job)
|
|
@@ -239,6 +280,20 @@ export class BraidJobs {
|
|
|
239
280
|
}))
|
|
240
281
|
.reverse();
|
|
241
282
|
}
|
|
283
|
+
/** Editor progress without copying outputs, transcripts, or execution snapshots. */
|
|
284
|
+
progress(jobId) {
|
|
285
|
+
const job = this.lookup(jobId);
|
|
286
|
+
if (!job)
|
|
287
|
+
return undefined;
|
|
288
|
+
const nodes = Object.values(job.live.nodes);
|
|
289
|
+
return {
|
|
290
|
+
total: nodes.length,
|
|
291
|
+
done: nodes.filter(node => node.status === "completed" || node.status === "failed" || node.status === "skipped").length,
|
|
292
|
+
failed: nodes.filter(node => node.status === "failed").length,
|
|
293
|
+
running: [...new Set(job.runningExecutions.values())],
|
|
294
|
+
paused: job.live.pausedExecutionIds?.length ?? 0,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
242
297
|
cancel(jobId) {
|
|
243
298
|
const job = this.lookup(jobId);
|
|
244
299
|
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
|
+
}
|