@dieulc/workflow 0.2.0 → 0.3.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/README.md +188 -6
- package/index.ts +1021 -65
- package/package.json +9 -4
- package/permissions.ts +1042 -0
- package/review-runtime.ts +931 -0
- package/review-tools.ts +749 -0
- package/review-ui.ts +421 -0
- package/review.ts +1704 -0
- package/roles.ts +164 -57
- package/utils.ts +474 -92
package/review-ui.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Mode — main-transcript UI.
|
|
3
|
+
*
|
|
4
|
+
* The reviewer runs in an in-process child session, so its output never reaches
|
|
5
|
+
* the main Pi transcript by itself. This module turns that output into
|
|
6
|
+
* display-only custom entries (`pi.appendEntry` + `pi.registerEntryRenderer`),
|
|
7
|
+
* which are durable in the TUI and never enter LLM context — Plan Mode (and
|
|
8
|
+
* later Build Mode) cannot see the reviewer's work.
|
|
9
|
+
*
|
|
10
|
+
* Colours: Plan Mode is `warning`, Review Mode is `accent`. `▌ REVIEW MODE`
|
|
11
|
+
* and `▌ REVIEW DONE` bands mark the audit phase, so sequential review
|
|
12
|
+
* sections stay distinguishable without recolouring Pi's own user/assistant
|
|
13
|
+
* messages (which extensions cannot do). Ordinary mode switches (Tab) emit no
|
|
14
|
+
* band at all — the footer status shows the mode — and consecutive identical
|
|
15
|
+
* bands are suppressed (`createReviewBandGate`) so the transcript cannot
|
|
16
|
+
* ping-pong. Everything here is display-only and best-effort: a malformed
|
|
17
|
+
* entry must render something readable instead of throwing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
21
|
+
import type {
|
|
22
|
+
EntryRenderer,
|
|
23
|
+
ThemeColor,
|
|
24
|
+
} from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import {
|
|
26
|
+
REVIEW_SUBMITTED_ENTRY_TYPE,
|
|
27
|
+
type ReviewLineKind,
|
|
28
|
+
type ReviewTranscriptOp,
|
|
29
|
+
} from "./review.ts";
|
|
30
|
+
|
|
31
|
+
/** Custom entry type for Plan/Review/Review-done bands. */
|
|
32
|
+
export const REVIEW_MODE_ENTRY_TYPE = "workflow-review-mode";
|
|
33
|
+
/** Custom entry type for streamed reviewer transcript chunks. */
|
|
34
|
+
export const REVIEW_TRANSCRIPT_ENTRY_TYPE = "workflow-review-transcript";
|
|
35
|
+
|
|
36
|
+
/** Transcript kinds plus the synthetic line for a user's routed note. */
|
|
37
|
+
export type ReviewEntryLineKind = ReviewLineKind | "user";
|
|
38
|
+
|
|
39
|
+
/** One rendered review line. */
|
|
40
|
+
export interface ReviewEntryLine {
|
|
41
|
+
kind: ReviewEntryLineKind;
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Payload of a `workflow-review-mode` band entry. */
|
|
46
|
+
export interface ReviewModeBandData {
|
|
47
|
+
mode: "plan" | "review" | "review-done";
|
|
48
|
+
at?: number;
|
|
49
|
+
modelLabel?: string;
|
|
50
|
+
round?: number;
|
|
51
|
+
verdict?: string;
|
|
52
|
+
reason?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Payload of a `workflow-review-transcript` chunk entry. */
|
|
56
|
+
export interface ReviewTranscriptEntryData {
|
|
57
|
+
at?: number;
|
|
58
|
+
lines?: ReviewEntryLine[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Flattened shape of a `workflow-review-submitted` entry (see review.ts). */
|
|
62
|
+
export interface ReviewSubmittedEntryData {
|
|
63
|
+
planPath?: string;
|
|
64
|
+
round?: number;
|
|
65
|
+
verdict?: string;
|
|
66
|
+
summary?: string;
|
|
67
|
+
modelLabel?: string;
|
|
68
|
+
findings?: Array<{
|
|
69
|
+
severity?: number;
|
|
70
|
+
confidence?: number;
|
|
71
|
+
category?: string;
|
|
72
|
+
disposition?: string;
|
|
73
|
+
}>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Minimal theme surface used by the pure formatters. Structural, so Pi's real
|
|
78
|
+
* `Theme` satisfies it and tests can pass a two-method double without casting.
|
|
79
|
+
*/
|
|
80
|
+
export interface ThemeLike {
|
|
81
|
+
fg(color: ThemeColor, text: string): string;
|
|
82
|
+
bold(text: string): string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Minimal component shape Pi accepts from an entry renderer. */
|
|
86
|
+
export interface ReviewEntryComponent {
|
|
87
|
+
render(width: number): string[];
|
|
88
|
+
invalidate(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Band identity / dedupe ───────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Stable identity of a mode-band payload. Deliberately excludes `at`: a
|
|
95
|
+
* timestamp changes on every emission, but two identical consecutive bands
|
|
96
|
+
* (re-entering Plan mode via Tab, re-running `/review` on the same round)
|
|
97
|
+
* must collapse into a single transcript entry.
|
|
98
|
+
*/
|
|
99
|
+
export function reviewBandKey(data: ReviewModeBandData | undefined): string {
|
|
100
|
+
return [
|
|
101
|
+
data?.mode ?? "unknown",
|
|
102
|
+
data?.round ?? "",
|
|
103
|
+
data?.modelLabel ?? "",
|
|
104
|
+
data?.verdict ?? "",
|
|
105
|
+
data?.reason ?? "",
|
|
106
|
+
].join("|");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Consecutive-duplicate gate for mode bands (see `reviewBandKey`). */
|
|
110
|
+
export interface ReviewBandGate {
|
|
111
|
+
/** Seed the last-seen key from a restored session entry (no emit). */
|
|
112
|
+
seed(data: ReviewModeBandData | undefined): void;
|
|
113
|
+
/** True when this band differs from the last accepted/seeded one. */
|
|
114
|
+
accept(data: ReviewModeBandData | undefined): boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create a gate that suppresses a band identical to the previous one. The
|
|
119
|
+
* caller seeds it from the session on restore so a `/reload` cannot re-emit
|
|
120
|
+
* the newest band, then calls `accept` before every `appendEntry`.
|
|
121
|
+
*/
|
|
122
|
+
export function createReviewBandGate(): ReviewBandGate {
|
|
123
|
+
let last: string | undefined;
|
|
124
|
+
return {
|
|
125
|
+
seed(data) {
|
|
126
|
+
last = reviewBandKey(data);
|
|
127
|
+
},
|
|
128
|
+
accept(data) {
|
|
129
|
+
const key = reviewBandKey(data);
|
|
130
|
+
if (key === last) return false;
|
|
131
|
+
last = key;
|
|
132
|
+
return true;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Buffering ────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const DEFAULT_MAX_LINES = 2000;
|
|
140
|
+
|
|
141
|
+
export interface ReviewLineBuffer {
|
|
142
|
+
/** Apply one reviewer transcript op. */
|
|
143
|
+
push(op: ReviewTranscriptOp): void;
|
|
144
|
+
/** Append a synthetic line (e.g. a note the user routed to the reviewer). */
|
|
145
|
+
pushLine(kind: ReviewEntryLineKind, text: string): void;
|
|
146
|
+
/** Remove and return complete lines; a still-growing tail stays buffered. */
|
|
147
|
+
takeComplete(max?: number): ReviewEntryLine[];
|
|
148
|
+
/** Finalize the tail and return every buffered line (round end / teardown). */
|
|
149
|
+
flush(): ReviewEntryLine[];
|
|
150
|
+
/** True once lines had to be dropped to respect the cap. */
|
|
151
|
+
readonly truncated: boolean;
|
|
152
|
+
readonly size: number;
|
|
153
|
+
clear(): void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Coalesce reviewer transcript ops into complete, emittable lines.
|
|
158
|
+
*
|
|
159
|
+
* Token deltas arrive as `append` ops that may or may not contain a newline, so
|
|
160
|
+
* only lines terminated by `\n` are handed out — the caller appends one entry
|
|
161
|
+
* per flush and the last partial line is kept until it finishes. Bounded by
|
|
162
|
+
* `maxLines`: a long review cannot grow the buffer (or the caller's memory)
|
|
163
|
+
* without limit, and dropping is recorded via `truncated`.
|
|
164
|
+
*/
|
|
165
|
+
export function createReviewLineBuffer(options?: {
|
|
166
|
+
maxLines?: number;
|
|
167
|
+
}): ReviewLineBuffer {
|
|
168
|
+
const maxLines = Math.max(
|
|
169
|
+
1,
|
|
170
|
+
Math.trunc(options?.maxLines ?? DEFAULT_MAX_LINES),
|
|
171
|
+
);
|
|
172
|
+
const complete: ReviewEntryLine[] = [];
|
|
173
|
+
let tail: ReviewEntryLine | undefined;
|
|
174
|
+
let truncated = false;
|
|
175
|
+
|
|
176
|
+
const cap = (): void => {
|
|
177
|
+
if (complete.length > maxLines) {
|
|
178
|
+
complete.splice(0, complete.length - maxLines);
|
|
179
|
+
truncated = true;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const commitTail = (): void => {
|
|
184
|
+
if (!tail) return;
|
|
185
|
+
if (tail.text.length > 0) complete.push(tail);
|
|
186
|
+
tail = undefined;
|
|
187
|
+
cap();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const appendText = (kind: ReviewEntryLineKind, text: string): void => {
|
|
191
|
+
if (tail && tail.kind !== kind) commitTail();
|
|
192
|
+
if (!tail) tail = { kind, text: "" };
|
|
193
|
+
tail.text += text;
|
|
194
|
+
let idx = tail.text.indexOf("\n");
|
|
195
|
+
while (idx >= 0) {
|
|
196
|
+
complete.push({ kind: tail.kind, text: tail.text.slice(0, idx) });
|
|
197
|
+
tail.text = tail.text.slice(idx + 1);
|
|
198
|
+
idx = tail.text.indexOf("\n");
|
|
199
|
+
}
|
|
200
|
+
cap();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
push(op) {
|
|
205
|
+
if (op.op === "append") {
|
|
206
|
+
appendText(op.kind, op.text);
|
|
207
|
+
} else {
|
|
208
|
+
commitTail();
|
|
209
|
+
appendText(op.kind, op.text);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
pushLine(kind, text) {
|
|
213
|
+
commitTail();
|
|
214
|
+
appendText(kind, text);
|
|
215
|
+
},
|
|
216
|
+
takeComplete(max) {
|
|
217
|
+
const n =
|
|
218
|
+
max === undefined
|
|
219
|
+
? complete.length
|
|
220
|
+
: Math.max(0, Math.trunc(Number(max) || 0));
|
|
221
|
+
return complete.splice(0, n);
|
|
222
|
+
},
|
|
223
|
+
flush() {
|
|
224
|
+
commitTail();
|
|
225
|
+
return complete.splice(0, complete.length);
|
|
226
|
+
},
|
|
227
|
+
get truncated() {
|
|
228
|
+
return truncated;
|
|
229
|
+
},
|
|
230
|
+
get size() {
|
|
231
|
+
return complete.length + (tail && tail.text.length > 0 ? 1 : 0);
|
|
232
|
+
},
|
|
233
|
+
clear() {
|
|
234
|
+
complete.length = 0;
|
|
235
|
+
tail = undefined;
|
|
236
|
+
truncated = false;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Formatting ───────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Colour map for transcript lines. `text`/`thinking` are no longer produced by
|
|
245
|
+
* `translateReviewEvent` (the main-transcript stream is action headers plus the
|
|
246
|
+
* summary card), but they are kept so entries restored from older sessions
|
|
247
|
+
* still render readably instead of throwing.
|
|
248
|
+
*/
|
|
249
|
+
const LINE_COLORS: Record<ReviewEntryLineKind, ThemeColor> = {
|
|
250
|
+
text: "accent",
|
|
251
|
+
thinking: "dim",
|
|
252
|
+
tool: "muted",
|
|
253
|
+
finding: "warning",
|
|
254
|
+
notice: "warning",
|
|
255
|
+
status: "muted",
|
|
256
|
+
user: "success",
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Colour review lines by kind; junk lines degrade to plain accent text. */
|
|
260
|
+
export function formatReviewTranscriptLines(
|
|
261
|
+
lines: ReviewEntryLine[] | undefined,
|
|
262
|
+
theme: ThemeLike,
|
|
263
|
+
): string[] {
|
|
264
|
+
const out: string[] = [];
|
|
265
|
+
for (const line of Array.isArray(lines) ? lines : []) {
|
|
266
|
+
const kind = (
|
|
267
|
+
line && typeof line.kind === "string" ? line.kind : "text"
|
|
268
|
+
) as ReviewEntryLineKind;
|
|
269
|
+
const text = line && typeof line.text === "string" ? line.text : "";
|
|
270
|
+
if (text.length === 0) continue;
|
|
271
|
+
const color = LINE_COLORS[kind] ?? "accent";
|
|
272
|
+
if (kind === "user") {
|
|
273
|
+
out.push(theme.fg(color, theme.bold(text)));
|
|
274
|
+
} else {
|
|
275
|
+
out.push(theme.fg(color, text));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Colour a Plan/Review/Review-done band. */
|
|
282
|
+
export function formatReviewModeBand(
|
|
283
|
+
data: ReviewModeBandData | undefined,
|
|
284
|
+
theme: ThemeLike,
|
|
285
|
+
): string[] {
|
|
286
|
+
const mode = data?.mode;
|
|
287
|
+
if (mode === "plan") {
|
|
288
|
+
const title = theme.fg("warning", theme.bold("▌ PLAN MODE"));
|
|
289
|
+
const hint = theme.fg(
|
|
290
|
+
"muted",
|
|
291
|
+
" read-only · the reviewer audits after Plan Mode finishes",
|
|
292
|
+
);
|
|
293
|
+
return [title + hint];
|
|
294
|
+
}
|
|
295
|
+
if (mode === "review") {
|
|
296
|
+
const bits: string[] = [];
|
|
297
|
+
if (typeof data?.round === "number" && data.round > 1) {
|
|
298
|
+
bits.push(`round ${data.round}`);
|
|
299
|
+
}
|
|
300
|
+
if (typeof data?.modelLabel === "string" && data.modelLabel) {
|
|
301
|
+
bits.push(data.modelLabel);
|
|
302
|
+
}
|
|
303
|
+
bits.push("sequential audit");
|
|
304
|
+
const title = theme.fg("accent", theme.bold("▌ REVIEW MODE"));
|
|
305
|
+
return [title + theme.fg("muted", ` ${bits.join(" · ")}`)];
|
|
306
|
+
}
|
|
307
|
+
if (mode === "review-done") {
|
|
308
|
+
const clean = data?.verdict === "approve" && !data?.reason;
|
|
309
|
+
const color: ThemeColor = clean ? "success" : "warning";
|
|
310
|
+
let detail = "";
|
|
311
|
+
if (data?.reason) {
|
|
312
|
+
detail = ` ${data.reason}`;
|
|
313
|
+
} else if (typeof data?.verdict === "string" && data.verdict) {
|
|
314
|
+
detail = ` verdict: ${data.verdict}`;
|
|
315
|
+
}
|
|
316
|
+
return [
|
|
317
|
+
theme.fg(color, theme.bold("▌ REVIEW DONE")) + theme.fg("muted", detail),
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
return [theme.fg("muted", "▌ review")];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const MAX_SUMMARY_FINDINGS = 15;
|
|
324
|
+
|
|
325
|
+
/** Render the `workflow-review-submitted` audit entry as a dated summary card. */
|
|
326
|
+
export function formatReviewSubmittedEntry(
|
|
327
|
+
data: ReviewSubmittedEntryData | undefined,
|
|
328
|
+
theme: ThemeLike,
|
|
329
|
+
): string[] {
|
|
330
|
+
const findings = Array.isArray(data?.findings) ? data.findings : [];
|
|
331
|
+
const verdict = typeof data?.verdict === "string" ? data.verdict : "unknown";
|
|
332
|
+
const color: ThemeColor = verdict === "approve" ? "success" : "warning";
|
|
333
|
+
const count = `${findings.length} finding${findings.length === 1 ? "" : "s"}`;
|
|
334
|
+
const model =
|
|
335
|
+
typeof data?.modelLabel === "string" && data.modelLabel
|
|
336
|
+
? ` · ${data.modelLabel}`
|
|
337
|
+
: "";
|
|
338
|
+
const out: string[] = [
|
|
339
|
+
theme.fg(color, theme.bold("▌ REVIEW SUMMARY")) +
|
|
340
|
+
theme.fg("muted", ` ${verdict} · ${count}${model}`),
|
|
341
|
+
];
|
|
342
|
+
if (typeof data?.summary === "string" && data.summary.trim()) {
|
|
343
|
+
out.push(theme.fg("text", data.summary.trim()));
|
|
344
|
+
}
|
|
345
|
+
if (typeof data?.planPath === "string" && data.planPath) {
|
|
346
|
+
out.push(theme.fg("muted", `plan: ${data.planPath}`));
|
|
347
|
+
}
|
|
348
|
+
const shown = findings.slice(0, MAX_SUMMARY_FINDINGS);
|
|
349
|
+
for (const f of shown) {
|
|
350
|
+
const sev = typeof f?.severity === "number" ? f.severity : 0;
|
|
351
|
+
const conf = typeof f?.confidence === "number" ? f.confidence : 0;
|
|
352
|
+
let sevColor: ThemeColor = "muted";
|
|
353
|
+
if (sev >= 8) sevColor = "error";
|
|
354
|
+
else if (sev >= 5) sevColor = "warning";
|
|
355
|
+
const bits = [`sev ${sev}`, `conf ${conf}`];
|
|
356
|
+
if (typeof f?.disposition === "string" && f.disposition) {
|
|
357
|
+
bits.push(f.disposition);
|
|
358
|
+
}
|
|
359
|
+
if (typeof f?.category === "string" && f.category) bits.push(f.category);
|
|
360
|
+
out.push(theme.fg(sevColor, ` • ${bits.join(" · ")}`));
|
|
361
|
+
}
|
|
362
|
+
if (findings.length > shown.length) {
|
|
363
|
+
out.push(
|
|
364
|
+
theme.fg(
|
|
365
|
+
"muted",
|
|
366
|
+
` … +${findings.length - shown.length} more (see /review-status)`,
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Registration ─────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
/** A component over pre-formatted lines. `render` is width-bounded. */
|
|
376
|
+
export function createReviewEntryComponent(
|
|
377
|
+
lines: string[],
|
|
378
|
+
): ReviewEntryComponent {
|
|
379
|
+
const content = Array.isArray(lines) ? lines : [];
|
|
380
|
+
return {
|
|
381
|
+
render(width: number): string[] {
|
|
382
|
+
const w = Math.max(1, Math.trunc(Number(width) || 80));
|
|
383
|
+
return content.map((line) => truncateToWidth(line, w, "…"));
|
|
384
|
+
},
|
|
385
|
+
invalidate(): void {
|
|
386
|
+
/* static content */
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The subset of `ExtensionAPI` this module needs (test-friendly). */
|
|
392
|
+
export interface EntryRendererHost {
|
|
393
|
+
registerEntryRenderer<T = unknown>(
|
|
394
|
+
customType: string,
|
|
395
|
+
renderer: EntryRenderer<T>,
|
|
396
|
+
): void;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Register the Review Mode transcript renderers. Called once when the extension
|
|
401
|
+
* loads; registering a customType again overwrites, so `/reload` is idempotent.
|
|
402
|
+
*/
|
|
403
|
+
export function registerReviewEntryRenderers(host: EntryRendererHost): void {
|
|
404
|
+
host.registerEntryRenderer<ReviewModeBandData>(
|
|
405
|
+
REVIEW_MODE_ENTRY_TYPE,
|
|
406
|
+
(entry, _options, theme) =>
|
|
407
|
+
createReviewEntryComponent(formatReviewModeBand(entry?.data, theme)),
|
|
408
|
+
);
|
|
409
|
+
host.registerEntryRenderer<ReviewTranscriptEntryData>(
|
|
410
|
+
REVIEW_TRANSCRIPT_ENTRY_TYPE,
|
|
411
|
+
(entry, _options, theme) =>
|
|
412
|
+
createReviewEntryComponent(
|
|
413
|
+
formatReviewTranscriptLines(entry?.data?.lines, theme),
|
|
414
|
+
),
|
|
415
|
+
);
|
|
416
|
+
host.registerEntryRenderer<ReviewSubmittedEntryData>(
|
|
417
|
+
REVIEW_SUBMITTED_ENTRY_TYPE,
|
|
418
|
+
(entry, _options, theme) =>
|
|
419
|
+
createReviewEntryComponent(formatReviewSubmittedEntry(entry?.data, theme)),
|
|
420
|
+
);
|
|
421
|
+
}
|