@jpkme/diff-review 0.1.7 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +156 -34
  3. package/lib/acp-policy.mjs +136 -0
  4. package/lib/acp-policy.test.mjs +83 -0
  5. package/lib/acp-shell.mjs +379 -0
  6. package/lib/acp-shell.test.mjs +127 -0
  7. package/lib/acp.mjs +368 -0
  8. package/lib/acp.test.mjs +58 -0
  9. package/lib/agent-login.mjs +105 -0
  10. package/lib/agents.mjs +102 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +31 -12
  13. package/lib/check-report.test.mjs +1 -0
  14. package/lib/extract-cache.test.mjs +162 -0
  15. package/lib/extract.mjs +197 -1
  16. package/lib/model-label.mjs +263 -0
  17. package/lib/model-label.test.mjs +77 -0
  18. package/lib/models-cache.mjs +75 -0
  19. package/lib/models-cache.test.mjs +50 -0
  20. package/lib/output-name.mjs +8 -0
  21. package/lib/prompt.mjs +178 -31
  22. package/lib/review-dir.mjs +108 -0
  23. package/lib/review-meta.mjs +35 -0
  24. package/lib/review-meta.test.mjs +41 -0
  25. package/lib/review-prep.mjs +63 -0
  26. package/lib/review.test.mjs +1 -0
  27. package/lib/validate.mjs +28 -11
  28. package/lib/validate.test.mjs +25 -0
  29. package/lib/wait-output.mjs +46 -23
  30. package/lib/wait-output.test.mjs +25 -1
  31. package/package.json +3 -1
  32. package/schema.json +9 -10
  33. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +1 -0
  34. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
  35. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
  36. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +1 -0
  37. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
  38. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
  39. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +1 -0
  40. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
  41. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
  42. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js +2 -0
  43. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
  44. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
  45. package/web/build/client/_app/immutable/chunks/CPyWH418.js +1 -0
  46. package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
  47. package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
  48. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  49. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  50. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  51. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +210 -0
  52. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
  53. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
  54. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  55. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  56. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  57. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js +2 -0
  58. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
  59. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
  60. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +1 -0
  61. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
  62. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
  63. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js +1 -0
  64. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.br +0 -0
  65. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.gz +0 -0
  66. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js +1 -0
  67. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +2 -0
  68. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
  69. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +10 -0
  70. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
  71. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
  72. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +171 -0
  73. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
  74. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
  75. package/web/build/client/_app/version.json +1 -1
  76. package/web/build/client/_app/version.json.br +0 -0
  77. package/web/build/client/_app/version.json.gz +0 -0
  78. package/web/build/handler.js +3 -3
  79. package/web/build/index.js +3 -3
  80. package/web/build/server/chunks/{0-SseDy__O.js → 0-BYok4D0n.js} +2 -2
  81. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-BYok4D0n.js.map} +1 -1
  82. package/web/build/server/chunks/1-C7zcB0q0.js +7 -0
  83. package/web/build/server/chunks/1-C7zcB0q0.js.map +1 -0
  84. package/web/build/server/chunks/2-v6ejI_0-.js +7 -0
  85. package/web/build/server/chunks/2-v6ejI_0-.js.map +1 -0
  86. package/web/build/server/chunks/3-DAAmG6VX.js +7 -0
  87. package/web/build/server/chunks/3-DAAmG6VX.js.map +1 -0
  88. package/web/build/server/chunks/_server.ts-qYVrMYPB.js +62 -0
  89. package/web/build/server/chunks/_server.ts-qYVrMYPB.js.map +1 -0
  90. package/web/build/server/chunks/agent-sessions-DSqGavSn.js +2688 -0
  91. package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +1 -0
  92. package/web/build/server/chunks/{remote-1g4ayag-B8KZPTkn.js → git-review-CpO_G8z8.js} +70 -704
  93. package/web/build/server/chunks/git-review-CpO_G8z8.js.map +1 -0
  94. package/web/build/server/chunks/{handler-cq3QoEzW.js → handler-Di1wJQuU.js} +3 -3
  95. package/web/build/server/chunks/{handler-cq3QoEzW.js.map → handler-Di1wJQuU.js.map} +1 -1
  96. package/web/build/server/chunks/hooks.server-BLOHobEK.js +42 -0
  97. package/web/build/server/chunks/hooks.server-BLOHobEK.js.map +1 -0
  98. package/web/build/server/chunks/{index.js-D4qkHe3r.js → index.js-yLYmrAWy.js} +3 -5
  99. package/web/build/server/chunks/index.js-yLYmrAWy.js.map +1 -0
  100. package/web/build/server/chunks/manifest.js-CXj8IIo3.js +58 -0
  101. package/web/build/server/chunks/manifest.js-CXj8IIo3.js.map +1 -0
  102. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js +144 -0
  103. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js.map +1 -0
  104. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js +162 -0
  105. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js.map +1 -0
  106. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  107. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  108. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +0 -1
  109. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  110. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  111. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css +0 -1
  112. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
  113. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.gz +0 -0
  114. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  115. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  116. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  117. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +0 -2
  118. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  119. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  120. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js +0 -174
  121. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  122. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  123. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  124. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  125. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  126. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  127. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  128. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  129. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js +0 -2
  130. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  131. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  132. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +0 -1
  133. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +0 -1
  134. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  135. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  136. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  137. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  138. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +0 -1
  139. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  140. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  141. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +0 -66
  142. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  143. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  144. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js +0 -170
  145. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  146. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  147. package/web/build/server/chunks/1-0X6Jp9nH.js +0 -7
  148. package/web/build/server/chunks/1-0X6Jp9nH.js.map +0 -1
  149. package/web/build/server/chunks/2-HlcVALg6.js +0 -7
  150. package/web/build/server/chunks/2-HlcVALg6.js.map +0 -1
  151. package/web/build/server/chunks/3-BN1eS7L2.js +0 -7
  152. package/web/build/server/chunks/3-BN1eS7L2.js.map +0 -1
  153. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  154. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  155. package/web/build/server/chunks/index.js-D4qkHe3r.js.map +0 -1
  156. package/web/build/server/chunks/manifest.js-DQHMKyJh.js +0 -50
  157. package/web/build/server/chunks/manifest.js-DQHMKyJh.js.map +0 -1
  158. package/web/build/server/chunks/remote-1g4ayag-B8KZPTkn.js.map +0 -1
package/lib/extract.mjs CHANGED
@@ -2,14 +2,195 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { capDiff, collectGuidelines, isIgnored, isTestPath, loadReviewConfig } from "./config.mjs";
4
4
  import { commitLog, git } from "./git.mjs";
5
+ import { readDiffCache } from "./review-dir.mjs";
5
6
 
6
7
  export function flattenFiles(report) {
7
8
  return Array.isArray(report?.files) ? report.files : [];
8
9
  }
9
10
 
10
- export function extractBranchDiff({ repo, branch, base = "develop" }) {
11
+ /**
12
+ * Parte un `git diff` multi-archivo (texto de `writeDiffCache`) en entradas por path.
13
+ * @param {string} text
14
+ * @returns {Array<{ path: string, oldPath?: string, changeType: string, hunkDiff: string }>}
15
+ */
16
+ export function parseGitDiffFiles(text) {
17
+ const raw = String(text || "").replace(/\r\n/g, "\n");
18
+ if (!raw.trim()) return [];
19
+
20
+ const parts = raw.split(/^diff --git /m);
21
+ /** @type {Array<{ path: string, oldPath?: string, changeType: string, hunkDiff: string }>} */
22
+ const out = [];
23
+
24
+ for (const part of parts) {
25
+ const chunk = part.trimStart();
26
+ if (!chunk) continue;
27
+
28
+ const firstNl = chunk.indexOf("\n");
29
+ const firstLine = (firstNl === -1 ? chunk : chunk.slice(0, firstNl)).trim();
30
+ // `a/path b/path` (paths sin espacios raros; el formato de git suele ser este)
31
+ const paths = firstLine.match(/^a\/(.+) b\/(.+)$/);
32
+ if (!paths) continue;
33
+
34
+ let oldPath = paths[1];
35
+ let path = paths[2];
36
+ const body = firstNl === -1 ? "" : chunk.slice(firstNl + 1);
37
+
38
+ let changeType = "modified";
39
+ if (/^new file mode /m.test(body) || /^--- \/dev\/null/m.test(body)) changeType = "added";
40
+ else if (/^deleted file mode /m.test(body) || /^\+\+\+ \/dev\/null/m.test(body)) changeType = "deleted";
41
+ else if (/^rename from /m.test(body) || /^rename to /m.test(body)) {
42
+ changeType = "renamed";
43
+ const from = body.match(/^rename from (.+)$/m);
44
+ const to = body.match(/^rename to (.+)$/m);
45
+ if (from) oldPath = from[1].trim();
46
+ if (to) path = to[1].trim();
47
+ }
48
+
49
+ let hunkDiff = "";
50
+ const idx = `\n${body}`.indexOf("\n@@");
51
+ if (idx !== -1) hunkDiff = `\n${body}`.slice(idx + 1);
52
+ else if (/^Binary files /m.test(body)) hunkDiff = "";
53
+
54
+ out.push({
55
+ path,
56
+ oldPath: oldPath !== path ? oldPath : undefined,
57
+ changeType,
58
+ hunkDiff,
59
+ });
60
+ }
61
+
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Stats baratos desde el texto del diff (sin volver a llamar a git).
67
+ * @param {string} text
68
+ */
69
+ function statsFromDiffText(text) {
70
+ let insertions = 0;
71
+ let deletions = 0;
72
+ for (const line of String(text || "").split("\n")) {
73
+ if (!line.length) continue;
74
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff ") || line.startsWith("index ")) {
75
+ continue;
76
+ }
77
+ if (line.startsWith("+")) insertions += 1;
78
+ else if (line.startsWith("-")) deletions += 1;
79
+ }
80
+ return { insertions, deletions };
81
+ }
82
+
83
+ /**
84
+ * Tips / merge-base si git responde; si no, strings vacíos (el contenido del diff ya viene del cache).
85
+ */
86
+ function tryTips(repo, branch, base) {
87
+ try {
88
+ return {
89
+ mergeBase: git(repo, ["merge-base", base, branch]).trim(),
90
+ branchSha: git(repo, ["rev-parse", branch]).trim(),
91
+ baseSha: git(repo, ["rev-parse", base]).trim(),
92
+ };
93
+ } catch {
94
+ return { mergeBase: "", branchSha: "", baseSha: "" };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Arma el mismo shape que `extractBranchDiff` a partir de un texto de `git diff` ya cacheado.
100
+ * No vuelve a correr `git diff`.
101
+ */
102
+ function skeletonFromCachedDiff(repo, branch, base, cachedDiff) {
103
+ const config = loadReviewConfig(repo);
104
+ const tips = tryTips(repo, branch, base);
105
+ const parsed = parseGitDiffFiles(cachedDiff);
106
+
107
+ const ignored = [];
108
+ const included = [];
109
+ for (const f of parsed) {
110
+ if (isIgnored(f.path, config.ignore)) ignored.push(f.path);
111
+ else included.push(f);
112
+ }
113
+
114
+ const files = included.map((f) => {
115
+ const capped = capDiff(f.hunkDiff);
116
+ return {
117
+ path: f.path,
118
+ oldPath: f.oldPath || undefined,
119
+ changeType: f.changeType,
120
+ diff: capped.diff,
121
+ diffChars: capped.diffChars,
122
+ truncated: capped.truncated,
123
+ modelChars: capped.diff.length,
124
+ group: "",
125
+ };
126
+ });
127
+
128
+ const { insertions, deletions } = statsFromDiffText(cachedDiff);
129
+ let commits = [];
130
+ if (tips.mergeBase) {
131
+ try {
132
+ commits = commitLog(repo, tips.mergeBase, branch);
133
+ } catch {
134
+ commits = [];
135
+ }
136
+ }
137
+
138
+ return {
139
+ version: 2,
140
+ repo,
141
+ branch,
142
+ baseBranch: base,
143
+ mergeBase: tips.mergeBase,
144
+ branchSha: tips.branchSha,
145
+ baseSha: tips.baseSha,
146
+ generatedAt: new Date().toISOString(),
147
+ intent: "",
148
+ stats: {
149
+ filesChanged: files.length,
150
+ filesIgnored: ignored.length,
151
+ filesTruncated: files.filter((f) => f.truncated).length,
152
+ insertions,
153
+ deletions,
154
+ },
155
+ context: {
156
+ commits,
157
+ rules: config.rules,
158
+ rulesFile: config.rulesFile,
159
+ ignored: ignored.slice(0, 40),
160
+ testsTouched: files.filter((f) => isTestPath(f.path)).map((f) => f.path),
161
+ testsMissing: files.filter((f) => !isTestPath(f.path)).length,
162
+ guidelines: collectGuidelines(repo, files.map((f) => f.path), {
163
+ rulesFile: config.rulesFile,
164
+ skipNames: config.rules.trim() ? [config.rulesFile] : [],
165
+ maxDepth: 1,
166
+ maxChars: 8_000,
167
+ maxTotalChars: 12_000,
168
+ }),
169
+ },
170
+ files,
171
+ groups: [],
172
+ blocks: [],
173
+ findings: [],
174
+ skipped: [],
175
+ };
176
+ }
177
+
178
+ /**
179
+ * @param {{
180
+ * repo: string,
181
+ * branch: string,
182
+ * base?: string,
183
+ * cachedDiff?: string | null,
184
+ * }} opts
185
+ * `cachedDiff`: texto de `git diff base...branch` ya materializado; si viene, no se vuelve a calcular el diff.
186
+ */
187
+ export function extractBranchDiff({ repo, branch, base = "develop", cachedDiff = null } = {}) {
11
188
  if (!branch) throw new Error("Falta el branch a revisar.");
12
189
 
190
+ if (typeof cachedDiff === "string" && cachedDiff.length) {
191
+ return skeletonFromCachedDiff(repo, branch, base, cachedDiff);
192
+ }
193
+
13
194
  const config = loadReviewConfig(repo);
14
195
  const mergeBase = git(repo, ["merge-base", base, branch]).trim();
15
196
  const branchSha = git(repo, ["rev-parse", branch]).trim();
@@ -103,6 +284,21 @@ export function extractBranchDiff({ repo, branch, base = "develop" }) {
103
284
  };
104
285
  }
105
286
 
287
+ /**
288
+ * Preferí el companion `.diff` en `.diff-review/` si existe; si no, `git diff` vía `extractBranchDiff`.
289
+ * @param {{ repo: string, branch: string, base?: string, filename?: string }} opts
290
+ */
291
+ export function loadBranchDiff({ repo, branch, base = "develop", filename } = {}) {
292
+ const name = String(filename || "").trim();
293
+ if (name) {
294
+ const cached = readDiffCache(repo, name);
295
+ if (cached != null && cached.length) {
296
+ return extractBranchDiff({ repo, branch, base, cachedDiff: cached });
297
+ }
298
+ }
299
+ return extractBranchDiff({ repo, branch, base });
300
+ }
301
+
106
302
  export function writeSkeleton(outPath, skeleton) {
107
303
  const resolved = resolve(outPath);
108
304
  mkdirSync(dirname(resolved), { recursive: true });
@@ -0,0 +1,263 @@
1
+ /** Compara nombres de modelo ignorando mayúsculas, espacios y puntuación. */
2
+ export function modelSlug(s) {
3
+ return String(s || "")
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-+|-+$/g, "");
7
+ }
8
+
9
+ /** Parece un id técnico (claude-opus-5-5) más que un nombre legible. */
10
+ function isSlugLike(s) {
11
+ const t = String(s || "").trim();
12
+ if (!t) return true;
13
+ if (/\s/.test(t)) return false;
14
+ if (/[A-Z]/.test(t) && /[a-z]/.test(t) && !t.includes("-")) return false; // "Sonnet", "Auto"
15
+ return /^[a-z0-9][a-z0-9._|-]*$/i.test(t) && (t.includes("-") || t.includes(".") || t.includes("["));
16
+ }
17
+
18
+ /**
19
+ * claude-opus-5-5 → Claude Opus 5.5; gpt-5.6-sol → GPT-5.6 Sol; gemini-3.8-flash → Gemini 3.8 Flash
20
+ * @param {string} id
21
+ */
22
+ export function humanizeModelId(id) {
23
+ const base = String(id || "")
24
+ .trim()
25
+ .replace(/\[.*\]$/, "");
26
+ if (!base) return "";
27
+ const parts = base.split(/[-_]/).filter(Boolean);
28
+ const out = [];
29
+ for (let i = 0; i < parts.length; i++) {
30
+ const p = parts[i];
31
+ const lower = p.toLowerCase();
32
+ if (lower === "gpt" || lower === "gpt5") {
33
+ out.push(lower === "gpt5" ? "GPT-5" : "GPT");
34
+ continue;
35
+ }
36
+ if (lower === "claude" || lower === "gemini" || lower === "grok" || lower === "composer" || lower === "muse") {
37
+ out.push(lower.charAt(0).toUpperCase() + lower.slice(1));
38
+ continue;
39
+ }
40
+ if (lower === "sonnet" || lower === "opus" || lower === "haiku" || lower === "flash" || lower === "codex") {
41
+ out.push(lower.charAt(0).toUpperCase() + lower.slice(1));
42
+ continue;
43
+ }
44
+ // 5-5 → 5.5 (versión mayor-menor)
45
+ if (/^\d+$/.test(p) && i + 1 < parts.length && /^\d+$/.test(parts[i + 1])) {
46
+ out.push(`${p}.${parts[i + 1]}`);
47
+ i++;
48
+ continue;
49
+ }
50
+ if (/^\d+(\.\d+)?$/.test(p)) {
51
+ out.push(p);
52
+ continue;
53
+ }
54
+ out.push(p.charAt(0).toUpperCase() + p.slice(1).toLowerCase());
55
+ }
56
+ return out.join(" ");
57
+ }
58
+
59
+ /**
60
+ * context=300k,effort=medium,fast=false → ["300k", "medium"]
61
+ * @param {string} raw
62
+ * @returns {string[]}
63
+ */
64
+ export function compactModelMeta(raw) {
65
+ const s = String(raw || "").trim();
66
+ if (!s) return [];
67
+ /** @type {string[]} */
68
+ const badges = [];
69
+ for (const piece of s.split(",")) {
70
+ const part = piece.trim();
71
+ if (!part) continue;
72
+ const eq = part.indexOf("=");
73
+ if (eq < 0) {
74
+ // bare token: "1m", "1M"
75
+ badges.push(part);
76
+ continue;
77
+ }
78
+ const key = part.slice(0, eq).trim().toLowerCase();
79
+ const val = part.slice(eq + 1).trim();
80
+ if (!val || val === "false" || val === "0") continue;
81
+ if (key === "fast" || key === "thinking") {
82
+ if (val === "true" || val === "1") badges.push(key);
83
+ continue;
84
+ }
85
+ if (key === "context" || key === "ctx") {
86
+ badges.push(val);
87
+ continue;
88
+ }
89
+ if (key === "effort" || key === "reasoning_effort" || key === "reasoning") {
90
+ badges.push(val);
91
+ continue;
92
+ }
93
+ badges.push(val);
94
+ }
95
+ return badges;
96
+ }
97
+
98
+ /**
99
+ * @param {string} name
100
+ * @param {string} value
101
+ * @returns {{ title: string, badges: string[], label: string, hint?: string }}
102
+ */
103
+ export function formatModelOption(name, value) {
104
+ const n = String(name ?? "").trim();
105
+ const v = String(value ?? "").trim();
106
+ if (!v && !n) return { title: "", badges: [], label: "" };
107
+ if (/^default(\[\])?$/i.test(v) && n) {
108
+ return { title: n, badges: [], label: n };
109
+ }
110
+
111
+ const bracket = /^(.+?)\[(.*)\]$/.exec(v);
112
+ const base = bracket ? bracket[1].trim() : v;
113
+ const metaRaw = bracket ? bracket[2].trim() : "";
114
+ let badges = compactModelMeta(metaRaw);
115
+
116
+ let title;
117
+ if (n && !isSlugLike(n)) {
118
+ title = n;
119
+ } else if (n && modelSlug(n) === modelSlug(base)) {
120
+ title = isSlugLike(n) ? humanizeModelId(base || n) : n;
121
+ } else if (n && isSlugLike(n)) {
122
+ title = humanizeModelId(base || n);
123
+ } else if (n) {
124
+ title = n;
125
+ } else {
126
+ title = humanizeModelId(base) || base || v;
127
+ }
128
+
129
+ // Si el título ya menciona el contexto (p. ej. "Sonnet (1M context)"), no repetir el badge "1m".
130
+ if (badges.length && /\(\s*\d+\s*m\b/i.test(title)) {
131
+ badges = badges.filter((b) => !/^\d+m$/i.test(b));
132
+ }
133
+ const titleSlug = modelSlug(title);
134
+ if (badges.length && titleSlug.includes(modelSlug(badges.join("")))) {
135
+ /* keep badges anyway for effort/fast */
136
+ }
137
+
138
+ const hint = badges.length ? `[ ${badges.join(", ")} ]` : undefined;
139
+ const label = hint ? `${title} · ${hint}` : title;
140
+ return { title, badges, label, ...(hint ? { hint } : {}) };
141
+ }
142
+
143
+ /**
144
+ * Etiqueta compacta (compat / un solo string).
145
+ * @param {string} name
146
+ * @param {string} value
147
+ */
148
+ export function modelOptionLabel(name, value) {
149
+ return formatModelOption(name, value).label || String(name || value || "").trim();
150
+ }
151
+
152
+ /**
153
+ * Etiqueta del ítem "usar el default del agente".
154
+ * @param {{ value: string, name: string } | undefined} current
155
+ * @param {string} [configuredOptionLabel]
156
+ */
157
+ export function defaultModelOptionLabel(current, configuredOptionLabel) {
158
+ const name = String(current?.name ?? "").trim();
159
+ const value = String(current?.value ?? "").trim();
160
+ const generic =
161
+ !name ||
162
+ value === "default" ||
163
+ value === "auto" ||
164
+ /^default(\b|\s|\()/i.test(name) ||
165
+ /^auto(\b|\s|\()/i.test(name) ||
166
+ /\(recommended\)/i.test(name);
167
+
168
+ if (generic) {
169
+ const configured = String(configuredOptionLabel ?? "").trim();
170
+ if (configured && configured !== "Por defecto del agente") return configured;
171
+ return "Por defecto del agente";
172
+ }
173
+
174
+ const { title, hint } = formatModelOption(name, value);
175
+ return hint ? `Por defecto · ${title} · ${hint}` : `Por defecto · ${title}`;
176
+ }
177
+
178
+ /**
179
+ * Orden de la lista: default (`""`) y `auto` primero, resto A→Z por label.
180
+ * @template {{ value: string, label?: string, title?: string }} T
181
+ * @param {T[]} options
182
+ * @returns {T[]}
183
+ */
184
+ export function sortModelSelectOptions(options) {
185
+ const list = Array.isArray(options) ? [...options] : [];
186
+
187
+ /** @param {{ value: string, label?: string, title?: string }} o */
188
+ const isAuto = (o) => {
189
+ const v = String(o.value ?? "").toLowerCase();
190
+ if (v === "auto" || v.startsWith("auto[")) return true;
191
+ const t = String(o.title || o.label || "").trim();
192
+ return /^auto\b/i.test(t);
193
+ };
194
+
195
+ const defaults = list.filter((o) => o.value === "");
196
+ const autos = list.filter((o) => o.value !== "" && isAuto(o));
197
+ const middle = list.filter((o) => o.value !== "" && !isAuto(o));
198
+
199
+ /** @param {{ label?: string, title?: string, value: string }} a */
200
+ /** @param {{ label?: string, title?: string, value: string }} b */
201
+ const byLabel = (a, b) =>
202
+ String(a.label || a.title || a.value).localeCompare(String(b.label || b.title || b.value), "es", {
203
+ sensitivity: "base",
204
+ numeric: true,
205
+ });
206
+
207
+ autos.sort(byLabel);
208
+ middle.sort(byLabel);
209
+ return [...defaults, ...autos, ...middle];
210
+ }
211
+
212
+ /**
213
+ * Misma lista que la UI: default + aliases fijos + modelos ACP.
214
+ * Clack: `label` = título amigable, `hint` = `[ 300k, medium ]`.
215
+ * UI: también `title` + `badges` para chips.
216
+ *
217
+ * @param {{ models: { value: string, name: string }[], currentModel: string }} live
218
+ * @param {{ options: { value: string, label: string, hint?: string }[], initialValue?: string }} fixed
219
+ * @returns {{ options: { value: string, label: string, hint?: string, title?: string, badges?: string[] }[], initialValue: string }}
220
+ */
221
+ export function buildAcpModelSelectOptions(live, fixed) {
222
+ const fixedOptions = fixed?.options ?? [];
223
+ const fallback = fixedOptions.filter((m) => m.value !== "");
224
+ const configuredLabel = fixedOptions.find((m) => m.value === "")?.label;
225
+ const current = live.models.find((m) => m.value === live.currentModel);
226
+ const seen = new Set(["", "default"]);
227
+ /** @type {{ value: string, label: string, hint?: string, title?: string, badges?: string[] }[]} */
228
+ const options = [
229
+ {
230
+ value: "",
231
+ label: defaultModelOptionLabel(current, configuredLabel),
232
+ title: defaultModelOptionLabel(current, configuredLabel),
233
+ badges: [],
234
+ },
235
+ ];
236
+
237
+ for (const m of fallback) {
238
+ if (seen.has(m.value)) continue;
239
+ seen.add(m.value);
240
+ options.push({
241
+ value: m.value,
242
+ label: m.label,
243
+ title: m.label,
244
+ badges: [],
245
+ ...(m.hint ? { hint: m.hint } : {}),
246
+ });
247
+ }
248
+
249
+ for (const m of live.models) {
250
+ if (seen.has(m.value)) continue;
251
+ seen.add(m.value);
252
+ const formatted = formatModelOption(m.name, m.value);
253
+ options.push({
254
+ value: m.value,
255
+ label: formatted.title,
256
+ title: formatted.title,
257
+ badges: formatted.badges,
258
+ ...(formatted.hint ? { hint: formatted.hint } : {}),
259
+ });
260
+ }
261
+
262
+ return { options: sortModelSelectOptions(options), initialValue: "" };
263
+ }
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ buildAcpModelSelectOptions,
5
+ compactModelMeta,
6
+ formatModelOption,
7
+ humanizeModelId,
8
+ modelOptionLabel,
9
+ } from "./model-label.mjs";
10
+
11
+ test("humanizeModelId", () => {
12
+ assert.equal(humanizeModelId("claude-opus-5-5"), "Claude Opus 5.5");
13
+ assert.equal(humanizeModelId("claude-sonnet-5.5"), "Claude Sonnet 5.5");
14
+ assert.equal(humanizeModelId("gemini-3.8-flash"), "Gemini 3.8 Flash");
15
+ assert.equal(humanizeModelId("gpt-5.6-sol"), "GPT 5.6 Sol");
16
+ });
17
+
18
+ test("compactModelMeta", () => {
19
+ assert.deepEqual(compactModelMeta("context=300k,effort=medium,fast=false"), ["300k", "medium"]);
20
+ assert.deepEqual(compactModelMeta("thinking=true,context=300k,effort=high,fast=false"), [
21
+ "thinking",
22
+ "300k",
23
+ "high",
24
+ ]);
25
+ assert.deepEqual(compactModelMeta("1m"), ["1m"]);
26
+ });
27
+
28
+ test("formatModelOption compacto y amigable", () => {
29
+ const a = formatModelOption("claude-opus-5-5", "claude-opus-5-5[context=300k,effort=medium,fast=false]");
30
+ assert.equal(a.title, "Claude Opus 5.5");
31
+ assert.deepEqual(a.badges, ["300k", "medium"]);
32
+ assert.equal(a.label, "Claude Opus 5.5 · [ 300k, medium ]");
33
+ assert.equal(a.hint, "[ 300k, medium ]");
34
+
35
+ const b = formatModelOption("Claude Sonnet 5.5", "claude-sonnet-5.5");
36
+ assert.equal(b.title, "Claude Sonnet 5.5");
37
+ assert.deepEqual(b.badges, []);
38
+ assert.equal(b.label, "Claude Sonnet 5.5");
39
+
40
+ const c = formatModelOption("Sonnet (1M context)", "sonnet[1m]");
41
+ assert.equal(c.title, "Sonnet (1M context)");
42
+ assert.deepEqual(c.badges, []);
43
+ });
44
+
45
+ test("buildAcpModelSelectOptions fusiona aliases y formatea ACP", () => {
46
+ const { options } = buildAcpModelSelectOptions(
47
+ {
48
+ currentModel: "default",
49
+ models: [
50
+ { value: "default", name: "Default (recommended)" },
51
+ {
52
+ value: "claude-opus-5-5[context=300k,effort=medium,fast=false]",
53
+ name: "claude-opus-5-5",
54
+ },
55
+ ],
56
+ },
57
+ {
58
+ options: [
59
+ { value: "", label: "Por defecto (opus)" },
60
+ { value: "opus", label: "opus", hint: "el más capaz" },
61
+ ],
62
+ }
63
+ );
64
+ assert.equal(options[0].label, "Por defecto (opus)");
65
+ assert.ok(options.some((o) => o.value === "opus"));
66
+ const opusAcp = options.find((o) => o.value.startsWith("claude-opus-5-5"));
67
+ assert.equal(opusAcp?.title, "Claude Opus 5.5");
68
+ assert.deepEqual(opusAcp?.badges, ["300k", "medium"]);
69
+ assert.equal(opusAcp?.hint, "[ 300k, medium ]");
70
+ });
71
+
72
+ test("modelOptionLabel compat", () => {
73
+ assert.equal(
74
+ modelOptionLabel("grok-4.7", "grok-4.7[context=256k,fast=true]"),
75
+ "Grok 4.7 · [ 256k, fast ]"
76
+ );
77
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Caché en disco de las listas de modelos ACP por agente.
3
+ * TTL 24h: los catálogos cambian de a poco (semanas); un día evita spawnear el CLI en cada review
4
+ * y sigue fresco si aparece un modelo nuevo.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+
10
+ /** 24 horas. */
11
+ export const MODELS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
12
+
13
+ /** @returns {string} */
14
+ export function modelsCachePath(home = homedir()) {
15
+ return join(home, ".diff-review", "models-cache.json");
16
+ }
17
+
18
+ /**
19
+ * @param {string} agentId
20
+ * @param {{ home?: string, now?: number, ttlMs?: number }} [opts]
21
+ * @returns {{ models: { value: string, name: string }[], currentModel: string, fetchedAt: string } | null}
22
+ */
23
+ export function readModelsCache(agentId, opts = {}) {
24
+ const id = String(agentId || "").trim();
25
+ if (!id) return null;
26
+ const path = modelsCachePath(opts.home);
27
+ let root;
28
+ try {
29
+ root = JSON.parse(readFileSync(path, "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ const entry = root?.agents?.[id];
34
+ if (!entry || !Array.isArray(entry.models)) return null;
35
+ const fetchedAt = Date.parse(String(entry.fetchedAt || ""));
36
+ if (!Number.isFinite(fetchedAt)) return null;
37
+ const ttl = opts.ttlMs ?? MODELS_CACHE_TTL_MS;
38
+ const now = opts.now ?? Date.now();
39
+ if (now - fetchedAt > ttl) return null;
40
+ return {
41
+ models: entry.models
42
+ .filter((m) => m && typeof m.value === "string")
43
+ .map((m) => ({ value: m.value, name: String(m.name ?? m.value) })),
44
+ currentModel: String(entry.currentModel ?? ""),
45
+ fetchedAt: new Date(fetchedAt).toISOString(),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * @param {string} agentId
51
+ * @param {{ models: { value: string, name: string }[], currentModel?: string }} data
52
+ * @param {{ home?: string, now?: number }} [opts]
53
+ */
54
+ export function writeModelsCache(agentId, data, opts = {}) {
55
+ const id = String(agentId || "").trim();
56
+ if (!id || !Array.isArray(data?.models) || !data.models.length) return;
57
+ const path = modelsCachePath(opts.home);
58
+ let root = { version: 1, agents: {} };
59
+ try {
60
+ if (existsSync(path)) root = { ...root, ...JSON.parse(readFileSync(path, "utf8")) };
61
+ } catch {
62
+ /* archivo nuevo */
63
+ }
64
+ if (!root.agents || typeof root.agents !== "object") root.agents = {};
65
+ root.agents[id] = {
66
+ fetchedAt: new Date(opts.now ?? Date.now()).toISOString(),
67
+ currentModel: String(data.currentModel ?? ""),
68
+ models: data.models.map((m) => ({
69
+ value: String(m.value),
70
+ name: String(m.name ?? m.value),
71
+ })),
72
+ };
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
75
+ }
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import {
7
+ MODELS_CACHE_TTL_MS,
8
+ modelsCachePath,
9
+ readModelsCache,
10
+ writeModelsCache,
11
+ } from "./models-cache.mjs";
12
+ import { sortModelSelectOptions } from "./model-label.mjs";
13
+
14
+ test("sortModelSelectOptions: default y auto primero, resto lexico", () => {
15
+ const sorted = sortModelSelectOptions([
16
+ { value: "zulu", label: "Zulu" },
17
+ { value: "auto", label: "Auto" },
18
+ { value: "alpha", label: "Alpha" },
19
+ { value: "", label: "Por defecto" },
20
+ { value: "bravo", label: "Bravo" },
21
+ ]);
22
+ assert.deepEqual(
23
+ sorted.map((o) => o.value),
24
+ ["", "auto", "alpha", "bravo", "zulu"]
25
+ );
26
+ });
27
+
28
+ test("models cache roundtrip + TTL", () => {
29
+ const home = mkdtempSync(join(tmpdir(), "diff-review-mc-"));
30
+ const now = Date.parse("2026-01-01T12:00:00.000Z");
31
+ writeModelsCache(
32
+ "claude",
33
+ {
34
+ models: [{ value: "opus", name: "Opus" }],
35
+ currentModel: "default",
36
+ },
37
+ { home, now }
38
+ );
39
+ assert.ok(readFileSync(modelsCachePath(home), "utf8").includes("opus"));
40
+
41
+ const hit = readModelsCache("claude", { home, now: now + 1000 });
42
+ assert.equal(hit?.currentModel, "default");
43
+ assert.equal(hit?.models[0].value, "opus");
44
+
45
+ const expired = readModelsCache("claude", {
46
+ home,
47
+ now: now + MODELS_CACHE_TTL_MS + 1,
48
+ });
49
+ assert.equal(expired, null);
50
+ });
@@ -11,6 +11,14 @@ export const OUTPUT_PREFIX = "diff-rev";
11
11
  export const OUTPUT_PREFIX_LEGACY = "diff-review-output";
12
12
  export const OUTPUT_PREFIX_REPORT = "diff-report";
13
13
 
14
+ /** Carpeta (en la raíz del repo revisado) donde va el JSON, el diff y la sesión del agente. */
15
+ export const OUTPUT_DIR = ".diff-review";
16
+
17
+ /** Path relativo a la raíz del repo, con `/` (el que se le da al agente): `.diff-review/<nombre>`. */
18
+ export function outputRelPath(filename) {
19
+ return `${OUTPUT_DIR}/${String(filename || "").replace(/^.*[\\/]/, "")}`;
20
+ }
21
+
14
22
  /** Nombre legacy fijo. */
15
23
  export const OUTPUT_FILENAME_LEGACY = `${OUTPUT_PREFIX_LEGACY}.json`;
16
24