@jpkme/diff-review 0.1.10 → 0.1.13

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 (159) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +151 -34
  3. package/lib/acp-policy.mjs +223 -0
  4. package/lib/acp-policy.test.mjs +136 -0
  5. package/lib/acp-shell.mjs +409 -0
  6. package/lib/acp-shell.test.mjs +158 -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 +87 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +30 -11
  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 +168 -22
  22. package/lib/review-dir.mjs +108 -0
  23. package/lib/review-meta.mjs +5 -3
  24. package/lib/review-meta.test.mjs +11 -0
  25. package/lib/review-prep.mjs +63 -0
  26. package/lib/validate.mjs +2 -2
  27. package/lib/wait-output.mjs +46 -23
  28. package/lib/wait-output.test.mjs +24 -1
  29. package/package.json +3 -1
  30. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
  31. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
  32. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
  33. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css +1 -0
  34. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.br +0 -0
  35. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.gz +0 -0
  36. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
  37. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
  38. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
  39. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css +1 -0
  40. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.br +0 -0
  41. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.gz +0 -0
  42. package/web/build/client/_app/immutable/chunks/BXUWMim4.js +213 -0
  43. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.br +0 -0
  44. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.gz +0 -0
  45. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js +1 -0
  46. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.br +0 -0
  47. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.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/{BBWAo_yW.js → DPfXzQnm.js} +2 -2
  52. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.br +0 -0
  53. package/web/build/client/_app/immutable/chunks/DPfXzQnm.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.CTgiuiU1.js +2 -0
  58. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.br +0 -0
  59. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.gz +0 -0
  60. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js +1 -0
  61. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.br +0 -0
  62. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.gz +0 -0
  63. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js +1 -0
  64. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.br +0 -0
  65. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.gz +0 -0
  66. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js +1 -0
  67. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.br +2 -0
  68. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.gz +0 -0
  69. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js +10 -0
  70. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.br +0 -0
  71. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.gz +0 -0
  72. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js +171 -0
  73. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.br +0 -0
  74. package/web/build/client/_app/immutable/nodes/3.AciugOtL.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-DUStDOsr.js +11 -0
  81. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-DUStDOsr.js.map} +1 -1
  82. package/web/build/server/chunks/1-C2qlInYk.js +7 -0
  83. package/web/build/server/chunks/1-C2qlInYk.js.map +1 -0
  84. package/web/build/server/chunks/2-DyE177MK.js +7 -0
  85. package/web/build/server/chunks/2-DyE177MK.js.map +1 -0
  86. package/web/build/server/chunks/3-SO3atuWO.js +7 -0
  87. package/web/build/server/chunks/3-SO3atuWO.js.map +1 -0
  88. package/web/build/server/chunks/_server.ts-CRNhzL2a.js +62 -0
  89. package/web/build/server/chunks/_server.ts-CRNhzL2a.js.map +1 -0
  90. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js +2819 -0
  91. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js.map +1 -0
  92. package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CXKcjYgZ.js} +70 -704
  93. package/web/build/server/chunks/git-review-CXKcjYgZ.js.map +1 -0
  94. package/web/build/server/chunks/{handler-B5P65tLk.js → handler-_E0NucKN.js} +3 -3
  95. package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-_E0NucKN.js.map} +1 -1
  96. package/web/build/server/chunks/hooks.server-D7yPODoc.js +42 -0
  97. package/web/build/server/chunks/hooks.server-D7yPODoc.js.map +1 -0
  98. package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-lMtDM8ZQ.js} +3 -5
  99. package/web/build/server/chunks/index.js-lMtDM8ZQ.js.map +1 -0
  100. package/web/build/server/chunks/manifest.js-BIm6jot5.js +58 -0
  101. package/web/build/server/chunks/manifest.js-BIm6jot5.js.map +1 -0
  102. package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js +144 -0
  103. package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js.map +1 -0
  104. package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js +162 -0
  105. package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.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/0.CLQiq3Ed.css.br +0 -0
  109. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
  110. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  111. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  112. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
  113. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  114. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.gz +0 -0
  115. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  116. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  117. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  118. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  119. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  120. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  121. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  122. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  123. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js +0 -176
  124. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  125. package/web/build/client/_app/immutable/chunks/QdZF3nkf.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.Dce6uJU3.js +0 -2
  130. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  131. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  132. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
  133. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  134. package/web/build/client/_app/immutable/entry/start.DnBSC38l.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.5Iv4ssDT.js +0 -1
  139. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  140. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  141. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
  142. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  143. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  144. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
  145. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  146. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  147. package/web/build/server/chunks/0-SseDy__O.js +0 -11
  148. package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
  149. package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
  150. package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
  151. package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
  152. package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
  153. package/web/build/server/chunks/3-ZIl1rHef.js.map +0 -1
  154. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  155. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  156. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +0 -1
  157. package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
  158. package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
  159. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.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 hasta dos veces 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,28 +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, resolvedRunModel, 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';
26
32
  import { stampReviewProvenance } from '../lib/review-meta.mjs';
27
33
  import { runValidateCli } from '../lib/check-report.mjs';
28
34
 
29
35
  const here = dirname(fileURLToPath(import.meta.url));
30
36
  const packageRoot = join(here, '..');
31
- 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
+ })();
32
44
  const entry = join(buildDir, 'index.js');
33
45
 
34
46
  const DEFAULT_HOST = '127.0.0.1';
@@ -63,6 +75,9 @@ un solo select lista el portapapeles y cada agente detectado.
63
75
  Si pasás --branch y --base juntos, saltea los selects.
64
76
  Con --agent: saltea esa lista; elegís un agente instalado y el modelo,
65
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.
66
81
  Con --ui: abre la UI de inmediato (web-first).
67
82
 
68
83
  Options:
@@ -72,6 +87,7 @@ Options:
72
87
  --base <b> Base del merge-base (salta el select de base)
73
88
  --branch <b> Branch a revisar (salta el select de branch)
74
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)
75
91
  --no-open No abrir el navegador
76
92
  -h, --help Esta ayuda
77
93
 
@@ -87,6 +103,7 @@ function parseArgs(argv) {
87
103
  help: false,
88
104
  ui: false,
89
105
  agent: false,
106
+ headless: false,
90
107
  base: '',
91
108
  branch: ''
92
109
  };
@@ -96,6 +113,7 @@ function parseArgs(argv) {
96
113
  else if (a === '--no-open') opts.open = false;
97
114
  else if (a === '--ui') opts.ui = true;
98
115
  else if (a === '--agent') opts.agent = true;
116
+ else if (a === '--headless') opts.headless = true;
99
117
  else if (a === '--host') opts.host = argv[++i] || opts.host;
100
118
  else if (a === '--base') opts.base = argv[++i] || opts.base;
101
119
  else if (a === '--branch') opts.branch = argv[++i] || opts.branch;
@@ -308,28 +326,55 @@ function copyPromptOrWarn(prompt) {
308
326
  }
309
327
 
310
328
  /**
311
- * 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.
312
330
  * @param {import('../lib/agents.mjs').DetectedAgent} agent
331
+ * @param {{ acpCwd?: string }} opts
313
332
  */
314
- async function pickModel(agent) {
315
- if (!isInteractive()) return '';
316
-
317
- const { options, initialValue } = modelChoices(agent.def);
318
- const choice = await p.select({ message: 'Modelo', options, initialValue });
319
- if (p.isCancel(choice)) {
320
- p.cancel('Cancelado.');
321
- 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;
322
355
  }
323
- if (choice !== OTHER_MODEL) return /** @type {string} */ (choice);
356
+ }
324
357
 
325
- const typed = ensureValue(
326
- await p.text({
327
- message: 'Id del modelo',
328
- validate: (v) => (String(v ?? '').trim() ? undefined : 'Escribí el id del modelo.')
329
- }),
330
- '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
+ )
331
377
  );
332
- return typed.trim();
333
378
  }
334
379
 
335
380
  /**
@@ -337,8 +382,9 @@ async function pickModel(agent) {
337
382
  * Si `preset` ya viene elegido, solo pregunta el modelo.
338
383
  * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
339
384
  * @param {import('../lib/agents.mjs').DetectedAgent} [preset]
385
+ * @param {{ repo: string, headless: boolean }} [run] para consultar los modelos por ACP
340
386
  */
341
- async function pickAgentAndModel(detected, preset) {
387
+ async function pickAgentAndModel(detected, preset, run) {
342
388
  if (!isInteractive()) return { agent: preset ?? detected[0], model: '' };
343
389
 
344
390
  let agent = preset;
@@ -354,11 +400,12 @@ async function pickAgentAndModel(detected, preset) {
354
400
  agent = detected.find((a) => a.def.id === id) ?? detected[0];
355
401
  }
356
402
 
357
- 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 }) };
358
405
  }
359
406
 
360
407
  /**
361
- * Portapapeles primero y después cada agente detectado.
408
+ * Agentes detectados primero (default el primero); portapapeles como opción secundaria.
362
409
  * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
363
410
  * @returns {Promise<import('../lib/agents.mjs').DetectedAgent | null>} `null` = portapapeles
364
411
  */
@@ -367,10 +414,10 @@ async function pickDelivery(detected) {
367
414
  await p.select({
368
415
  message: '¿Cómo generás el review?',
369
416
  options: [
370
- { value: 'clipboard', label: 'Copiar el prompt al portapapeles', hint: 'lo pegás en tu agente' },
371
- ...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' }
372
419
  ],
373
- initialValue: 'clipboard'
420
+ initialValue: detected[0].def.id
374
421
  }),
375
422
  'modo'
376
423
  );
@@ -407,7 +454,7 @@ async function reuseExistingReview(existing, outputFilename) {
407
454
  */
408
455
  async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, useClack }) {
409
456
  const what = `${agent.def.label} (${model || 'modelo por defecto'})`;
410
- 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.`;
411
458
  if (useClack) p.outro(msg);
412
459
  else console.log(msg);
413
460
 
@@ -447,11 +494,33 @@ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, use
447
494
  return /** @type {{ path: string }} */ (result);
448
495
  }
449
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
+
450
519
  function explainWaitingForAgent(outputFilename, { copied, useClack }) {
451
520
  const copyMsg = copied
452
521
  ? 'Se copió un prompt al portapapeles: pegalo en el chat de tu agente de IA para generar el contenido de la review.'
453
522
  : 'Generá el JSON con tu agente de IA usando el prompt de esta sesión (no se pudo copiar solo al portapapeles).';
454
- 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.`;
455
524
 
456
525
  if (useClack) {
457
526
  p.log.success(copyMsg);
@@ -465,7 +534,8 @@ function explainWaitingForAgent(outputFilename, { copied, useClack }) {
465
534
  }
466
535
 
467
536
  /**
468
- * @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
469
539
  */
470
540
  async function startServer(cfg) {
471
541
  if (!existsSync(entry)) {
@@ -495,8 +565,11 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
495
565
  HOST: cfg.host,
496
566
  PORT: String(port),
497
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',
498
570
  DIFF_REVIEW_LAUNCH_CWD: cfg.launchCwd,
499
- ...(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' } : {})
500
573
  },
501
574
  stdio: ['ignore', 'pipe', 'inherit'],
502
575
  windowsHide: false
@@ -514,6 +587,7 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
514
587
  ready = true;
515
588
  console.log(`diff-review listo en ${terminalLink(origin)} — Ctrl+C para salir.`);
516
589
  if (cfg.open) openBrowser(openUrl);
590
+ else if (cfg.agentRun) console.log(`Progreso del agente: ${terminalLink(openUrl)}`);
517
591
  }
518
592
  continue;
519
593
  }
@@ -548,6 +622,22 @@ async function runUiMode(opts, launch, launchCwd) {
548
622
  if (launch) {
549
623
  console.log(`Repo: ${launch.repo}`);
550
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
+ }
551
641
  const prompt = buildPrompt(
552
642
  {
553
643
  source: 'local',
@@ -556,7 +646,7 @@ async function runUiMode(opts, launch, launchCwd) {
556
646
  base,
557
647
  remoteUrl: launch.remoteUrl
558
648
  },
559
- { outputFilename }
649
+ promptOpts
560
650
  );
561
651
  if (copyTextToClipboard(prompt)) {
562
652
  console.log('Prompt copiado al portapapeles.');
@@ -623,12 +713,28 @@ async function main() {
623
713
  }
624
714
 
625
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
+ }
626
732
 
627
733
  if (!prompted) {
628
734
  console.log(`Repo: ${launch.repo}`);
629
735
  console.log(`Branch: ${branch} (base: ${base})`);
630
736
  } else {
631
- p.note(`${branch} → ${base}\n${outputFilename}`, 'Review');
737
+ p.note(`${branch} → ${base}\n${outputRelPath(outputFilename)}`, 'Review');
632
738
  }
633
739
 
634
740
  const prompt = buildPrompt(
@@ -639,7 +745,7 @@ async function main() {
639
745
  base,
640
746
  remoteUrl: launch.remoteUrl
641
747
  },
642
- { outputFilename }
748
+ promptOpts
643
749
  );
644
750
 
645
751
  // Sin --agent también se detectan (con terminal interactiva) para ofrecer lanzar uno en vez del portapapeles.
@@ -665,7 +771,18 @@ async function main() {
665
771
  }
666
772
  const useAgent = !result && detected.length > 0 && (opts.agent || chosen != null);
667
773
  if (useAgent) {
668
- 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
+ }
669
786
  result = await runAgentAndWait(agent, {
670
787
  repo: launch.repo,
671
788
  prompt,
@@ -0,0 +1,223 @@
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
+ * el JSON de salida en `.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 { realpathSync } from "node:fs";
11
+ import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
12
+ import { checkShell } from "./acp-shell.mjs";
13
+ import { outputRelPath } from "./output-name.mjs";
14
+
15
+ /**
16
+ * @typedef {"review" | "ask" | "edit"} PolicyMode
17
+ * review = generar el JSON; ask = preguntas (no puede escribir nada); edit = modificar el JSON.
18
+ * @typedef {{ repo: string, outputFilename: string, mode: PolicyMode }} PolicyContext
19
+ * @typedef {{
20
+ * kind?: string | null,
21
+ * title?: string | null,
22
+ * rawInput?: unknown,
23
+ * locations?: { path: string }[] | null,
24
+ * content?: { type: string, path?: string }[] | null,
25
+ * }} ToolCallLike
26
+ * @typedef {{ allow: boolean, reason: string }} Decision
27
+ */
28
+
29
+ /** @param {string} a @param {string} b */
30
+ function samePath(a, b, platform = process.platform) {
31
+ const na = normalize(a);
32
+ const nb = normalize(b);
33
+ return platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
34
+ }
35
+
36
+ /**
37
+ * Resuelve symlinks cuando el path (o un ancestro) existe. Sin eso, un link dentro del repo
38
+ * puede apuntar fuera y pasar `isInside` con solo `resolve`.
39
+ * @param {string} path
40
+ */
41
+ function canonicalPath(path) {
42
+ try {
43
+ return realpathSync(path);
44
+ } catch {
45
+ const parts = [];
46
+ let cur = normalize(path);
47
+ for (;;) {
48
+ const parent = dirname(cur);
49
+ if (!parent || parent === cur) return normalize(path);
50
+ parts.unshift(basename(cur));
51
+ try {
52
+ return resolve(realpathSync(parent), ...parts);
53
+ } catch {
54
+ cur = parent;
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ /** @param {string} child @param {string} root */
61
+ function isInside(child, root, platform = process.platform) {
62
+ const c = normalize(canonicalPath(child));
63
+ const r = normalize(canonicalPath(root)).replace(/[\\/]+$/, "");
64
+ const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
65
+ return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
66
+ }
67
+
68
+ /** Paths que toca la tool call (`locations`, diffs de `content` y los campos típicos de `rawInput`). */
69
+ export function toolCallPaths(toolCall, repo) {
70
+ const raw = toolCall.rawInput && typeof toolCall.rawInput === "object" ? /** @type {Record<string, unknown>} */ (toolCall.rawInput) : {};
71
+ const candidates = [
72
+ ...(toolCall.locations ?? []).map((l) => l?.path),
73
+ ...(toolCall.content ?? []).map((c) => (c?.type === "diff" ? c.path : undefined)),
74
+ raw.file_path,
75
+ raw.path,
76
+ raw.filePath,
77
+ raw.notebook_path,
78
+ ];
79
+ const out = [];
80
+ for (const c of candidates) {
81
+ if (typeof c !== "string" || !c.trim()) continue;
82
+ const abs = isAbsolute(c) ? c : resolve(repo, c);
83
+ if (!out.some((p) => samePath(p, abs))) out.push(abs);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** @param {unknown} rawInput */
89
+ function commandOf(rawInput) {
90
+ if (!rawInput || typeof rawInput !== "object") return "";
91
+ const cmd = /** @type {Record<string, unknown>} */ (rawInput).command;
92
+ if (Array.isArray(cmd)) return cmd.map(String).join(" ");
93
+ return typeof cmd === "string" ? cmd : "";
94
+ }
95
+
96
+ /**
97
+ * Comando de shell que la política evalúa (rawInput, backticks o título entero).
98
+ * Cursor a veces envuelve el script con `@…@` en el título.
99
+ * @param {ToolCallLike} toolCall
100
+ */
101
+ export function toolCallCommand(toolCall) {
102
+ const fromRaw = commandOf(toolCall.rawInput).trim();
103
+ if (fromRaw) return fromRaw;
104
+ const title = String(toolCall.title ?? "").trim();
105
+ const tick = /^`([\s\S]+)`$/.exec(title);
106
+ if (tick) return tick[1].trim();
107
+ const at = /^@\s*([\s\S]*?)\s*@$/.exec(title);
108
+ if (at) return at[1].trim();
109
+ return title;
110
+ }
111
+
112
+ /**
113
+ * Etiqueta corta del agente si el título no es el comando mismo.
114
+ * @param {ToolCallLike} toolCall
115
+ * @param {string} [command]
116
+ */
117
+ export function toolCallLabel(toolCall, command = toolCallCommand(toolCall)) {
118
+ const title = String(toolCall.title ?? "").trim();
119
+ if (!title) return "";
120
+ if (/^`[\s\S]+`$/.test(title) || /^@\s*[\s\S]*?\s*@$/.test(title)) return "";
121
+ const norm = (s) => s.replace(/\s+/g, " ").trim();
122
+ if (command && norm(title) === norm(command)) return "";
123
+ if (command && command.length >= 24 && title.includes(command.slice(0, 24))) return "";
124
+ return title;
125
+ }
126
+
127
+ /**
128
+ * Motivo de denegación legible en la UI de permisos.
129
+ * @param {string} reason
130
+ */
131
+ export function humanizePolicyReason(reason) {
132
+ const r = String(reason || "").trim();
133
+ if (!r) return "La política bloqueó este pedido.";
134
+ let m;
135
+ if ((m = /^(.+?) no está permitido$/.exec(r))) {
136
+ return `La política bloqueó «${m[1]}» (no está en la allowlist de comandos).`;
137
+ }
138
+ if ((m = /^variables de entorno en el comando:\s*(.*)$/s.exec(r))) {
139
+ const token = String(m[1] || "?").trim().slice(0, 40);
140
+ return `La política interpretó parte del comando como variable de entorno (${token}) y lo bloqueó.`;
141
+ }
142
+ if ((m = /^path fuera del repo:\s*(.*)$/s.exec(r))) {
143
+ return `El path queda fuera del repo: ${m[1]}`;
144
+ }
145
+ if ((m = /^lectura fuera del repo:\s*(.*)$/s.exec(r))) {
146
+ return `Lectura fuera del repo: ${m[1]}`;
147
+ }
148
+ if ((m = /^opción global de git no permitida:\s*(.*)$/s.exec(r))) {
149
+ return `Opción de git no permitida: ${m[1]}`;
150
+ }
151
+ if (r.startsWith("git ")) return `Comando git no permitido por la política: ${r}`;
152
+ if (r.startsWith("solo se puede escribir") || r.includes("sin path") || r.startsWith("modo pregunta")) return r;
153
+ return `La política bloqueó el pedido: ${r}`;
154
+ }
155
+
156
+ /**
157
+ * ¿El comando de shell solo lee? Ver `checkShell` en `acp-shell.mjs`.
158
+ * @param {string} command
159
+ * @param {{ repo?: string }} [opts]
160
+ */
161
+ export function isAllowedShell(command, opts) {
162
+ return checkShell(command, opts).ok;
163
+ }
164
+
165
+ /**
166
+ * @param {ToolCallLike} toolCall
167
+ * @param {PolicyContext} ctx
168
+ * @returns {Decision}
169
+ */
170
+ export function decidePermission(toolCall, { repo, outputFilename, mode }) {
171
+ const kind = toolCall.kind ?? "other";
172
+ const paths = toolCallPaths(toolCall, repo);
173
+
174
+ if (kind === "read" || kind === "search") {
175
+ if (!paths.length) {
176
+ return { allow: false, reason: `${kind === "search" ? "búsqueda" : "lectura"} sin path` };
177
+ }
178
+ const outside = paths.find((p) => !isInside(p, repo));
179
+ return outside ? { allow: false, reason: `lectura fuera del repo: ${outside}` } : { allow: true, reason: "lectura" };
180
+ }
181
+ if (kind === "think") return { allow: true, reason: "pensar" };
182
+
183
+ if (kind === "execute") {
184
+ // Cursor no manda `rawInput`: el comando viene en el título (backticks / @…@ / texto).
185
+ const cmd = toolCallCommand(toolCall);
186
+ const check = checkShell(cmd, { repo });
187
+ return check.ok ? { allow: true, reason: "comando de solo lectura" } : { allow: false, reason: check.reason };
188
+ }
189
+
190
+ if (kind === "edit" || kind === "delete") {
191
+ if (mode === "ask") return { allow: false, reason: "modo pregunta: no se edita nada" };
192
+ if (!paths.length) return { allow: false, reason: `${kind === "delete" ? "borrado" : "edición"} sin path` };
193
+ const target = resolve(repo, outputRelPath(outputFilename));
194
+ const outside = paths.find((p) => !samePath(canonicalPath(p), canonicalPath(target)));
195
+ return outside
196
+ ? {
197
+ allow: false,
198
+ reason: `solo se puede escribir ${outputRelPath(outputFilename)} (no ${outside})`
199
+ }
200
+ : { allow: true, reason: `escritura de ${outputRelPath(outputFilename)}` };
201
+ }
202
+
203
+ return { allow: false, reason: `${kind} no permitido: ${toolCall.title ?? "?"}` };
204
+ }
205
+
206
+ /**
207
+ * Respuesta ACP para una decisión del usuario / política.
208
+ * - allow + once → `allow_once` (la política se vuelve a evaluar en el próximo pedido).
209
+ * - allow + always → `allow_always` si el agente la ofrece; si no, cae a `allow_once`.
210
+ * @param {{ optionId: string, kind: string }[]} options
211
+ * @param {boolean} allow
212
+ * @param {{ always?: boolean }} [opts]
213
+ */
214
+ export function permissionOutcome(options, allow, opts = {}) {
215
+ const always = Boolean(opts.always);
216
+ const kinds = allow
217
+ ? always
218
+ ? ["allow_always", "allow_once"]
219
+ : ["allow_once"]
220
+ : ["reject_once", "reject_always"];
221
+ const option = kinds.map((kind) => options.find((o) => o.kind === kind)).find(Boolean);
222
+ return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
223
+ }