@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/utils.ts
CHANGED
|
@@ -1,101 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared utils for workflow extension — copied from plan-mode example
|
|
3
|
-
* Pure functions, testable.
|
|
3
|
+
* Pure functions, testable. Deliberately dependency-free (no Pi / pi-tui
|
|
4
|
+
* imports) so it stays loadable in plain Node tests.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
/\brm\b/i,
|
|
8
|
-
/\brmdir\b/i,
|
|
9
|
-
/\bmv\b/i,
|
|
10
|
-
/\bcp\b/i,
|
|
11
|
-
/\bmkdir\b/i,
|
|
12
|
-
/\btouch\b/i,
|
|
13
|
-
/\bchmod\b/i,
|
|
14
|
-
/\bchown\b/i,
|
|
15
|
-
/\bchgrp\b/i,
|
|
16
|
-
/\bln\b/i,
|
|
17
|
-
/\btee\b/i,
|
|
18
|
-
/\btruncate\b/i,
|
|
19
|
-
/\bdd\b/i,
|
|
20
|
-
/\bshred\b/i,
|
|
21
|
-
/(^|[^<])>(?!>)/,
|
|
22
|
-
/>>/,
|
|
23
|
-
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
24
|
-
/\byarn\s+(add|remove|install|publish)/i,
|
|
25
|
-
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
26
|
-
/\bpip\s+(install|uninstall)/i,
|
|
27
|
-
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
28
|
-
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
29
|
-
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
30
|
-
/\bsudo\b/i,
|
|
31
|
-
/\bsu\b/i,
|
|
32
|
-
/\bkill\b/i,
|
|
33
|
-
/\bpkill\b/i,
|
|
34
|
-
/\bkillall\b/i,
|
|
35
|
-
/\breboot\b/i,
|
|
36
|
-
/\bshutdown\b/i,
|
|
37
|
-
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
38
|
-
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
39
|
-
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
const SAFE_PATTERNS = [
|
|
43
|
-
/^\s*cat\b/,
|
|
44
|
-
/^\s*head\b/,
|
|
45
|
-
/^\s*tail\b/,
|
|
46
|
-
/^\s*less\b/,
|
|
47
|
-
/^\s*more\b/,
|
|
48
|
-
/^\s*grep\b/,
|
|
49
|
-
/^\s*find\b/,
|
|
50
|
-
/^\s*ls\b/,
|
|
51
|
-
/^\s*pwd\b/,
|
|
52
|
-
/^\s*echo\b/,
|
|
53
|
-
/^\s*printf\b/,
|
|
54
|
-
/^\s*wc\b/,
|
|
55
|
-
/^\s*sort\b/,
|
|
56
|
-
/^\s*uniq\b/,
|
|
57
|
-
/^\s*diff\b/,
|
|
58
|
-
/^\s*file\b/,
|
|
59
|
-
/^\s*stat\b/,
|
|
60
|
-
/^\s*du\b/,
|
|
61
|
-
/^\s*df\b/,
|
|
62
|
-
/^\s*tree\b/,
|
|
63
|
-
/^\s*which\b/,
|
|
64
|
-
/^\s*whereis\b/,
|
|
65
|
-
/^\s*type\b/,
|
|
66
|
-
/^\s*env\b/,
|
|
67
|
-
/^\s*printenv\b/,
|
|
68
|
-
/^\s*uname\b/,
|
|
69
|
-
/^\s*whoami\b/,
|
|
70
|
-
/^\s*id\b/,
|
|
71
|
-
/^\s*date\b/,
|
|
72
|
-
/^\s*cal\b/,
|
|
73
|
-
/^\s*uptime\b/,
|
|
74
|
-
/^\s*ps\b/,
|
|
75
|
-
/^\s*top\b/,
|
|
76
|
-
/^\s*htop\b/,
|
|
77
|
-
/^\s*free\b/,
|
|
78
|
-
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
|
79
|
-
/^\s*git\s+ls-/i,
|
|
80
|
-
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
81
|
-
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
82
|
-
/^\s*node\s+--version/i,
|
|
83
|
-
/^\s*python\s+--version/i,
|
|
84
|
-
/^\s*curl\s/i,
|
|
85
|
-
/^\s*wget\s+-O\s*-/i,
|
|
86
|
-
/^\s*jq\b/,
|
|
87
|
-
/^\s*sed\s+-n/i,
|
|
88
|
-
/^\s*awk\b/,
|
|
89
|
-
/^\s*rg\b/,
|
|
90
|
-
/^\s*fd\b/,
|
|
91
|
-
/^\s*bat\b/,
|
|
92
|
-
/^\s*eza\b/,
|
|
93
|
-
];
|
|
7
|
+
import { isCommandAllowedForRole } from "./permissions.ts";
|
|
94
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Backward-compatible alias for Plan Mode's command policy (read-only plus
|
|
11
|
+
* test/lint/typecheck). New code should call
|
|
12
|
+
* `isCommandAllowedForRole(role, command)` from `./permissions.ts` directly so
|
|
13
|
+
* the role is explicit.
|
|
14
|
+
*/
|
|
95
15
|
export function isSafeCommand(command: string): boolean {
|
|
96
|
-
|
|
97
|
-
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
|
|
98
|
-
return !isDestructive && isSafe;
|
|
16
|
+
return isCommandAllowedForRole("planner", command);
|
|
99
17
|
}
|
|
100
18
|
|
|
101
19
|
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
|
|
@@ -1201,3 +1119,467 @@ export function isUserInteractionEntry(e: any): boolean {
|
|
|
1201
1119
|
return true;
|
|
1202
1120
|
return false;
|
|
1203
1121
|
}
|
|
1122
|
+
|
|
1123
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1124
|
+
// Review Mode — pure helpers
|
|
1125
|
+
//
|
|
1126
|
+
// Review Mode (see review.ts) audits Plan Mode's plan and rewrites it. The
|
|
1127
|
+
// helpers below are the deterministic part: they never call a model and never
|
|
1128
|
+
// touch the Pi SDK, so they are fully unit-testable.
|
|
1129
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
1130
|
+
|
|
1131
|
+
/** Markdown heading that separates the human plan from Review Mode's changelog. */
|
|
1132
|
+
export const REVIEW_CHANGELOG_HEADING = "## Review changes";
|
|
1133
|
+
/** Markdown heading appended when Review Mode could not clear its findings. */
|
|
1134
|
+
export const REVIEW_UNRESOLVED_HEADING = "## Unresolved findings";
|
|
1135
|
+
|
|
1136
|
+
/** How a reviewer finding was resolved by the rewrite. */
|
|
1137
|
+
export type ReviewDisposition = "accepted" | "rejected" | "deferred";
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Severity tiers used for the changelog and for deciding whether the
|
|
1141
|
+
* self-verification pass runs. Thresholds mirror the house style used by
|
|
1142
|
+
* other Pi reviewers (severity 1-10, confidence 0-100).
|
|
1143
|
+
*/
|
|
1144
|
+
export type ReviewTier = "Critical" | "Important" | "Minor" | "Info";
|
|
1145
|
+
|
|
1146
|
+
/** A single reviewer finding. */
|
|
1147
|
+
export interface ReviewFinding {
|
|
1148
|
+
id: string;
|
|
1149
|
+
/** 1-10, higher is worse. */
|
|
1150
|
+
severity: number;
|
|
1151
|
+
/** 0-100. */
|
|
1152
|
+
confidence: number;
|
|
1153
|
+
/** Short category label, e.g. "correctness", "framework-alignment". */
|
|
1154
|
+
category: string;
|
|
1155
|
+
/** Repo-relative path, when the finding is anchored to a file. */
|
|
1156
|
+
file?: string;
|
|
1157
|
+
/** e.g. "42" or "42-58". */
|
|
1158
|
+
lineRange?: string;
|
|
1159
|
+
summary: string;
|
|
1160
|
+
rationale: string;
|
|
1161
|
+
disposition: ReviewDisposition;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Tier for a finding, using severity first and confidence as the gate. */
|
|
1165
|
+
export function reviewFindingTier(f: {
|
|
1166
|
+
severity: number;
|
|
1167
|
+
confidence: number;
|
|
1168
|
+
}): ReviewTier {
|
|
1169
|
+
const sev = Number(f?.severity) || 0;
|
|
1170
|
+
const conf = Number(f?.confidence) || 0;
|
|
1171
|
+
if (sev >= 8 && conf >= 70) return "Critical";
|
|
1172
|
+
if (sev >= 5 && conf >= 60) return "Important";
|
|
1173
|
+
if (sev >= 3 && conf >= 50) return "Minor";
|
|
1174
|
+
return "Info";
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** True when a severity tier is high enough to warrant the verify pass. */
|
|
1178
|
+
export function reviewNeedsVerification(findings: ReviewFinding[]): boolean {
|
|
1179
|
+
if (!Array.isArray(findings)) return false;
|
|
1180
|
+
return findings.some(
|
|
1181
|
+
(f) => Number(f?.severity) >= 5 && Number(f?.confidence) >= 60,
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const TIER_ICON: Record<ReviewTier, string> = {
|
|
1186
|
+
Critical: "🔴",
|
|
1187
|
+
Important: "🟡",
|
|
1188
|
+
Minor: "🔵",
|
|
1189
|
+
Info: "⚪",
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
const TIER_ORDER: ReviewTier[] = ["Critical", "Important", "Minor", "Info"];
|
|
1193
|
+
|
|
1194
|
+
function escapeRegExpChars(s: string): string {
|
|
1195
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function findingLocation(f: ReviewFinding): string {
|
|
1199
|
+
if (!f?.file) return "";
|
|
1200
|
+
return f.lineRange ? `\`${f.file}:${f.lineRange}\`` : `\`${f.file}\``;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function oneLine(s: string): string {
|
|
1204
|
+
return String(s ?? "")
|
|
1205
|
+
.replace(/\s+/g, " ")
|
|
1206
|
+
.trim();
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Render the changelog that gets appended to the reviewed plan so the user can
|
|
1211
|
+
* see exactly what Review Mode changed and why. Deterministic and pure.
|
|
1212
|
+
*/
|
|
1213
|
+
export function renderReviewChangelog(
|
|
1214
|
+
findings: ReviewFinding[],
|
|
1215
|
+
meta?: { modelLabel?: string; verdict?: string; originalLength?: number },
|
|
1216
|
+
): string {
|
|
1217
|
+
const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
|
|
1218
|
+
const lines: string[] = [];
|
|
1219
|
+
|
|
1220
|
+
if (meta?.modelLabel || meta?.verdict) {
|
|
1221
|
+
const bits: string[] = [];
|
|
1222
|
+
if (meta?.modelLabel) bits.push(`reviewer \`${meta.modelLabel}\``);
|
|
1223
|
+
if (meta?.verdict) bits.push(`verdict **${meta.verdict}**`);
|
|
1224
|
+
if (bits.length) lines.push(bits.join(" · "), "");
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (list.length === 0) {
|
|
1228
|
+
lines.push("No findings — the plan was submitted unchanged.");
|
|
1229
|
+
return lines.join("\n").trimEnd();
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const accepted = list.filter((f) => f.disposition === "accepted");
|
|
1233
|
+
const rejected = list.filter((f) => f.disposition === "rejected");
|
|
1234
|
+
const deferred = list.filter((f) => f.disposition === "deferred");
|
|
1235
|
+
|
|
1236
|
+
const counts: string[] = [];
|
|
1237
|
+
if (accepted.length) counts.push(`${accepted.length} addressed`);
|
|
1238
|
+
if (rejected.length) counts.push(`${rejected.length} rejected`);
|
|
1239
|
+
if (deferred.length) counts.push(`${deferred.length} deferred`);
|
|
1240
|
+
lines.push(`**Findings:** ${counts.join(", ") || "none"}`);
|
|
1241
|
+
lines.push("");
|
|
1242
|
+
|
|
1243
|
+
if (accepted.length) {
|
|
1244
|
+
lines.push("### Addressed", "");
|
|
1245
|
+
for (const tier of TIER_ORDER) {
|
|
1246
|
+
const group = accepted.filter((f) => reviewFindingTier(f) === tier);
|
|
1247
|
+
if (!group.length) continue;
|
|
1248
|
+
lines.push(`**${tier}**`, "");
|
|
1249
|
+
for (const f of group) {
|
|
1250
|
+
const loc = findingLocation(f);
|
|
1251
|
+
lines.push(
|
|
1252
|
+
`- ${TIER_ICON[tier]} ${loc ? loc + " — " : ""}${oneLine(f.summary)} _(severity ${f.severity}, confidence ${f.confidence}%, ${oneLine(f.category) || "general"})_`,
|
|
1253
|
+
);
|
|
1254
|
+
const why = oneLine(f.rationale);
|
|
1255
|
+
if (why) lines.push(` - ${why}`);
|
|
1256
|
+
}
|
|
1257
|
+
lines.push("");
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (deferred.length) {
|
|
1262
|
+
lines.push("### Deferred", "");
|
|
1263
|
+
for (const f of deferred) {
|
|
1264
|
+
const loc = findingLocation(f);
|
|
1265
|
+
lines.push(
|
|
1266
|
+
`- ⏸ ${loc ? loc + " — " : ""}${oneLine(f.summary)} _(severity ${f.severity}, confidence ${f.confidence}%)_`,
|
|
1267
|
+
);
|
|
1268
|
+
const why = oneLine(f.rationale);
|
|
1269
|
+
if (why) lines.push(` - ${why}`);
|
|
1270
|
+
}
|
|
1271
|
+
lines.push("");
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (rejected.length) {
|
|
1275
|
+
lines.push("### Rejected by the reviewer", "");
|
|
1276
|
+
for (const f of rejected) {
|
|
1277
|
+
const loc = findingLocation(f);
|
|
1278
|
+
lines.push(`- ❌ ${loc ? loc + " — " : ""}${oneLine(f.summary)}`);
|
|
1279
|
+
const why = oneLine(f.rationale);
|
|
1280
|
+
if (why) lines.push(` - ${why}`);
|
|
1281
|
+
}
|
|
1282
|
+
lines.push("");
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return lines.join("\n").trimEnd();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Strip any previously appended Review Mode appendix from a plan.
|
|
1290
|
+
* Returns the plan body exactly as the author wrote it.
|
|
1291
|
+
*/
|
|
1292
|
+
export function stripReviewAppendix(planText: string): string {
|
|
1293
|
+
const text = String(planText ?? "");
|
|
1294
|
+
if (!text) return "";
|
|
1295
|
+
let cut = -1;
|
|
1296
|
+
for (const heading of [REVIEW_CHANGELOG_HEADING, REVIEW_UNRESOLVED_HEADING]) {
|
|
1297
|
+
const re = new RegExp(`^${escapeRegExpChars(heading)}\\s*$`, "m");
|
|
1298
|
+
const hit = text.search(re);
|
|
1299
|
+
if (hit !== -1 && (cut === -1 || hit < cut)) cut = hit;
|
|
1300
|
+
}
|
|
1301
|
+
if (cut === -1) return text;
|
|
1302
|
+
// Also drop a `---` horizontal rule immediately preceding the appendix.
|
|
1303
|
+
return text.slice(0, cut).replace(/\n+---\s*\n*$/, "\n");
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Append (or replace) the Review Mode changelog section. Idempotent: calling
|
|
1308
|
+
* it twice with the same changelog yields the same result.
|
|
1309
|
+
*/
|
|
1310
|
+
export function appendReviewChangelog(
|
|
1311
|
+
planText: string,
|
|
1312
|
+
changelog: string,
|
|
1313
|
+
): string {
|
|
1314
|
+
const base = stripReviewAppendix(planText).replace(/\s+$/, "");
|
|
1315
|
+
const body = String(changelog ?? "").trim();
|
|
1316
|
+
if (!body) return base + "\n";
|
|
1317
|
+
return `${base}\n\n---\n\n${REVIEW_CHANGELOG_HEADING}\n\n${body}\n`;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Content hash of a plan body, ignoring any Review Mode appendix so a re-review
|
|
1322
|
+
* of the same revision is detected instead of re-triggering forever.
|
|
1323
|
+
* FNV-1a over whitespace-normalized text; stable across platforms.
|
|
1324
|
+
*/
|
|
1325
|
+
export function planHash(planText: string): string {
|
|
1326
|
+
const normalized = stripReviewAppendix(planText)
|
|
1327
|
+
.replace(/\r\n/g, "\n")
|
|
1328
|
+
.replace(/[ \t]+$/gm, "")
|
|
1329
|
+
.trim();
|
|
1330
|
+
let h = 0x811c9dc5;
|
|
1331
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1332
|
+
h ^= normalized.charCodeAt(i);
|
|
1333
|
+
h = Math.imul(h, 0x01000193);
|
|
1334
|
+
}
|
|
1335
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ── Context pruning for the reviewer session ────────────────────────
|
|
1339
|
+
|
|
1340
|
+
export interface PruneReviewContextOptions {
|
|
1341
|
+
/** Max characters kept per string field. Longer values are elided. */
|
|
1342
|
+
maxToolResultChars?: number;
|
|
1343
|
+
/** Max entries handed to the reviewer (most recent are kept). */
|
|
1344
|
+
maxEntries?: number;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const REVIEW_PRUNE_MAX_DEPTH = 6;
|
|
1348
|
+
|
|
1349
|
+
/** Extension-injected entries that must not leak into the reviewer's context. */
|
|
1350
|
+
function isReviewDroppedEntry(e: any): boolean {
|
|
1351
|
+
if (!e || typeof e !== "object") return true;
|
|
1352
|
+
const ct = typeof e.customType === "string" ? e.customType : "";
|
|
1353
|
+
return ct.startsWith("workflow");
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Tool calls carried by an assistant message entry.
|
|
1358
|
+
*
|
|
1359
|
+
* Real session format (docs/session-format.md):
|
|
1360
|
+
* { type: "message", message: { role: "assistant",
|
|
1361
|
+
* content: [{ type: "toolCall", id, name, arguments }] } }
|
|
1362
|
+
* The top-level `input` shape is also accepted because the Plan Mode handoff
|
|
1363
|
+
* already probes it (`e?.input?.path`) and older entries may carry it.
|
|
1364
|
+
*/
|
|
1365
|
+
function collectToolCalls(e: any): Array<{ name?: string; args?: any }> {
|
|
1366
|
+
const out: Array<{ name?: string; args?: any }> = [];
|
|
1367
|
+
const blocks = e?.message?.content;
|
|
1368
|
+
if (!Array.isArray(blocks)) return out;
|
|
1369
|
+
for (const b of blocks) {
|
|
1370
|
+
if (!b || typeof b !== "object") continue;
|
|
1371
|
+
if (b.type !== "toolCall" && b.type !== "tool_use") continue;
|
|
1372
|
+
out.push({
|
|
1373
|
+
name: typeof b.name === "string" ? b.name : undefined,
|
|
1374
|
+
args: b.arguments ?? b.input,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
return out;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** Plan-file path written by an entry, if it is a plan write. */
|
|
1381
|
+
function entryPlanWritePath(e: any): string | undefined {
|
|
1382
|
+
const candidates: unknown[] = [
|
|
1383
|
+
e?.input?.path,
|
|
1384
|
+
e?.input?.file_path,
|
|
1385
|
+
e?.data?.planFile,
|
|
1386
|
+
];
|
|
1387
|
+
for (const call of collectToolCalls(e)) {
|
|
1388
|
+
if (call.name !== "write" && call.name !== "edit") continue;
|
|
1389
|
+
const a = call.args;
|
|
1390
|
+
if (a && typeof a === "object") {
|
|
1391
|
+
candidates.push((a as any).path, (a as any).file_path);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
for (const cand of candidates) {
|
|
1395
|
+
if (typeof cand !== "string") continue;
|
|
1396
|
+
const norm = cand.replace(/\\/g, "/").toLowerCase();
|
|
1397
|
+
if (norm.includes(".pi/plans/")) return cand;
|
|
1398
|
+
}
|
|
1399
|
+
return undefined;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function shrinkReviewString(s: string, maxChars: number): string {
|
|
1403
|
+
if (s.length <= maxChars) return s;
|
|
1404
|
+
return `${s.slice(0, maxChars)}\n… [review context: ${s.length - maxChars} chars elided]`;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/** Recursively clone a value, eliding any string longer than maxChars. */
|
|
1408
|
+
function shrinkReviewNode(node: any, maxChars: number, depth = 0): any {
|
|
1409
|
+
if (typeof node === "string") return shrinkReviewString(node, maxChars);
|
|
1410
|
+
if (depth >= REVIEW_PRUNE_MAX_DEPTH) return node;
|
|
1411
|
+
if (Array.isArray(node))
|
|
1412
|
+
return node.map((n) => shrinkReviewNode(n, maxChars, depth + 1));
|
|
1413
|
+
if (node && typeof node === "object") {
|
|
1414
|
+
const out: Record<string, unknown> = {};
|
|
1415
|
+
for (const [k, v] of Object.entries(node)) {
|
|
1416
|
+
out[k] = shrinkReviewNode(v, maxChars, depth + 1);
|
|
1417
|
+
}
|
|
1418
|
+
return out;
|
|
1419
|
+
}
|
|
1420
|
+
return node;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Build the conversation history handed to the reviewer session.
|
|
1425
|
+
*
|
|
1426
|
+
* - drops workflow-injected entries (plan/todo context markers, handoff notes)
|
|
1427
|
+
* - elides oversized tool results so the reviewer's window is spent on reasoning
|
|
1428
|
+
* - stops at the plan-file write: anything the author did *after* writing the
|
|
1429
|
+
* plan is not part of the plan under review
|
|
1430
|
+
* - keeps the most recent `maxEntries` when the result is still too long
|
|
1431
|
+
*
|
|
1432
|
+
* Never throws; malformed entries are skipped.
|
|
1433
|
+
*/
|
|
1434
|
+
export function pruneContextEntriesForReview(
|
|
1435
|
+
entries: unknown,
|
|
1436
|
+
opts: PruneReviewContextOptions = {},
|
|
1437
|
+
): any[] {
|
|
1438
|
+
const maxChars = Number.isFinite(opts.maxToolResultChars)
|
|
1439
|
+
? Math.max(200, Number(opts.maxToolResultChars))
|
|
1440
|
+
: 4000;
|
|
1441
|
+
const maxEntries = Number.isFinite(opts.maxEntries)
|
|
1442
|
+
? Math.max(1, Number(opts.maxEntries))
|
|
1443
|
+
: 120;
|
|
1444
|
+
if (!Array.isArray(entries)) return [];
|
|
1445
|
+
const kept: any[] = [];
|
|
1446
|
+
try {
|
|
1447
|
+
for (const e of entries) {
|
|
1448
|
+
if (isReviewDroppedEntry(e)) continue;
|
|
1449
|
+
kept.push(shrinkReviewNode(e, maxChars));
|
|
1450
|
+
if (entryPlanWritePath(e)) break;
|
|
1451
|
+
}
|
|
1452
|
+
} catch {
|
|
1453
|
+
/* keep whatever was collected */
|
|
1454
|
+
}
|
|
1455
|
+
return kept.length > maxEntries ? kept.slice(kept.length - maxEntries) : kept;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Validate a reviewer-submitted plan before it replaces the author's plan.
|
|
1460
|
+
*
|
|
1461
|
+
* Returns a list of human-readable problems; empty means valid.
|
|
1462
|
+
*
|
|
1463
|
+
* The length guard exists so a degenerate rewrite cannot silently discard the
|
|
1464
|
+
* plan (a stub that looks authoritative but carries no plan).
|
|
1465
|
+
*
|
|
1466
|
+
* Why the bounds are deliberately generous and asymmetric: two of the review
|
|
1467
|
+
* criteria REQURE the length to change. "Requirement coverage" findings add
|
|
1468
|
+
* whole steps, and "simplicity" findings delete them. A tight ±60% window (the
|
|
1469
|
+
* obvious first guess) would reject exactly the reviews that are doing their
|
|
1470
|
+
* job. So the bounds only catch catastrophes: a stub (below `minRatio`) or an
|
|
1471
|
+
* accidental paste / wholesale plan substitution (above `maxRatio`). Everything
|
|
1472
|
+
* in between is the reviewer legitimately doing its work, and every change is
|
|
1473
|
+
* still made visible to the user through the `## Review changes` appendix.
|
|
1474
|
+
*/
|
|
1475
|
+
export function validateReviewedPlan(
|
|
1476
|
+
planMarkdown: unknown,
|
|
1477
|
+
originalMarkdown: unknown,
|
|
1478
|
+
opts: { minRatio?: number; maxRatio?: number } = {},
|
|
1479
|
+
): string[] {
|
|
1480
|
+
const problems: string[] = [];
|
|
1481
|
+
const next = typeof planMarkdown === "string" ? planMarkdown : "";
|
|
1482
|
+
const prev = typeof originalMarkdown === "string" ? originalMarkdown : "";
|
|
1483
|
+
const minRatio = opts.minRatio ?? 0.25;
|
|
1484
|
+
const maxRatio = opts.maxRatio ?? 4;
|
|
1485
|
+
|
|
1486
|
+
if (!next.trim()) {
|
|
1487
|
+
problems.push("planMarkdown is empty");
|
|
1488
|
+
return problems;
|
|
1489
|
+
}
|
|
1490
|
+
if (!/^#{1,6}\s+\S/m.test(next)) {
|
|
1491
|
+
problems.push("planMarkdown contains no markdown heading");
|
|
1492
|
+
}
|
|
1493
|
+
const prevBody = stripReviewAppendix(prev);
|
|
1494
|
+
if (prevBody.trim().length > 0) {
|
|
1495
|
+
const ratio = next.length / prevBody.length;
|
|
1496
|
+
if (ratio < minRatio)
|
|
1497
|
+
problems.push(
|
|
1498
|
+
`planMarkdown is too short (${Math.round(ratio * 100)}% of the original; minimum ${Math.round(minRatio * 100)}%) — resubmit the complete plan rather than a summary`,
|
|
1499
|
+
);
|
|
1500
|
+
if (ratio > maxRatio)
|
|
1501
|
+
problems.push(
|
|
1502
|
+
`planMarkdown is unexpectedly long (${Math.round(ratio * 100)}% of the original; maximum ${Math.round(maxRatio * 100)}%) — this looks like a pasted document rather than an adjustment of the original plan`,
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
return problems;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// ── Rendering the shared context for the reviewer's prompt ───────────
|
|
1509
|
+
|
|
1510
|
+
function contentBlocksToText(content: unknown): string {
|
|
1511
|
+
if (typeof content === "string") return content;
|
|
1512
|
+
if (!Array.isArray(content)) return "";
|
|
1513
|
+
const parts: string[] = [];
|
|
1514
|
+
for (const b of content) {
|
|
1515
|
+
if (!b || typeof b !== "object") continue;
|
|
1516
|
+
const block = b as Record<string, unknown>;
|
|
1517
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
1518
|
+
parts.push(block.text);
|
|
1519
|
+
} else if (block.type === "image") {
|
|
1520
|
+
parts.push("[image]");
|
|
1521
|
+
} else if (block.type === "toolCall" || block.type === "tool_use") {
|
|
1522
|
+
const name = typeof block.name === "string" ? block.name : "tool";
|
|
1523
|
+
const args = (block.arguments ?? block.input) as
|
|
1524
|
+
| Record<string, unknown>
|
|
1525
|
+
| undefined;
|
|
1526
|
+
const hint =
|
|
1527
|
+
args && typeof args === "object"
|
|
1528
|
+
? String(args.path ?? args.file_path ?? args.command ?? "")
|
|
1529
|
+
.slice(0, 120)
|
|
1530
|
+
.replace(/\s+/g, " ")
|
|
1531
|
+
: "";
|
|
1532
|
+
parts.push(`[tool call: ${name}${hint ? ` ${hint}` : ""}]`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return parts.join("\n");
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* Render the pruned Plan Mode history into a plain-text block for the
|
|
1540
|
+
* reviewer's first prompt.
|
|
1541
|
+
*
|
|
1542
|
+
* Why the prompt rather than the session manager: `SessionManager.inMemory()`
|
|
1543
|
+
* only accepts pre-existing entries from Pi 0.85.1 onward, and this package
|
|
1544
|
+
* declares `peerDependencies: { "@earendil-works/pi-coding-agent": "*" }`.
|
|
1545
|
+
* Passing entries to a 0.84.x runtime would be silently ignored, handing the
|
|
1546
|
+
* reviewer an empty context — a silent failure. Rendering into the prompt
|
|
1547
|
+
* works identically on every supported version and is explicit about what the
|
|
1548
|
+
* reviewer can and cannot see.
|
|
1549
|
+
*
|
|
1550
|
+
* Never throws.
|
|
1551
|
+
*/
|
|
1552
|
+
export function renderReviewContextBlock(
|
|
1553
|
+
entries: unknown,
|
|
1554
|
+
opts: { maxChars?: number } = {},
|
|
1555
|
+
): string {
|
|
1556
|
+
const maxChars = Number.isFinite(opts.maxChars)
|
|
1557
|
+
? Math.max(1000, Number(opts.maxChars))
|
|
1558
|
+
: 60_000;
|
|
1559
|
+
if (!Array.isArray(entries) || entries.length === 0) return "";
|
|
1560
|
+
const lines: string[] = [];
|
|
1561
|
+
try {
|
|
1562
|
+
for (const raw of entries) {
|
|
1563
|
+
if (!raw || typeof raw !== "object") continue;
|
|
1564
|
+
const e = raw as Record<string, unknown>;
|
|
1565
|
+
const msg = e.message as Record<string, unknown> | undefined;
|
|
1566
|
+
if (!msg) continue;
|
|
1567
|
+
const role = typeof msg.role === "string" ? msg.role : "unknown";
|
|
1568
|
+
const text = contentBlocksToText(msg.content).trim();
|
|
1569
|
+
if (!text) continue;
|
|
1570
|
+
if (role === "user") lines.push(`### User\n${text}`);
|
|
1571
|
+
else if (role === "assistant") lines.push(`### Plan Mode\n${text}`);
|
|
1572
|
+
else if (role === "toolResult" || role === "tool") {
|
|
1573
|
+
const tool = typeof msg.toolName === "string" ? msg.toolName : "tool";
|
|
1574
|
+
lines.push(`### Tool result (${tool})\n${text}`);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
} catch {
|
|
1578
|
+
/* return what we have */
|
|
1579
|
+
}
|
|
1580
|
+
let out = lines.join("\n\n");
|
|
1581
|
+
if (out.length > maxChars) {
|
|
1582
|
+
out = `${out.slice(0, maxChars)}\n\n… [context truncated at ${maxChars} chars]`;
|
|
1583
|
+
}
|
|
1584
|
+
return out;
|
|
1585
|
+
}
|