@gaberrb/polypus 0.4.4 → 0.4.6

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/dist/index.js CHANGED
@@ -137,6 +137,10 @@ var en = {
137
137
  "run.autocorrect": "\u21BB tool failed \u2014 auto-correcting with extra context",
138
138
  "run.cancelled": "\u25A0 cancelled",
139
139
  "run.jsonNeedsTask": "--json requires a task argument (headless mode has no interactive REPL).",
140
+ "review.approveAll": "approve all",
141
+ "review.reject": "reject",
142
+ "review.pickHunks": "pick hunks\u2026",
143
+ "review.selectHunks": "Select the hunks to apply (space to toggle, enter to confirm)",
140
144
  // repl
141
145
  "repl.welcome": "Polypus interactive session.",
142
146
  "repl.welcomeHint": " Type /help for commands, /exit to quit.",
@@ -291,7 +295,10 @@ var en = {
291
295
  // @-mentions
292
296
  "mentions.injectedHeader": "Referenced files (@-mentions)",
293
297
  "mentions.dirHeader": "@{path} (directory listing)",
294
- "mentions.notFound": "(could not resolve @{path}: not found or outside the allow-list)"
298
+ "mentions.notFound": "(could not resolve @{path}: not found or outside the allow-list)",
299
+ // safety policy
300
+ "policy.blockedCommand": "blocked by safety policy ({reason}) \u2014 refusing in all modes",
301
+ "policy.secretFound": "write blocked: a possible secret ({kind}) was found on line {line}. Remove it or load it from an environment variable instead of hard-coding it."
295
302
  };
296
303
  var ptBR = {
297
304
  "common.default": "padr\xE3o",
@@ -362,6 +369,10 @@ var ptBR = {
362
369
  "run.autocorrect": "\u21BB tool falhou \u2014 autocorrigindo com contexto extra",
363
370
  "run.cancelled": "\u25A0 cancelado",
364
371
  "run.jsonNeedsTask": "--json exige um argumento de tarefa (o modo headless n\xE3o tem REPL interativo).",
372
+ "review.approveAll": "aprovar tudo",
373
+ "review.reject": "rejeitar",
374
+ "review.pickHunks": "escolher hunks\u2026",
375
+ "review.selectHunks": "Selecione os hunks a aplicar (espa\xE7o alterna, enter confirma)",
365
376
  "repl.welcome": "Sess\xE3o interativa do Polypus.",
366
377
  "repl.welcomeHint": " Digite /help para comandos, /exit para sair.",
367
378
  "repl.modeChanged": "modo \u2192 {mode}",
@@ -490,6 +501,9 @@ var ptBR = {
490
501
  "mentions.injectedHeader": "Arquivos referenciados (@-mentions)",
491
502
  "mentions.dirHeader": "@{path} (conte\xFAdo do diret\xF3rio)",
492
503
  "mentions.notFound": "(n\xE3o foi poss\xEDvel resolver @{path}: n\xE3o encontrado ou fora da allow-list)",
504
+ // safety policy
505
+ "policy.blockedCommand": "bloqueado pela pol\xEDtica de seguran\xE7a ({reason}) \u2014 recusado em todos os modos",
506
+ "policy.secretFound": "escrita bloqueada: poss\xEDvel segredo ({kind}) encontrado na linha {line}. Remova-o ou carregue de uma vari\xE1vel de ambiente em vez de fix\xE1-lo no c\xF3digo.",
493
507
  "models.fetching": "Buscando modelos do OpenRouter\u2026",
494
508
  "models.fetchError": "N\xE3o foi poss\xEDvel buscar modelos: {msg}",
495
509
  "models.none": "Nenhum modelo corresponde aos filtros.",
@@ -957,6 +971,10 @@ function createProvider(agent) {
957
971
  return { config: agent, provider, toolMode: resolveToolMode(agent) };
958
972
  }
959
973
 
974
+ // src/core/permissions/modes.ts
975
+ import { readFile as readFile2 } from "fs/promises";
976
+ import { resolve as resolve2 } from "path";
977
+
960
978
  // src/core/permissions/allowlist.ts
961
979
  import { isAbsolute, relative, resolve, sep } from "path";
962
980
  function globToRegExp(glob) {
@@ -1005,6 +1023,158 @@ function isCommandPreApproved(allowedCommands, command) {
1005
1023
  return allowedCommands.some((prefix) => c === prefix || c.startsWith(prefix.trim() + " "));
1006
1024
  }
1007
1025
 
1026
+ // src/core/permissions/policy.ts
1027
+ var DANGEROUS_COMMANDS = [
1028
+ { re: /--no-preserve-root/i, reason: "rm --no-preserve-root" },
1029
+ { re: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, reason: "fork bomb" },
1030
+ { re: /\bmkfs(\.\w+)?\b/i, reason: "filesystem format (mkfs)" },
1031
+ { re: /\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk)/i, reason: "dd writing to a raw disk device" },
1032
+ { re: />\s*\/dev\/(sd|nvme|hd|disk)/i, reason: "redirect to a raw disk device" },
1033
+ { re: /\bchmod\s+(-[a-z]*\s+)*-?R?\s*777\s+\//i, reason: "chmod 777 on /" },
1034
+ { re: /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i, reason: "piping a downloaded script straight into a shell" }
1035
+ ];
1036
+ function isDangerousRm(command) {
1037
+ if (!/\brm\b/i.test(command)) return false;
1038
+ const recursive = /(?:^|\s)-[a-z]*r[a-z]*f/i.test(command) || /(?:^|\s)-[a-z]*f[a-z]*r/i.test(command) || /(?:^|\s)-[a-z]*r\b/i.test(command) && /(?:^|\s)-[a-z]*f\b/i.test(command);
1039
+ if (!recursive) return false;
1040
+ return /(?:\s|^)(?:\/\*|\/|~|\$HOME|\*)(?:\s|$)/.test(command);
1041
+ }
1042
+ function screenCommand(command) {
1043
+ if (isDangerousRm(command)) {
1044
+ return { blocked: true, reason: "recursive force-delete of / ~ or *" };
1045
+ }
1046
+ for (const { re, reason } of DANGEROUS_COMMANDS) {
1047
+ if (re.test(command)) return { blocked: true, reason };
1048
+ }
1049
+ return { blocked: false };
1050
+ }
1051
+ var SECRET_PATTERNS = [
1052
+ { re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/, kind: "private key block" },
1053
+ { re: /\bAKIA[0-9A-Z]{16}\b/, kind: "AWS access key id" },
1054
+ { re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, kind: "GitHub token" },
1055
+ { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, kind: "Slack token" },
1056
+ { re: /\bsk-[A-Za-z0-9]{32,}\b/, kind: "OpenAI-style secret key" },
1057
+ { re: /\bAIza[0-9A-Za-z_-]{35}\b/, kind: "Google API key" }
1058
+ ];
1059
+ function scanSecrets(text2) {
1060
+ const findings = [];
1061
+ const lines = text2.split("\n");
1062
+ for (let i = 0; i < lines.length; i++) {
1063
+ for (const { re, kind } of SECRET_PATTERNS) {
1064
+ if (re.test(lines[i])) {
1065
+ findings.push({ line: i + 1, kind });
1066
+ break;
1067
+ }
1068
+ }
1069
+ }
1070
+ return findings;
1071
+ }
1072
+
1073
+ // src/core/permissions/diff.ts
1074
+ var CONTEXT = 3;
1075
+ function splitLines(text2) {
1076
+ return text2 === "" ? [] : text2.split("\n");
1077
+ }
1078
+ function lcsOps(a, b) {
1079
+ const n = a.length;
1080
+ const m = b.length;
1081
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
1082
+ for (let i2 = n - 1; i2 >= 0; i2--) {
1083
+ for (let j2 = m - 1; j2 >= 0; j2--) {
1084
+ dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
1085
+ }
1086
+ }
1087
+ const out = [];
1088
+ let i = 0;
1089
+ let j = 0;
1090
+ while (i < n && j < m) {
1091
+ if (a[i] === b[j]) {
1092
+ out.push({ type: " ", text: a[i] });
1093
+ i++;
1094
+ j++;
1095
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
1096
+ out.push({ type: "-", text: a[i] });
1097
+ i++;
1098
+ } else {
1099
+ out.push({ type: "+", text: b[j] });
1100
+ j++;
1101
+ }
1102
+ }
1103
+ while (i < n) out.push({ type: "-", text: a[i++] });
1104
+ while (j < m) out.push({ type: "+", text: b[j++] });
1105
+ return out;
1106
+ }
1107
+ function computeHunks(oldText, newText) {
1108
+ const a = splitLines(oldText);
1109
+ const b = splitLines(newText);
1110
+ const ops = lcsOps(a, b);
1111
+ const isChange = ops.map((o) => o.type !== " ");
1112
+ const keep = new Array(ops.length).fill(false);
1113
+ for (let k2 = 0; k2 < ops.length; k2++) {
1114
+ if (isChange[k2]) {
1115
+ for (let c = Math.max(0, k2 - CONTEXT); c <= Math.min(ops.length - 1, k2 + CONTEXT); c++) {
1116
+ keep[c] = true;
1117
+ }
1118
+ }
1119
+ }
1120
+ const hunks = [];
1121
+ let oldLine = 0;
1122
+ let newLine = 0;
1123
+ let k = 0;
1124
+ while (k < ops.length) {
1125
+ const op = ops[k];
1126
+ if (!keep[k]) {
1127
+ if (op.type !== "+") oldLine++;
1128
+ if (op.type !== "-") newLine++;
1129
+ k++;
1130
+ continue;
1131
+ }
1132
+ const oldStart = oldLine;
1133
+ const newStart = newLine;
1134
+ const lines = [];
1135
+ let oldCount = 0;
1136
+ let newCount = 0;
1137
+ while (k < ops.length && keep[k]) {
1138
+ const cur = ops[k];
1139
+ lines.push(cur);
1140
+ if (cur.type !== "+") {
1141
+ oldLine++;
1142
+ oldCount++;
1143
+ }
1144
+ if (cur.type !== "-") {
1145
+ newLine++;
1146
+ newCount++;
1147
+ }
1148
+ k++;
1149
+ }
1150
+ hunks.push({ oldStart, oldCount, newStart, newCount, lines });
1151
+ }
1152
+ return hunks;
1153
+ }
1154
+ function applyHunks(oldText, hunks, approved) {
1155
+ const a = splitLines(oldText);
1156
+ const out = [];
1157
+ let oldIdx = 0;
1158
+ hunks.forEach((hunk, idx) => {
1159
+ while (oldIdx < hunk.oldStart) out.push(a[oldIdx++]);
1160
+ if (approved.has(idx)) {
1161
+ for (const l of hunk.lines) if (l.type !== "-") out.push(l.text);
1162
+ } else {
1163
+ for (const l of hunk.lines) if (l.type !== "+") out.push(l.text);
1164
+ }
1165
+ oldIdx = hunk.oldStart + hunk.oldCount;
1166
+ });
1167
+ while (oldIdx < a.length) out.push(a[oldIdx++]);
1168
+ return out.join("\n");
1169
+ }
1170
+ function hunkLabel(hunk) {
1171
+ const added = hunk.lines.filter((l) => l.type === "+").length;
1172
+ const removed = hunk.lines.filter((l) => l.type === "-").length;
1173
+ const firstChange = hunk.lines.find((l) => l.type !== " ");
1174
+ const preview = firstChange ? firstChange.text.trim().slice(0, 50) : "";
1175
+ return `@@ -${hunk.oldStart + 1},${hunk.oldCount} +${hunk.newStart + 1},${hunk.newCount} @@ (+${added}/-${removed}) ${preview}`;
1176
+ }
1177
+
1008
1178
  // src/core/permissions/modes.ts
1009
1179
  var PermissionEngine = class {
1010
1180
  constructor(opts) {
@@ -1019,24 +1189,48 @@ var PermissionEngine = class {
1019
1189
  const d = checkPath(this.opts.policy, target);
1020
1190
  return d.allowed ? { allowed: true } : { allowed: false, reason: d.reason };
1021
1191
  }
1022
- async authorizeWrite(target, preview) {
1192
+ async authorizeWrite(target, preview, content) {
1023
1193
  const d = checkPath(this.opts.policy, target);
1024
1194
  if (!d.allowed) return { allowed: false, reason: d.reason };
1195
+ let oldContent = "";
1196
+ try {
1197
+ oldContent = await readFile2(resolve2(this.opts.policy.workspace, target), "utf8");
1198
+ } catch {
1199
+ }
1200
+ const hunks = content !== void 0 ? computeHunks(oldContent, content) : [];
1201
+ const added = hunks.flatMap((h) => h.lines.filter((l) => l.type === "+").map((l) => l.text)).join("\n");
1202
+ const findings = scanSecrets(hunks.length > 0 ? added : content ?? preview ?? "");
1203
+ if (findings.length > 0) {
1204
+ const first = findings[0];
1205
+ return {
1206
+ allowed: false,
1207
+ reason: t("policy.secretFound", { kind: first.kind, line: first.line })
1208
+ };
1209
+ }
1025
1210
  if (this.opts.mode === "plan") {
1026
1211
  return { allowed: false, reason: "plan mode: file modifications are disabled" };
1027
1212
  }
1028
1213
  if (this.opts.mode === "bypass") return { allowed: true };
1029
- const ok = await this.ask({ kind: "write", summary: `write ${d.rel}`, preview });
1030
- return ok ? { allowed: true } : { allowed: false, reason: "rejected by user" };
1214
+ const res = await this.ask({ kind: "write", summary: `write ${d.rel}`, preview, hunks });
1215
+ if (res === true) return { allowed: true };
1216
+ if (res === false) return { allowed: false, reason: "rejected by user" };
1217
+ const approved = new Set(res);
1218
+ if (approved.size === 0) return { allowed: false, reason: "rejected by user" };
1219
+ if (approved.size === hunks.length) return { allowed: true };
1220
+ return { allowed: true, content: applyHunks(oldContent, hunks, approved) };
1031
1221
  }
1032
1222
  async authorizeCommand(command) {
1223
+ const screen = screenCommand(command);
1224
+ if (screen.blocked) {
1225
+ return { allowed: false, reason: t("policy.blockedCommand", { reason: screen.reason ?? "" }) };
1226
+ }
1033
1227
  if (this.opts.mode === "plan") {
1034
1228
  return { allowed: false, reason: "plan mode: running commands is disabled" };
1035
1229
  }
1036
1230
  if (this.opts.mode === "bypass") return { allowed: true };
1037
1231
  if (isCommandPreApproved(this.opts.allowedCommands, command)) return { allowed: true };
1038
- const ok = await this.ask({ kind: "command", summary: `run: ${command}` });
1039
- return ok ? { allowed: true } : { allowed: false, reason: "rejected by user" };
1232
+ const res = await this.ask({ kind: "command", summary: `run: ${command}` });
1233
+ return res === true ? { allowed: true } : { allowed: false, reason: "rejected by user" };
1040
1234
  }
1041
1235
  async ask(req) {
1042
1236
  if (!this.opts.confirm) return false;
@@ -1265,8 +1459,8 @@ function makeDriver(kind, tools) {
1265
1459
  }
1266
1460
 
1267
1461
  // src/core/tools/edit-file.ts
1268
- import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
1269
- import { resolve as resolve2 } from "path";
1462
+ import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1463
+ import { resolve as resolve3 } from "path";
1270
1464
  import { z as z2 } from "zod";
1271
1465
  var Args = z2.object({
1272
1466
  path: z2.string().min(1),
@@ -1296,10 +1490,10 @@ var editFileTool = {
1296
1490
  output: "edit_file needs three arguments: 'path', 'search' (exact text to find), and 'replace'. Resend the tool call with all three filled in."
1297
1491
  };
1298
1492
  }
1299
- const abs = resolve2(ctx.workspace, args.data.path);
1493
+ const abs = resolve3(ctx.workspace, args.data.path);
1300
1494
  let content;
1301
1495
  try {
1302
- content = await readFile2(abs, "utf8");
1496
+ content = await readFile3(abs, "utf8");
1303
1497
  } catch (err) {
1304
1498
  return { ok: false, output: `Could not read file to edit: ${err.message}` };
1305
1499
  }
@@ -1317,11 +1511,12 @@ var editFileTool = {
1317
1511
  const decision = await ctx.permissions.authorizeWrite(
1318
1512
  args.data.path,
1319
1513
  `- ${firstLine(args.data.search)}
1320
- + ${firstLine(args.data.replace)}`
1514
+ + ${firstLine(args.data.replace)}`,
1515
+ updated
1321
1516
  );
1322
1517
  if (!decision.allowed) return { ok: false, output: `Edit denied: ${decision.reason}` };
1323
1518
  try {
1324
- await writeFile2(abs, updated, "utf8");
1519
+ await writeFile2(abs, decision.content ?? updated, "utf8");
1325
1520
  return { ok: true, output: `Edited ${args.data.path}.` };
1326
1521
  } catch (err) {
1327
1522
  return { ok: false, output: `Could not write edit: ${err.message}` };
@@ -1334,7 +1529,7 @@ function firstLine(s) {
1334
1529
 
1335
1530
  // src/core/tools/list-dir.ts
1336
1531
  import { readdir } from "fs/promises";
1337
- import { resolve as resolve3 } from "path";
1532
+ import { resolve as resolve4 } from "path";
1338
1533
  import { z as z3 } from "zod";
1339
1534
  var Args2 = z3.object({ path: z3.string().default(".") });
1340
1535
  var listDirTool = {
@@ -1355,7 +1550,7 @@ var listDirTool = {
1355
1550
  return { ok: false, output: `List denied: ${decision.reason}` };
1356
1551
  }
1357
1552
  try {
1358
- const entries = await readdir(resolve3(ctx.workspace, path), { withFileTypes: true });
1553
+ const entries = await readdir(resolve4(ctx.workspace, path), { withFileTypes: true });
1359
1554
  const lines = entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).sort();
1360
1555
  return { ok: true, output: lines.length ? lines.join("\n") : "(empty)" };
1361
1556
  } catch (err) {
@@ -1365,8 +1560,8 @@ var listDirTool = {
1365
1560
  };
1366
1561
 
1367
1562
  // src/core/tools/read-file.ts
1368
- import { readFile as readFile3 } from "fs/promises";
1369
- import { resolve as resolve4 } from "path";
1563
+ import { readFile as readFile4 } from "fs/promises";
1564
+ import { resolve as resolve5 } from "path";
1370
1565
  import { z as z4 } from "zod";
1371
1566
  var Args3 = z4.object({ path: z4.string().min(1) });
1372
1567
  var MAX_CHARS = 6e4;
@@ -1387,7 +1582,7 @@ var readFileTool = {
1387
1582
  const decision = ctx.permissions.authorizeRead(args.data.path);
1388
1583
  if (!decision.allowed) return { ok: false, output: `Read denied: ${decision.reason}` };
1389
1584
  try {
1390
- const content = await readFile3(resolve4(ctx.workspace, args.data.path), "utf8");
1585
+ const content = await readFile4(resolve5(ctx.workspace, args.data.path), "utf8");
1391
1586
  const truncated = content.length > MAX_CHARS;
1392
1587
  return {
1393
1588
  ok: true,
@@ -1448,8 +1643,8 @@ function clamp(s) {
1448
1643
  }
1449
1644
 
1450
1645
  // src/core/tools/search-file.ts
1451
- import { readdir as readdir2, readFile as readFile4, stat } from "fs/promises";
1452
- import { join as join2, resolve as resolve5 } from "path";
1646
+ import { readdir as readdir2, readFile as readFile5, stat } from "fs/promises";
1647
+ import { join as join2, resolve as resolve6 } from "path";
1453
1648
  import { z as z6 } from "zod";
1454
1649
  var Args5 = z6.object({
1455
1650
  query: z6.string().min(1),
@@ -1501,7 +1696,7 @@ var searchTool = {
1501
1696
  }
1502
1697
  const globRe = glob ? globToRegExp(glob) : void 0;
1503
1698
  const limit = max_results ?? DEFAULT_MAX_RESULTS;
1504
- const root = resolve5(ctx.workspace, path);
1699
+ const root = resolve6(ctx.workspace, path);
1505
1700
  const matches = [];
1506
1701
  let truncated = false;
1507
1702
  const walk = async (dir) => {
@@ -1527,7 +1722,7 @@ var searchTool = {
1527
1722
  try {
1528
1723
  const info = await stat(abs);
1529
1724
  if (info.size > MAX_FILE_BYTES) continue;
1530
- const content = await readFile4(abs, "utf8");
1725
+ const content = await readFile5(abs, "utf8");
1531
1726
  if (content.includes(NUL)) continue;
1532
1727
  const lines = content.split("\n");
1533
1728
  for (let i = 0; i < lines.length; i++) {
@@ -1572,7 +1767,7 @@ var FINISH_TOOL = {
1572
1767
 
1573
1768
  // src/core/tools/write-file.ts
1574
1769
  import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
1575
- import { dirname, resolve as resolve6 } from "path";
1770
+ import { dirname, resolve as resolve7 } from "path";
1576
1771
  import { z as z7 } from "zod";
1577
1772
  var Args6 = z7.object({ path: z7.string().min(1), content: z7.string() });
1578
1773
  var writeFileTool = {
@@ -1599,13 +1794,14 @@ var writeFileTool = {
1599
1794
  };
1600
1795
  }
1601
1796
  const preview = previewContent(args.data.content);
1602
- const decision = await ctx.permissions.authorizeWrite(args.data.path, preview);
1797
+ const decision = await ctx.permissions.authorizeWrite(args.data.path, preview, args.data.content);
1603
1798
  if (!decision.allowed) return { ok: false, output: `Write denied: ${decision.reason}` };
1799
+ const finalContent = decision.content ?? args.data.content;
1604
1800
  try {
1605
- const abs = resolve6(ctx.workspace, args.data.path);
1801
+ const abs = resolve7(ctx.workspace, args.data.path);
1606
1802
  await mkdir2(dirname(abs), { recursive: true });
1607
- await writeFile3(abs, args.data.content, "utf8");
1608
- const lines = args.data.content.split("\n").length;
1803
+ await writeFile3(abs, finalContent, "utf8");
1804
+ const lines = finalContent.split("\n").length;
1609
1805
  return { ok: true, output: `Wrote ${args.data.path} (${lines} lines).` };
1610
1806
  } catch (err) {
1611
1807
  return { ok: false, output: `Could not write file: ${err.message}` };
@@ -1634,8 +1830,8 @@ function getTool(name) {
1634
1830
  }
1635
1831
 
1636
1832
  // src/core/agent/correction.ts
1637
- import { readFile as readFile5, readdir as readdir3 } from "fs/promises";
1638
- import { dirname as dirname2, resolve as resolve7 } from "path";
1833
+ import { readFile as readFile6, readdir as readdir3 } from "fs/promises";
1834
+ import { dirname as dirname2, resolve as resolve8 } from "path";
1639
1835
  function truncationGuidance(toolName) {
1640
1836
  const fileHint = toolName === "write_file" || toolName === "edit_file" ? " Write large files in parts: create the file with the first chunk via write_file, then append the rest with edit_file in the next steps." : "";
1641
1837
  return [
@@ -1735,7 +1931,7 @@ ${text2}` : null;
1735
1931
  }
1736
1932
  async function readWorkspaceFile(workspace, path) {
1737
1933
  try {
1738
- return await readFile5(resolve7(workspace, path), "utf8");
1934
+ return await readFile6(resolve8(workspace, path), "utf8");
1739
1935
  } catch {
1740
1936
  return null;
1741
1937
  }
@@ -1793,11 +1989,11 @@ async function occurrenceLines(workspace, path, search) {
1793
1989
  return out;
1794
1990
  }
1795
1991
  async function listNearest(workspace, path) {
1796
- let dir = dirname2(resolve7(workspace, path));
1992
+ let dir = dirname2(resolve8(workspace, path));
1797
1993
  for (let i = 0; i < 8; i++) {
1798
1994
  try {
1799
1995
  const entries = await readdir3(dir, { withFileTypes: true });
1800
- const rel = dir === resolve7(workspace) ? "." : dir;
1996
+ const rel = dir === resolve8(workspace) ? "." : dir;
1801
1997
  const names = entries.slice(0, 40).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
1802
1998
  return `${rel}:
1803
1999
  ${names.join(" ") || "(empty)"}`;
@@ -1819,14 +2015,14 @@ function formatSchema(spec) {
1819
2015
  }
1820
2016
 
1821
2017
  // src/core/agent/project-context.ts
1822
- import { readFile as readFile6 } from "fs/promises";
2018
+ import { readFile as readFile7 } from "fs/promises";
1823
2019
  import { join as join3 } from "path";
1824
2020
  var INSTRUCTION_FILES = [join3(".poly", "agents.md"), "AGENTS.md"];
1825
2021
  var MAX_CHARS2 = 8e3;
1826
2022
  async function loadProjectInstructions(workspace) {
1827
2023
  for (const rel of INSTRUCTION_FILES) {
1828
2024
  try {
1829
- const raw = (await readFile6(join3(workspace, rel), "utf8")).trim();
2025
+ const raw = (await readFile7(join3(workspace, rel), "utf8")).trim();
1830
2026
  if (!raw) continue;
1831
2027
  return raw.length > MAX_CHARS2 ? raw.slice(0, MAX_CHARS2) + "\n\u2026(truncated)" : raw;
1832
2028
  } catch {
@@ -1987,8 +2183,8 @@ ${guidance}`;
1987
2183
  }
1988
2184
 
1989
2185
  // src/core/context/mentions.ts
1990
- import { readdir as readdir4, readFile as readFile7, stat as stat2 } from "fs/promises";
1991
- import { resolve as resolve8 } from "path";
2186
+ import { readdir as readdir4, readFile as readFile8, stat as stat2 } from "fs/promises";
2187
+ import { resolve as resolve9 } from "path";
1992
2188
  var MAX_FILE_CHARS = 1e4;
1993
2189
  var MENTION_RE = /(?:^|\s)@([\w./-]+)/g;
1994
2190
  async function resolveMentions(task, policy) {
@@ -2004,7 +2200,7 @@ async function resolveMentions(task, policy) {
2004
2200
  ${t("mentions.notFound", { path: token })}`);
2005
2201
  continue;
2006
2202
  }
2007
- const abs = resolve8(policy.workspace, token);
2203
+ const abs = resolve9(policy.workspace, token);
2008
2204
  try {
2009
2205
  const info = await stat2(abs);
2010
2206
  if (info.isDirectory()) {
@@ -2014,7 +2210,7 @@ ${t("mentions.notFound", { path: token })}`);
2014
2210
  ${listing || "(empty)"}`);
2015
2211
  injected.push(decision.rel);
2016
2212
  } else {
2017
- const raw = await readFile7(abs, "utf8");
2213
+ const raw = await readFile8(abs, "utf8");
2018
2214
  const content = raw.length > MAX_FILE_CHARS ? raw.slice(0, MAX_FILE_CHARS) + "\n\u2026[truncated]" : raw;
2019
2215
  blocks.push(`## @${decision.rel}
2020
2216
  \`\`\`
@@ -2728,10 +2924,10 @@ async function readLineTTY(prompt) {
2728
2924
  stdin.resume();
2729
2925
  stdin.on("data", onData);
2730
2926
  try {
2731
- const line = await new Promise((resolve10) => {
2732
- rl.question(prompt).then(resolve10, () => resolve10(null));
2733
- rl.on("SIGINT", () => resolve10(null));
2734
- rl.on("close", () => resolve10(null));
2927
+ const line = await new Promise((resolve11) => {
2928
+ rl.question(prompt).then(resolve11, () => resolve11(null));
2929
+ rl.on("SIGINT", () => resolve11(null));
2930
+ rl.on("close", () => resolve11(null));
2735
2931
  });
2736
2932
  return line === null ? null : store.expand(line);
2737
2933
  } finally {
@@ -3483,11 +3679,39 @@ function listenForCancel(controller) {
3483
3679
  return { pause: detach, resume: attach, dispose: detach };
3484
3680
  }
3485
3681
  async function confirmAction(req) {
3682
+ if (req.kind === "write" && req.hunks && req.hunks.length > 0) {
3683
+ renderDiff(req.hunks);
3684
+ const options = [
3685
+ { value: "approve", label: t("review.approveAll") },
3686
+ { value: "reject", label: t("review.reject") },
3687
+ ...req.hunks.length > 1 ? [{ value: "hunks", label: t("review.pickHunks") }] : []
3688
+ ];
3689
+ const choice = await p2.select({ message: t("run.confirm", { summary: req.summary }), options });
3690
+ if (p2.isCancel(choice) || choice === "reject") return false;
3691
+ if (choice === "approve") return true;
3692
+ const selected = await p2.multiselect({
3693
+ message: t("review.selectHunks"),
3694
+ options: req.hunks.map((h, i) => ({ value: i, label: hunkLabel(h) })),
3695
+ required: false
3696
+ });
3697
+ if (p2.isCancel(selected)) return false;
3698
+ return selected;
3699
+ }
3486
3700
  if (req.preview) console.log(pc8.dim(req.preview));
3487
3701
  const answer = await p2.confirm({ message: t("run.confirm", { summary: req.summary }) });
3488
3702
  if (p2.isCancel(answer)) return false;
3489
3703
  return answer === true;
3490
3704
  }
3705
+ function renderDiff(hunks) {
3706
+ for (const h of hunks) {
3707
+ console.log(pc8.cyan(`@@ -${h.oldStart + 1},${h.oldCount} +${h.newStart + 1},${h.newCount} @@`));
3708
+ for (const l of h.lines) {
3709
+ if (l.type === "+") console.log(pc8.green(`+${l.text}`));
3710
+ else if (l.type === "-") console.log(pc8.red(`-${l.text}`));
3711
+ else console.log(pc8.dim(` ${l.text}`));
3712
+ }
3713
+ }
3714
+ }
3491
3715
  function renderEvents(spinner3) {
3492
3716
  return {
3493
3717
  onStep() {
@@ -3876,7 +4100,7 @@ async function resolveOpenRouterKey() {
3876
4100
  }
3877
4101
 
3878
4102
  // src/cli/commands/prd.ts
3879
- import { writeFile as writeFile5, readFile as readFile8 } from "fs/promises";
4103
+ import { writeFile as writeFile5, readFile as readFile9 } from "fs/promises";
3880
4104
  import { execFile } from "child_process";
3881
4105
  import { promisify as promisify2 } from "util";
3882
4106
  import pc11 from "picocolors";
@@ -3968,13 +4192,13 @@ async function withRetry(fn, opts = {}) {
3968
4192
 
3969
4193
  // src/cli/commands/cli-io.ts
3970
4194
  import { readFileSync, existsSync as existsSync2 } from "fs";
3971
- import { resolve as resolve9 } from "path";
4195
+ import { resolve as resolve10 } from "path";
3972
4196
  var GUIDE_MAX = 12e3;
3973
4197
  function readProjectGuide(files) {
3974
4198
  const parts = [];
3975
4199
  for (const file of files) {
3976
4200
  try {
3977
- const path = resolve9(process.cwd(), file);
4201
+ const path = resolve10(process.cwd(), file);
3978
4202
  if (existsSync2(path)) parts.push(`# ${file}
3979
4203
  ${readFileSync(path, "utf8").trim()}`);
3980
4204
  } catch {
@@ -4015,7 +4239,7 @@ async function prd(issueRef, opts) {
4015
4239
  }
4016
4240
  async function loadIssue(issueRef, input) {
4017
4241
  if (input) {
4018
- const raw = input === "-" ? await readStdin() : await readFile8(input, "utf8");
4242
+ const raw = input === "-" ? await readStdin() : await readFile9(input, "utf8");
4019
4243
  return normalize2(JSON.parse(stripBom(raw)));
4020
4244
  }
4021
4245
  const num = numericRef(issueRef);
@@ -4034,7 +4258,7 @@ function normalize2(raw) {
4034
4258
  }
4035
4259
 
4036
4260
  // src/cli/commands/review.ts
4037
- import { writeFile as writeFile6, readFile as readFile9 } from "fs/promises";
4261
+ import { writeFile as writeFile6, readFile as readFile10 } from "fs/promises";
4038
4262
  import { execFile as execFile2 } from "child_process";
4039
4263
  import { promisify as promisify3 } from "util";
4040
4264
  import pc12 from "picocolors";
@@ -4110,7 +4334,7 @@ async function review(prRef, opts) {
4110
4334
  }
4111
4335
  }
4112
4336
  async function loadDiff(num, input) {
4113
- if (input) return input === "-" ? readStdin() : readFile9(input, "utf8");
4337
+ if (input) return input === "-" ? readStdin() : readFile10(input, "utf8");
4114
4338
  const { stdout: stdout2 } = await exec3("gh", ["pr", "diff", num]);
4115
4339
  return stdout2;
4116
4340
  }