@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/prompt.mjs CHANGED
@@ -5,13 +5,17 @@
5
5
  import {
6
6
  makeOutputFilename,
7
7
  isOutputFilename,
8
+ OUTPUT_DIR,
8
9
  OUTPUT_FILENAME_LEGACY,
10
+ outputRelPath,
9
11
  } from "./output-name.mjs";
10
12
 
11
13
  export {
12
14
  makeOutputFilename,
13
15
  isOutputFilename,
16
+ OUTPUT_DIR,
14
17
  OUTPUT_FILENAME_LEGACY,
18
+ outputRelPath,
15
19
  };
16
20
 
17
21
  export const OUTPUT_SCHEMA_BLOCK = `{
@@ -21,31 +25,31 @@ export const OUTPUT_SCHEMA_BLOCK = `{
21
25
  "branch": "<branch under review>",
22
26
  "base": "develop",
23
27
  "remoteUrl": "<origin URL; optional when source=url>",
24
- "generatedAt": "<ISO-8601 datetime; optional, informational>",
25
- "agent": "<tool name e.g. Claude Code / Cursor; optional, informational>",
26
- "model": "<model id e.g. claude-opus; optional, informational>"
28
+ "generatedAt": "<ISO-8601 datetime; optional>",
29
+ "agent": "<required: product name you are, e.g. Claude Code>",
30
+ "model": "<required: exact model id you are running>"
27
31
  },
28
32
  "intent": "string",
29
33
  "groups": [
30
34
  {
31
- "id": "g1",
35
+ "id": "t1",
32
36
  "kind": "feat|fix|refactor|perf|test|chore|docs|infra",
33
- "title": "string ≤72 chars, imperative, no kind prefix",
37
+ "title": "string, aim for ≤72 chars, imperative, no kind prefix",
34
38
  "intent": "string"
35
39
  }
36
40
  ],
37
41
  "blocks": [
38
42
  {
39
43
  "id": "b1",
40
- "group": "g1",
44
+ "group": "t1",
41
45
  "file": "relative/path.ext",
42
46
  "lines": "L40-58",
43
47
  "side": "new|old",
44
48
  "start": 40,
45
49
  "end": 58,
46
50
  "op": "add|mod|del|rename",
47
- "what": "string",
48
- "why": "string",
51
+ "what": "string, aim for ≤120 chars: concrete change + effect",
52
+ "why": "string, aim for ≤120 chars",
49
53
  "source": "code|commit|pr|issue|inferred"
50
54
  }
51
55
  ],
@@ -59,8 +63,8 @@ export const OUTPUT_SCHEMA_BLOCK = `{
59
63
  "file": "relative/path.ext",
60
64
  "line": 87,
61
65
  "block": "b1",
62
- "what": "string",
63
- "fix": "string"
66
+ "what": "string, aim for ≤120 chars: concrete cause + observable consequence (what fails / risk / cost)",
67
+ "fix": "string, aim for ≤120 chars: suggested change + what the reader gains or avoids if applied"
64
68
  }
65
69
  ],
66
70
  "skipped": [
@@ -69,16 +73,29 @@ export const OUTPUT_SCHEMA_BLOCK = `{
69
73
  "notes": ["optional short limitations"]
70
74
  }`;
71
75
 
72
- const RULES = `- Coverage (hard): every path in the diff MUST appear in "blocks" and/or "skipped". Prefer a skipped glob when many files share one reason.
76
+ const RULES = `- Ids: themes use \`t1\`, \`t2\`, …; findings use \`f1\`, \`f2\`, …; blocks use \`b1\`, \`b2\`, …. Reference themes from blocks via \`"group": "t1"\`.
77
+ - Coverage (hard): every path in the diff MUST appear in "blocks" and/or "skipped". Prefer a skipped glob when many files share one reason.
73
78
  - Do NOT invent files, hunks, or line ranges that are not in the diff.
74
79
  - 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.
75
80
  - 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.
76
81
  - Quality findings must name a concrete cost, not a vague feeling.
82
+ - Finding "what": state the concrete cause and the observable consequence (what fails, the risk, or the cost) — not only a symptom.
83
+ - Finding "fix": state the suggested change and what the reader gains or avoids if they apply it.
77
84
  - Signal over volume: prefer fewer high-signal findings. Order findings by severity (high, then nit). Use "nit" sparingly.
78
85
  - "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
79
- - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.`;
86
+ - When several read-only lookups are independent (files, searches), batch them in the same turn if your tools allow parallel calls.
87
+ - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.
88
+ - Length is a soft target, not a check: aim for title ≤72 characters and what/why/fix ≤120 characters. Prefer clarity over shortening. Longer text is still valid. Do not rewrite the file only to shorten it.`;
80
89
 
81
- function workflowLocal(base, branch) {
90
+ /** @param {{ hasDiff?: boolean }} [ctx] */
91
+ function workflowLocal(base, branch, ctx = {}) {
92
+ if (ctx.hasDiff) {
93
+ return `Workflow:
94
+ 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.
95
+ 2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
96
+ 3. Emit the JSON file. Do not modify the repo.
97
+ 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.`;
98
+ }
82
99
  return `Workflow:
83
100
  1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
84
101
  2. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
@@ -87,13 +104,22 @@ function workflowLocal(base, branch) {
87
104
  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.`;
88
105
  }
89
106
 
90
- /** Comando que el agente debe correr; npx por si el bin no está en PATH. */
107
+ /** Comando que el agente debe correr (sobre `.diff-review/<nombre>`); npx por si el bin no está en PATH. */
91
108
  export function validateCliCommand(filename) {
92
- const file = String(filename || "diff-rev_<branchTip>_<baseTip>.json").trim();
109
+ const file = outputRelPath(String(filename || "diff-rev_<branchTip>_<baseTip>.json").trim());
93
110
  return `npx --yes @jpkme/diff-review validate ${file}`;
94
111
  }
95
112
 
96
- function workflowUrl(base, branch) {
113
+ /** @param {{ hasDiff?: boolean }} [ctx] */
114
+ function workflowUrl(base, branch, ctx = {}) {
115
+ if (ctx.hasDiff) {
116
+ return `Workflow:
117
+ 1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
118
+ 2. 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.
119
+ 3. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
120
+ 4. Emit the JSON file.
121
+ 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.`;
122
+ }
97
123
  return `Workflow:
98
124
  1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
99
125
  2. Resolve the merge-base of \`${base}\` and \`${branch}\`.
@@ -103,6 +129,35 @@ function workflowUrl(base, branch) {
103
129
  6. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
104
130
  }
105
131
 
132
+ /** Workflow sin paso de validate por CLI (sesiones ACP: valida el server). */
133
+ function workflowLocalAcp(ctx = {}) {
134
+ if (ctx.hasDiff) {
135
+ return `Workflow:
136
+ 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.
137
+ 2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
138
+ 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.`;
139
+ }
140
+ return `Workflow:
141
+ 1. Resolve the merge-base of the base and branch named above, then run the three-dot \`git diff base...branch\`.
142
+ 2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
143
+ 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.`;
144
+ }
145
+
146
+ function workflowUrlAcp(ctx = {}) {
147
+ if (ctx.hasDiff) {
148
+ return `Workflow:
149
+ 1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
150
+ 2. 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.
151
+ 3. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
152
+ 4. Write the JSON with your file Write/Edit tool (never shell redirection). The host validates when your turn ends — do not run the validate CLI.`;
153
+ }
154
+ return `Workflow:
155
+ 1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
156
+ 2. Resolve the merge-base, then run the three-dot \`git diff base...branch\`.
157
+ 3. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
158
+ 4. Write the JSON with your file Write/Edit tool (never shell redirection). The host validates when your turn ends — do not run the validate CLI.`;
159
+ }
160
+
106
161
  /**
107
162
  * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
108
163
  */
@@ -122,25 +177,120 @@ export function outputSchemaBlock(meta) {
122
177
  if (remoteUrl) metaLines.push(` "remoteUrl": ${JSON.stringify(remoteUrl)},`);
123
178
  metaLines.push(
124
179
  ` "generatedAt": "<ISO-8601 datetime>",`,
125
- ` "agent": "<tool name, e.g. Claude Code / Cursor>",`,
126
- ` "model": "<model id>"`
180
+ ` "agent": "<required: product name you are, e.g. Claude Code>",`,
181
+ ` "model": "<required: exact model id you are running>"`
127
182
  );
128
183
  const metaBlock = `"meta": {\n${metaLines.join("\n")}\n }`;
129
184
  return OUTPUT_SCHEMA_BLOCK.replace(/"meta": \{[\s\S]*?\n \}/, metaBlock);
130
185
  }
131
186
 
187
+ /**
188
+ * Bloque de contexto git ya resuelto (diff en disco, tips, lista de archivos, ignores).
189
+ * @param {{
190
+ * diffRelPath?: string | null,
191
+ * mergeBase?: string,
192
+ * branchSha?: string,
193
+ * baseSha?: string,
194
+ * files?: Array<{ path: string, changeType?: string }>,
195
+ * ignored?: string[],
196
+ * ignoreGlobs?: string[],
197
+ * }} ctx
198
+ */
199
+ function formatPrecomputedContext(ctx) {
200
+ const lines = ["Precomputed git context (do not rediscover):"];
201
+ if (ctx.mergeBase) lines.push(`- Merge-base SHA: \`${ctx.mergeBase}\``);
202
+ if (ctx.branchSha) lines.push(`- Branch tip SHA: \`${ctx.branchSha}\``);
203
+ if (ctx.baseSha) lines.push(`- Base tip SHA: \`${ctx.baseSha}\``);
204
+ if (ctx.diffRelPath) {
205
+ lines.push(
206
+ `- Diff file (relative to repo root): \`${ctx.diffRelPath}\` — read this file; do not re-run \`git diff\` unless it is missing.`
207
+ );
208
+ }
209
+ const files = Array.isArray(ctx.files) ? ctx.files : [];
210
+ if (files.length) {
211
+ lines.push(`- Files in the diff (${files.length}):`);
212
+ const limit = 200;
213
+ for (const f of files.slice(0, limit)) {
214
+ const path = String(f?.path || "").trim();
215
+ if (!path) continue;
216
+ const kind = String(f?.changeType || "").trim();
217
+ lines.push(kind ? ` - ${kind}: ${path}` : ` - ${path}`);
218
+ }
219
+ if (files.length > limit) lines.push(` - … and ${files.length - limit} more`);
220
+ }
221
+ const ignored = Array.isArray(ctx.ignored) ? ctx.ignored.filter(Boolean) : [];
222
+ if (ignored.length) {
223
+ lines.push(
224
+ `- Ignored paths (excluded from the file list; if you mention them use "skipped" with reason "ignored"):`
225
+ );
226
+ for (const p of ignored.slice(0, 40)) lines.push(` - ${p}`);
227
+ if (ignored.length > 40) lines.push(` - … and ${ignored.length - 40} more`);
228
+ }
229
+ const globs = Array.isArray(ctx.ignoreGlobs) ? ctx.ignoreGlobs.filter(Boolean) : [];
230
+ if (globs.length) {
231
+ lines.push(`- Ignore globs (DEFAULT + config): ${globs.map((g) => `\`${g}\``).join(", ")}`);
232
+ }
233
+ return lines.join("\n");
234
+ }
235
+
132
236
  /**
133
237
  * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
134
- * @param {{ outputFilename?: string }} [opts]
238
+ * @param {{
239
+ * outputFilename?: string,
240
+ * acp?: boolean,
241
+ * diffRelPath?: string | null,
242
+ * mergeBase?: string,
243
+ * branchSha?: string,
244
+ * baseSha?: string,
245
+ * files?: Array<{ path: string, changeType?: string }>,
246
+ * ignored?: string[],
247
+ * ignoreGlobs?: string[],
248
+ * }} [opts]
135
249
  */
136
250
  export function buildPrompt(meta, opts = {}) {
137
251
  const outputFilename = opts.outputFilename || makeOutputFilename();
252
+ const outputPath = outputRelPath(outputFilename);
138
253
  const repo =
139
254
  String(meta.repo || "").trim() ||
140
255
  (meta.source === "url" ? "<git URL>" : "<absolute repo path>");
141
256
  const branch = String(meta.branch || "").trim() || "<branch under review>";
142
257
  const base = String(meta.base || "").trim() || "develop";
143
- const workflow = meta.source === "url" ? workflowUrl(base, branch) : workflowLocal(base, branch);
258
+ const acp = Boolean(opts.acp);
259
+ const hasDiff = Boolean(String(opts.diffRelPath || "").trim());
260
+ const workflow = acp
261
+ ? meta.source === "url"
262
+ ? workflowUrlAcp({ hasDiff })
263
+ : workflowLocalAcp({ hasDiff })
264
+ : meta.source === "url"
265
+ ? workflowUrl(base, branch, { hasDiff })
266
+ : workflowLocal(base, branch, { hasDiff });
267
+
268
+ const precomputed = formatPrecomputedContext({
269
+ diffRelPath: opts.diffRelPath,
270
+ mergeBase: opts.mergeBase,
271
+ branchSha: opts.branchSha,
272
+ baseSha: opts.baseSha,
273
+ files: opts.files,
274
+ ignored: opts.ignored,
275
+ ignoreGlobs: opts.ignoreGlobs,
276
+ });
277
+ const hasPrecomputed =
278
+ hasDiff ||
279
+ opts.mergeBase ||
280
+ opts.branchSha ||
281
+ opts.baseSha ||
282
+ (opts.files && opts.files.length) ||
283
+ (opts.ignored && opts.ignored.length) ||
284
+ (opts.ignoreGlobs && opts.ignoreGlobs.length);
285
+
286
+ const validateSection = acp
287
+ ? `When done writing \`${outputPath}\`, stop. The host validates the JSON automatically and will send you any problems — do not run \`npx … validate\` yourself.`
288
+ : `After writing the file, validate it (npx so it works even if \`diff-review\` is not on PATH):
289
+
290
+ \`${validateCliCommand(outputFilename)}\`
291
+
292
+ - Exit 0 and a line starting with \`OK\` means the JSON is acceptable. Then tell me that \`${outputPath}\` was written.
293
+ - 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.`;
144
294
 
145
295
  return `You are a senior code reviewer: strict but fair.
146
296
 
@@ -149,25 +299,22 @@ Branch under review: ${branch}
149
299
  Base (merge-base): ${base}
150
300
 
151
301
  ${workflow}
152
-
302
+ ${hasPrecomputed ? `\n${precomputed}\n` : ""}
153
303
  Rules:
154
304
  ${RULES}
155
305
 
156
- Write the result to \`${outputFilename}\` at the repo root.
306
+ 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.
307
+ - 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.
308
+ - If it does not exist yet: Write is fine. Do NOT use shell redirection (\`>\`, \`>>\`, \`Out-File\`, \`tee\`, etc.) — those are rejected.
157
309
  - One JSON object only: first character \`{\`, last character \`}\`.
158
310
  - No prose before/after, no markdown fences.
159
- - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; fill generatedAt/agent/model):
311
+ - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; "agent" and "model" are required):
160
312
 
161
313
  ${outputSchemaBlock(meta)}
162
314
 
163
- Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). Also set informational fields: "generatedAt" (ISO-8601 now), "agent" (tool name), "model" (model id). That lets someone reopen the report elsewhere without re-picking the repo.
164
-
165
- Do NOT include diffs or a "files" array. The tool computes them with git when opening the report. Do not invent hunks or paste patches into the JSON.
315
+ 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).
166
316
 
167
- After writing the file, validate it (npx so it works even if \`diff-review\` is not on PATH):
317
+ 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.
168
318
 
169
- \`${validateCliCommand(outputFilename)}\`
170
-
171
- - Exit 0 and a line starting with \`OK\` means the JSON is acceptable. Then tell me that \`${outputFilename}\` was written.
172
- - 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.`;
319
+ ${validateSection}`;
173
320
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Carpeta `.diff-review/` en la raíz del repo revisado: el JSON del reporte, el diff con el que se hizo y la sesión
3
+ * del agente, todo con el mismo nombre base (`diff-rev_<branchTip>_<baseTip>`). Se ignora sola con su propio
4
+ * `.gitignore`, así no deja cambios en archivos trackeados del repo.
5
+ *
6
+ * Los JSON viejos en la raíz del repo se siguen encontrando (`locateReviewFile`).
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
9
+ import { basename, join } from "node:path";
10
+ import { git } from "./git.mjs";
11
+ import { OUTPUT_DIR } from "./output-name.mjs";
12
+
13
+ export { OUTPUT_DIR };
14
+
15
+ /** @typedef {{ agentId: string, sessionId: string, model?: string, updatedAt?: string }} SessionRef */
16
+
17
+ export function reviewDir(repoRoot) {
18
+ return join(repoRoot, OUTPUT_DIR);
19
+ }
20
+
21
+ /** Crea `.diff-review/` (con su `.gitignore`) si no existe. */
22
+ export function ensureReviewDir(repoRoot) {
23
+ const dir = reviewDir(repoRoot);
24
+ mkdirSync(dir, { recursive: true });
25
+ const ignore = join(dir, ".gitignore");
26
+ if (!existsSync(ignore)) writeFileSync(ignore, "# Generado por diff-review: nada de esta carpeta va al repo.\n*\n");
27
+ return dir;
28
+ }
29
+
30
+ /** `diff-rev_a_b.json` → `{ json, diff, session }` dentro de `.diff-review/`. */
31
+ export function companionPaths(repoRoot, filename) {
32
+ const name = basename(String(filename || ""));
33
+ const stem = name.replace(/\.json$/i, "");
34
+ const dir = reviewDir(repoRoot);
35
+ return {
36
+ json: join(dir, name),
37
+ diff: join(dir, `${stem}.diff`),
38
+ session: join(dir, `${stem}.session.json`),
39
+ };
40
+ }
41
+
42
+ /** Dónde está el JSON de la review: `.diff-review/` primero y, si no, la raíz del repo (formato viejo). */
43
+ export function locateReviewFile(repoRoot, filename) {
44
+ const name = basename(String(filename || ""));
45
+ if (!name) return null;
46
+ for (const candidate of [companionPaths(repoRoot, name).json, join(repoRoot, name)]) {
47
+ try {
48
+ if (statSync(candidate).isFile()) return candidate;
49
+ } catch {
50
+ /* siguiente */
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Guarda `git diff base...branch` junto al JSON (una vez por par de commits: el nombre ya trae los tips).
58
+ * @returns {string | null} el path, o null si git falló
59
+ */
60
+ export function writeDiffCache(repoRoot, filename, { branch, base }) {
61
+ const { diff } = companionPaths(repoRoot, filename);
62
+ if (existsSync(diff)) return diff;
63
+ try {
64
+ const text = git(repoRoot, ["diff", `${base}...${branch}`]);
65
+ ensureReviewDir(repoRoot);
66
+ writeFileSync(diff, text);
67
+ return diff;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** Guarda un diff ya armado (p. ej. el que muestra la web), pisando el anterior. */
74
+ export function writeDiffText(repoRoot, filename, text) {
75
+ ensureReviewDir(repoRoot);
76
+ const { diff } = companionPaths(repoRoot, filename);
77
+ writeFileSync(diff, String(text ?? ""));
78
+ return diff;
79
+ }
80
+
81
+ /** @returns {string | null} texto del companion `.diff`, o null si no existe / no se pudo leer */
82
+ export function readDiffCache(repoRoot, filename) {
83
+ const { diff } = companionPaths(repoRoot, filename);
84
+ try {
85
+ if (!existsSync(diff)) return null;
86
+ return readFileSync(diff, "utf8");
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /** @returns {SessionRef | null} */
93
+ export function readSessionRef(repoRoot, filename) {
94
+ try {
95
+ const ref = JSON.parse(readFileSync(companionPaths(repoRoot, filename).session, "utf8"));
96
+ return ref && typeof ref.agentId === "string" && typeof ref.sessionId === "string" ? ref : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /** @param {SessionRef} ref */
103
+ export function writeSessionRef(repoRoot, filename, ref) {
104
+ ensureReviewDir(repoRoot);
105
+ const full = { ...ref, updatedAt: new Date().toISOString() };
106
+ writeFileSync(companionPaths(repoRoot, filename).session, `${JSON.stringify(full, null, 2)}\n`);
107
+ return full;
108
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Estampa en el JSON quién generó la review. La CLI lo sabe mejor que el modelo.
3
+ */
4
+ import { readFileSync, writeFileSync } from "node:fs";
5
+ import { decodePayload } from "./json-payload.mjs";
6
+
7
+ /**
8
+ * `agentSession` = la sesión ACP que generó la review, para seguir conversando con el agente desde la web.
9
+ * @param {string} filePath
10
+ * @param {{ agent?: string, model?: string, agentSession?: { agentId: string, sessionId: string, outputFilename: string } }} provenance
11
+ * @returns {boolean}
12
+ */
13
+ export function stampReviewProvenance(filePath, { agent, model, agentSession } = {}) {
14
+ const agentName = String(agent || "").trim();
15
+ const modelId = String(model || "").trim();
16
+ if (!agentName && !modelId && !agentSession) return false;
17
+
18
+ let payload;
19
+ try {
20
+ payload = decodePayload(readFileSync(filePath, "utf8"));
21
+ } catch {
22
+ return false;
23
+ }
24
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
25
+
26
+ const current = payload.meta;
27
+ const meta =
28
+ current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
29
+ if (agentName) meta.agent = agentName;
30
+ if (modelId) meta.model = modelId;
31
+ if (agentSession) meta.agentSession = { ...agentSession };
32
+ payload.meta = meta;
33
+ writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
34
+ return true;
35
+ }
@@ -0,0 +1,41 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { stampReviewProvenance } from "./review-meta.mjs";
7
+
8
+ test("stampReviewProvenance escribe agente y modelo sin perder el resto", () => {
9
+ const dir = mkdtempSync(join(tmpdir(), "diff-review-meta-"));
10
+ const file = join(dir, "diff-rev_a_b.json");
11
+ writeFileSync(file, JSON.stringify({ intent: "algo concreto", meta: { branch: "feat" } }));
12
+
13
+ assert.equal(stampReviewProvenance(file, { agent: "Claude Code", model: "opus" }), true);
14
+
15
+ const saved = JSON.parse(readFileSync(file, "utf8"));
16
+ assert.equal(saved.intent, "algo concreto");
17
+ assert.deepEqual(saved.meta, { branch: "feat", agent: "Claude Code", model: "opus" });
18
+ });
19
+
20
+ test("stampReviewProvenance no pisa el modelo si no hay uno", () => {
21
+ const dir = mkdtempSync(join(tmpdir(), "diff-review-meta-"));
22
+ const file = join(dir, "diff-rev_a_b.json");
23
+ writeFileSync(file, JSON.stringify({ meta: { model: "sonnet" }, intent: "x" }));
24
+
25
+ stampReviewProvenance(file, { agent: "Codex" });
26
+
27
+ const saved = JSON.parse(readFileSync(file, "utf8"));
28
+ assert.equal(saved.meta.agent, "Codex");
29
+ assert.equal(saved.meta.model, "sonnet");
30
+ });
31
+
32
+ test("stampReviewProvenance guarda la sesión del agente", () => {
33
+ const dir = mkdtempSync(join(tmpdir(), "diff-review-meta-"));
34
+ const file = join(dir, "diff-rev_a_b.json");
35
+ writeFileSync(file, JSON.stringify({ meta: { agent: "x" }, intent: "x" }));
36
+
37
+ const agentSession = { agentId: "claude", sessionId: "s-1", outputFilename: "diff-rev_a_b.json" };
38
+ stampReviewProvenance(file, { agent: "Claude Code", model: "opus", agentSession });
39
+
40
+ assert.deepEqual(JSON.parse(readFileSync(file, "utf8")).meta.agentSession, agentSession);
41
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Precomputa contexto git para el prompt de review (diff cache + lista de archivos).
3
+ */
4
+ import { loadReviewConfig } from "./config.mjs";
5
+ import { extractBranchDiff } from "./extract.mjs";
6
+ import { outputRelPath } from "./output-name.mjs";
7
+ import { ensureReviewDir, readDiffCache, writeDiffCache } from "./review-dir.mjs";
8
+
9
+ /**
10
+ * Cachea el three-dot diff y arma las opciones de `buildPrompt` (tips, files, ignores).
11
+ * @param {{
12
+ * repo: string,
13
+ * branch: string,
14
+ * base?: string,
15
+ * outputFilename: string,
16
+ * acp?: boolean,
17
+ * }} input
18
+ */
19
+ export function prepareReviewPromptOpts(input) {
20
+ const repo = String(input.repo || "").trim();
21
+ const branch = String(input.branch || "").trim();
22
+ const base = String(input.base || "develop").trim() || "develop";
23
+ const outputFilename = String(input.outputFilename || "").trim();
24
+ if (!repo || !branch || !outputFilename) {
25
+ throw new Error("prepareReviewPromptOpts requiere repo, branch y outputFilename.");
26
+ }
27
+
28
+ ensureReviewDir(repo);
29
+ writeDiffCache(repo, outputFilename, { branch, base });
30
+ const cached = readDiffCache(repo, outputFilename);
31
+
32
+ let extracted;
33
+ try {
34
+ extracted = extractBranchDiff({
35
+ repo,
36
+ branch,
37
+ base,
38
+ ...(cached ? { cachedDiff: cached } : {}),
39
+ });
40
+ } catch {
41
+ extracted = extractBranchDiff({ repo, branch, base });
42
+ }
43
+
44
+ const config = loadReviewConfig(repo);
45
+ const diffRelPath = cached
46
+ ? outputRelPath(outputFilename).replace(/\.json$/i, ".diff")
47
+ : null;
48
+
49
+ return {
50
+ outputFilename,
51
+ acp: Boolean(input.acp),
52
+ diffRelPath,
53
+ mergeBase: extracted.mergeBase || undefined,
54
+ branchSha: extracted.branchSha || undefined,
55
+ baseSha: extracted.baseSha || undefined,
56
+ files: (extracted.files || []).map((f) => ({
57
+ path: f.path,
58
+ changeType: f.changeType,
59
+ })),
60
+ ignored: extracted.context?.ignored || [],
61
+ ignoreGlobs: config.ignore,
62
+ };
63
+ }
@@ -132,6 +132,7 @@ test("buildReview deduplica hallazgos repetidos y limpia referencias colgadas",
132
132
 
133
133
  test("una respuesta JSON del modelo atraviesa decode, validación y normalización", () => {
134
134
  const answer = JSON.stringify({
135
+ meta: { agent: "Claude Code", model: "claude-opus" },
135
136
  intent: "Hace que las búsquedas con varias palabras matcheen los tokens en cualquier orden.",
136
137
  groups: [
137
138
  { id: "g1", kind: "feat", title: "feat(search): matchear tokens", intent: "el cambio" },