@jpkme/diff-review 0.1.7 → 0.1.12

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 (158) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +156 -34
  3. package/lib/acp-policy.mjs +136 -0
  4. package/lib/acp-policy.test.mjs +83 -0
  5. package/lib/acp-shell.mjs +379 -0
  6. package/lib/acp-shell.test.mjs +127 -0
  7. package/lib/acp.mjs +368 -0
  8. package/lib/acp.test.mjs +58 -0
  9. package/lib/agent-login.mjs +105 -0
  10. package/lib/agents.mjs +102 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +31 -12
  13. package/lib/check-report.test.mjs +1 -0
  14. package/lib/extract-cache.test.mjs +162 -0
  15. package/lib/extract.mjs +197 -1
  16. package/lib/model-label.mjs +263 -0
  17. package/lib/model-label.test.mjs +77 -0
  18. package/lib/models-cache.mjs +75 -0
  19. package/lib/models-cache.test.mjs +50 -0
  20. package/lib/output-name.mjs +8 -0
  21. package/lib/prompt.mjs +178 -31
  22. package/lib/review-dir.mjs +108 -0
  23. package/lib/review-meta.mjs +35 -0
  24. package/lib/review-meta.test.mjs +41 -0
  25. package/lib/review-prep.mjs +63 -0
  26. package/lib/review.test.mjs +1 -0
  27. package/lib/validate.mjs +28 -11
  28. package/lib/validate.test.mjs +25 -0
  29. package/lib/wait-output.mjs +46 -23
  30. package/lib/wait-output.test.mjs +25 -1
  31. package/package.json +3 -1
  32. package/schema.json +9 -10
  33. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +1 -0
  34. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
  35. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
  36. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +1 -0
  37. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
  38. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
  39. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +1 -0
  40. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
  41. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
  42. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js +2 -0
  43. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
  44. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
  45. package/web/build/client/_app/immutable/chunks/CPyWH418.js +1 -0
  46. package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
  47. package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
  48. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  49. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  50. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  51. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +210 -0
  52. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
  53. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
  54. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  55. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  56. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  57. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js +2 -0
  58. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
  59. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
  60. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +1 -0
  61. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
  62. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
  63. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js +1 -0
  64. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.br +0 -0
  65. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.gz +0 -0
  66. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js +1 -0
  67. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +2 -0
  68. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
  69. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +10 -0
  70. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
  71. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
  72. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +171 -0
  73. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
  74. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
  75. package/web/build/client/_app/version.json +1 -1
  76. package/web/build/client/_app/version.json.br +0 -0
  77. package/web/build/client/_app/version.json.gz +0 -0
  78. package/web/build/handler.js +3 -3
  79. package/web/build/index.js +3 -3
  80. package/web/build/server/chunks/{0-SseDy__O.js → 0-BYok4D0n.js} +2 -2
  81. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-BYok4D0n.js.map} +1 -1
  82. package/web/build/server/chunks/1-C7zcB0q0.js +7 -0
  83. package/web/build/server/chunks/1-C7zcB0q0.js.map +1 -0
  84. package/web/build/server/chunks/2-v6ejI_0-.js +7 -0
  85. package/web/build/server/chunks/2-v6ejI_0-.js.map +1 -0
  86. package/web/build/server/chunks/3-DAAmG6VX.js +7 -0
  87. package/web/build/server/chunks/3-DAAmG6VX.js.map +1 -0
  88. package/web/build/server/chunks/_server.ts-qYVrMYPB.js +62 -0
  89. package/web/build/server/chunks/_server.ts-qYVrMYPB.js.map +1 -0
  90. package/web/build/server/chunks/agent-sessions-DSqGavSn.js +2688 -0
  91. package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +1 -0
  92. package/web/build/server/chunks/{remote-1g4ayag-B8KZPTkn.js → git-review-CpO_G8z8.js} +70 -704
  93. package/web/build/server/chunks/git-review-CpO_G8z8.js.map +1 -0
  94. package/web/build/server/chunks/{handler-cq3QoEzW.js → handler-Di1wJQuU.js} +3 -3
  95. package/web/build/server/chunks/{handler-cq3QoEzW.js.map → handler-Di1wJQuU.js.map} +1 -1
  96. package/web/build/server/chunks/hooks.server-BLOHobEK.js +42 -0
  97. package/web/build/server/chunks/hooks.server-BLOHobEK.js.map +1 -0
  98. package/web/build/server/chunks/{index.js-D4qkHe3r.js → index.js-yLYmrAWy.js} +3 -5
  99. package/web/build/server/chunks/index.js-yLYmrAWy.js.map +1 -0
  100. package/web/build/server/chunks/manifest.js-CXj8IIo3.js +58 -0
  101. package/web/build/server/chunks/manifest.js-CXj8IIo3.js.map +1 -0
  102. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js +144 -0
  103. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js.map +1 -0
  104. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js +162 -0
  105. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js.map +1 -0
  106. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  107. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  108. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +0 -1
  109. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  110. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  111. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css +0 -1
  112. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
  113. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.gz +0 -0
  114. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  115. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  116. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  117. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +0 -2
  118. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  119. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  120. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js +0 -174
  121. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  122. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  123. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  124. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  125. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  126. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  127. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  128. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  129. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js +0 -2
  130. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  131. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  132. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +0 -1
  133. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +0 -1
  134. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  135. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  136. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  137. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  138. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +0 -1
  139. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  140. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  141. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +0 -66
  142. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  143. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  144. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js +0 -170
  145. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  146. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  147. package/web/build/server/chunks/1-0X6Jp9nH.js +0 -7
  148. package/web/build/server/chunks/1-0X6Jp9nH.js.map +0 -1
  149. package/web/build/server/chunks/2-HlcVALg6.js +0 -7
  150. package/web/build/server/chunks/2-HlcVALg6.js.map +0 -1
  151. package/web/build/server/chunks/3-BN1eS7L2.js +0 -7
  152. package/web/build/server/chunks/3-BN1eS7L2.js.map +0 -1
  153. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  154. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  155. package/web/build/server/chunks/index.js-D4qkHe3r.js.map +0 -1
  156. package/web/build/server/chunks/manifest.js-DQHMKyJh.js +0 -50
  157. package/web/build/server/chunks/manifest.js-DQHMKyJh.js.map +0 -1
  158. package/web/build/server/chunks/remote-1g4ayag-B8KZPTkn.js.map +0 -1
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # diff-review
2
2
 
3
- App local para revisar un branch: generás un prompt, lo corrés vos con tu agente
4
- (Claude, Cursor, Copilot, etc.), soltás el JSON que te devuelve, y la UI completa
5
- los diffs con `git` y te deja triager hallazgos.
6
-
7
- No spawnea agentes ni corre reviews sola: el modelo lo manejás vos a mano.
3
+ App local para revisar un branch con tu agente (Claude, Cursor, Copilot, etc.):
4
+ lanzás la review desde la UI o la CLI, el agente escribe el JSON, la UI completa
5
+ los diffs con `git` y te deja triager hallazgos. Por ACP ves el proceso en vivo y
6
+ después podés preguntarle o pedirle cambios al reporte desde la web. También podés
7
+ copiar el prompt y correrlo en otro agente a mano.
8
8
 
9
9
  ## Instalación
10
10
 
@@ -27,8 +27,8 @@ npx @jpkme/diff-review
27
27
  Desde un **repo git**, el default es terminal-first:
28
28
 
29
29
  1. Elegís **branch** y **base** con prompts en la terminal (`@clack/prompts`; tipeá para filtrar).
30
- 2. Copia el prompt al portapapeles y espera el JSON (`diff-rev_{branchTip}_{baseTip}.json`).
31
- 3. Valida y abre el viewer con el reporte ya importado.
30
+ 2. Si hay agentes instalados, el default es lanzar uno (ACP en la web cuando aplica); también podés copiar el prompt al portapapeles.
31
+ 3. El agente escribe `diff-rev_{branchTip}_{baseTip}.json`; la CLI valida y abre el viewer.
32
32
 
33
33
  Si pasás `--branch` y `--base` juntos, saltea los selects. Si quedan iguales (diff vacío), abre la UI.
34
34
 
@@ -38,8 +38,8 @@ caso el archivo viejo no cuenta hasta que el agente lo reescriba. Sin terminal i
38
38
 
39
39
  ### Lanzar el agente (`--agent`)
40
40
 
41
- Si hay agentes instalados, después de branch y base un solo select lista copiar el prompt al portapapeles (primera
42
- opción) y cada agente detectado. Con `--agent` saltea esa lista y va directo a elegir agente y modelo:
41
+ Si hay agentes instalados, después de branch y base un solo select lista cada agente detectado (default el primero)
42
+ y, al final, copiar el prompt al portapapeles. Con `--agent` saltea esa lista y va directo a elegir agente y modelo:
43
43
 
44
44
  ```bash
45
45
  diff-review --agent
@@ -63,13 +63,38 @@ instalados, sigue con el portapapeles. Sin terminal interactiva usa el primer ag
63
63
 
64
64
  El agente tiene que estar logueado (p. ej. `claude /login` una vez).
65
65
 
66
+ #### Por ACP, con la web (Claude Code, Cursor CLI, GitHub Copilot CLI)
67
+
68
+ Si el agente elegido habla ACP, la CLI abre la UI enseguida y el agente corre dentro del server local. Claude Code
69
+ va por el adaptador `@agentclientprotocol/claude-agent-acp` (se baja con `npx` la primera vez); Cursor
70
+ (`cursor-agent acp`) y Copilot (`copilot --acp`) lo traen de fábrica. Codex y Gemini siguen por la terminal.
71
+
72
+ - El select de modelo muestra los que ofrece cada agente (se consultan por ACP la primera vez).
73
+ - La web muestra lo que va haciendo (comandos, archivos que lee, plan). La terminal solo avisa fallos duros o JSON válido.
74
+ - Al terminar cada turno, el server valida el JSON; si falta algo se lo pide al agente una vez y, cuando valida,
75
+ la web abre el reporte.
76
+ - Cada pedido de permiso pasa por una política fija: leer dentro del repo, escribir únicamente el JSON del review
77
+ y comandos de solo lectura: `git` (diff, log, show, blame…), `cat`, `head`, `tail`, `grep`, `rg`, `find`, `ls`,
78
+ `wc`, `sed -n 'N,Mp'` y sus equivalentes de PowerShell, encadenados con `|`, `&&`, `||` o `;`, con los paths
79
+ dentro del repo. Se rechazan redirecciones a archivos (salvo `/dev/null`), `$(…)`, red, instalaciones y todo lo
80
+ demás; el agente recibe las reglas en el prompt para no intentarlo.
81
+ - La allowlist propia de cada agente no se usa en estas sesiones (aprobaría por nombre de comando, p. ej.
82
+ `echo x > archivo`): Claude corre sin cargar `~/.claude/settings.json` ni el `.claude/` del repo, y Cursor con
83
+ una copia de su configuración sin `permissions.allow` (en el temp del sistema; el login se conserva).
84
+ - La sesión queda viva: en el reporte, el botón **Agente** abre un chat para preguntarle sobre la review
85
+ (no puede escribir nada) o pedirle cambios al JSON (el reporte se actualiza y el triage de los hallazgos que
86
+ siguen se conserva). Si el server se reinició, la sesión se retoma con `session/load`.
87
+
88
+ También se puede lanzar desde la UI, en "2. Generar review" → **Lanzar agente**. Con `--headless` corre en la
89
+ terminal como el resto de los agentes.
90
+
66
91
  Para abrir la UI primero (sin esperar el JSON):
67
92
 
68
93
  ```bash
69
94
  diff-review --ui
70
95
  ```
71
96
 
72
- Opciones: `--port` (si está ocupado, usa el siguiente libre), `--host`, `--base`, `--branch`, `--agent`, `--no-open`.
97
+ Opciones: `--port` (si está ocupado, usa el siguiente libre), `--host`, `--base`, `--branch`, `--agent`, `--headless`, `--no-open`.
73
98
 
74
99
  Si lo lanzás desde un repositorio git, la UI precarga esa ruta y el branch actual.
75
100
 
@@ -85,11 +110,9 @@ npm start # mismo que el bin global
85
110
  ## Flujo (UI)
86
111
 
87
112
  1. Pegá la ruta (o URL) del repo y elegí branch / base — o lanzá `diff-review` / `diff-review --ui` desde el repo.
88
- 2. Copiá el prompt (o el comando de skill `/diff-review`).
89
- 3. Corrélo en tu agente; que escriba `diff-rev_{branchTip}_{baseTip}.json` y lo valide con `npx --yes @jpkme/diff-review validate <archivo>` (también acepta nombres legacy).
90
- 4. Arrastrá ese JSON a la dropzone (o dejá que el CLI lo importe solo). La UI hidrata los diffs con git.
91
-
92
- "Ver ejemplo" carga un reporte de muestra sin tocar un repo.
113
+ 2. Si hay agentes ACP, el default es **Lanzar agente**; si no, usá **Copiar prompt** y pegalo en tu agente.
114
+ 3. El agente escribe `.diff-review/diff-rev_{branchTip}_{baseTip}.json` (en sesiones ACP el server valida solo; si corriste el prompt a mano, `npx --yes @jpkme/diff-review validate <archivo>`).
115
+ 4. Con agente integrado o CLI el reporte se abre solo; si no, arrastrá el JSON a la dropzone. La UI hidrata los diffs con git (reusa el companion `.diff` si existe).
93
116
 
94
117
  ## Qué hace git (en tu máquina)
95
118
 
@@ -7,27 +7,40 @@
7
7
  * diff-review --port 5191
8
8
  * diff-review --branch x --base y → saltea selects de Clack
9
9
  * diff-review --agent → elegir agente + modelo y lanzarlo con el prompt (sin portapapeles)
10
+ * diff-review --headless → con agente: correrlo en la terminal en vez de por ACP con la web
10
11
  * diff-review --no-open
11
12
  */
12
13
 
13
14
  import { spawn, spawnSync } from 'node:child_process';
15
+ import { randomUUID } from 'node:crypto';
14
16
  import { existsSync } from 'node:fs';
15
17
  import { createServer } from 'node:net';
16
18
  import { dirname, join } from 'node:path';
17
19
  import { fileURLToPath } from 'node:url';
18
20
  import * as p from '@clack/prompts';
19
- import { AGENTS, detectAgents, modelChoices, OTHER_MODEL, runAgent } from '../lib/agents.mjs';
21
+ import { AGENTS, detectAgents, modelChoices, resolvedRunModel, runAgent } from '../lib/agents.mjs';
22
+ import { listAcpModelsCached, supportsAcp } from '../lib/acp.mjs';
23
+ import { buildAcpModelSelectOptions, sortModelSelectOptions } from '../lib/model-label.mjs';
20
24
  import { autocompleteEs } from '../lib/autocomplete-es.mjs';
21
25
  import { copyTextToClipboard } from '../lib/clipboard.mjs';
22
26
  import { branchTips, getRemoteUrl, listBranches, tryResolveGitRoot } from '../lib/git.mjs';
23
27
  import { makeOutputFilename } from '../lib/output-name.mjs';
24
- import { buildPrompt } from '../lib/prompt.mjs';
28
+ import { buildPrompt, outputRelPath } from '../lib/prompt.mjs';
29
+ import { prepareReviewPromptOpts } from '../lib/review-prep.mjs';
30
+ import { ensureReviewDir, writeDiffCache } from '../lib/review-dir.mjs';
25
31
  import { existingReviewOutput, waitForReviewOutput } from '../lib/wait-output.mjs';
32
+ import { stampReviewProvenance } from '../lib/review-meta.mjs';
26
33
  import { runValidateCli } from '../lib/check-report.mjs';
27
34
 
28
35
  const here = dirname(fileURLToPath(import.meta.url));
29
36
  const packageRoot = join(here, '..');
30
- const buildDir = join(packageRoot, 'web', 'build');
37
+ // `DIFF_REVIEW_OUT=build-next` cuando `web/build` está bloqueado por un server en marcha (EBUSY en Windows).
38
+ const buildDir = (() => {
39
+ const primary = join(packageRoot, 'web', 'build');
40
+ const overlay = join(packageRoot, 'web', 'build-next');
41
+ if (existsSync(join(overlay, 'index.js'))) return overlay;
42
+ return primary;
43
+ })();
31
44
  const entry = join(buildDir, 'index.js');
32
45
 
33
46
  const DEFAULT_HOST = '127.0.0.1';
@@ -62,6 +75,9 @@ un solo select lista el portapapeles y cada agente detectado.
62
75
  Si pasás --branch y --base juntos, saltea los selects.
63
76
  Con --agent: saltea esa lista; elegís un agente instalado y el modelo,
64
77
  y diff-review lo lanza con el prompt.
78
+ Si el agente habla ACP (Claude Code), corre desde la UI web: ves el
79
+ proceso en vivo y después podés preguntarle o pedirle cambios al reporte.
80
+ Con --headless corre en la terminal como antes.
65
81
  Con --ui: abre la UI de inmediato (web-first).
66
82
 
67
83
  Options:
@@ -71,6 +87,7 @@ Options:
71
87
  --base <b> Base del merge-base (salta el select de base)
72
88
  --branch <b> Branch a revisar (salta el select de branch)
73
89
  --agent Lanzar un agente instalado (${AGENTS.map((a) => a.bins[0]).join(', ')}) con el prompt
90
+ --headless Con agente: correrlo en la terminal (sin ACP ni chat en la web)
74
91
  --no-open No abrir el navegador
75
92
  -h, --help Esta ayuda
76
93
 
@@ -86,6 +103,7 @@ function parseArgs(argv) {
86
103
  help: false,
87
104
  ui: false,
88
105
  agent: false,
106
+ headless: false,
89
107
  base: '',
90
108
  branch: ''
91
109
  };
@@ -95,6 +113,7 @@ function parseArgs(argv) {
95
113
  else if (a === '--no-open') opts.open = false;
96
114
  else if (a === '--ui') opts.ui = true;
97
115
  else if (a === '--agent') opts.agent = true;
116
+ else if (a === '--headless') opts.headless = true;
98
117
  else if (a === '--host') opts.host = argv[++i] || opts.host;
99
118
  else if (a === '--base') opts.base = argv[++i] || opts.base;
100
119
  else if (a === '--branch') opts.branch = argv[++i] || opts.branch;
@@ -307,28 +326,55 @@ function copyPromptOrWarn(prompt) {
307
326
  }
308
327
 
309
328
  /**
310
- * Modelo del agente: select con TTY; sin TTY, el modelo por defecto (string vacío).
329
+ * Opciones de modelo: las que ofrece el agente por ACP (si va a correr así) o, si no, la lista fija.
311
330
  * @param {import('../lib/agents.mjs').DetectedAgent} agent
331
+ * @param {{ acpCwd?: string }} opts
312
332
  */
313
- async function pickModel(agent) {
314
- if (!isInteractive()) return '';
315
-
316
- const { options, initialValue } = modelChoices(agent.def);
317
- const choice = await p.select({ message: 'Modelo', options, initialValue });
318
- if (p.isCancel(choice)) {
319
- p.cancel('Cancelado.');
320
- process.exit(0);
333
+ async function modelOptions(agent, { acpCwd }) {
334
+ const fixed = modelChoices(agent.def);
335
+ const sortedFixed = {
336
+ ...fixed,
337
+ options: sortModelSelectOptions(fixed.options)
338
+ };
339
+ if (!acpCwd) return sortedFixed;
340
+ const spin = p.spinner();
341
+ spin.start(`Consultando los modelos de ${agent.def.label}`);
342
+ try {
343
+ const { models, currentModel, fromCache } = await listAcpModelsCached(agent, { cwd: acpCwd });
344
+ spin.stop(
345
+ fromCache
346
+ ? `Modelos de ${agent.def.label} (caché)`
347
+ : `Modelos de ${agent.def.label}`
348
+ );
349
+ if (!models.length) return sortedFixed;
350
+ // Misma fusión/labels/orden que la UI.
351
+ return buildAcpModelSelectOptions({ models, currentModel }, fixed);
352
+ } catch (err) {
353
+ spin.stop(`No pude consultar los modelos (${err instanceof Error ? err.message.split('\n')[0] : err}).`);
354
+ return sortedFixed;
321
355
  }
322
- if (choice !== OTHER_MODEL) return /** @type {string} */ (choice);
356
+ }
323
357
 
324
- const typed = ensureValue(
325
- await p.text({
326
- message: 'Id del modelo',
327
- validate: (v) => (String(v ?? '').trim() ? undefined : 'Escribí el id del modelo.')
328
- }),
329
- 'modelo'
358
+ /**
359
+ * Modelo del agente: autocomplete con búsqueda; sin TTY, el default (string vacío).
360
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
361
+ * @param {{ acpCwd?: string }} [opts] con `acpCwd`, la lista sale del agente por ACP
362
+ */
363
+ async function pickModel(agent, opts = {}) {
364
+ if (!isInteractive()) return '';
365
+
366
+ const { options, initialValue } = await modelOptions(agent, opts);
367
+ return /** @type {string} */ (
368
+ ensureValue(
369
+ await autocompleteEs({
370
+ message: 'Modelo',
371
+ options,
372
+ initialValue,
373
+ placeholder: 'opus, sonnet, gpt…'
374
+ }),
375
+ 'modelo'
376
+ )
330
377
  );
331
- return typed.trim();
332
378
  }
333
379
 
334
380
  /**
@@ -336,8 +382,9 @@ async function pickModel(agent) {
336
382
  * Si `preset` ya viene elegido, solo pregunta el modelo.
337
383
  * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
338
384
  * @param {import('../lib/agents.mjs').DetectedAgent} [preset]
385
+ * @param {{ repo: string, headless: boolean }} [run] para consultar los modelos por ACP
339
386
  */
340
- async function pickAgentAndModel(detected, preset) {
387
+ async function pickAgentAndModel(detected, preset, run) {
341
388
  if (!isInteractive()) return { agent: preset ?? detected[0], model: '' };
342
389
 
343
390
  let agent = preset;
@@ -353,11 +400,12 @@ async function pickAgentAndModel(detected, preset) {
353
400
  agent = detected.find((a) => a.def.id === id) ?? detected[0];
354
401
  }
355
402
 
356
- return { agent, model: await pickModel(agent) };
403
+ const acpCwd = run && !run.headless && supportsAcp(agent) ? run.repo : undefined;
404
+ return { agent, model: await pickModel(agent, { acpCwd }) };
357
405
  }
358
406
 
359
407
  /**
360
- * Portapapeles primero y después cada agente detectado.
408
+ * Agentes detectados primero (default el primero); portapapeles como opción secundaria.
361
409
  * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
362
410
  * @returns {Promise<import('../lib/agents.mjs').DetectedAgent | null>} `null` = portapapeles
363
411
  */
@@ -366,10 +414,10 @@ async function pickDelivery(detected) {
366
414
  await p.select({
367
415
  message: '¿Cómo generás el review?',
368
416
  options: [
369
- { value: 'clipboard', label: 'Copiar el prompt al portapapeles', hint: 'lo pegás en tu agente' },
370
- ...detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path }))
417
+ ...detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path })),
418
+ { value: 'clipboard', label: 'Copiar el prompt al portapapeles', hint: 'lo pegás en tu agente' }
371
419
  ],
372
- initialValue: 'clipboard'
420
+ initialValue: detected[0].def.id
373
421
  }),
374
422
  'modo'
375
423
  );
@@ -406,7 +454,7 @@ async function reuseExistingReview(existing, outputFilename) {
406
454
  */
407
455
  async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, useClack }) {
408
456
  const what = `${agent.def.label} (${model || 'modelo por defecto'})`;
409
- const msg = `Lanzando ${what}. Espera ${outputFilename} en la raíz del repo; Ctrl+C cancela.`;
457
+ const msg = `Lanzando ${what}. Espera ${outputRelPath(outputFilename)}; Ctrl+C cancela.`;
410
458
  if (useClack) p.outro(msg);
411
459
  else console.log(msg);
412
460
 
@@ -439,14 +487,40 @@ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, use
439
487
  process.exit(1);
440
488
  }
441
489
  if (code !== 0) console.warn(`${agent.def.label} terminó con código ${code}, pero el JSON es válido.`);
490
+ stampReviewProvenance(result.path, {
491
+ agent: agent.def.label,
492
+ model: resolvedRunModel(agent.def, model)
493
+ });
442
494
  return /** @type {{ path: string }} */ (result);
443
495
  }
444
496
 
497
+ /**
498
+ * El agente corre dentro del server de la UI (ACP): la web muestra el proceso y abre el reporte; la terminal ve un
499
+ * resumen. La sesión queda viva para preguntas y cambios desde el reporte.
500
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
501
+ * @param {{ repo: string, branch: string, base: string, model: string, outputFilename: string, useClack: boolean }} run
502
+ * @param {{ host: string, port: number, open: boolean }} opts
503
+ */
504
+ async function runAgentInUi(agent, { repo, branch, base, model, outputFilename, useClack }, opts) {
505
+ const id = randomUUID();
506
+ const msg = `Lanzando ${agent.def.label} (${model || 'modelo por defecto'}). El progreso se ve en la web; Ctrl+C corta.`;
507
+ if (useClack) p.outro(msg);
508
+ else console.log(msg);
509
+ await startServer({
510
+ host: opts.host,
511
+ port: opts.port,
512
+ open: opts.open,
513
+ launchCwd: repo,
514
+ openPath: `/?agent=${encodeURIComponent(id)}`,
515
+ agentRun: { id, repo, branch, base, outputFilename, agentId: agent.def.id, ...(model ? { model } : {}) }
516
+ });
517
+ }
518
+
445
519
  function explainWaitingForAgent(outputFilename, { copied, useClack }) {
446
520
  const copyMsg = copied
447
521
  ? 'Se copió un prompt al portapapeles: pegalo en el chat de tu agente de IA para generar el contenido de la review.'
448
522
  : 'Generá el JSON con tu agente de IA usando el prompt de esta sesión (no se pudo copiar solo al portapapeles).';
449
- const waitMsg = `Aguardando el resultado del agente (${outputFilename} en la raíz del repo). La UI web se abre cuando ese archivo esté listo.`;
523
+ const waitMsg = `Aguardando el resultado del agente (${outputRelPath(outputFilename)}). La UI web se abre cuando ese archivo esté listo.`;
450
524
 
451
525
  if (useClack) {
452
526
  p.log.success(copyMsg);
@@ -460,7 +534,8 @@ function explainWaitingForAgent(outputFilename, { copied, useClack }) {
460
534
  }
461
535
 
462
536
  /**
463
- * @param {{ host: string, port: number, open: boolean, launchCwd: string, importFile?: string, openPath?: string }} cfg
537
+ * @typedef {{ id: string, repo: string, branch: string, base: string, outputFilename: string, agentId: string, model?: string }} AgentRun
538
+ * @param {{ host: string, port: number, open: boolean, launchCwd: string, importFile?: string, openPath?: string, agentRun?: AgentRun }} cfg
464
539
  */
465
540
  async function startServer(cfg) {
466
541
  if (!existsSync(entry)) {
@@ -490,8 +565,11 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
490
565
  HOST: cfg.host,
491
566
  PORT: String(port),
492
567
  ORIGIN: origin,
568
+ // Reviews con seed/diff o import JSON suelen pasar los 512K por defecto del adapter-node.
569
+ BODY_SIZE_LIMIT: process.env.BODY_SIZE_LIMIT || '16M',
493
570
  DIFF_REVIEW_LAUNCH_CWD: cfg.launchCwd,
494
- ...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {})
571
+ ...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {}),
572
+ ...(cfg.agentRun ? { DIFF_REVIEW_AGENT_RUN: JSON.stringify(cfg.agentRun), DIFF_REVIEW_MIRROR: '1' } : {})
495
573
  },
496
574
  stdio: ['ignore', 'pipe', 'inherit'],
497
575
  windowsHide: false
@@ -509,6 +587,7 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
509
587
  ready = true;
510
588
  console.log(`diff-review listo en ${terminalLink(origin)} — Ctrl+C para salir.`);
511
589
  if (cfg.open) openBrowser(openUrl);
590
+ else if (cfg.agentRun) console.log(`Progreso del agente: ${terminalLink(openUrl)}`);
512
591
  }
513
592
  continue;
514
593
  }
@@ -543,6 +622,22 @@ async function runUiMode(opts, launch, launchCwd) {
543
622
  if (launch) {
544
623
  console.log(`Repo: ${launch.repo}`);
545
624
  console.log(`Branch: ${branch} (base: ${base})`);
625
+ let promptOpts = { outputFilename };
626
+ try {
627
+ promptOpts = prepareReviewPromptOpts({
628
+ repo: launch.repo,
629
+ branch,
630
+ base,
631
+ outputFilename
632
+ });
633
+ } catch (err) {
634
+ console.warn(
635
+ 'No pude precomputar el diff:',
636
+ err instanceof Error ? err.message : err
637
+ );
638
+ ensureReviewDir(launch.repo);
639
+ writeDiffCache(launch.repo, outputFilename, { branch, base });
640
+ }
546
641
  const prompt = buildPrompt(
547
642
  {
548
643
  source: 'local',
@@ -551,7 +646,7 @@ async function runUiMode(opts, launch, launchCwd) {
551
646
  base,
552
647
  remoteUrl: launch.remoteUrl
553
648
  },
554
- { outputFilename }
649
+ promptOpts
555
650
  );
556
651
  if (copyTextToClipboard(prompt)) {
557
652
  console.log('Prompt copiado al portapapeles.');
@@ -618,12 +713,28 @@ async function main() {
618
713
  }
619
714
 
620
715
  const outputFilename = buildOutputFilename(launch.repo, branch, base);
716
+ // El agente escribe en .diff-review/ (que se ignora sola) y lee de ahí el diff ya calculado.
717
+ let promptOpts = { outputFilename };
718
+ try {
719
+ promptOpts = prepareReviewPromptOpts({
720
+ repo: launch.repo,
721
+ branch,
722
+ base,
723
+ outputFilename
724
+ });
725
+ } catch (err) {
726
+ const msg = err instanceof Error ? err.message : String(err);
727
+ if (prompted) p.log.warn(`No pude precomputar el diff: ${msg}`);
728
+ else console.warn('No pude precomputar el diff:', msg);
729
+ ensureReviewDir(launch.repo);
730
+ writeDiffCache(launch.repo, outputFilename, { branch, base });
731
+ }
621
732
 
622
733
  if (!prompted) {
623
734
  console.log(`Repo: ${launch.repo}`);
624
735
  console.log(`Branch: ${branch} (base: ${base})`);
625
736
  } else {
626
- p.note(`${branch} ← ${base}\n${outputFilename}`, 'Review');
737
+ p.note(`${branch} → ${base}\n${outputRelPath(outputFilename)}`, 'Review');
627
738
  }
628
739
 
629
740
  const prompt = buildPrompt(
@@ -634,7 +745,7 @@ async function main() {
634
745
  base,
635
746
  remoteUrl: launch.remoteUrl
636
747
  },
637
- { outputFilename }
748
+ promptOpts
638
749
  );
639
750
 
640
751
  // Sin --agent también se detectan (con terminal interactiva) para ofrecer lanzar uno en vez del portapapeles.
@@ -660,7 +771,18 @@ async function main() {
660
771
  }
661
772
  const useAgent = !result && detected.length > 0 && (opts.agent || chosen != null);
662
773
  if (useAgent) {
663
- const { agent, model } = await pickAgentAndModel(detected, chosen ?? undefined);
774
+ const { agent, model } = await pickAgentAndModel(detected, chosen ?? undefined, {
775
+ repo: launch.repo,
776
+ headless: opts.headless
777
+ });
778
+ if (supportsAcp(agent) && !opts.headless) {
779
+ await runAgentInUi(
780
+ agent,
781
+ { repo: launch.repo, branch, base, model, outputFilename, useClack },
782
+ opts
783
+ );
784
+ return;
785
+ }
664
786
  result = await runAgentAndWait(agent, {
665
787
  repo: launch.repo,
666
788
  prompt,
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Qué puede hacer el agente cuando corre por ACP. Cada `session/request_permission` pasa por acá: no hay nadie
3
+ * mirando la terminal para aprobar, así que la política es una allowlist (leer, comandos de solo lectura y escribir
4
+ * dentro de `.diff-review/`) y todo lo demás se rechaza.
5
+ *
6
+ * Ojo: el agente solo pide permiso para lo que *su* configuración no aprueba sola (p. ej. Claude Code aplica la
7
+ * allowlist de `~/.claude/settings.json` antes de preguntar). Esto acota lo que pide, no lo que el usuario ya
8
+ * habilitó en su agente.
9
+ */
10
+ import { isAbsolute, normalize, resolve } from "node:path";
11
+ import { checkShell } from "./acp-shell.mjs";
12
+ import { OUTPUT_DIR } from "./output-name.mjs";
13
+
14
+ /**
15
+ * @typedef {"review" | "ask" | "edit"} PolicyMode
16
+ * review = generar el JSON; ask = preguntas (no puede escribir nada); edit = modificar el JSON.
17
+ * @typedef {{ repo: string, outputFilename: string, mode: PolicyMode }} PolicyContext
18
+ * @typedef {{
19
+ * kind?: string | null,
20
+ * title?: string | null,
21
+ * rawInput?: unknown,
22
+ * locations?: { path: string }[] | null,
23
+ * content?: { type: string, path?: string }[] | null,
24
+ * }} ToolCallLike
25
+ * @typedef {{ allow: boolean, reason: string }} Decision
26
+ */
27
+
28
+ /** @param {string} a @param {string} b */
29
+ function samePath(a, b, platform = process.platform) {
30
+ const na = normalize(a);
31
+ const nb = normalize(b);
32
+ return platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
33
+ }
34
+
35
+ /** @param {string} child @param {string} root */
36
+ function isInside(child, root, platform = process.platform) {
37
+ const c = normalize(child);
38
+ const r = normalize(root).replace(/[\\/]+$/, "");
39
+ const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
40
+ return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
41
+ }
42
+
43
+ /** Paths que toca la tool call (`locations`, diffs de `content` y los campos típicos de `rawInput`). */
44
+ export function toolCallPaths(toolCall, repo) {
45
+ const raw = toolCall.rawInput && typeof toolCall.rawInput === "object" ? /** @type {Record<string, unknown>} */ (toolCall.rawInput) : {};
46
+ const candidates = [
47
+ ...(toolCall.locations ?? []).map((l) => l?.path),
48
+ ...(toolCall.content ?? []).map((c) => (c?.type === "diff" ? c.path : undefined)),
49
+ raw.file_path,
50
+ raw.path,
51
+ raw.filePath,
52
+ raw.notebook_path,
53
+ ];
54
+ const out = [];
55
+ for (const c of candidates) {
56
+ if (typeof c !== "string" || !c.trim()) continue;
57
+ const abs = isAbsolute(c) ? c : resolve(repo, c);
58
+ if (!out.some((p) => samePath(p, abs))) out.push(abs);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** @param {unknown} rawInput */
64
+ function commandOf(rawInput) {
65
+ if (!rawInput || typeof rawInput !== "object") return "";
66
+ const cmd = /** @type {Record<string, unknown>} */ (rawInput).command;
67
+ if (Array.isArray(cmd)) return cmd.map(String).join(" ");
68
+ return typeof cmd === "string" ? cmd : "";
69
+ }
70
+
71
+ /**
72
+ * ¿El comando de shell solo lee? Ver `checkShell` en `acp-shell.mjs`.
73
+ * @param {string} command
74
+ * @param {{ repo?: string }} [opts]
75
+ */
76
+ export function isAllowedShell(command, opts) {
77
+ return checkShell(command, opts).ok;
78
+ }
79
+
80
+ /**
81
+ * @param {ToolCallLike} toolCall
82
+ * @param {PolicyContext} ctx
83
+ * @returns {Decision}
84
+ */
85
+ export function decidePermission(toolCall, { repo, outputFilename, mode }) {
86
+ const kind = toolCall.kind ?? "other";
87
+ const paths = toolCallPaths(toolCall, repo);
88
+
89
+ if (kind === "read" || kind === "search") {
90
+ const outside = paths.find((p) => !isInside(p, repo));
91
+ return outside ? { allow: false, reason: `lectura fuera del repo: ${outside}` } : { allow: true, reason: "lectura" };
92
+ }
93
+ if (kind === "think") return { allow: true, reason: "pensar" };
94
+
95
+ if (kind === "execute") {
96
+ // Cursor no manda `rawInput`: el comando viene solo en el título, entre backticks.
97
+ const title = String(toolCall.title ?? "").trim();
98
+ const cmd = commandOf(toolCall.rawInput) || (/^`([\s\S]+)`$/.exec(title)?.[1] ?? title);
99
+ const check = checkShell(cmd, { repo });
100
+ return check.ok ? { allow: true, reason: "comando de solo lectura" } : { allow: false, reason: check.reason };
101
+ }
102
+
103
+ if (kind === "edit" || kind === "delete") {
104
+ if (mode === "ask") return { allow: false, reason: "modo pregunta: no se edita nada" };
105
+ if (!paths.length) return { allow: false, reason: `${kind === "delete" ? "borrado" : "edición"} sin path` };
106
+ const reviewRoot = resolve(repo, OUTPUT_DIR);
107
+ const outside = paths.find((p) => !isInside(p, reviewRoot) && !samePath(p, reviewRoot));
108
+ return outside
109
+ ? {
110
+ allow: false,
111
+ reason: `solo se puede escribir en ${OUTPUT_DIR}/ (no ${outside})`
112
+ }
113
+ : { allow: true, reason: `escritura en ${OUTPUT_DIR}/` };
114
+ }
115
+
116
+ return { allow: false, reason: `${kind} no permitido: ${toolCall.title ?? "?"}` };
117
+ }
118
+
119
+ /**
120
+ * Respuesta ACP para una decisión del usuario / política.
121
+ * - allow + once → `allow_once` (la política se vuelve a evaluar en el próximo pedido).
122
+ * - allow + always → `allow_always` si el agente la ofrece; si no, cae a `allow_once`.
123
+ * @param {{ optionId: string, kind: string }[]} options
124
+ * @param {boolean} allow
125
+ * @param {{ always?: boolean }} [opts]
126
+ */
127
+ export function permissionOutcome(options, allow, opts = {}) {
128
+ const always = Boolean(opts.always);
129
+ const kinds = allow
130
+ ? always
131
+ ? ["allow_always", "allow_once"]
132
+ : ["allow_once"]
133
+ : ["reject_once", "reject_always"];
134
+ const option = kinds.map((kind) => options.find((o) => o.kind === kind)).find(Boolean);
135
+ return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
136
+ }