@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/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
- const DESTRUCTIVE_PATTERNS = [
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
- const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
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
+ }