@jpkme/diff-review 0.1.10 → 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 (153) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +151 -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 +87 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +29 -11
  13. package/lib/extract-cache.test.mjs +162 -0
  14. package/lib/extract.mjs +197 -1
  15. package/lib/model-label.mjs +263 -0
  16. package/lib/model-label.test.mjs +77 -0
  17. package/lib/models-cache.mjs +75 -0
  18. package/lib/models-cache.test.mjs +50 -0
  19. package/lib/output-name.mjs +8 -0
  20. package/lib/prompt.mjs +168 -22
  21. package/lib/review-dir.mjs +108 -0
  22. package/lib/review-meta.mjs +5 -3
  23. package/lib/review-meta.test.mjs +11 -0
  24. package/lib/review-prep.mjs +63 -0
  25. package/lib/validate.mjs +2 -2
  26. package/lib/wait-output.mjs +46 -23
  27. package/lib/wait-output.test.mjs +24 -1
  28. package/package.json +3 -1
  29. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +1 -0
  30. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
  31. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
  32. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +1 -0
  33. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
  34. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
  35. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +1 -0
  36. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
  37. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
  38. package/web/build/client/_app/immutable/chunks/{BBWAo_yW.js → B5ByWcPE.js} +2 -2
  39. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
  40. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
  41. package/web/build/client/_app/immutable/chunks/CPyWH418.js +1 -0
  42. package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
  43. package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
  44. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  45. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  46. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  47. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +210 -0
  48. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
  49. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
  50. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  51. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  52. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  53. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js +2 -0
  54. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
  55. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
  56. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +1 -0
  57. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
  58. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
  59. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js +1 -0
  60. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.br +0 -0
  61. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.gz +0 -0
  62. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js +1 -0
  63. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +2 -0
  64. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
  65. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +10 -0
  66. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
  67. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
  68. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +171 -0
  69. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
  70. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
  71. package/web/build/client/_app/version.json +1 -1
  72. package/web/build/client/_app/version.json.br +0 -0
  73. package/web/build/client/_app/version.json.gz +0 -0
  74. package/web/build/handler.js +3 -3
  75. package/web/build/index.js +3 -3
  76. package/web/build/server/chunks/{0-SseDy__O.js → 0-BYok4D0n.js} +2 -2
  77. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-BYok4D0n.js.map} +1 -1
  78. package/web/build/server/chunks/1-C7zcB0q0.js +7 -0
  79. package/web/build/server/chunks/1-C7zcB0q0.js.map +1 -0
  80. package/web/build/server/chunks/2-v6ejI_0-.js +7 -0
  81. package/web/build/server/chunks/2-v6ejI_0-.js.map +1 -0
  82. package/web/build/server/chunks/3-DAAmG6VX.js +7 -0
  83. package/web/build/server/chunks/3-DAAmG6VX.js.map +1 -0
  84. package/web/build/server/chunks/_server.ts-qYVrMYPB.js +62 -0
  85. package/web/build/server/chunks/_server.ts-qYVrMYPB.js.map +1 -0
  86. package/web/build/server/chunks/agent-sessions-DSqGavSn.js +2688 -0
  87. package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +1 -0
  88. package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CpO_G8z8.js} +70 -704
  89. package/web/build/server/chunks/git-review-CpO_G8z8.js.map +1 -0
  90. package/web/build/server/chunks/{handler-B5P65tLk.js → handler-Di1wJQuU.js} +3 -3
  91. package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-Di1wJQuU.js.map} +1 -1
  92. package/web/build/server/chunks/hooks.server-BLOHobEK.js +42 -0
  93. package/web/build/server/chunks/hooks.server-BLOHobEK.js.map +1 -0
  94. package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-yLYmrAWy.js} +3 -5
  95. package/web/build/server/chunks/index.js-yLYmrAWy.js.map +1 -0
  96. package/web/build/server/chunks/manifest.js-CXj8IIo3.js +58 -0
  97. package/web/build/server/chunks/manifest.js-CXj8IIo3.js.map +1 -0
  98. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js +144 -0
  99. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js.map +1 -0
  100. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js +162 -0
  101. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js.map +1 -0
  102. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  103. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  104. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
  105. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  106. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  107. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
  108. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  109. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.gz +0 -0
  110. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  111. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  112. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  113. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  114. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  115. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  116. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  117. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  118. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js +0 -176
  119. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  120. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  121. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  122. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  123. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  124. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js +0 -2
  125. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  126. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  127. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
  128. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  129. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  130. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  131. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  132. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  133. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +0 -1
  134. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  135. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  136. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
  137. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  138. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  139. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
  140. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  141. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  142. package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
  143. package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
  144. package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
  145. package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
  146. package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
  147. package/web/build/server/chunks/3-ZIl1rHef.js.map +0 -1
  148. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  149. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  150. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +0 -1
  151. package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
  152. package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
  153. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +0 -1
@@ -0,0 +1,2688 @@
1
+ import { g as getRemoteUrl, e as ensureReviewDir, w as writeDiffCache, p as readDiffCache, q as extractBranchDiff, u as loadReviewConfig, v as outputRelPath, m as makeOutputFilename, O as OUTPUT_DIR, x as writeSessionRef, h as companionPaths, d as decodePayload, t as tryResolveGitRoot, j as loadBranchDiff, y as validateAgainst, z as reviewIssues, R as REVIEW_SCHEMA, A as globMatch, C as writeDiffText } from './validate-BsTKyhsq.js';
2
+ import { existsSync, readFileSync, statSync, accessSync, constants, readdirSync, mkdirSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { join, delimiter, isAbsolute, resolve, basename, dirname, normalize } from 'node:path';
6
+ import { Writable, Readable } from 'node:stream';
7
+ import * as acp from '@agentclientprotocol/sdk';
8
+
9
+ //#region ../lib/agents.mjs
10
+ /**
11
+ * Agentes de código que la CLI puede lanzar con el prompt (`--agent`).
12
+ *
13
+ * No hay un registro de agentes en ningún SO: se detectan por nombre en el PATH más algunas rutas fijas
14
+ * conocidas (p. ej. el Claude Code que trae la app de escritorio en Windows). El prompt viaja por stdin, así
15
+ * no se toca el portapapeles ni se choca con el límite de largo de la línea de comandos de los shims `.cmd`.
16
+ */
17
+ /**
18
+ * @typedef {{ value: string, label: string, hint?: string }} ModelOption
19
+ * @typedef {{ model?: string, outputFilename: string }} RunSpec
20
+ * @typedef {{
21
+ * id: string,
22
+ * label: string,
23
+ * bins: string[],
24
+ * extraPaths?: (ctx: DetectContext) => string[],
25
+ * models: ModelOption[],
26
+ * configuredModel?: (ctx: DetectContext) => { model: string, source: string } | null,
27
+ * args: (spec: RunSpec) => string[],
28
+ * acp?: (ctx: { path: string }) => AcpLaunch,
29
+ * }} AgentDef
30
+ * @typedef {{ file: string, args: string[], env?: Record<string, string>, sessionMeta?: Record<string, unknown> }} AcpLaunch
31
+ * `env` se suma al del proceso; `sessionMeta` va como `_meta` en `session/new` / `session/load`.
32
+ * @typedef {{ env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string }} DetectContext
33
+ * @typedef {{ def: AgentDef, path: string }} DetectedAgent
34
+ */
35
+ /** @param {string} file */
36
+ function readJson(file) {
37
+ try {
38
+ return JSON.parse(readFileSync(file, "utf8"));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** Primer `model = "..."` de nivel raíz de un TOML (antes de cualquier `[sección]`). */
44
+ function topLevelTomlModel(text) {
45
+ for (const line of String(text).split(/\r?\n/)) {
46
+ if (/^\s*\[/.test(line)) break;
47
+ const m = line.match(/^\s*model\s*=\s*["']([^"']+)["']/);
48
+ if (m) return m[1];
49
+ }
50
+ return null;
51
+ }
52
+ /** Versiones tipo `2.1.286` de mayor a menor (las que no parsean, al final). */
53
+ function byVersionDesc(a, b) {
54
+ const pa = a.split(".").map(Number);
55
+ const pb = b.split(".").map(Number);
56
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
57
+ const d = (pb[i] || 0) - (pa[i] || 0);
58
+ if (d) return Number.isNaN(d) ? 0 : d;
59
+ }
60
+ return 0;
61
+ }
62
+ /** `<root>/<versión>/<hash>/<exe>` → rutas, de la versión más nueva a la más vieja. */
63
+ function versionedBinaries(root, exe) {
64
+ /** @type {string[]} */
65
+ const out = [];
66
+ let versions;
67
+ try {
68
+ versions = readdirSync(root).sort(byVersionDesc);
69
+ } catch {
70
+ return out;
71
+ }
72
+ for (const version of versions) {
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(join(root, version));
76
+ } catch {
77
+ continue;
78
+ }
79
+ for (const entry of entries) out.push(join(root, version, entry, exe));
80
+ }
81
+ return out;
82
+ }
83
+ /**
84
+ * Carpeta de configuración para Cursor por ACP: la del usuario sin su allowlist. Cursor aprueba solo lo que matchee
85
+ * `permissions.allow` mirando el nombre del comando (`Shell(echo)` deja pasar `echo x > archivo`); vaciándola,
86
+ * cada comando pasa por la política de diff-review. El login no vive ahí, así que se conserva. El modelo elegido
87
+ * por ACP se guarda en esta carpeta y no pisa el default del usuario.
88
+ */
89
+ function cursorAcpConfigDir(ctx) {
90
+ const c = context(ctx);
91
+ const userDir = c.env.CURSOR_CONFIG_DIR?.trim() || (c.env.XDG_CONFIG_HOME?.trim() ? join(c.env.XDG_CONFIG_HOME, "cursor") : join(c.home, ".cursor"));
92
+ const dir = join(tmpdir(), "diff-review", "cursor-config");
93
+ mkdirSync(dir, { recursive: true });
94
+ const config = readJson(join(userDir, "cli-config.json")) ?? {};
95
+ writeFileSync(join(dir, "cli-config.json"), JSON.stringify({
96
+ ...config,
97
+ permissions: {
98
+ allow: [],
99
+ deny: config.permissions?.deny ?? []
100
+ },
101
+ approvalMode: "allowlist"
102
+ }, null, 2));
103
+ const userAcp = join(userDir, "acp-config.json");
104
+ if (existsSync(userAcp)) copyFileSync(userAcp, join(dir, "acp-config.json"));
105
+ else rmSync(join(dir, "acp-config.json"), { force: true });
106
+ return dir;
107
+ }
108
+ /** Adaptador ACP de Claude Code (antes `@zed-industries/claude-agent-acp`). */
109
+ var CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp@^0.91.0";
110
+ /** @type {AgentDef[]} */
111
+ var AGENTS = [
112
+ {
113
+ id: "claude",
114
+ label: "Claude Code",
115
+ bins: ["claude"],
116
+ extraPaths: ({ env, platform, home }) => {
117
+ if (platform === "win32") return versionedBinaries(join(env.APPDATA || join(home, "AppData", "Roaming"), "Claude", "claude-code"), "claude.exe");
118
+ const paths = [join(home, ".local", "bin", "claude"), join(home, ".claude", "local", "claude")];
119
+ if (platform === "darwin") paths.push(...versionedBinaries(join(home, "Library", "Application Support", "Claude", "claude-code"), "claude"));
120
+ return paths;
121
+ },
122
+ models: [
123
+ {
124
+ value: "opus",
125
+ label: "opus",
126
+ hint: "el más capaz"
127
+ },
128
+ {
129
+ value: "sonnet",
130
+ label: "sonnet",
131
+ hint: "equilibrado"
132
+ },
133
+ {
134
+ value: "haiku",
135
+ label: "haiku",
136
+ hint: "el más rápido"
137
+ }
138
+ ],
139
+ configuredModel: ({ env, home }) => {
140
+ if (env.ANTHROPIC_MODEL) return {
141
+ model: env.ANTHROPIC_MODEL,
142
+ source: "ANTHROPIC_MODEL"
143
+ };
144
+ const model = readJson(join(home, ".claude", "settings.json"))?.model;
145
+ return typeof model === "string" && model ? {
146
+ model,
147
+ source: "~/.claude/settings.json"
148
+ } : null;
149
+ },
150
+ args: ({ model, outputFilename }) => [
151
+ "-p",
152
+ ...model ? ["--model", model] : [],
153
+ "--allowedTools",
154
+ "Read",
155
+ "Grep",
156
+ "Glob",
157
+ "Bash(git:*)",
158
+ "Bash(npx --yes @jpkme/diff-review validate:*)",
159
+ `Edit(./${outputRelPath(outputFilename)})`
160
+ ],
161
+ acp: () => {
162
+ const local = findOnPath("claude-agent-acp", context());
163
+ return {
164
+ ...local ? {
165
+ file: local,
166
+ args: []
167
+ } : {
168
+ file: "npx",
169
+ args: ["--yes", CLAUDE_ACP_PACKAGE]
170
+ },
171
+ sessionMeta: { claudeCode: { options: {
172
+ tools: [
173
+ "Read",
174
+ "Grep",
175
+ "Glob",
176
+ "Bash",
177
+ "Edit",
178
+ "Write"
179
+ ],
180
+ settingSources: []
181
+ } } }
182
+ };
183
+ }
184
+ },
185
+ {
186
+ id: "codex",
187
+ label: "Codex",
188
+ bins: ["codex"],
189
+ models: [],
190
+ configuredModel: ({ home }) => {
191
+ try {
192
+ const model = topLevelTomlModel(readFileSync(join(home, ".codex", "config.toml"), "utf8"));
193
+ return model ? {
194
+ model,
195
+ source: "~/.codex/config.toml"
196
+ } : null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ },
201
+ args: ({ model }) => [
202
+ "exec",
203
+ "--sandbox",
204
+ "workspace-write",
205
+ ...model ? ["-m", model] : [],
206
+ "-"
207
+ ]
208
+ },
209
+ {
210
+ id: "cursor",
211
+ label: "Cursor CLI",
212
+ bins: ["cursor-agent"],
213
+ extraPaths: ({ env, platform, home }) => platform === "win32" ? [join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "cursor-agent", "cursor-agent.cmd")] : [join(home, ".local", "bin", "cursor-agent")],
214
+ models: [],
215
+ configuredModel: ({ home }) => {
216
+ const config = readJson(join(home, ".cursor", "cli-config.json"));
217
+ const id = config?.selectedModel?.modelId ?? config?.model?.modelId;
218
+ if (typeof id !== "string" || !id) return null;
219
+ return {
220
+ model: id === "default" ? "auto" : id,
221
+ source: "~/.cursor/cli-config.json"
222
+ };
223
+ },
224
+ args: ({ model }) => [
225
+ "-p",
226
+ "--output-format",
227
+ "text",
228
+ "--trust",
229
+ "--force",
230
+ ...model ? ["--model", model] : []
231
+ ],
232
+ acp: ({ path }) => ({
233
+ file: path,
234
+ args: ["acp"],
235
+ env: { CURSOR_CONFIG_DIR: cursorAcpConfigDir() }
236
+ })
237
+ },
238
+ {
239
+ id: "copilot",
240
+ label: "GitHub Copilot CLI",
241
+ bins: ["copilot"],
242
+ models: [{
243
+ value: "auto",
244
+ label: "auto",
245
+ hint: "Copilot elige"
246
+ }],
247
+ configuredModel: ({ env, home }) => {
248
+ const model = readJson(join(env.COPILOT_HOME || join(home, ".copilot"), "settings.json"))?.model;
249
+ const source = env.COPILOT_HOME ? "$COPILOT_HOME/settings.json" : "~/.copilot/settings.json";
250
+ return typeof model === "string" && model ? {
251
+ model,
252
+ source
253
+ } : null;
254
+ },
255
+ args: ({ model, outputFilename }) => [
256
+ "-s",
257
+ ...model ? ["--model", model] : [],
258
+ "--allow-tool",
259
+ "shell(git:*)",
260
+ "--allow-tool",
261
+ "shell(npx:*)",
262
+ "--allow-tool",
263
+ `write(${outputRelPath(outputFilename)})`
264
+ ],
265
+ acp: ({ path }) => ({
266
+ file: path,
267
+ args: ["--acp"]
268
+ })
269
+ },
270
+ {
271
+ id: "gemini",
272
+ label: "Gemini CLI",
273
+ bins: ["gemini"],
274
+ models: [],
275
+ configuredModel: ({ home }) => {
276
+ const model = readJson(join(home, ".gemini", "settings.json"))?.model;
277
+ const name = typeof model === "string" ? model : model?.name;
278
+ return typeof name === "string" && name ? {
279
+ model: name,
280
+ source: "~/.gemini/settings.json"
281
+ } : null;
282
+ },
283
+ args: ({ model }) => [...model ? ["-m", model] : [], "--yolo"]
284
+ }
285
+ ];
286
+ /** @param {string} file @param {NodeJS.Platform} platform */
287
+ function isExecutable(file, platform) {
288
+ try {
289
+ if (!statSync(file).isFile()) return false;
290
+ if (platform !== "win32") accessSync(file, constants.X_OK);
291
+ return true;
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+ /** Busca `name` en el PATH (con las extensiones de PATHEXT en Windows). */
297
+ function findOnPath(name, { env, platform }) {
298
+ const dirs = String(env.PATH || env.Path || "").split(platform === "win32" ? ";" : delimiter).filter(Boolean);
299
+ const exts = platform === "win32" ? String(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
300
+ for (const dir of dirs) for (const ext of exts) {
301
+ const candidate = join(dir, name + ext.toLowerCase());
302
+ if (isExecutable(candidate, platform)) return candidate;
303
+ }
304
+ return null;
305
+ }
306
+ /** @param {Partial<DetectContext>} [ctx] */
307
+ function context(ctx = {}) {
308
+ return {
309
+ env: ctx.env ?? process.env,
310
+ platform: ctx.platform ?? process.platform,
311
+ home: ctx.home ?? homedir()
312
+ };
313
+ }
314
+ /**
315
+ * Agentes instalados, en el orden de `AGENTS`. Primero el PATH, después las rutas fijas.
316
+ * @param {Partial<DetectContext>} [ctx]
317
+ * @returns {DetectedAgent[]}
318
+ */
319
+ function detectAgents(ctx) {
320
+ const c = context(ctx);
321
+ /** @type {DetectedAgent[]} */
322
+ const found = [];
323
+ for (const def of AGENTS) {
324
+ let path = null;
325
+ for (const bin of def.bins) {
326
+ path = findOnPath(bin, c);
327
+ if (path) break;
328
+ }
329
+ if (!path) path = (def.extraPaths?.(c) ?? []).find((p) => existsSync(p) && isExecutable(p, c.platform)) ?? null;
330
+ if (path) found.push({
331
+ def,
332
+ path
333
+ });
334
+ }
335
+ return found;
336
+ }
337
+ /**
338
+ * Opciones del select de modelo: primero el default del agente (marcado como inicial), después los conocidos.
339
+ * Si el default configurado coincide con uno conocido, no se repite.
340
+ * @param {AgentDef} def
341
+ * @param {Partial<DetectContext>} [ctx]
342
+ * @returns {{ options: ModelOption[], initialValue: string }}
343
+ */
344
+ function modelChoices(def, ctx) {
345
+ const configured = def.configuredModel?.(context(ctx)) ?? null;
346
+ return {
347
+ options: [configured ? {
348
+ value: "",
349
+ label: `Por defecto (${configured.model})`,
350
+ hint: configured.source
351
+ } : {
352
+ value: "",
353
+ label: "Por defecto del agente",
354
+ hint: "no se pasa --model"
355
+ }, ...def.models.filter((m) => m.value !== configured?.model)],
356
+ initialValue: ""
357
+ };
358
+ }
359
+ /** Comillas para cmd.exe (solo hace falta con los shims .cmd/.bat de npm). */
360
+ function quoteForCmd(arg) {
361
+ return /^[\w./:@=-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, "\"\"")}"`;
362
+ }
363
+ /**
364
+ * `spawn` que también lanza los shims `.cmd`/`.bat` de npm en Windows (y `npx` a secas, que es uno de ellos).
365
+ * @param {string} file
366
+ * @param {string[]} args
367
+ * @param {import('node:child_process').SpawnOptions} options
368
+ */
369
+ function spawnCommand(file, args, options) {
370
+ const resolved = process.platform === "win32" && !/[\\/]/.test(file) ? findOnPath(file, context()) ?? file : file;
371
+ return process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved) ? spawn([resolved, ...args].map(quoteForCmd).join(" "), {
372
+ ...options,
373
+ shell: true
374
+ }) : spawn(resolved, args, options);
375
+ }
376
+ //#endregion
377
+ //#region ../lib/acp-shell.mjs
378
+ /**
379
+ * Qué comandos de terminal puede correr el agente por ACP: git de solo lectura, comandos que solo leen (cat, grep,
380
+ * find, sed -n …, y sus equivalentes de PowerShell) y el validate del paquete. Los paths tienen que quedar dentro
381
+ * del repo.
382
+ *
383
+ * El comando se tokeniza respetando comillas (un `grep "a\|b"` no es un pipe). Se aceptan `|`, `&&`, `||`, `;` y
384
+ * descartar salida a /dev/null; cualquier otra redirección, `$(…)`, backticks o `&` lo rechazan.
385
+ */
386
+ /** @typedef {{ ok: true } | { ok: false, reason: string }} ShellCheck */
387
+ /** Subcomandos de git que solo leen. */
388
+ var GIT_READ = /* @__PURE__ */ new Set([
389
+ "diff",
390
+ "log",
391
+ "show",
392
+ "status",
393
+ "merge-base",
394
+ "rev-parse",
395
+ "rev-list",
396
+ "blame",
397
+ "ls-files",
398
+ "ls-tree",
399
+ "cat-file",
400
+ "describe",
401
+ "shortlog",
402
+ "name-rev",
403
+ "for-each-ref",
404
+ "grep",
405
+ "whatchanged"
406
+ ]);
407
+ /** Flags de git que ejecutan algo o escriben archivos, en cualquier subcomando. */
408
+ var GIT_UNSAFE_FLAG = /^(--output|--ext-diff|-O|--open-files-in-pager)(=|$)/;
409
+ var BRANCH_LIST_FLAGS = /* @__PURE__ */ new Set([
410
+ "--show-current",
411
+ "--list",
412
+ "-a",
413
+ "--all",
414
+ "-r",
415
+ "--remotes",
416
+ "-v",
417
+ "-vv"
418
+ ]);
419
+ var CONFIG_READ_FLAGS = /* @__PURE__ */ new Set([
420
+ "--get",
421
+ "--get-all",
422
+ "--get-regexp",
423
+ "--list",
424
+ "-l"
425
+ ]);
426
+ /** Comandos que solo leen (o imprimen). Los de PowerShell van en minúscula: no distingue mayúsculas. */
427
+ var READ_COMMANDS = /* @__PURE__ */ new Set([
428
+ "cat",
429
+ "head",
430
+ "tail",
431
+ "wc",
432
+ "ls",
433
+ "nl",
434
+ "sort",
435
+ "uniq",
436
+ "cut",
437
+ "tr",
438
+ "grep",
439
+ "egrep",
440
+ "fgrep",
441
+ "rg",
442
+ "find",
443
+ "sed",
444
+ "file",
445
+ "stat",
446
+ "du",
447
+ "diff",
448
+ "cmp",
449
+ "jq",
450
+ "basename",
451
+ "dirname",
452
+ "realpath",
453
+ "tree",
454
+ "which",
455
+ "type",
456
+ "pwd",
457
+ "echo",
458
+ "printf",
459
+ "true",
460
+ "get-content",
461
+ "gc",
462
+ "select-string",
463
+ "sls",
464
+ "get-childitem",
465
+ "gci",
466
+ "dir",
467
+ "measure-object",
468
+ "measure",
469
+ "select-object",
470
+ "select",
471
+ "sort-object",
472
+ "get-location",
473
+ "test-path",
474
+ "resolve-path",
475
+ "write-output",
476
+ "out-string",
477
+ "format-list",
478
+ "format-table"
479
+ ]);
480
+ /** Comandos cuyos argumentos son texto, no paths. */
481
+ var TEXT_ARGS = /* @__PURE__ */ new Set([
482
+ "echo",
483
+ "printf",
484
+ "tr",
485
+ "write-output",
486
+ "which",
487
+ "type"
488
+ ]);
489
+ /** Destinos de redirección que solo descartan salida. */
490
+ var DISCARD = /* @__PURE__ */ new Set([
491
+ "/dev/null",
492
+ "nul",
493
+ "$null"
494
+ ]);
495
+ /** `/c/Users/...` (Git Bash) → `C:/Users/...` en Windows. */
496
+ function toNativePath(p, platform) {
497
+ if (platform === "win32") {
498
+ const msys = /^\/([a-zA-Z])(\/|$)(.*)$/.exec(p);
499
+ if (msys) return `${msys[1].toUpperCase()}:/${msys[3]}`;
500
+ }
501
+ return p;
502
+ }
503
+ function isInside$1(child, root, platform) {
504
+ const c = normalize(child).replace(/[\\/]+$/, "");
505
+ const r = normalize(root).replace(/[\\/]+$/, "");
506
+ const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
507
+ return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
508
+ }
509
+ /** ¿El argumento parece un path que puede salir del repo? (absoluto, `~` o con `..`). */
510
+ function escapingPath(arg) {
511
+ return /^([a-zA-Z]:[\\/]|[\\/]|~)/.test(arg) || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
512
+ }
513
+ /**
514
+ * Parte el comando en cadenas (separadas por `&&`, `||`, `;`, saltos de línea) de pipelines (`|`) de argv.
515
+ * @param {string} command
516
+ * @returns {{ ok: true, pipelines: string[][][] } | { ok: false, reason: string }}
517
+ */
518
+ function tokenizeShell(command) {
519
+ const src = String(command ?? "");
520
+ /** @type {string[][][]} */
521
+ const pipelines = [];
522
+ /** @type {string[][]} */
523
+ let pipeline = [];
524
+ /** @type {string[]} */
525
+ let words = [];
526
+ let word = "";
527
+ let inWord = false;
528
+ /** @type {null | "'" | '"'} */
529
+ let quote = null;
530
+ const endWord = () => {
531
+ if (inWord) words.push(word);
532
+ word = "";
533
+ inWord = false;
534
+ };
535
+ const endSegment = () => {
536
+ endWord();
537
+ if (words.length) pipeline.push(words);
538
+ words = [];
539
+ };
540
+ const endPipeline = () => {
541
+ endSegment();
542
+ if (pipeline.length) pipelines.push(pipeline);
543
+ pipeline = [];
544
+ };
545
+ const fail = (reason) => ({
546
+ ok: false,
547
+ reason
548
+ });
549
+ for (let i = 0; i < src.length; i++) {
550
+ const ch = src[i];
551
+ const next = src[i + 1];
552
+ if (quote === "'") {
553
+ if (ch === "'") quote = null;
554
+ else word += ch;
555
+ continue;
556
+ }
557
+ if (quote === "\"") {
558
+ if (ch === "\"") quote = null;
559
+ else if (ch === "`") return fail("backticks");
560
+ else if (ch === "$" && next === "(") return fail("sustitución $(…)");
561
+ else if (ch === "\\" && next !== void 0 && "\"\\$`".includes(next)) {
562
+ word += next;
563
+ i++;
564
+ } else word += ch;
565
+ continue;
566
+ }
567
+ if (ch === "'" || ch === "\"") {
568
+ quote = ch;
569
+ inWord = true;
570
+ continue;
571
+ }
572
+ if (ch === "\\" && next !== void 0) {
573
+ word += next;
574
+ inWord = true;
575
+ i++;
576
+ continue;
577
+ }
578
+ if (ch === "`") return fail("backticks");
579
+ if (ch === "$" && next === "(") return fail("sustitución $(…)");
580
+ if (/\s/.test(ch) && ch !== "\n" && ch !== "\r") {
581
+ endWord();
582
+ continue;
583
+ }
584
+ if (ch === "\n" || ch === "\r" || ch === ";") {
585
+ endPipeline();
586
+ continue;
587
+ }
588
+ if (ch === "&" && next === "&" || ch === "|" && next === "|") {
589
+ endPipeline();
590
+ i++;
591
+ continue;
592
+ }
593
+ if (ch === "|") {
594
+ endSegment();
595
+ continue;
596
+ }
597
+ if (ch === ">" || ch === "&" && next === ">") {
598
+ if (ch === "&") i++;
599
+ const fd = inWord && /^[12]$/.test(word) ? word : "";
600
+ if (inWord && !fd) return fail("redirección");
601
+ word = "";
602
+ inWord = false;
603
+ let j = i + 1;
604
+ if (src[j] === ">") j++;
605
+ if (src[j] === "&" && /[12]/.test(src[j + 1] ?? "")) {
606
+ i = j + 1;
607
+ continue;
608
+ }
609
+ while (src[j] === " " || src[j] === " ") j++;
610
+ let target = "";
611
+ while (j < src.length && !/[\s;|&<>]/.test(src[j])) target += src[j++];
612
+ if (!DISCARD.has(target.toLowerCase())) return fail(`redirección a ${target || "archivo"}`);
613
+ i = j - 1;
614
+ continue;
615
+ }
616
+ if (ch === "<") return fail("redirección de entrada");
617
+ if (ch === "&") return fail("comando en segundo plano (&)");
618
+ word += ch;
619
+ inWord = true;
620
+ }
621
+ if (quote) return fail("comillas sin cerrar");
622
+ endPipeline();
623
+ return {
624
+ ok: true,
625
+ pipelines
626
+ };
627
+ }
628
+ /**
629
+ * @param {string} arg
630
+ * @param {{ repo: string, platform: NodeJS.Platform }} ctx
631
+ * @returns {ShellCheck}
632
+ */
633
+ function checkPath(arg, { repo, platform }) {
634
+ if (DISCARD.has(arg.toLowerCase())) return { ok: true };
635
+ const native = toNativePath(arg.replace(/^~(?=[\\/]|$)/, "/__home__"), platform);
636
+ return isInside$1(isAbsolute(native) ? native : resolve(repo, native), repo, platform) ? { ok: true } : {
637
+ ok: false,
638
+ reason: `path fuera del repo: ${arg}`
639
+ };
640
+ }
641
+ /** Argumentos (y valores de `--opt=valor`) que parecen paths que salen del repo deben quedar adentro. */
642
+ function checkArgsInside(args, ctx) {
643
+ for (const arg of args) {
644
+ const value = arg.startsWith("-") ? arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : "" : arg;
645
+ if (value && escapingPath(value)) {
646
+ const res = checkPath(value, ctx);
647
+ if (!res.ok) return res;
648
+ }
649
+ }
650
+ return { ok: true };
651
+ }
652
+ /** @param {string[]} argv @returns {ShellCheck} */
653
+ function checkGit(argv, ctx) {
654
+ let i = 1;
655
+ while (argv[i]?.startsWith("-")) {
656
+ const flag = argv[i];
657
+ if (flag === "--no-pager" || flag === "-P") i++;
658
+ else if (flag === "-C") {
659
+ const res = checkPath(argv[i + 1] ?? "", ctx);
660
+ if (!res.ok) return res;
661
+ i += 2;
662
+ } else return {
663
+ ok: false,
664
+ reason: `opción global de git no permitida: ${flag}`
665
+ };
666
+ }
667
+ const sub = argv[i] ?? "";
668
+ const rest = argv.slice(i + 1);
669
+ const unsafe = rest.find((w) => GIT_UNSAFE_FLAG.test(w));
670
+ if (unsafe) return {
671
+ ok: false,
672
+ reason: `git ${sub} ${unsafe}`
673
+ };
674
+ if (GIT_READ.has(sub)) return { ok: true };
675
+ if (sub === "branch" && rest.every((w) => BRANCH_LIST_FLAGS.has(w))) return { ok: true };
676
+ if (sub === "remote" && (rest.length === 0 || rest.length === 1 && rest[0] === "-v" || rest[0] === "get-url")) return { ok: true };
677
+ if (sub === "config" && rest.some((w) => CONFIG_READ_FLAGS.has(w))) return { ok: true };
678
+ return {
679
+ ok: false,
680
+ reason: `git ${sub || "(sin subcomando)"} no es de solo lectura`
681
+ };
682
+ }
683
+ /** @param {string[]} argv @returns {ShellCheck} */
684
+ function checkReadCommand(name, argv, ctx) {
685
+ const args = argv.slice(1);
686
+ if (name === "sort" && args.some((a) => /^(-o|--output)/.test(a))) return {
687
+ ok: false,
688
+ reason: "sort -o escribe un archivo"
689
+ };
690
+ if (name === "uniq" && args.filter((a) => !a.startsWith("-")).length > 1) return {
691
+ ok: false,
692
+ reason: "uniq con archivo de salida"
693
+ };
694
+ if (name === "find" && args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint|fprint0|fprintf|fls)$/.test(a))) return {
695
+ ok: false,
696
+ reason: "find que ejecuta o escribe"
697
+ };
698
+ if (name === "rg" && args.some((a) => /^--pre(-glob)?(=|$)/.test(a))) return {
699
+ ok: false,
700
+ reason: "rg --pre ejecuta comandos"
701
+ };
702
+ if (name === "sed") {
703
+ if (!args.includes("-n") || args.some((a) => /^(-i|--in-place|-e|-f|--expression|--file)/.test(a))) return {
704
+ ok: false,
705
+ reason: "sed solo como `sed -n 'N,Mp'`"
706
+ };
707
+ const script = args.find((a) => !a.startsWith("-"));
708
+ if (!script || !/^\s*(\d+|\$)(\s*,\s*(\d+|\$))?\s*p\s*$/.test(script)) return {
709
+ ok: false,
710
+ reason: "sed solo como `sed -n 'N,Mp'`"
711
+ };
712
+ return checkArgsInside(args.filter((a) => a !== script), ctx);
713
+ }
714
+ if (TEXT_ARGS.has(name)) return { ok: true };
715
+ return checkArgsInside(args, ctx);
716
+ }
717
+ /** El validate del paquete (por npx o con el bin instalado), con el archivo como único argumento. */
718
+ function isValidate(argv) {
719
+ const line = argv.join(" ");
720
+ return /^(?:npx\s+(?:--yes\s+|-y\s+)?@jpkme\/diff-review(?:@[\w.^~-]+)?|diff-review)\s+validate\s+\S+$/.test(line);
721
+ }
722
+ /**
723
+ * `mkdir` / `md` / `New-Item -ItemType Directory` solo bajo `.diff-review/` del repo.
724
+ * @param {string[]} argv
725
+ * @param {{ repo: string, platform: NodeJS.Platform }} ctx
726
+ * @returns {ShellCheck | null} null = no es este caso
727
+ */
728
+ function checkReviewDirMkdir(argv, ctx) {
729
+ const name = argv[0].toLowerCase();
730
+ const reviewRoot = normalize(resolve(ctx.repo, ".diff-review")).replace(/[\\/]+$/, "");
731
+ /** @param {string} arg */
732
+ const underReviewDir = (arg) => {
733
+ const native = toNativePath(String(arg || "").replace(/^~(?=[\\/]|$)/, "/__home__"), ctx.platform);
734
+ const abs = normalize(isAbsolute(native) ? native : resolve(ctx.repo, native)).replace(/[\\/]+$/, "");
735
+ const [a, r] = ctx.platform === "win32" ? [abs.toLowerCase(), reviewRoot.toLowerCase()] : [abs, reviewRoot];
736
+ return a === r || a.startsWith(`${r}\\`) || a.startsWith(`${r}/`);
737
+ };
738
+ if (name === "mkdir" || name === "md") {
739
+ const paths = argv.slice(1).filter((a) => !a.startsWith("-"));
740
+ if (!paths.length || !paths.every(underReviewDir)) return {
741
+ ok: false,
742
+ reason: "mkdir solo permitido bajo .diff-review/"
743
+ };
744
+ return { ok: true };
745
+ }
746
+ if (name === "new-item") {
747
+ const joined = argv.join(" ").toLowerCase();
748
+ if (!/-itemtype\s+directory\b/.test(joined) && !/\b-type\s+directory\b/.test(joined)) return null;
749
+ const paths = argv.slice(1).filter((a) => !a.startsWith("-") && !/^(directory|file)$/i.test(a));
750
+ if (!paths.length || !paths.every(underReviewDir)) return {
751
+ ok: false,
752
+ reason: "New-Item Directory solo permitido bajo .diff-review/"
753
+ };
754
+ return { ok: true };
755
+ }
756
+ return null;
757
+ }
758
+ /**
759
+ * @param {string} command
760
+ * @param {{ repo?: string, platform?: NodeJS.Platform }} [opts]
761
+ * @returns {ShellCheck}
762
+ */
763
+ function checkShell(command, { repo, platform = process.platform } = {}) {
764
+ const parsed = tokenizeShell(command);
765
+ if (!parsed.ok) return parsed;
766
+ if (!parsed.pipelines.length) return {
767
+ ok: false,
768
+ reason: "comando vacío"
769
+ };
770
+ const ctx = {
771
+ repo: repo || process.cwd(),
772
+ platform
773
+ };
774
+ for (const pipeline of parsed.pipelines) for (const argv of pipeline) {
775
+ const name = argv[0].toLowerCase();
776
+ if (argv[0].includes("=")) return {
777
+ ok: false,
778
+ reason: `variables de entorno en el comando: ${argv[0]}`
779
+ };
780
+ /** @type {ShellCheck} */
781
+ let res;
782
+ if (name === "cd" || name === "set-location") res = repo && argv[1] ? checkPath(argv[1], ctx) : {
783
+ ok: false,
784
+ reason: "cd fuera del repo"
785
+ };
786
+ else if (name === "git") res = checkGit(argv, ctx);
787
+ else if (name === "npx" || name === "diff-review") res = isValidate(argv) ? { ok: true } : {
788
+ ok: false,
789
+ reason: `${argv.join(" ")}`
790
+ };
791
+ else if (READ_COMMANDS.has(name)) res = checkReadCommand(name, argv, ctx);
792
+ else res = checkReviewDirMkdir(argv, ctx) ?? {
793
+ ok: false,
794
+ reason: `${argv[0]} no está permitido`
795
+ };
796
+ if (!res.ok) return res;
797
+ }
798
+ return { ok: true };
799
+ }
800
+ //#endregion
801
+ //#region ../lib/acp-policy.mjs
802
+ /**
803
+ * Qué puede hacer el agente cuando corre por ACP. Cada `session/request_permission` pasa por acá: no hay nadie
804
+ * mirando la terminal para aprobar, así que la política es una allowlist (leer, comandos de solo lectura y escribir
805
+ * dentro de `.diff-review/`) y todo lo demás se rechaza.
806
+ *
807
+ * Ojo: el agente solo pide permiso para lo que *su* configuración no aprueba sola (p. ej. Claude Code aplica la
808
+ * allowlist de `~/.claude/settings.json` antes de preguntar). Esto acota lo que pide, no lo que el usuario ya
809
+ * habilitó en su agente.
810
+ */
811
+ /**
812
+ * @typedef {"review" | "ask" | "edit"} PolicyMode
813
+ * review = generar el JSON; ask = preguntas (no puede escribir nada); edit = modificar el JSON.
814
+ * @typedef {{ repo: string, outputFilename: string, mode: PolicyMode }} PolicyContext
815
+ * @typedef {{
816
+ * kind?: string | null,
817
+ * title?: string | null,
818
+ * rawInput?: unknown,
819
+ * locations?: { path: string }[] | null,
820
+ * content?: { type: string, path?: string }[] | null,
821
+ * }} ToolCallLike
822
+ * @typedef {{ allow: boolean, reason: string }} Decision
823
+ */
824
+ /** @param {string} a @param {string} b */
825
+ function samePath(a, b, platform = process.platform) {
826
+ const na = normalize(a);
827
+ const nb = normalize(b);
828
+ return platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
829
+ }
830
+ /** @param {string} child @param {string} root */
831
+ function isInside(child, root, platform = process.platform) {
832
+ const c = normalize(child);
833
+ const r = normalize(root).replace(/[\\/]+$/, "");
834
+ const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
835
+ return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
836
+ }
837
+ /** Paths que toca la tool call (`locations`, diffs de `content` y los campos típicos de `rawInput`). */
838
+ function toolCallPaths(toolCall, repo) {
839
+ const raw = toolCall.rawInput && typeof toolCall.rawInput === "object" ? toolCall.rawInput : {};
840
+ const candidates = [
841
+ ...(toolCall.locations ?? []).map((l) => l?.path),
842
+ ...(toolCall.content ?? []).map((c) => c?.type === "diff" ? c.path : void 0),
843
+ raw.file_path,
844
+ raw.path,
845
+ raw.filePath,
846
+ raw.notebook_path
847
+ ];
848
+ const out = [];
849
+ for (const c of candidates) {
850
+ if (typeof c !== "string" || !c.trim()) continue;
851
+ const abs = isAbsolute(c) ? c : resolve(repo, c);
852
+ if (!out.some((p) => samePath(p, abs))) out.push(abs);
853
+ }
854
+ return out;
855
+ }
856
+ /** @param {unknown} rawInput */
857
+ function commandOf(rawInput) {
858
+ if (!rawInput || typeof rawInput !== "object") return "";
859
+ const cmd = rawInput.command;
860
+ if (Array.isArray(cmd)) return cmd.map(String).join(" ");
861
+ return typeof cmd === "string" ? cmd : "";
862
+ }
863
+ /**
864
+ * @param {ToolCallLike} toolCall
865
+ * @param {PolicyContext} ctx
866
+ * @returns {Decision}
867
+ */
868
+ function decidePermission(toolCall, { repo, outputFilename, mode }) {
869
+ const kind = toolCall.kind ?? "other";
870
+ const paths = toolCallPaths(toolCall, repo);
871
+ if (kind === "read" || kind === "search") {
872
+ const outside = paths.find((p) => !isInside(p, repo));
873
+ return outside ? {
874
+ allow: false,
875
+ reason: `lectura fuera del repo: ${outside}`
876
+ } : {
877
+ allow: true,
878
+ reason: "lectura"
879
+ };
880
+ }
881
+ if (kind === "think") return {
882
+ allow: true,
883
+ reason: "pensar"
884
+ };
885
+ if (kind === "execute") {
886
+ const title = String(toolCall.title ?? "").trim();
887
+ const check = checkShell(commandOf(toolCall.rawInput) || (/^`([\s\S]+)`$/.exec(title)?.[1] ?? title), { repo });
888
+ return check.ok ? {
889
+ allow: true,
890
+ reason: "comando de solo lectura"
891
+ } : {
892
+ allow: false,
893
+ reason: check.reason
894
+ };
895
+ }
896
+ if (kind === "edit" || kind === "delete") {
897
+ if (mode === "ask") return {
898
+ allow: false,
899
+ reason: "modo pregunta: no se edita nada"
900
+ };
901
+ if (!paths.length) return {
902
+ allow: false,
903
+ reason: `${kind === "delete" ? "borrado" : "edición"} sin path`
904
+ };
905
+ const reviewRoot = resolve(repo, OUTPUT_DIR);
906
+ const outside = paths.find((p) => !isInside(p, reviewRoot) && !samePath(p, reviewRoot));
907
+ return outside ? {
908
+ allow: false,
909
+ reason: `solo se puede escribir en ${OUTPUT_DIR}/ (no ${outside})`
910
+ } : {
911
+ allow: true,
912
+ reason: `escritura en ${OUTPUT_DIR}/`
913
+ };
914
+ }
915
+ return {
916
+ allow: false,
917
+ reason: `${kind} no permitido: ${toolCall.title ?? "?"}`
918
+ };
919
+ }
920
+ /**
921
+ * Respuesta ACP para una decisión del usuario / política.
922
+ * - allow + once → `allow_once` (la política se vuelve a evaluar en el próximo pedido).
923
+ * - allow + always → `allow_always` si el agente la ofrece; si no, cae a `allow_once`.
924
+ * @param {{ optionId: string, kind: string }[]} options
925
+ * @param {boolean} allow
926
+ * @param {{ always?: boolean }} [opts]
927
+ */
928
+ function permissionOutcome(options, allow, opts = {}) {
929
+ const always = Boolean(opts.always);
930
+ const option = (allow ? always ? ["allow_always", "allow_once"] : ["allow_once"] : ["reject_once", "reject_always"]).map((kind) => options.find((o) => o.kind === kind)).find(Boolean);
931
+ return option ? { outcome: {
932
+ outcome: "selected",
933
+ optionId: option.optionId
934
+ } } : { outcome: { outcome: "cancelled" } };
935
+ }
936
+ //#endregion
937
+ //#region ../lib/agent-login.mjs
938
+ /**
939
+ * Login de agentes ACP: mensajes claros + abrir una terminal interactiva cuando se pueda.
940
+ */
941
+ /**
942
+ * @typedef {{ bin: string, args: string[], display: string }} LoginCommand
943
+ */
944
+ /**
945
+ * Comando de login interactivo por agente (null = no hay CLI de login, p. ej. Cursor app).
946
+ * @param {string} agentId
947
+ * @returns {LoginCommand | null}
948
+ */
949
+ function loginCommand(agentId) {
950
+ switch (agentId) {
951
+ case "claude": return {
952
+ bin: "claude",
953
+ args: ["/login"],
954
+ display: "claude /login"
955
+ };
956
+ case "copilot": return {
957
+ bin: "gh",
958
+ args: ["auth", "login"],
959
+ display: "gh auth login"
960
+ };
961
+ case "codex": return {
962
+ bin: "codex",
963
+ args: ["login"],
964
+ display: "codex login"
965
+ };
966
+ case "gemini": return {
967
+ bin: "gemini",
968
+ args: [],
969
+ display: "gemini"
970
+ };
971
+ default: return null;
972
+ }
973
+ }
974
+ /** @param {string} text */
975
+ function looksLikeAuthFailure(text) {
976
+ const s = String(text || "").toLowerCase();
977
+ return /(not\s+)?auth|login|unauthor|not logged|sign[\s-]?in|credentials|token.*(invalid|expired|missing)|please\s+run/.test(s);
978
+ }
979
+ /**
980
+ * Mensaje en español si el agente murió / falló por falta de login.
981
+ * @param {string} agentId
982
+ * @param {string} agentLabel
983
+ * @param {string} detail
984
+ * @returns {string | null}
985
+ */
986
+ function authFailureMessage$1(agentId, agentLabel, detail) {
987
+ if (!looksLikeAuthFailure(detail)) return null;
988
+ const cmd = loginCommand(agentId);
989
+ if (agentId === "cursor") return `${agentLabel} no está autenticado. Iniciá sesión en la app de Cursor y reintentá.`;
990
+ if (cmd) return `${agentLabel} no está autenticado. Corré \`${cmd.display}\` en una terminal y reintentá.`;
991
+ return `${agentLabel} no está autenticado. Revisá el login del agente y reintentá.`;
992
+ }
993
+ /**
994
+ * Abre una terminal nueva con el login del agente (Windows / macOS / Linux).
995
+ * @param {string} agentId
996
+ * @returns {{ display: string }}
997
+ */
998
+ function openLoginTerminal(agentId) {
999
+ const cmd = loginCommand(agentId);
1000
+ if (!cmd) throw new Error(agentId === "cursor" ? "Cursor se autentica desde la app de escritorio, no desde una terminal." : "Este agente no tiene un comando de login conocido.");
1001
+ const platform = process.platform;
1002
+ if (platform === "win32") spawn("cmd.exe", [
1003
+ "/c",
1004
+ "start",
1005
+ `${cmd.bin} login`,
1006
+ "cmd.exe",
1007
+ "/k",
1008
+ cmd.bin,
1009
+ ...cmd.args
1010
+ ], {
1011
+ detached: true,
1012
+ stdio: "ignore",
1013
+ windowsHide: false
1014
+ }).unref();
1015
+ else if (platform === "darwin") spawn("osascript", ["-e", `tell application "Terminal" to do script ${JSON.stringify(`${cmd.bin} ${cmd.args.join(" ")}`.trim())}`], {
1016
+ detached: true,
1017
+ stdio: "ignore"
1018
+ }).unref();
1019
+ else {
1020
+ const line = `${cmd.bin} ${cmd.args.join(" ")}`.trim();
1021
+ const tried = [
1022
+ ["x-terminal-emulator", [
1023
+ "-e",
1024
+ "bash",
1025
+ "-lc",
1026
+ `${line}; exec bash`
1027
+ ]],
1028
+ ["gnome-terminal", [
1029
+ "--",
1030
+ "bash",
1031
+ "-lc",
1032
+ `${line}; exec bash`
1033
+ ]],
1034
+ ["konsole", [
1035
+ "-e",
1036
+ "bash",
1037
+ "-lc",
1038
+ `${line}; exec bash`
1039
+ ]],
1040
+ ["xterm", [
1041
+ "-e",
1042
+ "bash",
1043
+ "-lc",
1044
+ `${line}; exec bash`
1045
+ ]]
1046
+ ];
1047
+ let ok = false;
1048
+ for (const [bin, args] of tried) try {
1049
+ spawn(bin, args, {
1050
+ detached: true,
1051
+ stdio: "ignore"
1052
+ }).unref();
1053
+ ok = true;
1054
+ break;
1055
+ } catch {}
1056
+ if (!ok) throw new Error(`No pude abrir una terminal. Corré a mano: ${cmd.display}`);
1057
+ }
1058
+ return { display: cmd.display };
1059
+ }
1060
+ /** @returns {string} */
1061
+ function modelsCachePath(home = homedir()) {
1062
+ return join(home, ".diff-review", "models-cache.json");
1063
+ }
1064
+ /**
1065
+ * @param {string} agentId
1066
+ * @param {{ home?: string, now?: number, ttlMs?: number }} [opts]
1067
+ * @returns {{ models: { value: string, name: string }[], currentModel: string, fetchedAt: string } | null}
1068
+ */
1069
+ function readModelsCache(agentId, opts = {}) {
1070
+ const id = String(agentId || "").trim();
1071
+ if (!id) return null;
1072
+ const path = modelsCachePath(opts.home);
1073
+ let root;
1074
+ try {
1075
+ root = JSON.parse(readFileSync(path, "utf8"));
1076
+ } catch {
1077
+ return null;
1078
+ }
1079
+ const entry = root?.agents?.[id];
1080
+ if (!entry || !Array.isArray(entry.models)) return null;
1081
+ const fetchedAt = Date.parse(String(entry.fetchedAt || ""));
1082
+ if (!Number.isFinite(fetchedAt)) return null;
1083
+ const ttl = opts.ttlMs ?? 864e5;
1084
+ if ((opts.now ?? Date.now()) - fetchedAt > ttl) return null;
1085
+ return {
1086
+ models: entry.models.filter((m) => m && typeof m.value === "string").map((m) => ({
1087
+ value: m.value,
1088
+ name: String(m.name ?? m.value)
1089
+ })),
1090
+ currentModel: String(entry.currentModel ?? ""),
1091
+ fetchedAt: new Date(fetchedAt).toISOString()
1092
+ };
1093
+ }
1094
+ /**
1095
+ * @param {string} agentId
1096
+ * @param {{ models: { value: string, name: string }[], currentModel?: string }} data
1097
+ * @param {{ home?: string, now?: number }} [opts]
1098
+ */
1099
+ function writeModelsCache(agentId, data, opts = {}) {
1100
+ const id = String(agentId || "").trim();
1101
+ if (!id || !Array.isArray(data?.models) || !data.models.length) return;
1102
+ const path = modelsCachePath(opts.home);
1103
+ let root = {
1104
+ version: 1,
1105
+ agents: {}
1106
+ };
1107
+ try {
1108
+ if (existsSync(path)) root = {
1109
+ ...root,
1110
+ ...JSON.parse(readFileSync(path, "utf8"))
1111
+ };
1112
+ } catch {}
1113
+ if (!root.agents || typeof root.agents !== "object") root.agents = {};
1114
+ root.agents[id] = {
1115
+ fetchedAt: new Date(opts.now ?? Date.now()).toISOString(),
1116
+ currentModel: String(data.currentModel ?? ""),
1117
+ models: data.models.map((m) => ({
1118
+ value: String(m.value),
1119
+ name: String(m.name ?? m.value)
1120
+ }))
1121
+ };
1122
+ mkdirSync(dirname(path), { recursive: true });
1123
+ writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
1124
+ }
1125
+ //#endregion
1126
+ //#region ../lib/acp.mjs
1127
+ /**
1128
+ * Sesión con un agente por ACP (Agent Client Protocol): JSON-RPC por stdin/stdout del proceso del agente.
1129
+ *
1130
+ * A diferencia de `runAgent` (una vuelta headless), el proceso queda vivo: la misma sesión sirve para generar la
1131
+ * review y después para las preguntas y los cambios que se le pidan desde la web. Cada pedido de permiso pasa por
1132
+ * `decide` (ver `acp-policy.mjs`): no hay nadie mirando una terminal para aprobar.
1133
+ */
1134
+ /** El agente tiene un modo ACP declarado (no todos los agentes lo traen). */
1135
+ function supportsAcp(agent) {
1136
+ return typeof agent?.def?.acp === "function";
1137
+ }
1138
+ /**
1139
+ * Opción de modelo (`configOptions` con id/categoría "model") de la respuesta de `session/new` o `session/load`.
1140
+ * @param {{ configOptions?: any[] | null }} response
1141
+ */
1142
+ function modelConfig(response) {
1143
+ const option = (response?.configOptions ?? []).find((o) => o?.category === "model" || o?.id === "model");
1144
+ if (!option || option.type !== "select") return null;
1145
+ const flat = (option.options ?? []).flatMap((o) => Array.isArray(o?.options) ? o.options : [o]);
1146
+ return {
1147
+ configId: String(option.id),
1148
+ current: String(option.currentValue ?? ""),
1149
+ options: flat.filter((o, i) => o && typeof o.value === "string" && flat.findIndex((x) => x?.value === o.value) === i).map((o) => ({
1150
+ value: o.value,
1151
+ name: String(o.name ?? o.value)
1152
+ }))
1153
+ };
1154
+ }
1155
+ /** Tiempo máximo para initialize + session/new|load (+ set model). */
1156
+ var ACP_START_TIMEOUT_MS = 9e4;
1157
+ /**
1158
+ * Lanza el agente en modo ACP, negocia y abre (o retoma) una sesión.
1159
+ * @param {DetectedAgent} agent
1160
+ * @param {AcpSessionOptions} opts
1161
+ * @returns {Promise<AcpSession>}
1162
+ */
1163
+ async function startAcpSession(agent, opts) {
1164
+ if (!supportsAcp(agent)) throw new Error(`${agent.def.label} no tiene modo ACP.`);
1165
+ const { file, args, env, sessionMeta } = agent.def.acp({ path: agent.path });
1166
+ const child = spawnCommand(file, args, {
1167
+ cwd: opts.cwd,
1168
+ stdio: [
1169
+ "pipe",
1170
+ "pipe",
1171
+ "pipe"
1172
+ ],
1173
+ ...env ? { env: {
1174
+ ...process.env,
1175
+ ...env
1176
+ } } : {}
1177
+ });
1178
+ let stderr = "";
1179
+ child.stderr?.on("data", (chunk) => {
1180
+ stderr = (stderr + String(chunk)).slice(-4e3);
1181
+ });
1182
+ child.stdin?.on("error", () => {});
1183
+ const conn = acp.client({ name: "diff-review" }).onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
1184
+ const toolCall = ctx.params.toolCall;
1185
+ const decision = safeDecide(opts.decide, toolCall);
1186
+ if (decision.allow) {
1187
+ opts.onPermission?.(toolCall, decision);
1188
+ return permissionOutcome(ctx.params.options, true);
1189
+ }
1190
+ let allow = false;
1191
+ let always = false;
1192
+ if (opts.onDeniedPermission) try {
1193
+ const answer = await opts.onDeniedPermission(toolCall, decision);
1194
+ if (answer && typeof answer === "object") {
1195
+ allow = Boolean(answer.allow);
1196
+ always = Boolean(answer.always);
1197
+ } else allow = Boolean(answer);
1198
+ } catch {
1199
+ allow = false;
1200
+ always = false;
1201
+ }
1202
+ const final = {
1203
+ allow,
1204
+ reason: allow ? always ? "permitido siempre" : "permitido por el usuario" : decision.reason
1205
+ };
1206
+ opts.onPermission?.(toolCall, final);
1207
+ return permissionOutcome(ctx.params.options, allow, { always });
1208
+ }).onNotification(acp.methods.client.session.update, (ctx) => {
1209
+ opts.onUpdate?.(ctx.params.update);
1210
+ }).connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
1211
+ new Promise((resolve) => {
1212
+ child.once("error", (err) => {
1213
+ stderr += `\n${err.message}`;
1214
+ resolve(null);
1215
+ });
1216
+ child.once("exit", (code) => resolve(code));
1217
+ }).then((code) => {
1218
+ conn.close(/* @__PURE__ */ new Error(`${agent.def.label} terminó (código ${code ?? "?"}).`));
1219
+ opts.onExit?.({
1220
+ code,
1221
+ stderr
1222
+ });
1223
+ });
1224
+ const close = () => {
1225
+ conn.close();
1226
+ if (child.exitCode == null) child.kill();
1227
+ };
1228
+ const timeoutMs = Math.max(15e3, Number(opts.timeoutMs) || ACP_START_TIMEOUT_MS);
1229
+ let timedOut = false;
1230
+ const timer = setTimeout(() => {
1231
+ timedOut = true;
1232
+ close();
1233
+ }, timeoutMs);
1234
+ try {
1235
+ const init = await conn.agent.request(acp.methods.agent.initialize, {
1236
+ protocolVersion: acp.PROTOCOL_VERSION,
1237
+ clientCapabilities: {
1238
+ fs: {
1239
+ readTextFile: false,
1240
+ writeTextFile: false
1241
+ },
1242
+ terminal: false
1243
+ },
1244
+ clientInfo: {
1245
+ name: "diff-review",
1246
+ version: "0"
1247
+ }
1248
+ });
1249
+ /** Métodos donde el agente maneja el login por `authenticate` (no terminal interactiva). */
1250
+ const agentAuthMethods = (Array.isArray(init.authMethods) ? init.authMethods : []).filter((m) => m && (!m.type || m.type === "agent") && m.id);
1251
+ /** Si session/new falla por auth, pedimos `authenticate` una vez y reintentamos. */
1252
+ const tryAuthenticate = async () => {
1253
+ for (const method of agentAuthMethods) try {
1254
+ await conn.agent.request(acp.methods.agent.authenticate, { methodId: method.id });
1255
+ return true;
1256
+ } catch {}
1257
+ return false;
1258
+ };
1259
+ const openNewSession = async () => {
1260
+ try {
1261
+ return await conn.agent.request(acp.methods.agent.session.new, {
1262
+ cwd: opts.cwd,
1263
+ mcpServers: [],
1264
+ ...sessionMeta ? { _meta: sessionMeta } : {}
1265
+ });
1266
+ } catch (err) {
1267
+ const msg = err instanceof Error ? err.message : String(err);
1268
+ if (agentAuthMethods.length && looksLikeAuthFailure(msg) && await tryAuthenticate()) return await conn.agent.request(acp.methods.agent.session.new, {
1269
+ cwd: opts.cwd,
1270
+ mcpServers: [],
1271
+ ...sessionMeta ? { _meta: sessionMeta } : {}
1272
+ });
1273
+ throw err;
1274
+ }
1275
+ };
1276
+ let sessionId = "";
1277
+ let response;
1278
+ let resumed = false;
1279
+ if (opts.resumeSessionId && init.agentCapabilities?.loadSession) try {
1280
+ response = await conn.agent.request(acp.methods.agent.session.load, {
1281
+ sessionId: opts.resumeSessionId,
1282
+ cwd: opts.cwd,
1283
+ mcpServers: [],
1284
+ ...sessionMeta ? { _meta: sessionMeta } : {}
1285
+ });
1286
+ sessionId = opts.resumeSessionId;
1287
+ resumed = true;
1288
+ } catch {}
1289
+ if (!resumed) {
1290
+ response = await openNewSession();
1291
+ sessionId = response.sessionId;
1292
+ }
1293
+ const models = modelConfig(response);
1294
+ let currentModel = models?.current ?? "";
1295
+ /**
1296
+ * @param {string} model
1297
+ * @returns {Promise<{ currentModel: string, warning?: string }>}
1298
+ */
1299
+ const setModel = async (model) => {
1300
+ const wanted = String(model || "").trim();
1301
+ if (!wanted || wanted === currentModel) return { currentModel };
1302
+ if (!models) return {
1303
+ currentModel,
1304
+ warning: `El agente no deja elegir el modelo por ACP; usa el suyo.`
1305
+ };
1306
+ if (!models.options.some((o) => o.value === wanted)) return {
1307
+ currentModel,
1308
+ warning: `El agente no ofrece el modelo "${wanted}"; sigue con "${currentModel}".`
1309
+ };
1310
+ await conn.agent.request(acp.methods.agent.session.setConfigOption, {
1311
+ sessionId,
1312
+ configId: models.configId,
1313
+ value: wanted
1314
+ });
1315
+ currentModel = wanted;
1316
+ return { currentModel };
1317
+ };
1318
+ let modelWarning;
1319
+ const initialWanted = String(opts.model || "").trim();
1320
+ if (initialWanted) {
1321
+ const applied = await setModel(initialWanted);
1322
+ if (applied.warning) modelWarning = applied.warning;
1323
+ }
1324
+ clearTimeout(timer);
1325
+ return {
1326
+ sessionId,
1327
+ resumed,
1328
+ models: models?.options ?? [],
1329
+ get currentModel() {
1330
+ return currentModel;
1331
+ },
1332
+ ...modelWarning ? { modelWarning } : {},
1333
+ setModel,
1334
+ prompt: async (text) => {
1335
+ const res = await conn.agent.request(acp.methods.agent.session.prompt, {
1336
+ sessionId,
1337
+ prompt: [{
1338
+ type: "text",
1339
+ text
1340
+ }]
1341
+ });
1342
+ return { stopReason: String(res.stopReason) };
1343
+ },
1344
+ cancel: () => conn.agent.notify(acp.methods.agent.session.cancel, { sessionId }),
1345
+ close,
1346
+ closed: conn.closed,
1347
+ stderrTail: () => stderr
1348
+ };
1349
+ } catch (err) {
1350
+ clearTimeout(timer);
1351
+ close();
1352
+ const tail = stderr.trim().split(/\r?\n/).slice(-8).join("\n");
1353
+ if (timedOut) {
1354
+ const msg = `Timeout (${Math.round(timeoutMs / 1e3)}s) al lanzar ${agent.def.label}. ¿Está autenticado el CLI?`;
1355
+ throw new Error(tail ? `${msg}\n${tail}` : msg);
1356
+ }
1357
+ const reason = err instanceof Error ? err.message : String(err);
1358
+ throw new Error(tail ? `${reason}\n${tail}` : reason);
1359
+ }
1360
+ }
1361
+ /**
1362
+ * Modelos ACP con caché en `~/.diff-review/models-cache.json` (TTL 24h).
1363
+ * @param {DetectedAgent} agent
1364
+ * @param {{ cwd: string, timeoutMs?: number, force?: boolean }} opts
1365
+ * @returns {Promise<{ models: ModelOption[], currentModel: string, fromCache: boolean }>}
1366
+ */
1367
+ async function listAcpModelsCached(agent, opts) {
1368
+ if (!Boolean(opts?.force)) {
1369
+ const hit = readModelsCache(agent.def.id);
1370
+ if (hit) return {
1371
+ models: hit.models,
1372
+ currentModel: hit.currentModel,
1373
+ fromCache: true
1374
+ };
1375
+ }
1376
+ const result = await listAcpModels(agent, opts);
1377
+ if (result.models.length) writeModelsCache(agent.def.id, result);
1378
+ return {
1379
+ ...result,
1380
+ fromCache: false
1381
+ };
1382
+ }
1383
+ /**
1384
+ * Modelos que ofrece el agente por ACP (abre una sesión, lee las opciones y la cierra; no manda ningún prompt).
1385
+ * @param {DetectedAgent} agent
1386
+ * @param {{ cwd: string, timeoutMs?: number }} opts
1387
+ * @returns {Promise<{ models: ModelOption[], currentModel: string }>}
1388
+ */
1389
+ async function listAcpModels(agent, { cwd, timeoutMs = 45e3 }) {
1390
+ const ms = Math.max(5e3, Number(timeoutMs) || 45e3);
1391
+ /** @type {AcpSession | null} */
1392
+ let session = null;
1393
+ const pending = startAcpSession(agent, {
1394
+ cwd,
1395
+ decide: () => ({
1396
+ allow: false,
1397
+ reason: "solo se listan modelos"
1398
+ })
1399
+ }).then((s) => {
1400
+ session = s;
1401
+ return s;
1402
+ });
1403
+ const timeout = new Promise((_, reject) => {
1404
+ setTimeout(() => {
1405
+ reject(/* @__PURE__ */ new Error(`Timeout (${Math.round(ms / 1e3)}s) al consultar modelos de ${agent.def.label}. Reintentá en unos segundos.`));
1406
+ }, ms);
1407
+ });
1408
+ try {
1409
+ const opened = await Promise.race([pending, timeout]);
1410
+ const out = {
1411
+ models: opened.models,
1412
+ currentModel: opened.currentModel
1413
+ };
1414
+ opened.close();
1415
+ session = null;
1416
+ return out;
1417
+ } catch (err) {
1418
+ pending.then((s) => s.close()).catch(() => {});
1419
+ try {
1420
+ session?.close();
1421
+ } catch {}
1422
+ throw err;
1423
+ }
1424
+ }
1425
+ /** Un error en la política no puede colgar al agente esperando respuesta: se rechaza. */
1426
+ function safeDecide(decide, toolCall) {
1427
+ try {
1428
+ return decide(toolCall);
1429
+ } catch (err) {
1430
+ return {
1431
+ allow: false,
1432
+ reason: `error en la política: ${err instanceof Error ? err.message : err}`
1433
+ };
1434
+ }
1435
+ }
1436
+ //#endregion
1437
+ //#region ../lib/check-report.mjs
1438
+ /**
1439
+ * Valida un JSON de review y arma un texto accionable para el agente.
1440
+ */
1441
+ /** Raíz del repo a partir del JSON (`.diff-review/foo.json` → parent, o la carpeta del archivo legacy). */
1442
+ function repoRootFromReviewFile(absJson) {
1443
+ const dir = dirname(absJson);
1444
+ return basename(dir) === ".diff-review" ? dirname(dir) : dir;
1445
+ }
1446
+ var MISSING_LIMIT = 40;
1447
+ /**
1448
+ * @param {unknown} payload
1449
+ * @param {string[]} [paths] rutas del diff (sin ignorados)
1450
+ * @returns {{ ok: boolean, errors: string[], notes: string[] }}
1451
+ */
1452
+ function checkPayload(payload, paths = []) {
1453
+ const errors = [...validateAgainst(REVIEW_SCHEMA, payload), ...reviewIssues(payload)];
1454
+ const notes = [];
1455
+ const known = (paths || []).map((p) => String(p || "").replaceAll("\\", "/")).filter(Boolean);
1456
+ if (known.length) {
1457
+ const blocks = Array.isArray(payload?.blocks) ? payload.blocks : [];
1458
+ const findings = Array.isArray(payload?.findings) ? payload.findings : [];
1459
+ const skippedPatterns = (Array.isArray(payload?.skipped) ? payload.skipped : []).map((s) => String(s?.file || "")).filter(Boolean);
1460
+ const covers = (file) => {
1461
+ const norm = String(file || "").replaceAll("\\", "/");
1462
+ if (blocks.some((b) => String(b?.file || "").replaceAll("\\", "/") === norm)) return true;
1463
+ return skippedPatterns.some((pattern) => {
1464
+ const p = pattern.replaceAll("\\", "/");
1465
+ return p === norm || globMatch(norm, p);
1466
+ });
1467
+ };
1468
+ for (const block of blocks) {
1469
+ const file = String(block?.file || "").replaceAll("\\", "/");
1470
+ if (file && !known.includes(file)) errors.push(`el bloque ${block.id || file} apunta a un archivo que no está en el diff: ${file}`);
1471
+ }
1472
+ for (const finding of findings) {
1473
+ const file = String(finding?.file || "").replaceAll("\\", "/");
1474
+ if (file && !known.includes(file)) errors.push(`el hallazgo ${finding.id || finding.what || file} apunta a un archivo fuera del diff: ${file}`);
1475
+ }
1476
+ const missing = known.filter((file) => !covers(file));
1477
+ if (missing.length) {
1478
+ const shown = missing.slice(0, MISSING_LIMIT);
1479
+ const rest = missing.length - shown.length;
1480
+ errors.push(`quedaron ${missing.length} archivo(s) del diff sin bloque ni skipped: ${shown.join(", ")}${rest > 0 ? ` … y ${rest} más` : ""}`);
1481
+ }
1482
+ } else notes.push("No contrasté cobertura contra git (sin lista de paths del diff).");
1483
+ return {
1484
+ ok: errors.length === 0,
1485
+ errors,
1486
+ notes
1487
+ };
1488
+ }
1489
+ /**
1490
+ * @param {string} filePath
1491
+ * @param {{ repo?: string, branch?: string, base?: string, cwd?: string, paths?: string[] }} [opts]
1492
+ * `paths`: lista ya conocida (p. ej. de la sesión); si viene, no se lee el `.diff` ni se llama a git.
1493
+ */
1494
+ function checkReportFile(filePath, opts = {}) {
1495
+ const cwd = opts.cwd || process.cwd();
1496
+ const abs = isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
1497
+ if (!existsSync(abs)) return {
1498
+ ok: false,
1499
+ file: abs,
1500
+ errors: [`No existe el archivo: ${abs}`],
1501
+ notes: [],
1502
+ usage: true
1503
+ };
1504
+ let payload;
1505
+ try {
1506
+ payload = decodePayload(readFileSync(abs, "utf8"));
1507
+ } catch (err) {
1508
+ return {
1509
+ ok: false,
1510
+ file: abs,
1511
+ errors: [err instanceof Error ? err.message : String(err)],
1512
+ notes: []
1513
+ };
1514
+ }
1515
+ const meta = payload && typeof payload === "object" ? payload.meta || {} : {};
1516
+ const branch = String(opts.branch || meta.branch || "").trim();
1517
+ const base = String(opts.base || meta.base || "develop").trim();
1518
+ const repoHint = String(opts.repo || meta.repo || "").trim();
1519
+ const repo = (repoHint && !/^(https?:\/\/|git@|ssh:\/\/)/i.test(repoHint) ? tryResolveGitRoot(repoHint) || (existsSync(repoHint) ? resolve(repoHint) : null) : null) || tryResolveGitRoot(cwd) || repoRootFromReviewFile(abs);
1520
+ /** @type {string[]} */
1521
+ let paths = Array.isArray(opts.paths) ? opts.paths.map(String).filter(Boolean) : [];
1522
+ /** @type {string[]} */
1523
+ const errors = [];
1524
+ if (paths.length) ; else if (!branch) errors.push("Falta meta.branch (o --branch) para contrastar el diff.");
1525
+ else if (!repo) errors.push("No encontré un repo local (meta.repo, el directorio actual, o la carpeta del JSON). Sin eso no puedo verificar cobertura.");
1526
+ else try {
1527
+ paths = (loadBranchDiff({
1528
+ repo,
1529
+ branch,
1530
+ base,
1531
+ filename: basename(abs)
1532
+ }).files || []).map((f) => f.path);
1533
+ } catch (err) {
1534
+ errors.push(`No pude listar el diff (${err instanceof Error ? err.message : String(err)}). Revisá meta.repo, meta.branch y meta.base, o el companion .diff.`);
1535
+ }
1536
+ const result = checkPayload(payload, paths);
1537
+ const allErrors = [...result.errors, ...errors];
1538
+ return {
1539
+ ok: allErrors.length === 0,
1540
+ errors: allErrors,
1541
+ notes: result.notes,
1542
+ file: abs
1543
+ };
1544
+ }
1545
+ /**
1546
+ * @param {string} file
1547
+ * @param {string} command
1548
+ * @param {{ ok: boolean, errors: string[], notes: string[] }} result
1549
+ */
1550
+ function formatCheckReport(file, command, result) {
1551
+ if (result.ok) return `OK: ${file}${result.notes?.length ? `\n${result.notes.map((n) => `Note: ${n}`).join("\n")}` : ""}\nSchema and references passed. You can stop.`;
1552
+ const problems = (result.errors || []).map((e) => `- ${e}`).join("\n");
1553
+ const notes = (result.notes || []).map((n) => `Note: ${n}`).join("\n");
1554
+ const lines = [
1555
+ `INVALID: ${file}`,
1556
+ "",
1557
+ "The JSON is not ready. Edit this same file until the command prints OK. Do not create a new filename.",
1558
+ "",
1559
+ "Problems:",
1560
+ problems || "- (unknown)"
1561
+ ];
1562
+ if (notes) lines.push("", notes);
1563
+ lines.push("", "How to continue:", "- Fix every problem above in place. Keep existing meta/intent/groups/blocks/findings; only change what is wrong or missing.", "- Every diff path must appear in \"blocks\" or \"skipped\" (a skipped glob counts).", "- meta.agent and meta.model are required (product name and the exact model id you are running).", "- intent ≥40 chars; enums exactly as in the schema.", "- Human-readable strings in Spanish.", `- Re-run exactly: \`${command}\``, "- Stop only when that command prints OK.");
1564
+ return lines.join("\n");
1565
+ }
1566
+ //#endregion
1567
+ //#region ../lib/review-meta.mjs
1568
+ /**
1569
+ * Estampa en el JSON quién generó la review. La CLI lo sabe mejor que el modelo.
1570
+ */
1571
+ /**
1572
+ * `agentSession` = la sesión ACP que generó la review, para seguir conversando con el agente desde la web.
1573
+ * @param {string} filePath
1574
+ * @param {{ agent?: string, model?: string, agentSession?: { agentId: string, sessionId: string, outputFilename: string } }} provenance
1575
+ * @returns {boolean}
1576
+ */
1577
+ function stampReviewProvenance(filePath, { agent, model, agentSession } = {}) {
1578
+ const agentName = String(agent || "").trim();
1579
+ const modelId = String(model || "").trim();
1580
+ if (!agentName && !modelId && !agentSession) return false;
1581
+ let payload;
1582
+ try {
1583
+ payload = decodePayload(readFileSync(filePath, "utf8"));
1584
+ } catch {
1585
+ return false;
1586
+ }
1587
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
1588
+ const current = payload.meta;
1589
+ const meta = current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
1590
+ if (agentName) meta.agent = agentName;
1591
+ if (modelId) meta.model = modelId;
1592
+ if (agentSession) meta.agentSession = { ...agentSession };
1593
+ payload.meta = meta;
1594
+ writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
1595
+ return true;
1596
+ }
1597
+ //#endregion
1598
+ //#region ../lib/prompt.mjs
1599
+ /**
1600
+ * Prompt de review para el agente (compartido CLI + UI).
1601
+ * Mantener alineado con el contrato de schema.json / validate.mjs.
1602
+ */
1603
+ var OUTPUT_SCHEMA_BLOCK = `{
1604
+ "meta": {
1605
+ "source": "local|url",
1606
+ "repo": "<absolute path or git URL>",
1607
+ "branch": "<branch under review>",
1608
+ "base": "develop",
1609
+ "remoteUrl": "<origin URL; optional when source=url>",
1610
+ "generatedAt": "<ISO-8601 datetime; optional>",
1611
+ "agent": "<required: product name you are, e.g. Claude Code>",
1612
+ "model": "<required: exact model id you are running>"
1613
+ },
1614
+ "intent": "string",
1615
+ "groups": [
1616
+ {
1617
+ "id": "t1",
1618
+ "kind": "feat|fix|refactor|perf|test|chore|docs|infra",
1619
+ "title": "string, aim for ≤72 chars, imperative, no kind prefix",
1620
+ "intent": "string"
1621
+ }
1622
+ ],
1623
+ "blocks": [
1624
+ {
1625
+ "id": "b1",
1626
+ "group": "t1",
1627
+ "file": "relative/path.ext",
1628
+ "lines": "L40-58",
1629
+ "side": "new|old",
1630
+ "start": 40,
1631
+ "end": 58,
1632
+ "op": "add|mod|del|rename",
1633
+ "what": "string, aim for ≤120 chars: concrete change + effect",
1634
+ "why": "string, aim for ≤120 chars",
1635
+ "source": "code|commit|pr|issue|inferred"
1636
+ }
1637
+ ],
1638
+ "findings": [
1639
+ {
1640
+ "id": "f1",
1641
+ "class": "risk|quality",
1642
+ "severity": "high|med|low|nit",
1643
+ "blocking": false,
1644
+ "kind": "bug|race|auth|security|api-break|missing-test|perf|maintainability|docs|other",
1645
+ "file": "relative/path.ext",
1646
+ "line": 87,
1647
+ "block": "b1",
1648
+ "what": "string, aim for ≤120 chars: concrete cause + observable consequence (what fails / risk / cost)",
1649
+ "fix": "string, aim for ≤120 chars: suggested change + what the reader gains or avoids if applied"
1650
+ }
1651
+ ],
1652
+ "skipped": [
1653
+ { "file": "relative/path-or-glob", "reason": "generated|lockfile|format|vendored|binary|trivial|ignored" }
1654
+ ],
1655
+ "notes": ["optional short limitations"]
1656
+ }`;
1657
+ var RULES = `- Ids: themes use \`t1\`, \`t2\`, …; findings use \`f1\`, \`f2\`, …; blocks use \`b1\`, \`b2\`, …. Reference themes from blocks via \`"group": "t1"\`.
1658
+ - Coverage (hard): every path in the diff MUST appear in "blocks" and/or "skipped". Prefer a skipped glob when many files share one reason.
1659
+ - Do NOT invent files, hunks, or line ranges that are not in the diff.
1660
+ - Line anchors: "lines"/"start"/"end"/"line" MUST match the chosen "side" ("new" = post-image / right side of @@; "old" = pre-image, typically deletions). One block per coherent change span, not one per line. Point findings at that block; leave "block" empty only if cross-cutting.
1661
+ - Do NOT report: pre-existing code outside the diff; linter/formatter noise; naming/style prefs with no real consequence; intentional branch behavior; silenced lint rules; "missing docs/coverage" without a concrete broken scenario.
1662
+ - Quality findings must name a concrete cost, not a vague feeling.
1663
+ - Finding "what": state the concrete cause and the observable consequence (what fails, the risk, or the cost) — not only a symptom.
1664
+ - Finding "fix": state the suggested change and what the reader gains or avoids if they apply it.
1665
+ - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high, then nit). Use "nit" sparingly.
1666
+ - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
1667
+ - When several read-only lookups are independent (files, searches), batch them in the same turn if your tools allow parallel calls.
1668
+ - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.
1669
+ - 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.`;
1670
+ /** @param {{ hasDiff?: boolean }} [ctx] */
1671
+ function workflowLocal(base, branch, ctx = {}) {
1672
+ if (ctx.hasDiff) return `Workflow:
1673
+ 1. Read the three-dot diff from the path given in "Precomputed git context" below. Do not re-run \`git diff\` unless that file is missing.
1674
+ 2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
1675
+ 3. Emit the JSON file. Do not modify the repo.
1676
+ 4. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
1677
+ return `Workflow:
1678
+ 1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
1679
+ 2. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
1680
+ 3. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
1681
+ 4. Emit the JSON file. Do not modify the repo.
1682
+ 5. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
1683
+ }
1684
+ /** Comando que el agente debe correr (sobre `.diff-review/<nombre>`); npx por si el bin no está en PATH. */
1685
+ function validateCliCommand(filename) {
1686
+ return `npx --yes @jpkme/diff-review validate ${outputRelPath(String(filename || "diff-rev_<branchTip>_<baseTip>.json").trim())}`;
1687
+ }
1688
+ /** Workflow sin paso de validate por CLI (sesiones ACP: valida el server). */
1689
+ function workflowLocalAcp(ctx = {}) {
1690
+ if (ctx.hasDiff) return `Workflow:
1691
+ 1. Read the three-dot diff from the path given in "Precomputed git context" below. Do not re-run \`git diff\` unless that file is missing.
1692
+ 2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
1693
+ 3. Write the JSON with your file Write/Edit tool (never shell redirection). Do not modify other repo files. The host validates when your turn ends — do not run the validate CLI.`;
1694
+ return `Workflow:
1695
+ 1. Resolve the merge-base of the base and branch named above, then run the three-dot \`git diff base...branch\`.
1696
+ 2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
1697
+ 3. Write the JSON with your file Write/Edit tool (never shell redirection). Do not modify other repo files. The host validates when your turn ends — do not run the validate CLI.`;
1698
+ }
1699
+ /**
1700
+ * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
1701
+ */
1702
+ function outputSchemaBlock(meta) {
1703
+ const source = "local";
1704
+ const repo = String(meta.repo || "").trim() || ("<absolute repo path>");
1705
+ const branch = String(meta.branch || "").trim() || "<branch under review>";
1706
+ const base = String(meta.base || "").trim() || "develop";
1707
+ const remoteUrl = String(meta.remoteUrl || "").trim() || ("");
1708
+ const metaLines = [
1709
+ ` "source": ${JSON.stringify(source)},`,
1710
+ ` "repo": ${JSON.stringify(repo)},`,
1711
+ ` "branch": ${JSON.stringify(branch)},`,
1712
+ ` "base": ${JSON.stringify(base)},`
1713
+ ];
1714
+ if (remoteUrl) metaLines.push(` "remoteUrl": ${JSON.stringify(remoteUrl)},`);
1715
+ metaLines.push(` "generatedAt": "<ISO-8601 datetime>",`, ` "agent": "<required: product name you are, e.g. Claude Code>",`, ` "model": "<required: exact model id you are running>"`);
1716
+ const metaBlock = `"meta": {\n${metaLines.join("\n")}\n }`;
1717
+ return OUTPUT_SCHEMA_BLOCK.replace(/"meta": \{[\s\S]*?\n \}/, metaBlock);
1718
+ }
1719
+ /**
1720
+ * Bloque de contexto git ya resuelto (diff en disco, tips, lista de archivos, ignores).
1721
+ * @param {{
1722
+ * diffRelPath?: string | null,
1723
+ * mergeBase?: string,
1724
+ * branchSha?: string,
1725
+ * baseSha?: string,
1726
+ * files?: Array<{ path: string, changeType?: string }>,
1727
+ * ignored?: string[],
1728
+ * ignoreGlobs?: string[],
1729
+ * }} ctx
1730
+ */
1731
+ function formatPrecomputedContext(ctx) {
1732
+ const lines = ["Precomputed git context (do not rediscover):"];
1733
+ if (ctx.mergeBase) lines.push(`- Merge-base SHA: \`${ctx.mergeBase}\``);
1734
+ if (ctx.branchSha) lines.push(`- Branch tip SHA: \`${ctx.branchSha}\``);
1735
+ if (ctx.baseSha) lines.push(`- Base tip SHA: \`${ctx.baseSha}\``);
1736
+ if (ctx.diffRelPath) lines.push(`- Diff file (relative to repo root): \`${ctx.diffRelPath}\` — read this file; do not re-run \`git diff\` unless it is missing.`);
1737
+ const files = Array.isArray(ctx.files) ? ctx.files : [];
1738
+ if (files.length) {
1739
+ lines.push(`- Files in the diff (${files.length}):`);
1740
+ const limit = 200;
1741
+ for (const f of files.slice(0, limit)) {
1742
+ const path = String(f?.path || "").trim();
1743
+ if (!path) continue;
1744
+ const kind = String(f?.changeType || "").trim();
1745
+ lines.push(kind ? ` - ${kind}: ${path}` : ` - ${path}`);
1746
+ }
1747
+ if (files.length > limit) lines.push(` - … and ${files.length - limit} more`);
1748
+ }
1749
+ const ignored = Array.isArray(ctx.ignored) ? ctx.ignored.filter(Boolean) : [];
1750
+ if (ignored.length) {
1751
+ lines.push(`- Ignored paths (excluded from the file list; if you mention them use "skipped" with reason "ignored"):`);
1752
+ for (const p of ignored.slice(0, 40)) lines.push(` - ${p}`);
1753
+ if (ignored.length > 40) lines.push(` - … and ${ignored.length - 40} more`);
1754
+ }
1755
+ const globs = Array.isArray(ctx.ignoreGlobs) ? ctx.ignoreGlobs.filter(Boolean) : [];
1756
+ if (globs.length) lines.push(`- Ignore globs (DEFAULT + config): ${globs.map((g) => `\`${g}\``).join(", ")}`);
1757
+ return lines.join("\n");
1758
+ }
1759
+ /**
1760
+ * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
1761
+ * @param {{
1762
+ * outputFilename?: string,
1763
+ * acp?: boolean,
1764
+ * diffRelPath?: string | null,
1765
+ * mergeBase?: string,
1766
+ * branchSha?: string,
1767
+ * baseSha?: string,
1768
+ * files?: Array<{ path: string, changeType?: string }>,
1769
+ * ignored?: string[],
1770
+ * ignoreGlobs?: string[],
1771
+ * }} [opts]
1772
+ */
1773
+ function buildPrompt(meta, opts = {}) {
1774
+ const outputFilename = opts.outputFilename || makeOutputFilename();
1775
+ const outputPath = outputRelPath(outputFilename);
1776
+ const repo = String(meta.repo || "").trim() || ("<absolute repo path>");
1777
+ const branch = String(meta.branch || "").trim() || "<branch under review>";
1778
+ const base = String(meta.base || "").trim() || "develop";
1779
+ const acp = Boolean(opts.acp);
1780
+ const hasDiff = Boolean(String(opts.diffRelPath || "").trim());
1781
+ const workflow = acp ? workflowLocalAcp({ hasDiff }) : workflowLocal(base, branch, { hasDiff });
1782
+ const precomputed = formatPrecomputedContext({
1783
+ diffRelPath: opts.diffRelPath,
1784
+ mergeBase: opts.mergeBase,
1785
+ branchSha: opts.branchSha,
1786
+ baseSha: opts.baseSha,
1787
+ files: opts.files,
1788
+ ignored: opts.ignored,
1789
+ ignoreGlobs: opts.ignoreGlobs
1790
+ });
1791
+ const hasPrecomputed = hasDiff || opts.mergeBase || opts.branchSha || opts.baseSha || opts.files && opts.files.length || opts.ignored && opts.ignored.length || opts.ignoreGlobs && opts.ignoreGlobs.length;
1792
+ const validateSection = acp ? `When done writing \`${outputPath}\`, stop. The host validates the JSON automatically and will send you any problems — do not run \`npx … validate\` yourself.` : `After writing the file, validate it (npx so it works even if \`diff-review\` is not on PATH):
1793
+
1794
+ \`${validateCliCommand(outputFilename)}\`
1795
+
1796
+ - Exit 0 and a line starting with \`OK\` means the JSON is acceptable. Then tell me that \`${outputPath}\` was written.
1797
+ - Exit 1 prints \`INVALID\`, the problems, and how to continue. Fix the same file and re-run that exact command until it prints OK. Do not stop before OK.`;
1798
+ return `You are a senior code reviewer: strict but fair.
1799
+
1800
+ Repo: ${repo}
1801
+ Branch under review: ${branch}
1802
+ Base (merge-base): ${base}
1803
+
1804
+ ${workflow}
1805
+ ${hasPrecomputed ? `\n${precomputed}\n` : ""}
1806
+ Rules:
1807
+ ${RULES}
1808
+
1809
+ Write the result to \`${outputPath}\` (relative to the repo root; use forward slashes, never \`\\\`). The \`${OUTPUT_DIR}/\` folder is already created by the host — do not \`mkdir\` it.
1810
+ - If that file already exists: **Read it first**, then Edit/overwrite (or Write after Read). Many agents reject Write with "File has not been read yet" unless you Read that exact path first.
1811
+ - If it does not exist yet: Write is fine. Do NOT use shell redirection (\`>\`, \`>>\`, \`Out-File\`, \`tee\`, etc.) — those are rejected.
1812
+ - One JSON object only: first character \`{\`, last character \`}\`.
1813
+ - No prose before/after, no markdown fences.
1814
+ - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; "agent" and "model" are required):
1815
+
1816
+ ${outputSchemaBlock(meta)}
1817
+
1818
+ 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).
1819
+
1820
+ 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.
1821
+
1822
+ ${validateSection}`;
1823
+ }
1824
+ //#endregion
1825
+ //#region ../lib/review-prep.mjs
1826
+ /**
1827
+ * Precomputa contexto git para el prompt de review (diff cache + lista de archivos).
1828
+ */
1829
+ /**
1830
+ * Cachea el three-dot diff y arma las opciones de `buildPrompt` (tips, files, ignores).
1831
+ * @param {{
1832
+ * repo: string,
1833
+ * branch: string,
1834
+ * base?: string,
1835
+ * outputFilename: string,
1836
+ * acp?: boolean,
1837
+ * }} input
1838
+ */
1839
+ function prepareReviewPromptOpts(input) {
1840
+ const repo = String(input.repo || "").trim();
1841
+ const branch = String(input.branch || "").trim();
1842
+ const base = String(input.base || "develop").trim() || "develop";
1843
+ const outputFilename = String(input.outputFilename || "").trim();
1844
+ if (!repo || !branch || !outputFilename) throw new Error("prepareReviewPromptOpts requiere repo, branch y outputFilename.");
1845
+ ensureReviewDir(repo);
1846
+ writeDiffCache(repo, outputFilename, {
1847
+ branch,
1848
+ base
1849
+ });
1850
+ const cached = readDiffCache(repo, outputFilename);
1851
+ let extracted;
1852
+ try {
1853
+ extracted = extractBranchDiff({
1854
+ repo,
1855
+ branch,
1856
+ base,
1857
+ ...cached ? { cachedDiff: cached } : {}
1858
+ });
1859
+ } catch {
1860
+ extracted = extractBranchDiff({
1861
+ repo,
1862
+ branch,
1863
+ base
1864
+ });
1865
+ }
1866
+ const config = loadReviewConfig(repo);
1867
+ const diffRelPath = cached ? outputRelPath(outputFilename).replace(/\.json$/i, ".diff") : null;
1868
+ return {
1869
+ outputFilename,
1870
+ acp: Boolean(input.acp),
1871
+ diffRelPath,
1872
+ mergeBase: extracted.mergeBase || void 0,
1873
+ branchSha: extracted.branchSha || void 0,
1874
+ baseSha: extracted.baseSha || void 0,
1875
+ files: (extracted.files || []).map((f) => ({
1876
+ path: f.path,
1877
+ changeType: f.changeType
1878
+ })),
1879
+ ignored: extracted.context?.ignored || [],
1880
+ ignoreGlobs: config.ignore
1881
+ };
1882
+ }
1883
+ //#endregion
1884
+ //#region src/lib/report/agent-events.ts
1885
+ /**
1886
+ * Aplica un evento a la lista: un `text` con el mismo `seq` que el último es la continuación de ese chunk.
1887
+ */
1888
+ function applyEvent(events, event) {
1889
+ const last = events[events.length - 1];
1890
+ if (last && last.seq === event.seq && last.type === "text" && event.type === "text") return [...events.slice(0, -1), {
1891
+ ...last,
1892
+ text: last.text + event.text
1893
+ }];
1894
+ if (event.type === "tool") {
1895
+ const i = events.findIndex((e) => e.type === "tool" && e.id === event.id);
1896
+ if (i >= 0) {
1897
+ const prev = events[i];
1898
+ const merged = {
1899
+ ...prev,
1900
+ title: event.title || prev.title,
1901
+ kind: event.kind || prev.kind,
1902
+ status: event.status || prev.status
1903
+ };
1904
+ return [
1905
+ ...events.slice(0, i),
1906
+ merged,
1907
+ ...events.slice(i + 1)
1908
+ ];
1909
+ }
1910
+ }
1911
+ if (event.type === "permission") {
1912
+ const i = events.findIndex((e) => e.type === "permission" && e.requestId === event.requestId);
1913
+ if (i >= 0) return [
1914
+ ...events.slice(0, i),
1915
+ event,
1916
+ ...events.slice(i + 1)
1917
+ ];
1918
+ }
1919
+ return [...events, event];
1920
+ }
1921
+ //#endregion
1922
+ //#region src/lib/server/agent-sessions.ts
1923
+ /**
1924
+ * Sesiones con agentes por ACP, del lado del server: el server es dueño del proceso del agente y la web lo sigue
1925
+ * por SSE (`/api/agent/[id]/events`). Una sesión genera la review y después queda viva para preguntas y cambios.
1926
+ *
1927
+ * Los procesos viven en memoria; lo que tiene que sobrevivir va a `.diff-review/` del repo: el JSON, el diff y la
1928
+ * sesión del agente (`<nombre>.session.json`). Para chatear sobre un reporte se retoma esa sesión
1929
+ * (`session/load`); si no se puede (se borró, otro agente, otra máquina) se abre una nueva y el primer mensaje le
1930
+ * pasa el contexto: repo, branch, base y dónde están el diff y el JSON.
1931
+ */
1932
+ /** Para que el agente no gaste turnos en comandos que la política va a rechazar. */
1933
+ var TOOL_RULES = "Tool rules for this session: read files with your file tools (read/search/glob). For the review JSON path: use forward slashes (e.g. .diff-review/foo.json). If that file already exists, Read it first, then Edit/Write — Write fails with \"File has not been read yet\" unless you Read that exact path. Never shell redirection (>, >>, Out-File, tee). The .diff-review/ folder already exists — do not mkdir it. In the terminal only read-only commands run (git diff/log/show/blame/…, cat, head, tail, grep, rg, find, ls, wc, sed -n). No redirections to files, no command substitution, no installs, no network. Anything else is rejected automatically: do not retry the same rejected command; use the file tool or another read-only way.";
1934
+ /**
1935
+ * En estas sesiones valida el server al terminar cada turno (y le devuelve los errores al agente): correr el
1936
+ * validate por npx es lento y puede resolver otra versión del paquete.
1937
+ */
1938
+ var SERVER_VALIDATES = "Note: in this session diff-review validates the JSON automatically when your turn ends and sends you any problems. Do not run the validate command yourself.";
1939
+ /** Sin actividad por este tiempo se cierra el proceso del agente (se retoma con `session/load`). */
1940
+ var IDLE_CLOSE_MS = 1800 * 1e3;
1941
+ var MAX_EVENTS = 4e3;
1942
+ /** Si el usuario no responde un permiso pendiente, se rechaza para no colgar el turno. */
1943
+ var PERMISSION_TIMEOUT_MS = 120 * 1e3;
1944
+ var sessions = /* @__PURE__ */ new Map();
1945
+ process.once("exit", () => {
1946
+ for (const entry of sessions.values()) entry.acp?.close();
1947
+ });
1948
+ function listAcpAgents() {
1949
+ return detectAgents().filter(supportsAcp).map((a) => ({
1950
+ id: a.def.id,
1951
+ label: a.def.label,
1952
+ path: a.path,
1953
+ models: modelChoices(a.def).options
1954
+ }));
1955
+ }
1956
+ var modelsCache = /* @__PURE__ */ new Map();
1957
+ /**
1958
+ * Modelos que ofrece el agente por ACP (abre una sesión de prueba; tarda unos segundos).
1959
+ * Solo cachea listas no vacías. `force` invalida la caché. Vacío o error → se puede reintentar.
1960
+ */
1961
+ function agentModels(agentId, cwd, opts) {
1962
+ if (opts?.force) modelsCache.delete(agentId);
1963
+ let hit = modelsCache.get(agentId);
1964
+ if (!hit) {
1965
+ const agent = findAgent(agentId);
1966
+ hit = listAcpModelsCached(agent, {
1967
+ cwd,
1968
+ force: Boolean(opts?.force)
1969
+ }).then((result) => {
1970
+ if (!result.models.length) modelsCache.delete(agentId);
1971
+ const { fromCache: _fromCache, ...rest } = result;
1972
+ return rest;
1973
+ }).catch((err) => {
1974
+ modelsCache.delete(agentId);
1975
+ const raw = err instanceof Error ? err.message : String(err);
1976
+ const auth = authFailureMessage(agentId, agent.def.label, raw);
1977
+ throw new Error(auth || raw);
1978
+ });
1979
+ modelsCache.set(agentId, hit);
1980
+ }
1981
+ return hit;
1982
+ }
1983
+ function findAgent(agentId) {
1984
+ const agent = detectAgents().find((a) => a.def.id === agentId);
1985
+ if (!agent) throw new Error(`No encontré el agente "${agentId}" instalado.`);
1986
+ if (!supportsAcp(agent)) throw new Error(`${agent.def.label} no tiene modo ACP.`);
1987
+ return agent;
1988
+ }
1989
+ function info(entry) {
1990
+ const { id, agentId, agentLabel, model, repo, branch, base, outputFilename, acpSessionId, status, busy, statusMessage, sessionAllowDenied } = entry;
1991
+ return {
1992
+ id,
1993
+ agentId,
1994
+ agentLabel,
1995
+ model,
1996
+ repo,
1997
+ branch,
1998
+ base,
1999
+ outputFilename,
2000
+ acpSessionId,
2001
+ status,
2002
+ busy,
2003
+ sessionTrust: Boolean(sessionAllowDenied),
2004
+ ...statusMessage ? { statusMessage } : {}
2005
+ };
2006
+ }
2007
+ function authFailureMessage(agentId, agentLabel, detail) {
2008
+ return authFailureMessage$1(agentId, agentLabel, detail);
2009
+ }
2010
+ /** Abre una terminal con el login del agente (Claude `/login`, `gh auth login`, …). */
2011
+ function openAgentLogin(agentId) {
2012
+ return openLoginTerminal(String(agentId || ""));
2013
+ }
2014
+ /** Escribe seed (JSON + .diff) en `.diff-review/` para que el agente los lea. */
2015
+ function materializeSeed(entry) {
2016
+ const seed = entry.seed;
2017
+ if (!seed) return;
2018
+ try {
2019
+ ensureReviewDir(entry.repo);
2020
+ const paths = companionPaths(entry.repo, entry.outputFilename);
2021
+ if (seed.payload !== void 0) writeFileSync(paths.json, `${JSON.stringify(seed.payload, null, 2)}\n`);
2022
+ if (typeof seed.diff === "string" && seed.diff.length) writeDiffText(entry.repo, entry.outputFilename, seed.diff);
2023
+ else writeDiffCache(entry.repo, entry.outputFilename, {
2024
+ branch: entry.branch,
2025
+ base: entry.base
2026
+ });
2027
+ } catch {}
2028
+ }
2029
+ /** Prefijo del primer mensaje cuando la sesión ACP es nueva. */
2030
+ function contextPreamble(entry) {
2031
+ materializeSeed(entry);
2032
+ const paths = companionPaths(entry.repo, entry.outputFilename);
2033
+ const jsonRel = outputRelPath(entry.outputFilename);
2034
+ const diffRel = `${outputRelPath(entry.outputFilename).replace(/\.json$/i, "")}.diff`;
2035
+ const hasJson = existsSync(paths.json);
2036
+ const hasDiff = existsSync(paths.diff);
2037
+ return [
2038
+ "Context for this chat (new agent session — read these files before answering):",
2039
+ `- Repo: ${entry.repo}`,
2040
+ `- Branch under review: ${entry.branch}`,
2041
+ `- Base: ${entry.base}`,
2042
+ hasJson ? `- Review JSON (relative to repo root): \`${jsonRel}\` — read this file.` : `- Review JSON: not available on disk (no seed/payload was provided). Say so if you need it.`,
2043
+ hasDiff ? `- Diff (relative to repo root): \`${diffRel}\` — read this file.` : `- Diff file: not available on disk (no seed/diff and git cache failed). Say so if you need it.`,
2044
+ "",
2045
+ "User message:"
2046
+ ].join("\n");
2047
+ }
2048
+ function permissionReasonLabel(allow, scope, deniedReason) {
2049
+ if (!allow) return deniedReason;
2050
+ if (scope === "always") return "permitido siempre";
2051
+ if (scope === "session") return "permitido en esta sesión";
2052
+ return "permitido una vez";
2053
+ }
2054
+ function askUserPermission(entry, toolCall, decision) {
2055
+ if (entry.sessionAllowDenied) return Promise.resolve({
2056
+ allow: true,
2057
+ always: false
2058
+ });
2059
+ const requestId = crypto.randomUUID();
2060
+ const title = toolCall.title || toolCall.kind || "herramienta";
2061
+ const kind = toolCall.kind || "";
2062
+ emit(entry, {
2063
+ type: "permission",
2064
+ requestId,
2065
+ title,
2066
+ kind,
2067
+ reason: decision.reason,
2068
+ allow: false,
2069
+ pending: true
2070
+ });
2071
+ return new Promise((resolve) => {
2072
+ const timer = setTimeout(() => {
2073
+ entry.pendingPermissions.delete(requestId);
2074
+ emit(entry, {
2075
+ type: "permission",
2076
+ requestId,
2077
+ title,
2078
+ kind,
2079
+ reason: "tiempo agotado",
2080
+ allow: false,
2081
+ pending: false
2082
+ });
2083
+ resolve({ allow: false });
2084
+ }, PERMISSION_TIMEOUT_MS);
2085
+ timer.unref?.();
2086
+ entry.pendingPermissions.set(requestId, {
2087
+ resolve: (allow, opts) => {
2088
+ clearTimeout(timer);
2089
+ entry.pendingPermissions.delete(requestId);
2090
+ const scope = opts?.scope ?? "once";
2091
+ if (allow && (scope === "session" || scope === "always")) entry.sessionAllowDenied = true;
2092
+ emit(entry, {
2093
+ type: "permission",
2094
+ requestId,
2095
+ title,
2096
+ kind,
2097
+ reason: permissionReasonLabel(allow, scope, decision.reason),
2098
+ allow,
2099
+ pending: false,
2100
+ quiet: opts?.quiet
2101
+ });
2102
+ resolve({
2103
+ allow,
2104
+ always: allow && scope === "always"
2105
+ });
2106
+ },
2107
+ timer
2108
+ });
2109
+ });
2110
+ }
2111
+ function sessionInfo(id) {
2112
+ const entry = sessions.get(id);
2113
+ return entry ? info(entry) : null;
2114
+ }
2115
+ /**
2116
+ * Con la CLI mirando (`DIFF_REVIEW_MIRROR=1`), solo fallos duros a la terminal.
2117
+ * El progreso (tools, permisos, mensajes) vive en la web.
2118
+ */
2119
+ function mirror(_entry, event) {
2120
+ if (process.env.DIFF_REVIEW_MIRROR !== "1") return;
2121
+ if (event.type === "status" && event.status === "error") console.log(`✗ ${event.message || "error del agente"}`);
2122
+ else if (event.type === "review" && event.ok) console.log(`✓ JSON válido: ${event.filename}`);
2123
+ }
2124
+ function emit(entry, event) {
2125
+ let seq;
2126
+ if (event.type === "text" && entry.textSeq?.kind === event.kind) seq = entry.textSeq.seq;
2127
+ else {
2128
+ seq = ++entry.seq;
2129
+ entry.textSeq = event.type === "text" ? {
2130
+ kind: event.kind,
2131
+ seq
2132
+ } : null;
2133
+ }
2134
+ const full = {
2135
+ ...event,
2136
+ seq
2137
+ };
2138
+ mirror(entry, event);
2139
+ entry.events = applyEvent(entry.events, full);
2140
+ if (entry.events.length > MAX_EVENTS) entry.events = entry.events.slice(-4e3);
2141
+ for (const listener of entry.listeners) listener(full);
2142
+ }
2143
+ function setStatus(entry, status, message) {
2144
+ entry.status = status;
2145
+ if (status === "error") entry.statusMessage = message || entry.statusMessage || "Error del agente.";
2146
+ else if (message !== void 0) delete entry.statusMessage;
2147
+ emit(entry, {
2148
+ type: "status",
2149
+ status,
2150
+ ...message || status === "error" ? { message: message || entry.statusMessage } : {}
2151
+ });
2152
+ }
2153
+ /** SessionUpdate de ACP → evento de la web (lo que no se muestra se descarta). */
2154
+ function toEvent(update) {
2155
+ switch (update.sessionUpdate) {
2156
+ case "agent_message_chunk":
2157
+ case "agent_thought_chunk": {
2158
+ const content = update.content;
2159
+ if (content.type !== "text" || !content.text) return null;
2160
+ return {
2161
+ type: "text",
2162
+ kind: update.sessionUpdate === "agent_message_chunk" ? "message" : "thought",
2163
+ text: content.text
2164
+ };
2165
+ }
2166
+ case "tool_call":
2167
+ case "tool_call_update": return {
2168
+ type: "tool",
2169
+ id: update.toolCallId,
2170
+ title: update.title ?? "",
2171
+ kind: update.kind ?? "",
2172
+ status: update.status ?? ""
2173
+ };
2174
+ case "plan": return {
2175
+ type: "plan",
2176
+ entries: update.entries.map((e) => ({
2177
+ content: e.content,
2178
+ status: e.status
2179
+ }))
2180
+ };
2181
+ default: return null;
2182
+ }
2183
+ }
2184
+ function scheduleIdleClose(entry) {
2185
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
2186
+ entry.idleTimer = setTimeout(() => {
2187
+ if (entry.busy || !entry.acp) return;
2188
+ entry.acp.close();
2189
+ entry.acp = null;
2190
+ }, IDLE_CLOSE_MS);
2191
+ entry.idleTimer.unref?.();
2192
+ }
2193
+ /** Aplica el modelo pedido a la sesión ACP viva (o no-op si ya está). */
2194
+ async function applyAcpModel(entry, acp, model) {
2195
+ const wanted = String(model || "").trim();
2196
+ if (!wanted) return acp;
2197
+ const result = await acp.setModel(wanted);
2198
+ if (result.currentModel && !result.currentModel.startsWith("default")) entry.model = result.currentModel;
2199
+ if (result.warning) emit(entry, {
2200
+ type: "status",
2201
+ status: entry.status,
2202
+ message: result.warning
2203
+ });
2204
+ return acp;
2205
+ }
2206
+ /** Proceso del agente vivo para esta entrada: lo lanza, o lo relanza retomando la sesión. */
2207
+ async function ensureAcp(entry, model) {
2208
+ if (entry.acp) return applyAcpModel(entry, entry.acp, model);
2209
+ if (entry.starting) return applyAcpModel(entry, await entry.starting, model);
2210
+ entry.starting = startAcpSession(entry.agent, {
2211
+ cwd: entry.repo,
2212
+ model,
2213
+ resumeSessionId: entry.acpSessionId || void 0,
2214
+ decide: (toolCall) => decidePermission(toolCall, {
2215
+ repo: entry.repo,
2216
+ outputFilename: entry.outputFilename,
2217
+ mode: entry.mode
2218
+ }),
2219
+ onUpdate: (update) => {
2220
+ if (!entry.acp && entry.acpSessionId) return;
2221
+ const event = toEvent(update);
2222
+ if (event) emit(entry, event);
2223
+ },
2224
+ onDeniedPermission: (toolCall, decision) => askUserPermission(entry, toolCall, decision),
2225
+ onPermission: (toolCall, decision) => {
2226
+ if (decision.allow) return;
2227
+ entry.rejections.push(`${toolCall.title || toolCall.kind || "herramienta"} (${decision.reason})`);
2228
+ },
2229
+ onExit: ({ code, stderr }) => {
2230
+ const wasBusy = entry.busy;
2231
+ entry.acp = null;
2232
+ if (wasBusy) {
2233
+ entry.busy = false;
2234
+ const auth = authFailureMessage(entry.agentId, entry.agentLabel, stderr);
2235
+ if (auth) {
2236
+ setStatus(entry, "error", auth);
2237
+ return;
2238
+ }
2239
+ const raw = stderr.trim() || `código ${code ?? "?"}`;
2240
+ const authFromCode = authFailureMessage(entry.agentId, entry.agentLabel, raw);
2241
+ if (authFromCode) {
2242
+ setStatus(entry, "error", authFromCode);
2243
+ return;
2244
+ }
2245
+ const tail = stderr.trim().split(/\r?\n/).slice(-3).join(" · ");
2246
+ setStatus(entry, "error", `${entry.agentLabel} terminó (código ${code ?? "?"}). ${tail}`.trim());
2247
+ }
2248
+ }
2249
+ }).then((acp) => {
2250
+ const wanted = entry.acpSessionId;
2251
+ entry.acp = acp;
2252
+ entry.acpSessionId = acp.sessionId;
2253
+ if (wanted && !acp.resumed) {
2254
+ entry.needsContext = true;
2255
+ emit(entry, {
2256
+ type: "status",
2257
+ status: entry.status,
2258
+ message: "No pude retomar la sesión anterior del agente: abro una nueva y le paso el contexto de la review."
2259
+ });
2260
+ }
2261
+ try {
2262
+ writeSessionRef(entry.repo, entry.outputFilename, {
2263
+ agentId: entry.agentId,
2264
+ sessionId: acp.sessionId,
2265
+ ...entry.model ? { model: entry.model } : {}
2266
+ });
2267
+ } catch {}
2268
+ if (acp.currentModel && !acp.currentModel.startsWith("default")) entry.model = acp.currentModel;
2269
+ if (acp.modelWarning) emit(entry, {
2270
+ type: "status",
2271
+ status: entry.status,
2272
+ message: acp.modelWarning
2273
+ });
2274
+ return acp;
2275
+ }).finally(() => {
2276
+ entry.starting = null;
2277
+ });
2278
+ return applyAcpModel(entry, await entry.starting, model);
2279
+ }
2280
+ function mtime(path) {
2281
+ try {
2282
+ return statSync(path).mtimeMs;
2283
+ } catch {
2284
+ return 0;
2285
+ }
2286
+ }
2287
+ /**
2288
+ * Después de un turno que podía escribir el JSON: validarlo, estamparle la sesión y, si quedó mal, pedirle al
2289
+ * agente que lo corrija (una vez).
2290
+ */
2291
+ function noWriteFollowUpPrompt(entry) {
2292
+ const rel = outputRelPath(entry.outputFilename);
2293
+ const rejected = entry.rejections.length ? `These commands were rejected by diff-review's policy and did not run:\n${entry.rejections.slice(-5).map((r) => `- ${r}`).join("\n")}\n\n` : "";
2294
+ const posixRel = rel.replace(/\\/g, "/");
2295
+ return `${rejected}Your turn ended without writing \`${posixRel}\` in this turn (mtime must change now). The user did not stop you.
2296
+
2297
+ Do this now:
2298
+ 1. Read \`${posixRel}\` if it exists (required before Write/Edit on an existing file).
2299
+ 2. Edit or Write the full review JSON to that same path (forward slashes only).
2300
+ Do NOT mkdir, and do NOT use shell redirection. Do not claim success just because an older file is present — overwrite it.
2301
+
2302
+ ${TOOL_RULES}
2303
+
2304
+ ${SERVER_VALIDATES}`;
2305
+ }
2306
+ async function checkOutput(entry, since, retry) {
2307
+ const path = companionPaths(entry.repo, entry.outputFilename).json;
2308
+ if (!(existsSync(path) && mtime(path) >= since)) {
2309
+ if (retry) return checkOutput(entry, await followUp(entry, noWriteFollowUpPrompt(entry), entry.rejections.length ? "Seguí: hubo comandos rechazados; escribí el JSON con la tool de archivos." : "Seguí: el JSON no se escribió en este turno."), false);
2310
+ if (entry.mode === "review") emit(entry, {
2311
+ type: "review",
2312
+ ok: false,
2313
+ filename: entry.outputFilename,
2314
+ errors: [`${entry.agentLabel} terminó sin escribir ${entry.outputFilename}.`],
2315
+ final: true
2316
+ });
2317
+ return;
2318
+ }
2319
+ const result = checkReportFile(path, {
2320
+ repo: entry.repo,
2321
+ branch: entry.branch,
2322
+ base: entry.base,
2323
+ ...entry.diffPaths?.length ? { paths: entry.diffPaths } : {}
2324
+ });
2325
+ if (result.ok) {
2326
+ stampReviewProvenance(path, {
2327
+ agent: entry.agentLabel,
2328
+ model: entry.model,
2329
+ agentSession: {
2330
+ agentId: entry.agentId,
2331
+ sessionId: entry.acpSessionId,
2332
+ outputFilename: entry.outputFilename
2333
+ }
2334
+ });
2335
+ emit(entry, {
2336
+ type: "review",
2337
+ ok: true,
2338
+ filename: entry.outputFilename,
2339
+ errors: [],
2340
+ final: true
2341
+ });
2342
+ return;
2343
+ }
2344
+ emit(entry, {
2345
+ type: "review",
2346
+ ok: false,
2347
+ filename: entry.outputFilename,
2348
+ errors: result.errors,
2349
+ final: !retry
2350
+ });
2351
+ if (!retry) return;
2352
+ await checkOutput(entry, await followUp(entry, `${formatCheckReport(path, validateCliCommand(entry.outputFilename), result)}\n\n${SERVER_VALIDATES}`, "Corregí los errores de validación del JSON."), false);
2353
+ }
2354
+ /** Turno extra que pide el server (corregir el JSON, seguir después de un rechazo). Devuelve cuándo empezó. */
2355
+ async function followUp(entry, prompt, shown) {
2356
+ emit(entry, {
2357
+ type: "user",
2358
+ text: shown,
2359
+ mode: "fix"
2360
+ });
2361
+ const acp = await ensureAcp(entry);
2362
+ const started = Date.now();
2363
+ entry.rejections = [];
2364
+ const { stopReason } = await acp.prompt(prompt);
2365
+ emit(entry, {
2366
+ type: "turn",
2367
+ stopReason
2368
+ });
2369
+ return started;
2370
+ }
2371
+ async function runTurn(entry, text, mode, opts) {
2372
+ if (entry.busy) throw new Error("El agente todavía está trabajando en el pedido anterior.");
2373
+ entry.busy = true;
2374
+ entry.mode = mode;
2375
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
2376
+ emit(entry, {
2377
+ type: "user",
2378
+ text: opts.shown,
2379
+ mode: mode === "review" ? "review" : mode
2380
+ });
2381
+ try {
2382
+ if (!entry.acp) setStatus(entry, "starting", `Lanzando ${entry.agentLabel}…`);
2383
+ const acp = await ensureAcp(entry, opts.model);
2384
+ setStatus(entry, "working", "El agente está revisando…");
2385
+ const since = Date.now();
2386
+ entry.rejections = [];
2387
+ const { stopReason } = await acp.prompt(text);
2388
+ emit(entry, {
2389
+ type: "turn",
2390
+ stopReason
2391
+ });
2392
+ if (mode !== "ask" && stopReason !== "cancelled") await checkOutput(entry, since, true);
2393
+ entry.busy = false;
2394
+ setStatus(entry, "idle");
2395
+ } catch (err) {
2396
+ entry.busy = false;
2397
+ const raw = err instanceof Error ? err.message : String(err);
2398
+ setStatus(entry, "error", authFailureMessage(entry.agentId, entry.agentLabel, raw) || raw);
2399
+ } finally {
2400
+ scheduleIdleClose(entry);
2401
+ }
2402
+ }
2403
+ function newEntry(agent, fields) {
2404
+ const acpSessionId = fields.acpSessionId ?? "";
2405
+ const entry = {
2406
+ id: fields.id || crypto.randomUUID(),
2407
+ agentId: agent.def.id,
2408
+ agentLabel: agent.def.label,
2409
+ model: fields.model,
2410
+ repo: fields.repo,
2411
+ branch: fields.branch,
2412
+ base: fields.base,
2413
+ outputFilename: fields.outputFilename,
2414
+ acpSessionId,
2415
+ status: "idle",
2416
+ busy: false,
2417
+ agent,
2418
+ acp: null,
2419
+ starting: null,
2420
+ mode: "ask",
2421
+ events: [],
2422
+ seq: 0,
2423
+ textSeq: null,
2424
+ listeners: /* @__PURE__ */ new Set(),
2425
+ idleTimer: null,
2426
+ rejections: [],
2427
+ needsContext: false,
2428
+ seed: null,
2429
+ diffPaths: null,
2430
+ pendingPermissions: /* @__PURE__ */ new Map(),
2431
+ sessionAllowDenied: false,
2432
+ cancelled: false
2433
+ };
2434
+ sessions.set(entry.id, entry);
2435
+ return entry;
2436
+ }
2437
+ /** Lanza la review con el agente elegido. Devuelve el id de la sesión enseguida; el resto llega por SSE. */
2438
+ function startReview(input) {
2439
+ const entry = newEntry(findAgent(input.agentId), {
2440
+ ...input,
2441
+ model: input.model || ""
2442
+ });
2443
+ emit(entry, {
2444
+ type: "phase",
2445
+ phase: "diff",
2446
+ detail: "Calculando el diff…"
2447
+ });
2448
+ setStatus(entry, "starting", "Calculando el diff…");
2449
+ continueReviewAfterDiff(entry, input);
2450
+ return info(entry);
2451
+ }
2452
+ async function continueReviewAfterDiff(entry, input) {
2453
+ await new Promise((resolve) => setImmediate(resolve));
2454
+ if (!sessions.has(entry.id) || entry.cancelled) return;
2455
+ let promptOpts = {
2456
+ outputFilename: input.outputFilename,
2457
+ acp: true,
2458
+ diffRelPath: null,
2459
+ files: [],
2460
+ ignored: [],
2461
+ ignoreGlobs: []
2462
+ };
2463
+ try {
2464
+ promptOpts = prepareReviewPromptOpts({
2465
+ repo: input.repo,
2466
+ branch: input.branch,
2467
+ base: input.base,
2468
+ outputFilename: input.outputFilename,
2469
+ acp: true
2470
+ });
2471
+ if (entry.cancelled || !sessions.has(entry.id)) return;
2472
+ entry.diffPaths = (promptOpts.files || []).map((f) => f.path);
2473
+ const n = entry.diffPaths.length;
2474
+ const detail = n > 0 ? `Diff listo · ${n} archivo${n === 1 ? "" : "s"}. Pasando el contexto al agente…` : "Diff listo. Pasando el contexto al agente…";
2475
+ emit(entry, {
2476
+ type: "phase",
2477
+ phase: "diff",
2478
+ detail
2479
+ });
2480
+ setStatus(entry, "starting", detail);
2481
+ } catch (err) {
2482
+ if (entry.cancelled || !sessions.has(entry.id)) return;
2483
+ const msg = `No pude precomputar el diff (${err instanceof Error ? err.message : String(err)}); el agente lo resolverá.`;
2484
+ emit(entry, {
2485
+ type: "phase",
2486
+ phase: "diff",
2487
+ detail: msg
2488
+ });
2489
+ emit(entry, {
2490
+ type: "status",
2491
+ status: entry.status,
2492
+ message: msg
2493
+ });
2494
+ }
2495
+ if (!sessions.has(entry.id) || entry.cancelled) return;
2496
+ emit(entry, {
2497
+ type: "phase",
2498
+ phase: "agent",
2499
+ detail: "El agente está revisando el branch…"
2500
+ });
2501
+ runTurn(entry, `${buildPrompt({
2502
+ repo: input.repo,
2503
+ branch: input.branch,
2504
+ base: input.base,
2505
+ remoteUrl: getRemoteUrl(input.repo) || void 0
2506
+ }, promptOpts)}\n\n${TOOL_RULES}\n\n${SERVER_VALIDATES}`, "review", {
2507
+ model: input.model,
2508
+ shown: `Review de ${input.branch} contra ${input.base}`
2509
+ });
2510
+ }
2511
+ /**
2512
+ * Sesión para chatear sobre un reporte: la que sigue viva en este server, o una nueva que retoma la del agente.
2513
+ * `sessionId` vacío = sesión brand-new (`session/new`) con `needsContext` desde el inicio.
2514
+ * Opcional `seed` (JSON + diff del reporte abierto) para materializarlos en `.diff-review/` antes del primer turno.
2515
+ */
2516
+ async function openChat(input) {
2517
+ const sessionId = String(input.sessionId || "").trim();
2518
+ if (sessionId) {
2519
+ for (const entry of sessions.values()) if (entry.acpSessionId === sessionId && entry.repo === input.repo) {
2520
+ if (input.seed) entry.seed = input.seed;
2521
+ return info(entry);
2522
+ }
2523
+ }
2524
+ const entry = newEntry(findAgent(input.agentId), {
2525
+ ...input,
2526
+ model: input.model || "",
2527
+ acpSessionId: sessionId
2528
+ });
2529
+ if (input.seed) entry.seed = input.seed;
2530
+ if (!sessionId) entry.needsContext = true;
2531
+ try {
2532
+ if (!entry.acp) setStatus(entry, "starting", `Lanzando ${entry.agentLabel}…`);
2533
+ await ensureAcp(entry, input.model);
2534
+ if (entry.status === "starting") setStatus(entry, "idle");
2535
+ } catch (err) {
2536
+ const raw = err instanceof Error ? err.message : String(err);
2537
+ setStatus(entry, "error", authFailureMessage(entry.agentId, entry.agentLabel, raw) || raw);
2538
+ }
2539
+ return info(entry);
2540
+ }
2541
+ function sendMessage(id, text, mode, opts) {
2542
+ const entry = sessions.get(id);
2543
+ if (!entry) throw new Error("La sesión del agente ya no existe. Recargá la página.");
2544
+ const body = text.trim();
2545
+ if (!body) throw new Error("El mensaje está vacío.");
2546
+ if (entry.busy) throw new Error("El agente todavía está trabajando en el pedido anterior.");
2547
+ let agentText = body;
2548
+ if (entry.needsContext) {
2549
+ agentText = `${contextPreamble(entry)}\n${body}`;
2550
+ entry.needsContext = false;
2551
+ entry.seed = null;
2552
+ }
2553
+ runTurn(entry, mode === "edit" ? `${agentText}\n\nApply this by editing ${entry.outputFilename} in place (keep everything else as is). Reply briefly in Spanish with what you changed.\n\n${SERVER_VALIDATES}` : `${agentText}\n\nAnswer in Spanish. This is a question about the review: do not modify any file.`, mode, {
2554
+ shown: body,
2555
+ model: String(opts?.model || "").trim() || void 0
2556
+ });
2557
+ return info(entry);
2558
+ }
2559
+ /**
2560
+ * Reintento manual tras un turno de review que no dejó el JSON válido (p. ej. redirect bloqueado o JSON viejo).
2561
+ * Misma sesión ACP; no recalcula el diff.
2562
+ */
2563
+ function retryReview(id) {
2564
+ const entry = sessions.get(String(id || ""));
2565
+ if (!entry) throw new Error("La sesión del agente ya no existe. Recargá la página.");
2566
+ if (entry.busy) throw new Error("El agente todavía está trabajando en el pedido anterior.");
2567
+ if (entry.status === "error") throw new Error(entry.statusMessage || "La sesión está en error; iniciá una nueva review.");
2568
+ runTurn(entry, `Continue the code review. For \`${outputRelPath(entry.outputFilename).replace(/\\/g, "/")}\`: Read it first if it exists, then Edit/Write the full JSON (forward slashes only). Do not mkdir. Never use shell redirection.
2569
+
2570
+ ${TOOL_RULES}
2571
+
2572
+ ${SERVER_VALIDATES}`, "review", { shown: "Reintentar: escribir el JSON de la review" });
2573
+ return info(entry);
2574
+ }
2575
+ /**
2576
+ * Respuesta del usuario a permiso(s) pendiente(s).
2577
+ * `scope`: once (default) | session (no volver a preguntar en esta sesión) | always (ACP allow_always).
2578
+ * `allowAll` / `requestIds` resuelven en lote (misma scope para todos).
2579
+ */
2580
+ function respondPermission(input) {
2581
+ const entry = sessions.get(String(input.id || ""));
2582
+ if (!entry) throw new Error("La sesión del agente ya no existe. Recargá la página.");
2583
+ const allow = Boolean(input.allow);
2584
+ const scope = input.scope === "session" || input.scope === "always" ? input.scope : "once";
2585
+ if (allow && (scope === "session" || scope === "always")) entry.sessionAllowDenied = true;
2586
+ let ids;
2587
+ if (input.allowAll) ids = [...entry.pendingPermissions.keys()];
2588
+ else if (Array.isArray(input.requestIds) && input.requestIds.length) ids = input.requestIds.map((id) => String(id || "")).filter(Boolean);
2589
+ else {
2590
+ const one = String(input.requestId || "");
2591
+ ids = one ? [one] : [];
2592
+ }
2593
+ if (!ids.length) throw new Error("No hay pedidos de permiso pendientes.");
2594
+ const batch = ids.length > 1 || Boolean(input.allowAll);
2595
+ let resolved = 0;
2596
+ for (const requestId of ids) {
2597
+ const pending = entry.pendingPermissions.get(requestId);
2598
+ if (!pending) {
2599
+ if (!batch) throw new Error("Ese pedido de permiso ya no está pendiente.");
2600
+ continue;
2601
+ }
2602
+ pending.resolve(allow, {
2603
+ quiet: batch,
2604
+ scope
2605
+ });
2606
+ resolved += 1;
2607
+ }
2608
+ if (!resolved) throw new Error("Ese pedido de permiso ya no está pendiente.");
2609
+ if (batch) emit(entry, {
2610
+ type: "permission_summary",
2611
+ allow,
2612
+ count: resolved
2613
+ });
2614
+ return info(entry);
2615
+ }
2616
+ /**
2617
+ * Activa/desactiva “confiar en esta sesión” (no pedir confirmación cuando la política niega).
2618
+ * Al activar, aprueba los pedidos pendientes actuales.
2619
+ */
2620
+ function setPermissionTrust(id, enabled) {
2621
+ const entry = sessions.get(String(id || ""));
2622
+ if (!entry) throw new Error("La sesión del agente ya no existe. Recargá la página.");
2623
+ entry.sessionAllowDenied = Boolean(enabled);
2624
+ if (enabled) {
2625
+ const ids = [...entry.pendingPermissions.keys()];
2626
+ let resolved = 0;
2627
+ for (const requestId of ids) {
2628
+ const pending = entry.pendingPermissions.get(requestId);
2629
+ if (!pending) continue;
2630
+ pending.resolve(true, {
2631
+ quiet: ids.length > 1,
2632
+ scope: "session"
2633
+ });
2634
+ resolved += 1;
2635
+ }
2636
+ if (resolved > 1) emit(entry, {
2637
+ type: "permission_summary",
2638
+ allow: true,
2639
+ count: resolved
2640
+ });
2641
+ emit(entry, {
2642
+ type: "status",
2643
+ status: entry.status,
2644
+ message: "Confianza de sesión: no se pedirá confirmación de permisos."
2645
+ });
2646
+ } else emit(entry, {
2647
+ type: "status",
2648
+ status: entry.status,
2649
+ message: "Confianza de sesión desactivada: se vuelve a pedir confirmación."
2650
+ });
2651
+ return info(entry);
2652
+ }
2653
+ async function cancelTurn(id) {
2654
+ const entry = sessions.get(id);
2655
+ if (!entry) return;
2656
+ entry.cancelled = true;
2657
+ if (entry.acp && entry.busy) {
2658
+ await entry.acp.cancel();
2659
+ return;
2660
+ }
2661
+ entry.busy = false;
2662
+ setStatus(entry, "error", "Cancelado.");
2663
+ }
2664
+ function subscribe(id, listener) {
2665
+ const entry = sessions.get(id);
2666
+ if (!entry) return null;
2667
+ listener({ type: "reset" });
2668
+ for (const event of entry.events) listener(event);
2669
+ entry.listeners.add(listener);
2670
+ return () => entry.listeners.delete(listener);
2671
+ }
2672
+ /**
2673
+ * Review que pidió la CLI al lanzar el server (`DIFF_REVIEW_AGENT_RUN`, JSON). Se arranca una sola vez, al iniciar,
2674
+ * así corre aunque no se abra el navegador.
2675
+ */
2676
+ function startPendingAgentRun() {
2677
+ const raw = String(process.env.DIFF_REVIEW_AGENT_RUN || "").trim();
2678
+ if (!raw) return;
2679
+ delete process.env.DIFF_REVIEW_AGENT_RUN;
2680
+ try {
2681
+ startReview(JSON.parse(raw));
2682
+ } catch (err) {
2683
+ console.error("[diff-review agent] no pude lanzar la review pedida por la CLI:", err instanceof Error ? err.message : err);
2684
+ }
2685
+ }
2686
+
2687
+ export { sendMessage as a, setPermissionTrust as b, cancelTurn as c, retryReview as d, openAgentLogin as e, listAcpAgents as f, agentModels as g, sessionInfo as h, subscribe as i, startPendingAgentRun as j, loginCommand as l, openChat as o, respondPermission as r, startReview as s };
2688
+ //# sourceMappingURL=agent-sessions-DSqGavSn.js.map