@jpkme/diff-review 0.1.14 → 0.1.16

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.
Files changed (129) hide show
  1. package/bin/diff-review.mjs +141 -77
  2. package/lib/acp-policy.mjs +10 -2
  3. package/lib/acp-policy.test.mjs +27 -0
  4. package/lib/acp-shell.mjs +23 -1
  5. package/lib/acp-shell.test.mjs +7 -0
  6. package/lib/output-name.mjs +7 -0
  7. package/lib/prompt.mjs +3 -2
  8. package/lib/prompt.test.mjs +37 -0
  9. package/lib/review-dir.mjs +14 -1
  10. package/lib/review.mjs +36 -4
  11. package/lib/review.test.mjs +35 -0
  12. package/lib/validate.mjs +10 -0
  13. package/lib/validate.test.mjs +47 -0
  14. package/package.json +1 -1
  15. package/schema.json +16 -1
  16. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css +1 -0
  17. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css.br +0 -0
  18. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css.gz +0 -0
  19. package/web/build/client/_app/immutable/assets/{storage.C8XGESwn.css → storage.BxR6930R.css} +1 -1
  20. package/web/build/client/_app/immutable/assets/storage.BxR6930R.css.br +0 -0
  21. package/web/build/client/_app/immutable/assets/{storage.C8XGESwn.css.gz → storage.BxR6930R.css.gz} +0 -0
  22. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js +1 -0
  23. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js.br +1 -0
  24. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js.gz +0 -0
  25. package/web/build/client/_app/immutable/chunks/{D8WOD_eS.js → CXhIC34H.js} +1 -1
  26. package/web/build/client/_app/immutable/chunks/CXhIC34H.js.br +0 -0
  27. package/web/build/client/_app/immutable/chunks/CXhIC34H.js.gz +0 -0
  28. package/web/build/client/_app/immutable/chunks/{C0hrdiUt.js → DHO7NTh1.js} +2 -2
  29. package/web/build/client/_app/immutable/chunks/DHO7NTh1.js.br +0 -0
  30. package/web/build/client/_app/immutable/chunks/DHO7NTh1.js.gz +0 -0
  31. package/web/build/client/_app/immutable/chunks/DpxY75GB.js +215 -0
  32. package/web/build/client/_app/immutable/chunks/DpxY75GB.js.br +0 -0
  33. package/web/build/client/_app/immutable/chunks/DpxY75GB.js.gz +0 -0
  34. package/web/build/client/_app/immutable/chunks/{FOc6rgIW.js → cYiFfRa3.js} +1 -1
  35. package/web/build/client/_app/immutable/chunks/cYiFfRa3.js.br +1 -0
  36. package/web/build/client/_app/immutable/chunks/cYiFfRa3.js.gz +0 -0
  37. package/web/build/client/_app/immutable/entry/{app.DcAtSJ3n.js → app.CCv57mvp.js} +2 -2
  38. package/web/build/client/_app/immutable/entry/app.CCv57mvp.js.br +0 -0
  39. package/web/build/client/_app/immutable/entry/app.CCv57mvp.js.gz +0 -0
  40. package/web/build/client/_app/immutable/entry/start.B012TSbW.js +1 -0
  41. package/web/build/client/_app/immutable/entry/start.B012TSbW.js.br +1 -0
  42. package/web/build/client/_app/immutable/entry/start.B012TSbW.js.gz +0 -0
  43. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js +1 -0
  44. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js.br +0 -0
  45. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js.gz +0 -0
  46. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js +1 -0
  47. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js.br +0 -0
  48. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js.gz +0 -0
  49. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js +10 -0
  50. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js.br +0 -0
  51. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js.gz +0 -0
  52. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js +173 -0
  53. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js.br +0 -0
  54. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js.gz +0 -0
  55. package/web/build/client/_app/version.json +1 -1
  56. package/web/build/client/_app/version.json.br +0 -0
  57. package/web/build/client/_app/version.json.gz +0 -0
  58. package/web/build/handler.js +3 -3
  59. package/web/build/index.js +3 -3
  60. package/web/build/server/chunks/{0-DUStDOsr.js → 0-D7NSxn1-.js} +2 -2
  61. package/web/build/server/chunks/{0-DUStDOsr.js.map → 0-D7NSxn1-.js.map} +1 -1
  62. package/web/build/server/chunks/1-DboYz8Fw.js +7 -0
  63. package/web/build/server/chunks/{1-BFhhV5JB.js.map → 1-DboYz8Fw.js.map} +1 -1
  64. package/web/build/server/chunks/2-B7LX817Q.js +7 -0
  65. package/web/build/server/chunks/2-B7LX817Q.js.map +1 -0
  66. package/web/build/server/chunks/3-DgJ_LmTL.js +7 -0
  67. package/web/build/server/chunks/{3-BV-TIUp1.js.map → 3-DgJ_LmTL.js.map} +1 -1
  68. package/web/build/server/chunks/{_server.ts-CdFoXw36.js → _server.ts-CP5cRLhW.js} +4 -4
  69. package/web/build/server/chunks/{_server.ts-CdFoXw36.js.map → _server.ts-CP5cRLhW.js.map} +1 -1
  70. package/web/build/server/chunks/{agent-sessions-D0WwtWgR.js → agent-sessions-2XGojSJ_.js} +105 -16
  71. package/web/build/server/chunks/agent-sessions-2XGojSJ_.js.map +1 -0
  72. package/web/build/server/chunks/{git-review-CgcBIbvM.js → git-review-jZ_JJYfv.js} +36 -8
  73. package/web/build/server/chunks/git-review-jZ_JJYfv.js.map +1 -0
  74. package/web/build/server/chunks/{handler-DbxjtK9M.js → handler-B_8yUXji.js} +3 -3
  75. package/web/build/server/chunks/{handler-DbxjtK9M.js.map → handler-B_8yUXji.js.map} +1 -1
  76. package/web/build/server/chunks/{hooks.server-Dg6gYUqs.js → hooks.server-rF_eBCff.js} +3 -3
  77. package/web/build/server/chunks/{hooks.server-Dg6gYUqs.js.map → hooks.server-rF_eBCff.js.map} +1 -1
  78. package/web/build/server/chunks/{index.js-NNne-njN.js → index.js-CGw8_Q7V.js} +884 -881
  79. package/web/build/server/chunks/index.js-CGw8_Q7V.js.map +1 -0
  80. package/web/build/server/chunks/{manifest.js-V8ViI5bl.js → manifest.js-CImZFS6G.js} +9 -9
  81. package/web/build/server/chunks/{manifest.js-V8ViI5bl.js.map → manifest.js-CImZFS6G.js.map} +1 -1
  82. package/web/build/server/chunks/{remote-1g4ayag-CCsG2QXw.js → remote-1g4ayag-BMpjk8UD.js} +4 -4
  83. package/web/build/server/chunks/{remote-1g4ayag-CCsG2QXw.js.map → remote-1g4ayag-BMpjk8UD.js.map} +1 -1
  84. package/web/build/server/chunks/{remote-wh6t6j-BCvmX37z.js → remote-wh6t6j-dYLCg8ks.js} +19 -7
  85. package/web/build/server/chunks/remote-wh6t6j-dYLCg8ks.js.map +1 -0
  86. package/web/build/server/chunks/{validate-CAQ31C7k.js → validate-Da6e71V3.js} +34 -5
  87. package/web/build/server/chunks/validate-Da6e71V3.js.map +1 -0
  88. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +0 -1
  89. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
  90. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
  91. package/web/build/client/_app/immutable/assets/storage.C8XGESwn.css.br +0 -0
  92. package/web/build/client/_app/immutable/chunks/C0hrdiUt.js.br +0 -0
  93. package/web/build/client/_app/immutable/chunks/C0hrdiUt.js.gz +0 -0
  94. package/web/build/client/_app/immutable/chunks/CpeIO6nz.js +0 -210
  95. package/web/build/client/_app/immutable/chunks/CpeIO6nz.js.br +0 -0
  96. package/web/build/client/_app/immutable/chunks/CpeIO6nz.js.gz +0 -0
  97. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  98. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  99. package/web/build/client/_app/immutable/chunks/FOc6rgIW.js.br +0 -2
  100. package/web/build/client/_app/immutable/chunks/FOc6rgIW.js.gz +0 -0
  101. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +0 -1
  102. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  103. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  104. package/web/build/client/_app/immutable/entry/app.DcAtSJ3n.js.br +0 -0
  105. package/web/build/client/_app/immutable/entry/app.DcAtSJ3n.js.gz +0 -0
  106. package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js +0 -1
  107. package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js.br +0 -0
  108. package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js.gz +0 -0
  109. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js +0 -1
  110. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.br +0 -0
  111. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.gz +0 -0
  112. package/web/build/client/_app/immutable/nodes/1.E-Hs7ura.js +0 -1
  113. package/web/build/client/_app/immutable/nodes/1.E-Hs7ura.js.br +0 -4
  114. package/web/build/client/_app/immutable/nodes/1.E-Hs7ura.js.gz +0 -0
  115. package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js +0 -10
  116. package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js.br +0 -0
  117. package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js.gz +0 -0
  118. package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js +0 -171
  119. package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js.br +0 -0
  120. package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js.gz +0 -0
  121. package/web/build/server/chunks/1-BFhhV5JB.js +0 -7
  122. package/web/build/server/chunks/2-BZ_MVqj8.js +0 -7
  123. package/web/build/server/chunks/2-BZ_MVqj8.js.map +0 -1
  124. package/web/build/server/chunks/3-BV-TIUp1.js +0 -7
  125. package/web/build/server/chunks/agent-sessions-D0WwtWgR.js.map +0 -1
  126. package/web/build/server/chunks/git-review-CgcBIbvM.js.map +0 -1
  127. package/web/build/server/chunks/index.js-NNne-njN.js.map +0 -1
  128. package/web/build/server/chunks/remote-wh6t6j-BCvmX37z.js.map +0 -1
  129. package/web/build/server/chunks/validate-CAQ31C7k.js.map +0 -1
@@ -233,8 +233,9 @@ function ensureValue(value, label) {
233
233
  * Selects interactivos salvo que --branch y --base vengan juntos.
234
234
  * @param {{ listed: { branches: string[], current: string, defaultBase: string }, repo: string }} launch
235
235
  * @param {{ branch: string, base: string }} opts
236
+ * @param {{ skipIntro?: boolean }} [pickOpts]
236
237
  */
237
- async function resolveBranchBase(launch, opts) {
238
+ async function resolveBranchBase(launch, opts, pickOpts = {}) {
238
239
  const flagBranch = String(opts.branch || '').trim();
239
240
  const flagBase = String(opts.base || '').trim();
240
241
  const bothFlags = Boolean(flagBranch && flagBase);
@@ -255,61 +256,86 @@ async function resolveBranchBase(launch, opts) {
255
256
  }
256
257
 
257
258
  const options = branchOptions(launch.listed);
258
- p.intro('diff-review');
259
- p.log.info(launch.repo);
259
+ if (!pickOpts.skipIntro) {
260
+ p.intro('diff-review');
261
+ p.log.info(launch.repo);
262
+ }
260
263
 
264
+ /** @type {string} */
261
265
  let branch = flagBranch;
262
- if (!branch) {
263
- branch = ensureValue(
264
- await autocompleteEs({
265
- message: 'Branch a revisar',
266
- options,
267
- initialValue: defaultBranch(launch.listed) || undefined,
268
- maxItems: 12
269
- }),
270
- 'branch'
271
- );
272
- } else {
273
- p.log.step(`Branch: ${branch}`);
274
- }
266
+ /** @type {string} */
267
+ let base = flagBase;
268
+ let first = true;
275
269
 
276
- const baseOptions = branchOptions(launch.listed, branch);
277
- if (!baseOptions.length) {
278
- p.cancel(`No hay otra base disponible distinta de \`${branch}\`.`);
279
- process.exit(1);
280
- }
270
+ for (;;) {
271
+ if (!first) {
272
+ // Diff vacío / tips iguales: volver a preguntar branch y base (no solo la base).
273
+ branch = '';
274
+ base = '';
275
+ }
276
+ first = false;
277
+
278
+ if (!branch) {
279
+ branch = ensureValue(
280
+ await autocompleteEs({
281
+ message: 'Branch a revisar',
282
+ options,
283
+ initialValue: defaultBranch(launch.listed) || undefined,
284
+ maxItems: 12
285
+ }),
286
+ 'branch'
287
+ );
288
+ } else {
289
+ p.log.step(`Branch: ${branch}`);
290
+ }
281
291
 
282
- let base = flagBase;
283
- if (!base) {
284
- base = ensureValue(
285
- await autocompleteEs({
286
- message: 'Base (merge-base)',
287
- options: baseOptions,
288
- initialValue: defaultBase(launch.listed, branch) || undefined,
289
- maxItems: 12
290
- }),
291
- 'base'
292
- );
293
- } else {
294
- p.log.step(`Base: ${base}`);
295
- }
292
+ const baseOptions = branchOptions(launch.listed, branch);
293
+ if (!baseOptions.length) {
294
+ p.cancel(`No hay otra base disponible distinta de \`${branch}\`.`);
295
+ process.exit(1);
296
+ }
296
297
 
297
- while (branch === base) {
298
- p.log.warn(`Branch y base son iguales (${branch}): el diff estaría vacío.`);
299
- base = ensureValue(
300
- await autocompleteEs({
301
- message: 'Elegí otra base',
302
- options: baseOptions,
303
- initialValue: defaultBase(launch.listed, branch) || undefined,
304
- maxItems: 12
305
- }),
306
- 'base'
307
- );
298
+ if (!base) {
299
+ base = ensureValue(
300
+ await autocompleteEs({
301
+ message: 'Base (merge-base)',
302
+ options: baseOptions,
303
+ initialValue: defaultBase(launch.listed, branch) || undefined,
304
+ maxItems: 12
305
+ }),
306
+ 'base'
307
+ );
308
+ } else {
309
+ p.log.step(`Base: ${base}`);
310
+ }
311
+
312
+ if (branch === base || sameCommitTips(launch.repo, branch, base)) {
313
+ p.log.warn(emptyDiffPickMessage(branch, base));
314
+ continue;
315
+ }
316
+ break;
308
317
  }
309
318
 
310
319
  return { branch, base, prompted: true };
311
320
  }
312
321
 
322
+ /** ¿Branch y base resuelven al mismo SHA? (diff committed vacío aunque los nombres difieran). */
323
+ function sameCommitTips(repo, branch, base) {
324
+ try {
325
+ const { branchSha, baseSha } = branchTips(repo, branch, base);
326
+ return Boolean(branchSha && baseSha && branchSha === baseSha);
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ function emptyDiffPickMessage(branch, base) {
333
+ if (branch === base) {
334
+ return `Branch y base son iguales (${branch}): no hay diff que revisar.`;
335
+ }
336
+ return `\`${branch}\` y \`${base}\` apuntan al mismo commit: no hay diff que revisar.`;
337
+ }
338
+
313
339
  function buildOutputFilename(repo, branch, base) {
314
340
  try {
315
341
  const tips = branchTips(repo, branch, base);
@@ -327,10 +353,10 @@ function buildOutputFilename(repo, branch, base) {
327
353
  }
328
354
 
329
355
  /**
330
- * Precomputa el diff en CLI (spinner si hay Clack). Si el resultado no tiene archivos, sale con error.
331
- * @returns {ReturnType<typeof prepareReviewPromptOpts>}
356
+ * Precomputa el diff en CLI (spinner si hay Clack).
357
+ * @returns {{ empty: true, message: string } | { empty: false, promptOpts: ReturnType<typeof prepareReviewPromptOpts> }}
332
358
  */
333
- function prepareContextOrFail(launch, branch, base, outputFilename, { useClack }) {
359
+ function prepareContext(launch, branch, base, outputFilename, { useClack }) {
334
360
  const spin = useClack ? p.spinner() : null;
335
361
  if (!useClack) console.log('Preparando el contexto…');
336
362
  spin?.start('Preparando el contexto…');
@@ -351,22 +377,22 @@ function prepareContextOrFail(launch, branch, base, outputFilename, { useClack }
351
377
  else console.warn('No pude precomputar el diff:', msg);
352
378
  ensureReviewDir(launch.repo);
353
379
  writeDiffCache(launch.repo, outputFilename, { branch, base });
354
- return promptOpts;
380
+ return { empty: false, promptOpts };
355
381
  }
356
382
 
357
383
  const n = Array.isArray(promptOpts.files) ? promptOpts.files.length : 0;
358
384
  if (n === 0) {
359
385
  spin?.stop('Diff vacío');
360
- const msg = `El diff entre \`${branch}\` y \`${base}\` está vacío: no hay nada que revisar.`;
361
- if (useClack) p.cancel(msg);
362
- else console.error(msg);
363
- process.exit(1);
386
+ return {
387
+ empty: true,
388
+ message: `El diff entre \`${branch}\` y \`${base}\` está vacío: no hay nada que revisar.`
389
+ };
364
390
  }
365
391
 
366
392
  const done = `Contexto listo · ${n} archivo${n === 1 ? '' : 's'}`;
367
393
  spin?.stop(done);
368
394
  if (!useClack) console.log(done);
369
- return promptOpts;
395
+ return { empty: false, promptOpts };
370
396
  }
371
397
 
372
398
  function copyPromptOrWarn(prompt) {
@@ -674,9 +700,15 @@ async function runUiMode(opts, launch, launchCwd) {
674
700
  if (launch) {
675
701
  console.log(`Repo: ${launch.repo}`);
676
702
  console.log(`Branch: ${branch} (base: ${base})`);
677
- const promptOpts = prepareContextOrFail(launch, branch, base, outputFilename, {
678
- useClack: false
679
- });
703
+ if (branch === base || sameCommitTips(launch.repo, branch, base)) {
704
+ console.error(emptyDiffPickMessage(branch, base));
705
+ process.exit(1);
706
+ }
707
+ const prep = prepareContext(launch, branch, base, outputFilename, { useClack: false });
708
+ if (prep.empty) {
709
+ console.error(prep.message);
710
+ process.exit(1);
711
+ }
680
712
  const prompt = buildPrompt(
681
713
  {
682
714
  source: 'local',
@@ -685,7 +717,7 @@ async function runUiMode(opts, launch, launchCwd) {
685
717
  base,
686
718
  remoteUrl: launch.remoteUrl
687
719
  },
688
- promptOpts
720
+ prep.promptOpts
689
721
  );
690
722
  if (copyTextToClipboard(prompt)) {
691
723
  console.log('Prompt copiado al portapapeles.');
@@ -734,27 +766,59 @@ async function main() {
734
766
  process.exit(1);
735
767
  }
736
768
 
737
- const { branch, base, prompted } = await resolveBranchBase(launch, opts);
769
+ /** @type {{ branch: string, base: string }} */
770
+ let pick = { branch: opts.branch, base: opts.base };
771
+ let skipIntro = false;
772
+ /** @type {string} */
773
+ let branch;
774
+ /** @type {string} */
775
+ let base;
776
+ /** @type {boolean} */
777
+ let prompted;
778
+ /** @type {string} */
779
+ let outputFilename;
780
+ /** @type {ReturnType<typeof prepareReviewPromptOpts>} */
781
+ let promptOpts;
738
782
 
739
- if (branch === base) {
740
- const msg = `Branch y base son iguales (${branch}): no hay diff que revisar.`;
741
- if (prompted) p.cancel(msg);
742
- else console.error(msg);
743
- process.exit(1);
744
- }
783
+ for (;;) {
784
+ ({ branch, base, prompted } = await resolveBranchBase(launch, pick, { skipIntro }));
785
+ if (prompted) skipIntro = true;
745
786
 
746
- const outputFilename = buildOutputFilename(launch.repo, branch, base);
747
- if (!prompted) {
748
- console.log(`Repo: ${launch.repo}`);
749
- console.log(`Branch: ${branch} (base: ${base})`);
750
- } else {
751
- p.note(`${branch} → ${base}\n${outputRelPath(outputFilename)}`, 'Review');
752
- }
787
+ if (branch === base || sameCommitTips(launch.repo, branch, base)) {
788
+ const msg = emptyDiffPickMessage(branch, base);
789
+ if (prompted || isInteractive()) {
790
+ p.log.warn(msg);
791
+ pick = { branch: '', base: '' };
792
+ continue;
793
+ }
794
+ console.error(msg);
795
+ process.exit(1);
796
+ }
753
797
 
754
- // El agente escribe en .diff-review/ (que se ignora sola) y lee de ahí el diff ya calculado.
755
- const promptOpts = prepareContextOrFail(launch, branch, base, outputFilename, {
756
- useClack: prompted || isInteractive()
757
- });
798
+ outputFilename = buildOutputFilename(launch.repo, branch, base);
799
+ if (!prompted) {
800
+ console.log(`Repo: ${launch.repo}`);
801
+ console.log(`Branch: ${branch} (base: ${base})`);
802
+ } else {
803
+ p.note(`${branch} → ${base}\n${outputRelPath(outputFilename)}`, 'Review');
804
+ }
805
+
806
+ // El agente escribe en .diff-review/ (que se ignora sola) y lee de ahí el diff ya calculado.
807
+ const prep = prepareContext(launch, branch, base, outputFilename, {
808
+ useClack: prompted || isInteractive()
809
+ });
810
+ if (prep.empty) {
811
+ if (prompted || isInteractive()) {
812
+ p.log.warn(prep.message);
813
+ pick = { branch: '', base: '' };
814
+ continue;
815
+ }
816
+ console.error(prep.message);
817
+ process.exit(1);
818
+ }
819
+ promptOpts = prep.promptOpts;
820
+ break;
821
+ }
758
822
 
759
823
  const prompt = buildPrompt(
760
824
  {
@@ -13,8 +13,9 @@ import { checkShell } from "./acp-shell.mjs";
13
13
  import { outputRelPath } from "./output-name.mjs";
14
14
 
15
15
  /**
16
- * @typedef {"review" | "ask" | "edit"} PolicyMode
17
- * review = generar el JSON; ask = preguntas (no puede escribir nada); edit = modificar el JSON.
16
+ * @typedef {"review" | "ask" | "edit" | "fix"} PolicyMode
17
+ * review = generar el JSON; ask = preguntas (no puede escribir nada); edit = modificar el JSON;
18
+ * fix = editar código dentro del repo (no solo el JSON).
18
19
  * @typedef {{ repo: string, outputFilename: string, mode: PolicyMode }} PolicyContext
19
20
  * @typedef {{
20
21
  * kind?: string | null,
@@ -190,6 +191,13 @@ export function decidePermission(toolCall, { repo, outputFilename, mode }) {
190
191
  if (kind === "edit" || kind === "delete") {
191
192
  if (mode === "ask") return { allow: false, reason: "modo pregunta: no se edita nada" };
192
193
  if (!paths.length) return { allow: false, reason: `${kind === "delete" ? "borrado" : "edición"} sin path` };
194
+ // fix mode: puede editar cualquier archivo dentro del repo (no solo el JSON de salida como edit mode).
195
+ if (mode === "fix") {
196
+ const outside = paths.find((p) => !isInside(p, repo));
197
+ return outside
198
+ ? { allow: false, reason: `path fuera del repo: ${outside}` }
199
+ : { allow: true, reason: "escritura en el repo" };
200
+ }
193
201
  const target = resolve(repo, outputRelPath(outputFilename));
194
202
  const outside = paths.find((p) => !samePath(canonicalPath(p), canonicalPath(target)));
195
203
  return outside
@@ -87,6 +87,33 @@ test("decidePermission: escritura solo del JSON objetivo, nunca en modo pregunta
87
87
  );
88
88
  });
89
89
 
90
+ test("decidePermission: modo fix permite edit/delete dentro del repo, deniega fuera", () => {
91
+ assert.equal(decidePermission(edit(join(repo, "lib", "a.mjs")), ctx("fix")).allow, true);
92
+ assert.equal(decidePermission(edit("lib/a.mjs"), ctx("fix")).allow, true);
93
+ assert.equal(decidePermission(edit(outPath), ctx("fix")).allow, true);
94
+ const home = process.platform === "win32" ? "C:\\Users\\me\\.ssh\\id_rsa" : "/home/me/.ssh/id_rsa";
95
+ assert.equal(decidePermission(edit(home), ctx("fix")).allow, false);
96
+ assert.equal(decidePermission(edit(join(repo, "..", "otro", "x")), ctx("fix")).allow, false);
97
+ assert.equal(decidePermission({ kind: "edit", title: "Edit", rawInput: {} }, ctx("fix")).allow, false);
98
+ assert.equal(
99
+ decidePermission(
100
+ { kind: "delete", title: "Delete", rawInput: { path: join(repo, "lib", "a.mjs") }, locations: [{ path: join(repo, "lib", "a.mjs") }] },
101
+ ctx("fix")
102
+ ).allow,
103
+ true
104
+ );
105
+ assert.equal(
106
+ decidePermission(
107
+ { kind: "delete", title: "Delete", rawInput: { path: home }, locations: [{ path: home }] },
108
+ ctx("fix")
109
+ ).allow,
110
+ false
111
+ );
112
+ // ask/edit JSON sin regresiones frente a paths de código.
113
+ assert.equal(decidePermission(edit(join(repo, "lib", "a.mjs")), ctx("ask")).allow, false);
114
+ assert.equal(decidePermission(edit(join(repo, "lib", "a.mjs")), ctx("edit")).allow, false);
115
+ });
116
+
90
117
  test("decidePermission: comandos según isAllowedShell y el resto rechazado", () => {
91
118
  assert.equal(decidePermission(bash("git diff a...b"), ctx("ask")).allow, true);
92
119
  assert.equal(decidePermission(bash("rm -rf ."), ctx()).allow, false);
package/lib/acp-shell.mjs CHANGED
@@ -5,6 +5,9 @@
5
5
  *
6
6
  * El comando se tokeniza respetando comillas (un `grep "a\|b"` no es un pipe). Se aceptan `|`, `&&`, `||`, `;` y
7
7
  * descartar salida a /dev/null; cualquier otra redirección, `$(…)`, backticks o `&` lo rechazan.
8
+ *
9
+ * En PowerShell, `(Get-Content f).Count` llega como `(Get-Content` + `f).Count`: se quitan paréntesis de
10
+ * agrupación y el `.Miembro` colgado al path/flag antes de evaluar la allowlist.
8
11
  */
9
12
  import { realpathSync } from "node:fs";
10
13
  import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
@@ -376,6 +379,23 @@ function checkReviewDirMkdir(argv, ctx) {
376
379
  return null;
377
380
  }
378
381
 
382
+ /**
383
+ * Quita agrupación PowerShell pegada al token: `(Get-Content` → `Get-Content`,
384
+ * `file.diff).Count` → `file.diff`, `-Line).Lines` → `-Line`.
385
+ * @param {string} token
386
+ */
387
+ function stripPsGrouping(token) {
388
+ let t = String(token ?? "");
389
+ t = t.replace(/^\(+/, "");
390
+ t = t.replace(/\)+(?:\.[A-Za-z_][\w]*(?:\(\)?)?)*$/, "");
391
+ return t;
392
+ }
393
+
394
+ /** @param {string[]} argv */
395
+ function normalizePsArgv(argv) {
396
+ return argv.map(stripPsGrouping).filter((w) => w.length > 0);
397
+ }
398
+
379
399
  /**
380
400
  * @param {string} command
381
401
  * @param {{ repo?: string, platform?: NodeJS.Platform }} [opts]
@@ -387,7 +407,9 @@ export function checkShell(command, { repo, platform = process.platform } = {})
387
407
  if (!parsed.pipelines.length) return { ok: false, reason: "comando vacío" };
388
408
  const ctx = { repo: repo || process.cwd(), platform };
389
409
  for (const pipeline of parsed.pipelines) {
390
- for (const argv of pipeline) {
410
+ for (const rawArgv of pipeline) {
411
+ const argv = normalizePsArgv(rawArgv);
412
+ if (!argv.length) continue;
391
413
  const name = argv[0].toLowerCase();
392
414
  if (argv[0].includes("=")) return { ok: false, reason: `variables de entorno en el comando: ${argv[0]}` };
393
415
  /** @type {ShellCheck} */
@@ -64,6 +64,10 @@ test("checkShell deja git de lectura, el validate y comandos que solo leen", ()
64
64
  "git diff a...b -- lib 2>/dev/null || true",
65
65
  "Get-Content lib/agents.mjs | Select-Object -First 40",
66
66
  "Select-String -Path lib/*.mjs -Pattern 'export'",
67
+ // Agrupación PowerShell: `(cmd).Property` (Copilot/Opus suele contar líneas así).
68
+ "(Get-Content lib/agents.mjs).Count",
69
+ "(Get-Content .diff-review/diff-rev_x.diff).Count",
70
+ "(Get-Content lib/a.mjs | Measure-Object -Line).Lines",
67
71
  ]) {
68
72
  assert.equal(ok(cmd), true, cmd);
69
73
  }
@@ -73,6 +77,9 @@ test("checkShell rechaza escrituras, ejecución y paths fuera del repo", () => {
73
77
  const home = win ? "C:\\Users\\me\\.ssh\\id_rsa" : "/home/me/.ssh/id_rsa";
74
78
  for (const cmd of [
75
79
  "",
80
+ // Agrupar no habilita comandos fuera de la allowlist.
81
+ "(Remove-Item lib/a.mjs)",
82
+ "(Invoke-WebRequest https://example.com).Content",
76
83
  "git checkout main",
77
84
  "git branch -D feat",
78
85
  "git push",
@@ -19,6 +19,13 @@ export function outputRelPath(filename) {
19
19
  return `${OUTPUT_DIR}/${String(filename || "").replace(/^.*[\\/]/, "")}`;
20
20
  }
21
21
 
22
+ /** Companion de handoff: mismo stem que el JSON → `.diff-review/<stem>.chat.md`. */
23
+ export function chatRelPath(filename) {
24
+ const name = String(filename || "").replace(/^.*[\\/]/, "");
25
+ const stem = name.replace(/\.json$/i, "") || "diff-rev";
26
+ return `${OUTPUT_DIR}/${stem}.chat.md`;
27
+ }
28
+
22
29
  /** Nombre legacy fijo. */
23
30
  export const OUTPUT_FILENAME_LEGACY = `${OUTPUT_PREFIX_LEGACY}.json`;
24
31
 
package/lib/prompt.mjs CHANGED
@@ -85,7 +85,8 @@ const RULES = `- Ids: themes use \`t1\`, \`t2\`, …; findings use \`f1\`, \`f2\
85
85
  - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
86
86
  - When several read-only lookups are independent (files, searches), batch them in the same turn if your tools allow parallel calls.
87
87
  - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.
88
- - Length is a soft target, not a check: aim for title ≤72 characters and what/why/fix ≤120 characters. Prefer clarity over shortening. Longer text is still valid. Do not rewrite the file only to shorten it.`;
88
+ - Length is a soft target, not a check: aim for title ≤72 characters and what/why/fix ≤120 characters. Prefer clarity over shortening. Longer text is still valid. Do not rewrite the file only to shorten it.
89
+ - If the output JSON already exists and findings have \`resolution\` ({ kind: ignored|fix|corrected, detail?: string }), preserve those fields on rewrite (coverage / edit / regenerate). Keep existing finding ids when possible; do not drop or reshuffle resolutions.`;
89
90
 
90
91
  /** @param {{ hasDiff?: boolean }} [ctx] */
91
92
  function workflowLocal(base, branch, ctx = {}) {
@@ -316,7 +317,7 @@ ${outputSchemaBlock(meta)}
316
317
 
317
318
  Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). "agent" and "model" are required: set "agent" to the product you are (Claude Code, Cursor, Codex, Copilot, Gemini, …) and "model" to the exact model id you are running. Do not leave placeholders or omit them; validation fails without both. "generatedAt" is optional (ISO-8601 now).
318
319
 
319
- Do NOT include diffs or a "files" array. The tool loads the companion \`.diff\` (or computes with git if missing) when opening the report. Do not invent hunks or paste patches into the JSON.
320
+ Do NOT include diffs or a "files" array. The tool loads the companion \`.diff\` (or computes with git if missing) when opening the report. Do not invent hunks or paste patches into the JSON. If you are rewriting an existing review JSON, copy through any per-finding \`resolution\` already present.
320
321
 
321
322
  ${validateSection}`;
322
323
  }
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { after, test } from "node:test";
6
+ import { buildPrompt } from "./prompt.mjs";
7
+ import { chatRelPath } from "./output-name.mjs";
8
+ import { companionPaths, writeHandoffChat } from "./review-dir.mjs";
9
+
10
+ const tmp = mkdtempSync(join(tmpdir(), "diff-review-prompt-"));
11
+ after(() => rmSync(tmp, { recursive: true, force: true }));
12
+
13
+ test("chatRelPath uses same stem as JSON", () => {
14
+ assert.equal(
15
+ chatRelPath("diff-rev_aaaaaaaaaa_bbbbbbbbbb.json"),
16
+ ".diff-review/diff-rev_aaaaaaaaaa_bbbbbbbbbb.chat.md"
17
+ );
18
+ });
19
+
20
+ test("writeHandoffChat overwrites companion .chat.md", () => {
21
+ const filename = "diff-rev_aaaaaaaaaa_bbbbbbbbbb.json";
22
+ writeHandoffChat(tmp, filename, "# first\n");
23
+ writeHandoffChat(tmp, filename, "# second\n");
24
+ const { chat } = companionPaths(tmp, filename);
25
+ assert.equal(readFileSync(chat, "utf8"), "# second\n");
26
+ assert.equal(chatRelPath(filename), `.diff-review/${filename.replace(/\.json$/i, "")}.chat.md`);
27
+ });
28
+
29
+ test("buildPrompt tells agent to preserve finding resolution on rewrite", () => {
30
+ const text = buildPrompt(
31
+ { source: "local", repo: "/tmp/r", branch: "feat", base: "develop" },
32
+ { outputFilename: "diff-rev_aaaaaaaaaa_bbbbbbbbbb.json" }
33
+ );
34
+ assert.match(text, /preserve those fields on rewrite/i);
35
+ assert.match(text, /resolution/);
36
+ assert.match(text, /copy through any per-finding `resolution`/i);
37
+ });
@@ -27,7 +27,7 @@ export function ensureReviewDir(repoRoot) {
27
27
  return dir;
28
28
  }
29
29
 
30
- /** `diff-rev_a_b.json` → `{ json, diff, session }` dentro de `.diff-review/`. */
30
+ /** `diff-rev_a_b.json` → `{ json, diff, session, chat }` dentro de `.diff-review/`. */
31
31
  export function companionPaths(repoRoot, filename) {
32
32
  const name = basename(String(filename || ""));
33
33
  const stem = name.replace(/\.json$/i, "");
@@ -36,6 +36,8 @@ export function companionPaths(repoRoot, filename) {
36
36
  json: join(dir, name),
37
37
  diff: join(dir, `${stem}.diff`),
38
38
  session: join(dir, `${stem}.session.json`),
39
+ /** Transcript legible al cambiar de agente (se pisa en cada switch). */
40
+ chat: join(dir, `${stem}.chat.md`),
39
41
  };
40
42
  }
41
43
 
@@ -106,3 +108,14 @@ export function writeSessionRef(repoRoot, filename, ref) {
106
108
  writeFileSync(companionPaths(repoRoot, filename).session, `${JSON.stringify(full, null, 2)}\n`);
107
109
  return full;
108
110
  }
111
+
112
+ /**
113
+ * Guarda (pisa) el transcript legible de handoff junto al JSON.
114
+ * @returns {string} path absoluto del `.chat.md`
115
+ */
116
+ export function writeHandoffChat(repoRoot, filename, text) {
117
+ ensureReviewDir(repoRoot);
118
+ const { chat } = companionPaths(repoRoot, filename);
119
+ writeFileSync(chat, String(text ?? ""));
120
+ return chat;
121
+ }
package/lib/review.mjs CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  BLOCK_OPS,
3
3
  FINDING_CLASSES,
4
4
  FINDING_KINDS,
5
+ FINDING_RESOLUTION_KINDS,
5
6
  FINDING_SEVERITIES,
6
7
  GROUP_KINDS,
7
8
  SKIP_REASONS,
@@ -111,6 +112,37 @@ function findingKey(finding) {
111
112
  return [finding.file, finding.line ?? "", finding.kind, finding.what].join("|");
112
113
  }
113
114
 
115
+ /** Triage UI (`ignored` | `fix` | `corrected`); se preserva al rehidratar desde disco. */
116
+ function normalizeResolution(raw) {
117
+ const kind = String(raw?.kind ?? "")
118
+ .trim()
119
+ .toLowerCase();
120
+ if (!FINDING_RESOLUTION_KINDS.includes(kind)) return undefined;
121
+ const detail = String(raw?.detail ?? "").trim();
122
+ return detail ? { kind, detail } : { kind };
123
+ }
124
+
125
+ function assignFindingIds(findings) {
126
+ const used = new Set();
127
+ for (const finding of findings) {
128
+ const id = String(finding.id || "").trim();
129
+ if (id && !used.has(id)) {
130
+ finding.id = id;
131
+ used.add(id);
132
+ } else {
133
+ finding.id = "";
134
+ }
135
+ }
136
+ let n = 1;
137
+ for (const finding of findings) {
138
+ if (finding.id) continue;
139
+ while (used.has(`f${n}`)) n += 1;
140
+ finding.id = `f${n}`;
141
+ used.add(finding.id);
142
+ n += 1;
143
+ }
144
+ }
145
+
114
146
  function normalizeFindings(raw, { blockIds, knownPaths }) {
115
147
  const seen = new Set();
116
148
  const findings = [];
@@ -123,8 +155,9 @@ function normalizeFindings(raw, { blockIds, knownPaths }) {
123
155
  const severity = oneOf(item.severity, FINDING_SEVERITIES, "med");
124
156
  const block = String(item?.block || "").trim();
125
157
  const line = Number.isFinite(Number(item?.line)) ? Number(item.line) : null;
158
+ const resolution = normalizeResolution(item?.resolution);
126
159
  const finding = {
127
- id: "",
160
+ id: String(item?.id || "").trim(),
128
161
  class: cls,
129
162
  severity,
130
163
  // Una mejora de calidad nunca frena un merge, diga lo que diga el modelo;
@@ -136,6 +169,7 @@ function normalizeFindings(raw, { blockIds, knownPaths }) {
136
169
  block: blockIds.has(block) ? block : "",
137
170
  what,
138
171
  fix: text(item.fix || item.detail, MAX.finding),
172
+ ...(resolution ? { resolution } : {}),
139
173
  };
140
174
  const key = findingKey(finding);
141
175
  if (seen.has(key)) continue;
@@ -150,9 +184,7 @@ function normalizeFindings(raw, { blockIds, knownPaths }) {
150
184
  rank[a.severity] - rank[b.severity] ||
151
185
  a.file.localeCompare(b.file),
152
186
  );
153
- findings.forEach((finding, i) => {
154
- finding.id = `f${i + 1}`;
155
- });
187
+ assignFindingIds(findings);
156
188
  return findings;
157
189
  }
158
190
 
@@ -130,6 +130,41 @@ test("buildReview deduplica hallazgos repetidos y limpia referencias colgadas",
130
130
  assert.equal(doc.findings[0].block, "");
131
131
  });
132
132
 
133
+ test("buildReview preserva resolution e ids al rehidratar triage desde disco", () => {
134
+ const withFindings = payload();
135
+ withFindings.findings = [
136
+ {
137
+ id: "f2",
138
+ class: "quality",
139
+ severity: "low",
140
+ kind: "docs",
141
+ file: "src/a.ts",
142
+ line: 3,
143
+ what: "comentario viejo",
144
+ fix: "actualizar",
145
+ resolution: { kind: "ignored" },
146
+ },
147
+ {
148
+ id: "f1",
149
+ class: "risk",
150
+ severity: "med",
151
+ kind: "bug",
152
+ file: "src/a.ts",
153
+ line: 22,
154
+ block: "b1",
155
+ what: "null sin chequear",
156
+ fix: "guardar",
157
+ resolution: { kind: "corrected", detail: "null-check en parse" },
158
+ },
159
+ ];
160
+ const doc = buildReview({ skeleton: skeleton(), payload: withFindings });
161
+ const byId = Object.fromEntries(doc.findings.map((f) => [f.id, f]));
162
+ assert.equal(doc.findings.length, 2);
163
+ assert.deepEqual(byId.f1.resolution, { kind: "corrected", detail: "null-check en parse" });
164
+ assert.deepEqual(byId.f2.resolution, { kind: "ignored" });
165
+ assert.equal(byId.f1.what, "null sin chequear");
166
+ });
167
+
133
168
  test("una respuesta JSON del modelo atraviesa decode, validación y normalización", () => {
134
169
  const answer = JSON.stringify({
135
170
  meta: { agent: "Claude Code", model: "claude-opus" },
package/lib/validate.mjs CHANGED
@@ -8,6 +8,7 @@ export const FINDING_KINDS = [
8
8
  "missing-test", "perf", "maintainability", "docs", "other",
9
9
  ];
10
10
  export const SKIP_REASONS = ["generated", "lockfile", "format", "vendored", "binary", "trivial", "ignored"];
11
+ export const FINDING_RESOLUTION_KINDS = ["ignored", "fix", "corrected"];
11
12
 
12
13
  /** Lo que tiene que devolver el modelo en un turno. El resto lo agrega el pipeline. */
13
14
  export const REVIEW_SCHEMA = {
@@ -77,6 +78,15 @@ export const REVIEW_SCHEMA = {
77
78
  block: { type: "string" },
78
79
  what: { type: "string", minLength: 1 },
79
80
  fix: { type: "string" },
81
+ resolution: {
82
+ type: "object",
83
+ required: ["kind"],
84
+ additionalProperties: true,
85
+ properties: {
86
+ kind: { type: "string", enum: FINDING_RESOLUTION_KINDS },
87
+ detail: { type: "string" },
88
+ },
89
+ },
80
90
  },
81
91
  },
82
92
  },