@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
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 = `{
@@ -28,7 +32,7 @@ export const OUTPUT_SCHEMA_BLOCK = `{
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
37
  "title": "string, aim for ≤72 chars, imperative, no kind prefix",
34
38
  "intent": "string"
@@ -37,14 +41,14 @@ export const OUTPUT_SCHEMA_BLOCK = `{
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, aim for ≤120 chars",
51
+ "what": "string, aim for ≤120 chars: concrete change + effect",
48
52
  "why": "string, aim for ≤120 chars",
49
53
  "source": "code|commit|pr|issue|inferred"
50
54
  }
@@ -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, aim for ≤120 chars",
63
- "fix": "string, aim for ≤120 chars"
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,17 +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".
86
+ - When several read-only lookups are independent (files, searches), batch them in the same turn if your tools allow parallel calls.
79
87
  - Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.
80
- - Length is a target, not a check: aim for title ≤72 characters and what/why/fix ≤120 characters. Longer text is still valid. Do not rewrite the file only to shorten it.`;
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.`;
81
89
 
82
- 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
+ }
83
99
  return `Workflow:
84
100
  1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
85
101
  2. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
@@ -88,13 +104,22 @@ function workflowLocal(base, branch) {
88
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.`;
89
105
  }
90
106
 
91
- /** 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. */
92
108
  export function validateCliCommand(filename) {
93
- const file = String(filename || "diff-rev_<branchTip>_<baseTip>.json").trim();
109
+ const file = outputRelPath(String(filename || "diff-rev_<branchTip>_<baseTip>.json").trim());
94
110
  return `npx --yes @jpkme/diff-review validate ${file}`;
95
111
  }
96
112
 
97
- 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
+ }
98
123
  return `Workflow:
99
124
  1. If the repo is not local, clone it (or use whatever access you have). Do not modify it.
100
125
  2. Resolve the merge-base of \`${base}\` and \`${branch}\`.
@@ -104,6 +129,35 @@ function workflowUrl(base, branch) {
104
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.`;
105
130
  }
106
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
+
107
161
  /**
108
162
  * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
109
163
  */
@@ -130,18 +184,113 @@ export function outputSchemaBlock(meta) {
130
184
  return OUTPUT_SCHEMA_BLOCK.replace(/"meta": \{[\s\S]*?\n \}/, metaBlock);
131
185
  }
132
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
+
133
236
  /**
134
237
  * @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
135
- * @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]
136
249
  */
137
250
  export function buildPrompt(meta, opts = {}) {
138
251
  const outputFilename = opts.outputFilename || makeOutputFilename();
252
+ const outputPath = outputRelPath(outputFilename);
139
253
  const repo =
140
254
  String(meta.repo || "").trim() ||
141
255
  (meta.source === "url" ? "<git URL>" : "<absolute repo path>");
142
256
  const branch = String(meta.branch || "").trim() || "<branch under review>";
143
257
  const base = String(meta.base || "").trim() || "develop";
144
- 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.`;
145
294
 
146
295
  return `You are a senior code reviewer: strict but fair.
147
296
 
@@ -150,11 +299,13 @@ Branch under review: ${branch}
150
299
  Base (merge-base): ${base}
151
300
 
152
301
  ${workflow}
153
-
302
+ ${hasPrecomputed ? `\n${precomputed}\n` : ""}
154
303
  Rules:
155
304
  ${RULES}
156
305
 
157
- 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.
158
309
  - One JSON object only: first character \`{\`, last character \`}\`.
159
310
  - No prose before/after, no markdown fences.
160
311
  - Shape (copy "meta" source/repo/branch/base/remoteUrl as given; "agent" and "model" are required):
@@ -163,12 +314,7 @@ ${outputSchemaBlock(meta)}
163
314
 
164
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).
165
316
 
166
- 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.
167
-
168
- 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.
169
318
 
170
- \`${validateCliCommand(outputFilename)}\`
171
-
172
- - Exit 0 and a line starting with \`OK\` means the JSON is acceptable. Then tell me that \`${outputFilename}\` was written.
173
- - 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}`;
174
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
+ }
@@ -5,14 +5,15 @@ import { readFileSync, writeFileSync } from "node:fs";
5
5
  import { decodePayload } from "./json-payload.mjs";
6
6
 
7
7
  /**
8
+ * `agentSession` = la sesión ACP que generó la review, para seguir conversando con el agente desde la web.
8
9
  * @param {string} filePath
9
- * @param {{ agent?: string, model?: string }} provenance
10
+ * @param {{ agent?: string, model?: string, agentSession?: { agentId: string, sessionId: string, outputFilename: string } }} provenance
10
11
  * @returns {boolean}
11
12
  */
12
- export function stampReviewProvenance(filePath, { agent, model } = {}) {
13
+ export function stampReviewProvenance(filePath, { agent, model, agentSession } = {}) {
13
14
  const agentName = String(agent || "").trim();
14
15
  const modelId = String(model || "").trim();
15
- if (!agentName && !modelId) return false;
16
+ if (!agentName && !modelId && !agentSession) return false;
16
17
 
17
18
  let payload;
18
19
  try {
@@ -27,6 +28,7 @@ export function stampReviewProvenance(filePath, { agent, model } = {}) {
27
28
  current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
28
29
  if (agentName) meta.agent = agentName;
29
30
  if (modelId) meta.model = modelId;
31
+ if (agentSession) meta.agentSession = { ...agentSession };
30
32
  payload.meta = meta;
31
33
  writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
32
34
  return true;
@@ -28,3 +28,14 @@ test("stampReviewProvenance no pisa el modelo si no hay uno", () => {
28
28
  assert.equal(saved.meta.agent, "Codex");
29
29
  assert.equal(saved.meta.model, "sonnet");
30
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
+ }
package/lib/validate.mjs CHANGED
@@ -218,9 +218,9 @@ TEXT LANGUAGE: Spanish (rioplatense, technical) for intent, groups.intent, block
218
218
 
219
219
  {
220
220
  "intent": "2-4 sentences on what the branch solves",
221
- "groups": [{ "id": "g1", "kind": "feat", "title": "scope: summary (do not repeat kind)", "intent": "why this theme exists" }],
221
+ "groups": [{ "id": "t1", "kind": "feat", "title": "scope: summary (do not repeat kind)", "intent": "why this theme exists" }],
222
222
  "meta": { "agent": "<required: product name, e.g. Claude Code>", "model": "<required: exact model id>" },
223
- "blocks": [{ "id": "b1", "group": "g1", "file": "exact/path.ts", "lines": "L40-58", "op": "mod", "what": "what changed", "why": "why", "source": "code" }],
223
+ "blocks": [{ "id": "b1", "group": "t1", "file": "exact/path.ts", "lines": "L40-58", "op": "mod", "what": "what changed", "why": "why", "source": "code" }],
224
224
  "findings": [{ "id": "f1", "class": "risk", "severity": "high", "blocking": true, "kind": "bug", "file": "path.ts", "line": 42, "block": "b1", "what": "what is wrong", "fix": "what to do" }],
225
225
  "skipped": [{ "file": "bun.lock", "reason": "lockfile" }]
226
226
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Espera a que aparezca un JSON de review válido en el repo.
2
+ * Espera a que aparezca un JSON de review válido en el repo: en `.diff-review/` o, como antes, en la raíz.
3
3
  */
4
4
  import { existsSync, readFileSync, statSync, watch } from "node:fs";
5
5
  import { basename, join } from "node:path";
6
6
  import { decodePayload } from "./json-payload.mjs";
7
+ import { companionPaths, ensureReviewDir, locateReviewFile } from "./review-dir.mjs";
7
8
  import { REVIEW_SCHEMA, validateAgainst } from "./validate.mjs";
8
9
 
9
10
  /**
@@ -38,7 +39,8 @@ function readReview(target, since) {
38
39
  * @returns {{ path: string, payload: unknown } | null}
39
40
  */
40
41
  export function existingReviewOutput(repoRoot, filename) {
41
- const target = join(repoRoot, filename);
42
+ const target = locateReviewFile(repoRoot, filename);
43
+ if (!target) return null;
42
44
  const read = readReview(target);
43
45
  return read && !read.errors.length ? { path: target, payload: read.payload } : null;
44
46
  }
@@ -51,13 +53,28 @@ export function existingReviewOutput(repoRoot, filename) {
51
53
  * @returns {Promise<{ path: string, payload: unknown }>}
52
54
  */
53
55
  export function waitForReviewOutput(repoRoot, filename, opts = {}) {
54
- const target = join(repoRoot, filename);
55
56
  const name = basename(filename);
57
+ // Primero `.diff-review/`; también la raíz del repo por compatibilidad con salidas viejas.
58
+ const targets = [companionPaths(repoRoot, name).json, join(repoRoot, name)];
56
59
  const pollMs = opts.pollMs ?? 800;
57
60
  let lastInvalidKey = "";
61
+ let reviewDir = "";
62
+ try {
63
+ reviewDir = ensureReviewDir(repoRoot);
64
+ } catch {
65
+ /* repo de solo lectura: se espera igual en la raíz */
66
+ }
58
67
 
59
68
  const tryRead = () => {
60
- const read = readReview(target, opts.since);
69
+ let target = "";
70
+ let read = null;
71
+ for (const candidate of targets) {
72
+ read = readReview(candidate, opts.since);
73
+ if (read) {
74
+ target = candidate;
75
+ break;
76
+ }
77
+ }
61
78
  if (!read) return null;
62
79
  const { payload, errors } = read;
63
80
  if (errors.length) {
@@ -73,8 +90,8 @@ export function waitForReviewOutput(repoRoot, filename, opts = {}) {
73
90
 
74
91
  return new Promise((resolve, reject) => {
75
92
  let settled = false;
76
- /** @type {import('node:fs').FSWatcher | null} */
77
- let watcher = null;
93
+ /** @type {import('node:fs').FSWatcher[]} */
94
+ const watchers = [];
78
95
  /** @type {ReturnType<typeof setInterval> | null} */
79
96
  let timer = null;
80
97
 
@@ -82,10 +99,12 @@ export function waitForReviewOutput(repoRoot, filename, opts = {}) {
82
99
  if (settled) return;
83
100
  settled = true;
84
101
  if (timer) clearInterval(timer);
85
- try {
86
- watcher?.close();
87
- } catch {
88
- /* ignore */
102
+ for (const w of watchers) {
103
+ try {
104
+ w.close();
105
+ } catch {
106
+ /* ignore */
107
+ }
89
108
  }
90
109
  resolve(result);
91
110
  };
@@ -94,10 +113,12 @@ export function waitForReviewOutput(repoRoot, filename, opts = {}) {
94
113
  if (settled) return;
95
114
  settled = true;
96
115
  if (timer) clearInterval(timer);
97
- try {
98
- watcher?.close();
99
- } catch {
100
- /* ignore */
116
+ for (const w of watchers) {
117
+ try {
118
+ w.close();
119
+ } catch {
120
+ /* ignore */
121
+ }
101
122
  }
102
123
  reject(err);
103
124
  };
@@ -114,15 +135,17 @@ export function waitForReviewOutput(repoRoot, filename, opts = {}) {
114
135
  tick();
115
136
  if (settled) return;
116
137
 
117
- try {
118
- watcher = watch(repoRoot, { persistent: true }, (_event, changed) => {
119
- if (!changed || String(changed) === name || String(changed).endsWith(name)) tick();
120
- });
121
- watcher.on("error", () => {
122
- /* poll sigue */
123
- });
124
- } catch {
125
- /* solo poll */
138
+ // Los watchers solo adelantan el aviso; el poll de abajo alcanza si fallan.
139
+ for (const dir of [reviewDir, repoRoot].filter(Boolean)) {
140
+ try {
141
+ const w = watch(dir, { persistent: true }, (_event, changed) => {
142
+ if (!changed || String(changed) === name || String(changed).endsWith(name)) tick();
143
+ });
144
+ w.on("error", () => {});
145
+ watchers.push(w);
146
+ } catch {
147
+ /* solo poll */
148
+ }
126
149
  }
127
150
 
128
151
  timer = setInterval(tick, pollMs);