@dforce2055/dai 0.8.1 → 0.9.0
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.
- package/.env.example +5 -2
- package/CHANGELOG.md +82 -0
- package/README.md +5 -4
- package/VERSION +1 -1
- package/cli/dai.mjs +90 -9
- package/cli/lib/bootstrap.mjs +11 -4
- package/cli/lib/forge-api.mjs +89 -2
- package/cli/lib/pm-adapter.mjs +4 -1
- package/cli/lib/pm-clickup.mjs +3 -1
- package/cli/lib/pm-jira.mjs +1 -1
- package/cli/lib/pr.mjs +36 -7
- package/cli/lib/review-findings.mjs +195 -0
- package/cli/lib/tracker-url.mjs +36 -0
- package/docs/PROBAR.md +2 -1
- package/docs/adr/0016-review-inline.md +128 -0
- package/docs/adr/README.md +1 -0
- package/package.json +1 -1
- package/skills/dai-review/SKILL.md +100 -34
- package/templates/pull-request.md +5 -1
package/.env.example
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
# md | jira | clickup
|
|
7
7
|
DAI_PM=md
|
|
8
8
|
|
|
9
|
-
# Plantilla del link al ticket (
|
|
10
|
-
|
|
9
|
+
# OPCIONAL. Plantilla del link al ticket (`dai ls`, `dai pr`, stamp). {id} se reemplaza.
|
|
10
|
+
# Con DAI_PM=jira o =clickup, dai ya deduce el link solo: esto es un OVERRIDE, y solo
|
|
11
|
+
# hace falta si tu tracker vive en otra URL (p. ej. un Jira Server con path propio).
|
|
12
|
+
# Si dai no puede saber el link, avisa y deja la PR sin él — nunca escribe el id pelado.
|
|
13
|
+
# DAI_TRACKER_URL_TEMPLATE=https://jira.miempresa.com/browse/{id}
|
|
11
14
|
|
|
12
15
|
# ── Backend md (local, offline) ──────────────────────────────────────────────
|
|
13
16
|
# Carpeta donde viven las US como <ID>.md (p. ej. ABC-482.md).
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,85 @@
|
|
|
3
3
|
Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
|
|
4
4
|
(ver `VERSION`).
|
|
5
5
|
|
|
6
|
+
## [0.9.0] — 2026-07-17
|
|
7
|
+
|
|
8
|
+
**El review de dai deja de ser un comentario al final del hilo y pasa a ser un review
|
|
9
|
+
_inline_: un resumen más un comentario anclado a cada `archivo:línea`, clasificado
|
|
10
|
+
low/medium/high — como el de Copilot, pero con la puerta humana y la validación que a
|
|
11
|
+
Copilot le faltan.**
|
|
12
|
+
|
|
13
|
+
### Agregado
|
|
14
|
+
- **`dai forge review <ref> --from <review.json>`** — review inline en GitHub y GitLab.
|
|
15
|
+
La skill `dai-review` produce un `review.json` (el criterio); el CLI hace lo mecánico
|
|
16
|
+
(ADR-0002): **valida que cada `path:line` exista de verdad en el diff** —traído con git,
|
|
17
|
+
local, por SSH— antes de salir a la red. Inventar líneas es el error más común de un
|
|
18
|
+
LLM revisando código, y el forge responde `422` sin decir cuál falló; en GitHub, que es
|
|
19
|
+
atómico, un hallazgo inventado tira los buenos. Lo descartado y lo filtrado **se
|
|
20
|
+
reportan**, nunca se caen en silencio.
|
|
21
|
+
- **Puerta humana explícita.** Sin `--yes` no se postea nada: se muestra el preview y se
|
|
22
|
+
corta. `--dry-run` valida sin postear. Modo desatendido para reviews simples
|
|
23
|
+
(`--yes --min-severity --min-confidence --max-comments`), pero es una **excepción que
|
|
24
|
+
el humano pide**, no un default. El review sale siempre con `event: COMMENT`, nunca
|
|
25
|
+
`APPROVE` — dai comenta, la persona firma ([Art. 5](docs/MANIFIESTO.md#art-5)).
|
|
26
|
+
- **Aviso de release en Discord.** Publicar un release de GitHub dispara el workflow
|
|
27
|
+
`discord-release.yml`, que postea al canal vía el secreto `DISCORD_WEBHOOK_URL`. El
|
|
28
|
+
secreto vive en GitHub Actions, nunca en el repo; el workflow no viaja en el paquete
|
|
29
|
+
npm (`.github/` fuera de `files`), así que no le impone notificaciones a nadie que use
|
|
30
|
+
dai. Ver [ADR-0016](docs/adr/0016-review-inline.md).
|
|
31
|
+
|
|
32
|
+
### Cambiado
|
|
33
|
+
- **`getPR` expone `headSha`, `baseRef` y `diffRefs`** — hacían falta para anclar un
|
|
34
|
+
comentario inline (GitHub necesita el sha del head; GitLab exige los tres shas de
|
|
35
|
+
`diff_refs` en cada comentario). `dai init` agrega `.dai/reviews/` al `.gitignore` del
|
|
36
|
+
repo (un review a medio editar no se commitea; `.dai/` sigue versionándose).
|
|
37
|
+
|
|
38
|
+
### Interno
|
|
39
|
+
- **224 tests** (+40): el parser de diff con varios hunks y archivos borrados, el
|
|
40
|
+
descarte de líneas inventadas por lado, los filtros de severidad/confianza/tope, y la
|
|
41
|
+
asimetría GitHub (atómico) vs. GitLab (no atómico: reporta los parciales en vez de
|
|
42
|
+
fingir atomicidad). Probado end-to-end contra una PR real: el comentario quedó inline
|
|
43
|
+
en el archivo, el review salió `COMMENTED`, y el hallazgo alucinado nunca tocó la red.
|
|
44
|
+
|
|
45
|
+
## [0.8.2] — 2026-07-17
|
|
46
|
+
|
|
47
|
+
**Dos agujeros que destapó el uso real, y que tienen la misma forma: dai hacía algo
|
|
48
|
+
hacia afuera sin que un humano lo viera, o dejaba que otro le pisara lo que había
|
|
49
|
+
escrito. El [Art. 5](docs/MANIFIESTO.md#art-5) no se cumple solo con no clickear
|
|
50
|
+
Approve.**
|
|
51
|
+
|
|
52
|
+
### Arreglado
|
|
53
|
+
- **`dai-review` posteaba el comentario sin mostrártelo.** La skill componía el review y
|
|
54
|
+
lo publicaba de una: el paso 6 decía *"Postear"* y no había gate. Y el comentario sale
|
|
55
|
+
con **tu token y tu nombre** (`GITHUB_TOKEN`/`GITLAB_TOKEN` son tuyos), así que en la
|
|
56
|
+
PR de un compañero figura como si lo hubieras escrito vos. El corte estaba puesto en el
|
|
57
|
+
lugar equivocado: no aprobar sin humano estaba bien, pero publicar un juicio sobre el
|
|
58
|
+
código de otro, firmado por alguien que no lo leyó, es el mismo problema con otro
|
|
59
|
+
disfraz. Ahora la skill **muestra el comentario entero y espera un OK explícito** en
|
|
60
|
+
ese turno; sin "sí", no se postea. Es el tercer corte duro de la skill.
|
|
61
|
+
- **`dai pr` escribía el id de la US disfrazado de link.** Sin `DAI_TRACKER_URL_TEMPLATE`,
|
|
62
|
+
`trackerUrl(id)` devolvía el **id pelado**; como un string es truthy, `composePrBody` lo
|
|
63
|
+
escribía igual y la PR quedaba con `- US: 86abc123` en vez de un enlace, sin un solo
|
|
64
|
+
aviso. Ahora la URL se resuelve por una cadena explícita —template > URL canónica del
|
|
65
|
+
tracker > derivada del backend > `null`— y **si dai no la sabe, avisa y omite la línea
|
|
66
|
+
en vez de mentir** (`lib/tracker-url.mjs`).
|
|
67
|
+
- **El bloque de enlaces de `dai pr` no sobrevivía a un edit.** Iba marcado con un
|
|
68
|
+
comentario suelto, así que cualquier agente que reescribiera *"Enlaces relacionados"*
|
|
69
|
+
se lo llevaba puesto sin dejar rastro — pasó en PRs reales. Y el propio template lo
|
|
70
|
+
invitaba: su hint pedía *"US en el tracker, commit ancla, docs, issues"*, o sea justo la
|
|
71
|
+
sección que `dai pr` acababa de llenar. dai se peleaba consigo mismo y ganaba el que
|
|
72
|
+
corría último. Ahora el bloque va **delimitado** (`<!-- dai:links:start … end -->`),
|
|
73
|
+
se **regenera de forma idempotente**, preserva lo que el humano sumó abajo, y el hint
|
|
74
|
+
del template pide solo lo que dai **no** sabe (docs, issues, PRs relacionadas).
|
|
75
|
+
|
|
76
|
+
### Cambiado
|
|
77
|
+
- **dai deduce el link al tracker solo.** Con `DAI_PM=jira` o `=clickup` ya no hace falta
|
|
78
|
+
`DAI_TRACKER_URL_TEMPLATE`: se deriva de la config (`/browse/<KEY>` y
|
|
79
|
+
`/t/<id>`), y `fetchUS` ahora devuelve la **URL canónica** del tracker — en ClickUp, la
|
|
80
|
+
que trae el `team_id`, que no se puede deducir del id. La variable queda como
|
|
81
|
+
**override** para trackers con URL propia. `dai init` dejó de scaffoldearla: era
|
|
82
|
+
contraproducente, porque el template gana sobre la canónica y le tapaba el `team_id`.
|
|
83
|
+
Los `.env` que ya la tienen siguen andando igual (el override sigue ganando).
|
|
84
|
+
|
|
6
85
|
## [0.8.1] — 2026-07-16
|
|
7
86
|
|
|
8
87
|
**Primera prueba real en Windows con analistas y devs de una empresa: el ciclo completo
|
|
@@ -326,6 +405,9 @@ ClickUp y Jira Cloud.
|
|
|
326
405
|
- Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
|
|
327
406
|
`files` de npm sin tests ni secretos.
|
|
328
407
|
|
|
408
|
+
[0.9.0]: https://github.com/dforce2055/dai/releases/tag/v0.9.0
|
|
409
|
+
[0.8.2]: https://github.com/dforce2055/dai/releases/tag/v0.8.2
|
|
410
|
+
[0.8.1]: https://github.com/dforce2055/dai/releases/tag/v0.8.1
|
|
329
411
|
[0.8.0]: https://github.com/dforce2055/dai/releases/tag/v0.8.0
|
|
330
412
|
[0.7.0]: https://github.com/dforce2055/dai/releases/tag/v0.7.0
|
|
331
413
|
[0.6.0]: https://github.com/dforce2055/dai/releases/tag/v0.6.0
|
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ dai check # ¿tu código sigue al día con la US? ✅ /
|
|
|
106
106
|
dai pr --assignee <compañero> # crea la PR precargada y se la asigna a un compañero
|
|
107
107
|
```
|
|
108
108
|
```text
|
|
109
|
-
/dai-review <PR> # tu compañero deja un review
|
|
109
|
+
/dai-review <PR> # tu compañero deja un review inline (comentario por línea); un humano aprueba
|
|
110
110
|
```
|
|
111
111
|
```bash
|
|
112
112
|
dai stamp # al mergear: estampa la cobertura en el tracker
|
|
@@ -164,7 +164,7 @@ flowchart TD
|
|
|
164
164
|
| 9 | **Code review propio** | revisas tu implementación (correctitud + calidad) antes de la PR | dev |
|
|
165
165
|
| 10 | **Smoke test** | pides al agente un smoke local del flujo | dev + IA |
|
|
166
166
|
| 11 | **Crear la PR** | `dai pr` → pregunta la branch base, arma el texto, lo muestra, confirma, pushea y crea la PR/MR | dev |
|
|
167
|
-
| 12 | **Review de un partner** | skill `/dai-review <PR>` deja un comentario
|
|
167
|
+
| 12 | **Review de un partner** | skill `/dai-review <PR>` deja un **review inline** (resumen + un comentario por línea, low/medium/high); te muestra el preview y **espera tu OK** antes de postear; un humano aprueba | partner |
|
|
168
168
|
| 13 | **Merge + estampar** | al mergear: `dai stamp` → cobertura inversa en el tracker | dev / CI |
|
|
169
169
|
| 14 | **Cerrar la US** | `dai done` → vuelve a la base, actualiza y borra la branch local (si está mergeada) | dev |
|
|
170
170
|
|
|
@@ -195,7 +195,8 @@ flowchart TD
|
|
|
195
195
|
| `dai stamp` | estampa la cobertura inversa en el tracker (branch + commit-ancla) |
|
|
196
196
|
| `dai done [--base main] [--force]` | cierra la US: vuelve a la base, `fetch --prune` + `pull`, y borra la branch local **si está mergeada** (chequeo estricto; `--force` la borra igual). Redes: no estar en la base, sin cambios sueltos, sin commits sin pushear |
|
|
197
197
|
| `dai archive [<change>] [--skip-specs]` | **funde los delta specs del change en las specs canónicas** (`openspec/specs/`) y lo archiva. Lo corre el **aprobador** de la PR (gate de aprobación, [ADR-0011](docs/adr/0011-archive-gate-de-aprobacion.md)); detecta el change activo o le pasás el nombre. Envuelve `openspec archive` |
|
|
198
|
-
| `dai forge
|
|
198
|
+
| `dai forge review <ref> --from <review.json>` `[--dry-run\|--yes]` | **review inline**: un resumen + un comentario anclado a cada `archivo:línea`, clasificado low/medium/high. **Valida cada posición contra el diff** (descarta lo que el modelo inventó) antes de postear; sin `--yes` muestra el preview y no postea nada. Modo desatendido: `--min-severity`/`--min-confidence`/`--max-comments`. El review sale con `event: COMMENT`, nunca `APPROVE` ([ADR-0016](docs/adr/0016-review-inline.md)) |
|
|
199
|
+
| `dai forge comment <ref> --body-file <f>` · `dai forge pr <ref>` | comentar / leer una PR/MR (GitHub/GitLab) — el fallback simple, sin anclar |
|
|
199
200
|
| `dai ac-hash <us.md>` | calcula el hash de los criterios de aceptación de una US |
|
|
200
201
|
| `dai doctor` · `dai docs <dest>` · `dai version` | diagnóstico del entorno (incluye **version-drift** del scaffold) · copiar la doc · versión (`dai version` avisa si tu repo quedó atrás) |
|
|
201
202
|
|
|
@@ -328,7 +329,7 @@ Además: [`docs/glosario.md`](docs/glosario.md) · guías por rol ([`po`](docs/g
|
|
|
328
329
|
dai/
|
|
329
330
|
├── cli/ 🖥️ el binario `dai` (Node, cero dependencias) + su suite de tests
|
|
330
331
|
├── docs/ 📖 la metodología: MANIFIESTO · METODOLOGIA · SCRUM-CON-IA · EJEMPLO ·
|
|
331
|
-
│ glosario · guias/ · detalle/ (10 pasos) · adr/ (0001–
|
|
332
|
+
│ glosario · guias/ · detalle/ (10 pasos) · adr/ (0001–0016)
|
|
332
333
|
├── templates/ 🧩 los moldes (formato-us · epica · DoR · DoD · adr · pull-request)
|
|
333
334
|
├── skills/ 🤖 doc-to-backlog · grill-intent · grill-epic · grill-user-story · link-us · tdd · dai-review
|
|
334
335
|
├── governance/ 🛡️ branch-naming · ci-rules · commit-convention
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.9.0
|
package/cli/dai.mjs
CHANGED
|
@@ -24,7 +24,9 @@ import { isValidKey, slugify, branchName, extractTitle, renderImplementsYaml } f
|
|
|
24
24
|
import { loadEnv } from "./lib/env.mjs";
|
|
25
25
|
import { getAdapter, coverageStatus, statusLabel } from "./lib/pm-adapter.mjs";
|
|
26
26
|
import { branchUrl, commitUrl, parseRemote, detectForge } from "./lib/forge-url.mjs";
|
|
27
|
-
import { parsePrRef, getPR, postComment } from "./lib/forge-api.mjs";
|
|
27
|
+
import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs";
|
|
28
|
+
import { trackerUrl } from "./lib/tracker-url.mjs";
|
|
29
|
+
import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
|
|
28
30
|
import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
|
|
29
31
|
import { dirsEqual } from "./lib/fsutil.mjs";
|
|
30
32
|
import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
|
|
@@ -66,10 +68,9 @@ function runNpmTool(name, args, opts = {}) {
|
|
|
66
68
|
const win = process.platform === "win32";
|
|
67
69
|
return execFileSync(win ? `${name}.cmd` : name, args, { shell: win, ...opts });
|
|
68
70
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
71
|
+
// La URL de la US: template > canónica del tracker > derivada del backend > null.
|
|
72
|
+
// Nunca el id pelado: ver el porqué en lib/tracker-url.mjs.
|
|
73
|
+
const usUrlFor = (id, liveUrl = null) => trackerUrl(id, { env: process.env, liveUrl });
|
|
73
74
|
|
|
74
75
|
// ── ac-hash ─────────────────────────────────────────────────────────────────
|
|
75
76
|
function cmdAcHash(arg) {
|
|
@@ -88,7 +89,7 @@ function cmdLs(opts) {
|
|
|
88
89
|
for (const im of f.implements || []) {
|
|
89
90
|
if (isPlaceholderId(im.id)) continue; // saltea plantillas sin completar
|
|
90
91
|
rows.push({ change: f.change, repo: f.repo, id: im.id, version: im.version,
|
|
91
|
-
ac_hash: im.ac_hash, link:
|
|
92
|
+
ac_hash: im.ac_hash, link: usUrlFor(im.id) });
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
if (opts.json) { process.stdout.write(JSON.stringify(rows, null, 2) + "\n"); return; }
|
|
@@ -242,9 +243,80 @@ async function cmdForge(sub, ref, opts) {
|
|
|
242
243
|
if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
|
|
243
244
|
const res = await postComment(pr, body, process.env);
|
|
244
245
|
process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
|
|
246
|
+
} else if (sub === "review") {
|
|
247
|
+
await cmdForgeReview(pr, opts);
|
|
245
248
|
} else {
|
|
246
|
-
fail("uso: dai forge <pr|comment> <ref> [--body-file f | --body t]", 1);
|
|
249
|
+
fail("uso: dai forge <pr|comment|review> <ref> [--body-file f | --body t | --from review.json]", 1);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// dai forge review <ref> --from review.json [--dry-run | --yes]
|
|
254
|
+
//
|
|
255
|
+
// El reparto del ADR-0002 en una función: la skill trajo el CRITERIO (el review.json),
|
|
256
|
+
// el CLI hace lo MECÁNICO — validar que cada hallazgo apunte al diff de verdad, filtrar,
|
|
257
|
+
// y postear. Lo que más valor tiene acá no es postear: es RECHAZAR lo que un LLM inventó
|
|
258
|
+
// antes de que el forge conteste 422 sin decir cuál falló.
|
|
259
|
+
async function cmdForgeReview(pr, opts) {
|
|
260
|
+
if (!opts.from) fail("falta --from <review.json>. Lo escribe la skill dai-review; revisalo antes de postear.", 1);
|
|
261
|
+
const review = parseFindings(readFileSync(opts.from, "utf8"));
|
|
262
|
+
|
|
263
|
+
// El diff sale de git (local, por SSH), no de la API: es la fuente de verdad de qué
|
|
264
|
+
// línea es comentable, y no gasta rate limit.
|
|
265
|
+
const remote = await Promise.resolve(getPR(pr, process.env)).catch(() => null);
|
|
266
|
+
if (!remote) fail("no pude leer la PR/MR del forge (¿token? ¿ref correcta?).", 1);
|
|
267
|
+
const base = opts.base || remote.baseRef;
|
|
268
|
+
if (!base) fail("no pude saber la branch base de la PR. Pasala con --base <branch>.", 1);
|
|
269
|
+
let diff = "";
|
|
270
|
+
try {
|
|
271
|
+
git(["fetch", "origin", base, remote.branch], { stdio: ["inherit", "pipe", "pipe"] });
|
|
272
|
+
diff = git(["diff", `origin/${base}...origin/${remote.branch}`]);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
const err = String(e.stderr || e.message).trim();
|
|
275
|
+
if (/couldn't find remote ref|no such ref/i.test(err)) {
|
|
276
|
+
fail(`la branch '${remote.branch}' ya no está en origin (¿la PR se mergeó y se borró la branch?). ` +
|
|
277
|
+
`Un review inline necesita el diff vivo; sobre una PR cerrada no hay dónde anclar.`, 1);
|
|
278
|
+
}
|
|
279
|
+
fail(`no pude traer el diff de ${base}...${remote.branch}: ${err}`, 1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 1. Validar contra el diff. 2. Filtrar. Nada se cae en silencio: todo se reporta.
|
|
283
|
+
const { valid, rejected } = validateFindings(review.findings, diffPositions(diff));
|
|
284
|
+
const { kept, suppressed } = filterFindings(valid, {
|
|
285
|
+
minSeverity: opts.minSeverity || "low",
|
|
286
|
+
minConfidence: opts.minConfidence ? Number(opts.minConfidence) : 0,
|
|
287
|
+
maxComments: opts.maxComments ? Number(opts.maxComments) : Infinity,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const body = renderReviewSummary(review, { kept, suppressed, rejected });
|
|
291
|
+
const comments = kept.map((f) => ({ path: f.path, line: f.line, side: f.side, body: renderFindingBody(f) }));
|
|
292
|
+
|
|
293
|
+
// ── Preview (acción hacia afuera: se muestra SIEMPRE, se postea solo con --yes) ──
|
|
294
|
+
process.stdout.write(`\n ── Review a postear en ${remote.url || `#${pr.number}`} ──────────\n`);
|
|
295
|
+
process.stdout.write(` forge: ${pr.forge}${pr.forge === "gitlab" ? " (no atómico: son N llamadas)" : " (atómico: 1 llamada)"}\n`);
|
|
296
|
+
process.stdout.write(` diff: ${base}...${remote.branch}\n`);
|
|
297
|
+
process.stdout.write(` hallazgos: ${review.findings.length} en el archivo · ${kept.length} a postear · ${suppressed.length} filtrados · ${rejected.length} descartados\n\n`);
|
|
298
|
+
for (const f of kept) process.stdout.write(` ✓ ${f.path}:${f.line} [${f.severity}] ${f.body.split("\n")[0].slice(0, 60)}\n`);
|
|
299
|
+
for (const { finding: f, reason } of suppressed) process.stdout.write(` ~ ${f.path}:${f.line} [${f.severity}] filtrado — ${reason}\n`);
|
|
300
|
+
for (const { finding: f, reason } of rejected) process.stdout.write(` ✗ ${f.path}:${f.line} [${f.severity}] DESCARTADO — ${reason}\n`);
|
|
301
|
+
process.stdout.write(`\n ───────────────────────────────────────────────\n${body}\n ───────────────────────────────────────────────\n`);
|
|
302
|
+
|
|
303
|
+
if (rejected.length) {
|
|
304
|
+
warn(`${rejected.length} hallazgo(s) NO apuntan al diff y no se postean. Corregí el 'path'/'line' en ${opts.from} o borralos.`);
|
|
247
305
|
}
|
|
306
|
+
if (opts.dryRun) { info("[dry-run] no se posteó nada."); return; }
|
|
307
|
+
if (!opts.yes) {
|
|
308
|
+
info(`Nada posteado. Revisá el preview y, si está bien: dai forge review ${pr.number} --from ${opts.from} --yes`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (!kept.length && !review.summary) fail("no hay nada que postear (0 comentarios y resumen vacío).", 1);
|
|
312
|
+
|
|
313
|
+
const res = await postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env);
|
|
314
|
+
ok(`review posteado${res.url ? `: ${res.url}` : ""} — ${res.posted} comentario(s) en línea.`);
|
|
315
|
+
if (res.failed.length) {
|
|
316
|
+
warn(`${res.failed.length} comentario(s) NO entraron (gitlab no es atómico: el resumen y el resto SÍ están posteados):`);
|
|
317
|
+
for (const f of res.failed) process.stdout.write(` ✗ ${f.path}:${f.line} — ${f.error}\n`);
|
|
318
|
+
}
|
|
319
|
+
info("La aprobación la firma un humano: dai comentó, no aprobó (Art. 5).");
|
|
248
320
|
}
|
|
249
321
|
|
|
250
322
|
// ── publish: crea la US en el tracker desde un .md (fallback del MCP) ──────────
|
|
@@ -394,8 +466,14 @@ async function cmdPr(opts) {
|
|
|
394
466
|
// Commits de la branch (para precargar "Cambios realizados").
|
|
395
467
|
let commits = [];
|
|
396
468
|
try { commits = git(["log", `${base}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* base local ausente */ }
|
|
469
|
+
// La canónica del tracker (live.url) gana sobre la derivada; el template gana sobre todo.
|
|
470
|
+
const usUrl = usUrlFor(id, live?.url);
|
|
471
|
+
if (!usUrl) {
|
|
472
|
+
warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
|
|
473
|
+
warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env (p. ej. https://tu-tracker/browse/{id}).`);
|
|
474
|
+
}
|
|
397
475
|
const body = composePrBody(readFileSync(tplPath, "utf8"), {
|
|
398
|
-
id, version, ac_hash, status, usUrl
|
|
476
|
+
id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
|
|
399
477
|
branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
|
|
400
478
|
});
|
|
401
479
|
const title = prTitle(opts, id, live?.title);
|
|
@@ -1154,7 +1232,10 @@ switch (cmd) {
|
|
|
1154
1232
|
" done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
|
|
1155
1233
|
" archive [<change>] [--skip-specs] funde los delta specs del change en las specs canónicas y lo archiva (lo corre el aprobador en la PR)\n" +
|
|
1156
1234
|
" pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
|
|
1157
|
-
" forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n
|
|
1235
|
+
" forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
|
|
1236
|
+
" forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
|
|
1237
|
+
" --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
|
|
1238
|
+
" Sin --yes no postea nada: muestra el preview y valida que cada hallazgo apunte al diff.\n\n" +
|
|
1158
1239
|
"Instalación:\n" +
|
|
1159
1240
|
" skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
|
|
1160
1241
|
" skills install --from <git-url|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -92,10 +92,15 @@ export function skillToCursor(md) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Contenido del .env según el backend de PM elegido (tokens vacíos, a completar).
|
|
95
|
+
//
|
|
96
|
+
// Sin DAI_TRACKER_URL_TEMPLATE a propósito: con jira/clickup, dai deduce el link solo
|
|
97
|
+
// (lib/tracker-url.mjs). Scaffoldearlo era peor que no ponerlo — el template GANA sobre
|
|
98
|
+
// la URL canónica que devuelve el tracker, así que el `/t/{id}` que escribíamos acá
|
|
99
|
+
// tapaba la de ClickUp con team_id. Queda como override manual para trackers raros.
|
|
95
100
|
export function envFor(pm) {
|
|
96
101
|
const head = "# Config de dai — completá lo que falte. NUNCA commitees tokens (.env está gitignored).\n";
|
|
97
102
|
if (pm === "clickup") {
|
|
98
|
-
return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\
|
|
103
|
+
return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n";
|
|
99
104
|
}
|
|
100
105
|
if (pm === "jira") {
|
|
101
106
|
return head +
|
|
@@ -107,8 +112,7 @@ export function envFor(pm) {
|
|
|
107
112
|
"DAI_JIRA_PROJECT=\n" +
|
|
108
113
|
"DAI_JIRA_ISSUETYPE=Story\n" +
|
|
109
114
|
"# Campos propios que tu Jira exige al crear. Si el archivo no existe, se ignora.\n" +
|
|
110
|
-
"DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n"
|
|
111
|
-
"DAI_TRACKER_URL_TEMPLATE=\n";
|
|
115
|
+
"DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n";
|
|
112
116
|
}
|
|
113
117
|
return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n";
|
|
114
118
|
}
|
|
@@ -149,7 +153,10 @@ export function upsertBlock(existing, block, marker = "dai") {
|
|
|
149
153
|
export function reconcileGitignore(text, want) {
|
|
150
154
|
const norm = (s) => { let t = s.trim(); while (t.startsWith("/")) { t = t.slice(1); } while (t.endsWith("/")) { t = t.slice(0, -1); } return t; };
|
|
151
155
|
const broad = new Set();
|
|
152
|
-
|
|
156
|
+
// `.dai/` NO va acá: ahí vive config que SÍ se versiona (jira-fields.json). Solo se
|
|
157
|
+
// ignora `.dai/reviews/`, que son borradores de `dai forge review` — efímeros, con
|
|
158
|
+
// hallazgos a medio editar, y no tienen por qué viajar en un commit.
|
|
159
|
+
const ensure = [".env", ".dai/reviews/"];
|
|
153
160
|
if (want.claude) { broad.add("CLAUDE.md"); broad.add(".claude"); ensure.push(".claude/settings.local.json"); }
|
|
154
161
|
if (want.cursor) { broad.add(".cursor"); }
|
|
155
162
|
let changed = false;
|
package/cli/lib/forge-api.mjs
CHANGED
|
@@ -40,6 +40,38 @@ export function commentApiUrl(ref) {
|
|
|
40
40
|
return `${apiBase(ref)}/projects/${encodeURIComponent(ref.projectPath)}/merge_requests/${ref.number}/notes`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// El endpoint del review INLINE (resumen + comentarios anclados a archivo:línea).
|
|
44
|
+
// Distinto de commentApiUrl, que postea al hilo de la PR: por eso el comentario de dai
|
|
45
|
+
// caía al final en vez de dentro del archivo.
|
|
46
|
+
// github → 1 POST con todo (atómico)
|
|
47
|
+
// gitlab → 1 nota (resumen) + 1 discussion por comentario (NO atómico)
|
|
48
|
+
export function reviewApiUrl(ref) {
|
|
49
|
+
if (ref.forge === "github") return `${apiBase(ref)}/repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/reviews`;
|
|
50
|
+
if (ref.forge === "gitlab") return `${apiBase(ref)}/projects/${encodeURIComponent(ref.projectPath)}/merge_requests/${ref.number}/discussions`;
|
|
51
|
+
throw new Error(`forge no soportado para review: ${ref.forge} (solo github/gitlab)`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// La posición de un comentario inline según el forge.
|
|
55
|
+
// github → { path, line, side, body }
|
|
56
|
+
// gitlab → position con los TRES shas de diff_refs + new_line/old_line según el lado
|
|
57
|
+
export function inlinePosition(ref, c, { diffRefs } = {}) {
|
|
58
|
+
if (ref.forge === "github") return { path: c.path, line: c.line, side: c.side || "RIGHT", body: c.body };
|
|
59
|
+
if (!diffRefs?.base_sha || !diffRefs?.head_sha) {
|
|
60
|
+
throw new Error("gitlab: faltan los diff_refs de la MR (base_sha/head_sha). Sin eso no se puede anclar un comentario.");
|
|
61
|
+
}
|
|
62
|
+
const position = {
|
|
63
|
+
base_sha: diffRefs.base_sha,
|
|
64
|
+
start_sha: diffRefs.start_sha || diffRefs.base_sha,
|
|
65
|
+
head_sha: diffRefs.head_sha,
|
|
66
|
+
position_type: "text",
|
|
67
|
+
new_path: c.path,
|
|
68
|
+
old_path: c.path,
|
|
69
|
+
};
|
|
70
|
+
if ((c.side || "RIGHT") === "LEFT") position.old_line = c.line;
|
|
71
|
+
else position.new_line = c.line;
|
|
72
|
+
return { body: c.body, position };
|
|
73
|
+
}
|
|
74
|
+
|
|
43
75
|
export function authHeaders(ref, env = process.env) {
|
|
44
76
|
if (ref.forge === "github") {
|
|
45
77
|
return { Authorization: `Bearer ${env.GITHUB_TOKEN || ""}`, Accept: "application/vnd.github+json", "User-Agent": "dai" };
|
|
@@ -75,13 +107,18 @@ export function renderReviewComment(r) {
|
|
|
75
107
|
}
|
|
76
108
|
|
|
77
109
|
// ── Efectos de red (testeados con fetch mockeado en cli/test) ────────────────
|
|
110
|
+
// `baseRef`/`headSha`/`diffRefs` son lo que hace falta para ANCLAR un comentario inline:
|
|
111
|
+
// GitHub necesita el sha del head; GitLab exige los tres shas de `diff_refs` en cada
|
|
112
|
+
// discussion. Sin esto no se puede postear un review inline.
|
|
78
113
|
export async function getPR(ref, env = process.env) {
|
|
79
114
|
const res = await fetch(prApiUrl(ref), { headers: authHeaders(ref, env) });
|
|
80
115
|
if (!res.ok) throw new Error(`forge ${res.status}: ${await res.text()}`);
|
|
81
116
|
const j = await res.json();
|
|
82
117
|
return ref.forge === "github"
|
|
83
|
-
? { title: j.title, state: j.state, body: j.body, branch: j.head?.ref, url: j.html_url
|
|
84
|
-
|
|
118
|
+
? { title: j.title, state: j.state, body: j.body, branch: j.head?.ref, url: j.html_url,
|
|
119
|
+
baseRef: j.base?.ref ?? null, headSha: j.head?.sha ?? null, diffRefs: null }
|
|
120
|
+
: { title: j.title, state: j.state, body: j.description, branch: j.source_branch, url: j.web_url,
|
|
121
|
+
baseRef: j.target_branch ?? null, headSha: j.diff_refs?.head_sha ?? null, diffRefs: j.diff_refs ?? null };
|
|
85
122
|
}
|
|
86
123
|
|
|
87
124
|
export async function postComment(ref, body, env = process.env) {
|
|
@@ -94,3 +131,53 @@ export async function postComment(ref, body, env = process.env) {
|
|
|
94
131
|
const j = await res.json();
|
|
95
132
|
return { url: j.html_url || j.web_url || null };
|
|
96
133
|
}
|
|
134
|
+
|
|
135
|
+
// ── Review inline ────────────────────────────────────────────────────────────
|
|
136
|
+
// `comments` viene con el body YA renderizado (el render vive en review-findings.mjs;
|
|
137
|
+
// acá solo se arma el payload y se postea).
|
|
138
|
+
//
|
|
139
|
+
// event: "COMMENT", NUNCA "APPROVE" — dai comenta, el humano firma (Art. 5). No es un
|
|
140
|
+
// default configurable: es un corte duro.
|
|
141
|
+
export async function postReview(ref, { body, comments = [], headSha = null, diffRefs = null }, env = process.env) {
|
|
142
|
+
if (ref.forge === "github") return postReviewGithub(ref, { body, comments, headSha }, env);
|
|
143
|
+
if (ref.forge === "gitlab") return postReviewGitlab(ref, { body, comments, diffRefs }, env);
|
|
144
|
+
throw new Error(`forge no soportado para review: ${ref.forge} (solo github/gitlab)`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// GitHub: TODO en un POST. O entra el review entero o no entra nada.
|
|
148
|
+
async function postReviewGithub(ref, { body, comments, headSha }, env) {
|
|
149
|
+
const payload = { event: "COMMENT", body, comments: comments.map((c) => inlinePosition(ref, c)) };
|
|
150
|
+
if (headSha) payload.commit_id = headSha;
|
|
151
|
+
const res = await fetch(reviewApiUrl(ref), {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { ...authHeaders(ref, env), "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify(payload),
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok) throw new Error(`forge ${res.status}: ${await res.text()}`);
|
|
157
|
+
const j = await res.json();
|
|
158
|
+
return { url: j.html_url || null, posted: comments.length, failed: [], atomic: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// GitLab: NO hay review atómico. Son 1 nota (el resumen) + N discussions sueltas, así que
|
|
162
|
+
// si la tercera falla, las dos primeras YA están publicadas. Se mitiga validando todo
|
|
163
|
+
// antes de la primera llamada (ver review-findings.mjs), pero no se puede prometer
|
|
164
|
+
// atomicidad — así que se reporta qué entró y qué no, en vez de fingirla.
|
|
165
|
+
async function postReviewGitlab(ref, { body, comments, diffRefs }, env) {
|
|
166
|
+
const summary = await postComment(ref, body, env);
|
|
167
|
+
const failed = [];
|
|
168
|
+
let posted = 0;
|
|
169
|
+
for (const c of comments) {
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(reviewApiUrl(ref), {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { ...authHeaders(ref, env), "Content-Type": "application/json" },
|
|
174
|
+
body: JSON.stringify(inlinePosition(ref, c, { diffRefs })),
|
|
175
|
+
});
|
|
176
|
+
if (!res.ok) throw new Error(`forge ${res.status}: ${await res.text()}`);
|
|
177
|
+
posted++;
|
|
178
|
+
} catch (e) {
|
|
179
|
+
failed.push({ path: c.path, line: c.line, error: String(e.message) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { url: summary.url, posted, failed, atomic: false };
|
|
183
|
+
}
|
package/cli/lib/pm-adapter.mjs
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
// clickup — REST v2 (pm-clickup.mjs)
|
|
8
8
|
//
|
|
9
9
|
// Interfaz (fetchUS/stamp pueden ser sync o async — el CLI siempre await-ea):
|
|
10
|
-
// fetchUS(id) → { id, title, spec_version, ac_hash } | null
|
|
10
|
+
// fetchUS(id) → { id, title, spec_version, ac_hash, url } | null
|
|
11
11
|
// stamp(id, record) → destino donde quedó la cobertura
|
|
12
12
|
// kind → nombre del backend
|
|
13
|
+
//
|
|
14
|
+
// `url` es la URL web canónica de la US según el tracker (opcional: null si el backend
|
|
15
|
+
// no la sabe, como md). El CLI la prefiere sobre la derivada — ver tracker-url.mjs.
|
|
13
16
|
|
|
14
17
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
15
18
|
import { join, dirname } from "node:path";
|
package/cli/lib/pm-clickup.mjs
CHANGED
|
@@ -29,7 +29,9 @@ export function clickupAdapter(env) {
|
|
|
29
29
|
const res = await fetch(clickupTaskUrl(id), { headers: clickupAuthHeaders(env) });
|
|
30
30
|
if (res.status === 404) return null;
|
|
31
31
|
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
32
|
-
|
|
32
|
+
const j = await res.json();
|
|
33
|
+
// `url` es la canónica (/t/<team_id>/<id>): la sabe ClickUp, no la deducimos.
|
|
34
|
+
return { id, ...parseUS(clickupTaskToText(j)), url: j.url || null };
|
|
33
35
|
},
|
|
34
36
|
async stamp(id, record) {
|
|
35
37
|
const res = await fetch(clickupCommentUrl(id), {
|
package/cli/lib/pm-jira.mjs
CHANGED
|
@@ -143,7 +143,7 @@ export function jiraAdapter(env) {
|
|
|
143
143
|
const res = await daiFetch(jiraIssueUrl(base, id), { headers: jiraAuthHeaders(env) });
|
|
144
144
|
if (res.status === 404) return null;
|
|
145
145
|
if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
|
|
146
|
-
return { id, ...parseUS(jiraIssueToText(await res.json())) };
|
|
146
|
+
return { id, ...parseUS(jiraIssueToText(await res.json())), url: `${trim(base)}/browse/${id}` };
|
|
147
147
|
},
|
|
148
148
|
async stamp(id, record) {
|
|
149
149
|
const res = await daiFetch(jiraCommentUrl(base, id), {
|
package/cli/lib/pr.mjs
CHANGED
|
@@ -29,15 +29,44 @@ export function composePrBody(template, d) {
|
|
|
29
29
|
if (d.commits && d.commits.length) {
|
|
30
30
|
b = replaceSection(b, "Cambios realizados", d.commits.map((c) => `- [x] ${c}`).join("\n"));
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
return upsertLinksBlock(b, d);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Bloque de enlaces ────────────────────────────────────────────────────────
|
|
36
|
+
// Delimitado y regenerable a propósito. Con el comentario suelto de antes, cualquier
|
|
37
|
+
// agente que reescribiera "Enlaces relacionados" se lo llevaba puesto sin dejar rastro
|
|
38
|
+
// (pasó en PRs reales). Con marcadores, el bloque se detecta, se preserva y se
|
|
39
|
+
// regenera — y quien edite el body ve que es de dai y que se pisa solo.
|
|
40
|
+
export const LINKS_START = "<!-- dai:links:start · generado por `dai pr` — no editar a mano -->";
|
|
41
|
+
export const LINKS_END = "<!-- dai:links:end -->";
|
|
42
|
+
|
|
43
|
+
// Los links que dai sabe. Sin URL no inventa la línea: prefiere no decir nada.
|
|
44
|
+
export function renderLinks(d) {
|
|
45
|
+
const links = [LINKS_START];
|
|
46
|
+
if (d.usUrl) links.push(`- US \`${d.id}\`: ${d.usUrl}`);
|
|
35
47
|
if (d.branchUrl) links.push(`- branch \`${d.branch}\`: ${d.branchUrl}`);
|
|
36
48
|
if (d.commitUrl) links.push(`- commit \`${(d.commit || "").slice(0, 8)}\`: ${d.commitUrl}`);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
links.push(LINKS_END);
|
|
50
|
+
return links.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Inserta o reemplaza el bloque de dai. Idempotente: correrlo N veces da lo mismo.
|
|
54
|
+
export function upsertLinksBlock(body, d) {
|
|
55
|
+
const block = renderLinks(d);
|
|
56
|
+
// 1. ¿Ya está el bloque delimitado? Se reemplaza entero (regenerar, no duplicar).
|
|
57
|
+
const delimited = /<!--\s*dai:links:start[\s\S]*?dai:links:end\s*-->/i;
|
|
58
|
+
if (delimited.test(body)) return body.replace(delimited, block);
|
|
59
|
+
// 2. ¿Está la sección del template? El bloque va debajo del heading, PRESERVANDO el
|
|
60
|
+
// hint HTML si lo hay: es la guía para quien edite (y es invisible al renderizar).
|
|
61
|
+
// dai suma, no borra — borrar el texto de otro es justo lo que estamos arreglando.
|
|
62
|
+
// (el `\s*` tolera la línea en blanco entre el heading y el hint; como solo matchea
|
|
63
|
+
// espacios, no puede saltar a la sección siguiente para buscarse un comentario)
|
|
64
|
+
const heading = /(^|\n)(##[^\n]*Enlaces relacionados[^\n]*\n)(\s*<!--[\s\S]*?-->[ \t]*\n)?/i;
|
|
65
|
+
if (heading.test(body)) {
|
|
66
|
+
return body.replace(heading, (m, pre, h, hint) => `${pre}${h}${hint || ""}\n${block}\n`);
|
|
67
|
+
}
|
|
68
|
+
// 3. Ni bloque ni sección: se apéndea con su propio heading.
|
|
69
|
+
return `${body.replace(/\s*$/, "")}\n\n## Enlaces relacionados\n\n${block}\n`;
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
// Título del PR: el pasado a mano, o "<ID>: <título de la US>", o solo el ID.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// dai · hallazgos de un review inline: parseo, validación contra el diff, filtros y render.
|
|
2
|
+
// Puro y sin red (ADR-0002: lo mecánico en el CLI, el criterio en la skill).
|
|
3
|
+
//
|
|
4
|
+
// El contrato con la skill es un `review.json`. La skill lo escribe, el humano lo lee y
|
|
5
|
+
// lo edita, y el CLI lo valida y lo postea. Ese archivo ES la puerta humana: es
|
|
6
|
+
// diff-eable, editable a mano y auditable, y no ata el flujo a ningún asistente.
|
|
7
|
+
|
|
8
|
+
export const SEVERITIES = ["low", "medium", "high"];
|
|
9
|
+
const SEV = {
|
|
10
|
+
low: { emoji: "🔵", label: "Low" },
|
|
11
|
+
medium: { emoji: "🟡", label: "Medium" },
|
|
12
|
+
high: { emoji: "🔴", label: "High" },
|
|
13
|
+
};
|
|
14
|
+
const rank = (s) => SEVERITIES.indexOf(s);
|
|
15
|
+
|
|
16
|
+
// ── Parseo del review.json ───────────────────────────────────────────────────
|
|
17
|
+
// Errores accionables: un LLM se equivoca, y "Unexpected token" no le dice dónde.
|
|
18
|
+
|
|
19
|
+
function fail(msg) {
|
|
20
|
+
throw new Error(`review.json inválido: ${msg}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseFindings(text) {
|
|
24
|
+
let j;
|
|
25
|
+
try {
|
|
26
|
+
j = typeof text === "string" ? JSON.parse(text) : text;
|
|
27
|
+
} catch (e) {
|
|
28
|
+
fail(`no es JSON válido (${e.message}).`);
|
|
29
|
+
}
|
|
30
|
+
if (!j || typeof j !== "object" || Array.isArray(j)) fail("la raíz tiene que ser un objeto.");
|
|
31
|
+
if (!Array.isArray(j.findings)) fail("falta el array 'findings' (puede ir vacío, pero tiene que estar).");
|
|
32
|
+
|
|
33
|
+
const findings = j.findings.map((f, i) => {
|
|
34
|
+
const at = `findings[${i}]`;
|
|
35
|
+
if (!f || typeof f !== "object") fail(`${at}: tiene que ser un objeto.`);
|
|
36
|
+
if (typeof f.path !== "string" || !f.path.trim()) fail(`${at}: falta 'path' (ruta del archivo, relativa a la raíz del repo).`);
|
|
37
|
+
if (!Number.isInteger(f.line) || f.line < 1) fail(`${at} (${f.path}): 'line' tiene que ser un entero ≥ 1, vino ${JSON.stringify(f.line)}.`);
|
|
38
|
+
if (typeof f.body !== "string" || !f.body.trim()) fail(`${at} (${f.path}:${f.line}): falta 'body' (el hallazgo, en prosa).`);
|
|
39
|
+
const side = f.side === undefined ? "RIGHT" : String(f.side).toUpperCase();
|
|
40
|
+
if (side !== "RIGHT" && side !== "LEFT") fail(`${at} (${f.path}:${f.line}): 'side' tiene que ser RIGHT o LEFT, vino ${JSON.stringify(f.side)}.`);
|
|
41
|
+
if (!SEVERITIES.includes(f.severity)) fail(`${at} (${f.path}:${f.line}): 'severity' tiene que ser ${SEVERITIES.join(" | ")}, vino ${JSON.stringify(f.severity)}.`);
|
|
42
|
+
const confidence = f.confidence === undefined ? 1 : f.confidence;
|
|
43
|
+
if (typeof confidence !== "number" || confidence < 0 || confidence > 1) {
|
|
44
|
+
fail(`${at} (${f.path}:${f.line}): 'confidence' tiene que ser un número entre 0 y 1, vino ${JSON.stringify(f.confidence)}.`);
|
|
45
|
+
}
|
|
46
|
+
return { path: f.path.trim(), line: f.line, side, severity: f.severity, confidence, body: f.body.trim() };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
us: j.us ?? null,
|
|
51
|
+
version: j.version ?? null,
|
|
52
|
+
checkStatus: j.checkStatus ?? null,
|
|
53
|
+
dod: j.dod ?? null,
|
|
54
|
+
summary: typeof j.summary === "string" ? j.summary.trim() : "",
|
|
55
|
+
good: Array.isArray(j.good) ? j.good.filter((g) => typeof g === "string" && g.trim()) : [],
|
|
56
|
+
findings,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Posiciones válidas según el diff ─────────────────────────────────────────
|
|
61
|
+
// El anti-alucinación. Un LLM inventa números de línea con una facilidad pasmosa, y el
|
|
62
|
+
// forge responde 422 sin decir cuál falló. Como el diff ya lo tenemos local, se verifica
|
|
63
|
+
// acá antes de salir a la red.
|
|
64
|
+
//
|
|
65
|
+
// Devuelve: Map<path, { right: Set<line>, left: Set<line> }>. Las líneas de contexto
|
|
66
|
+
// cuentan de los dos lados: el forge acepta comentarlas, son parte del hunk.
|
|
67
|
+
|
|
68
|
+
export function diffPositions(diff) {
|
|
69
|
+
const files = new Map();
|
|
70
|
+
let cur = null, newLine = 0, oldLine = 0;
|
|
71
|
+
|
|
72
|
+
for (const raw of String(diff ?? "").split(/\r?\n/)) {
|
|
73
|
+
// Ojo el orden: '+++ ' y '--- ' empiezan con '+' y '-'. Van ANTES que las de contenido.
|
|
74
|
+
if (raw.startsWith("+++ ")) {
|
|
75
|
+
const p = raw.slice(4).trim().replace(/\t.*$/, "");
|
|
76
|
+
if (p === "/dev/null") { cur = null; continue; } // archivo borrado: no hay lado derecho
|
|
77
|
+
cur = { right: new Set(), left: new Set() };
|
|
78
|
+
files.set(stripDiffPrefix(p), cur);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (raw.startsWith("--- ")) continue;
|
|
82
|
+
if (raw.startsWith("@@")) {
|
|
83
|
+
const m = raw.match(/^@@+ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
84
|
+
if (m) { oldLine = Number(m[1]); newLine = Number(m[2]); }
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!cur) continue;
|
|
88
|
+
if (raw.startsWith("+")) cur.right.add(newLine++);
|
|
89
|
+
else if (raw.startsWith("-")) cur.left.add(oldLine++);
|
|
90
|
+
else if (raw.startsWith(" ")) { cur.right.add(newLine++); cur.left.add(oldLine++); }
|
|
91
|
+
// "", "index …", "diff --git …", "similarity …": se ignoran
|
|
92
|
+
}
|
|
93
|
+
return files;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// "b/src/x.ts" → "src/x.ts". git usa a//b/ como prefijos del diff.
|
|
97
|
+
function stripDiffPrefix(p) {
|
|
98
|
+
return p.replace(/^[ab]\//, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Validación ───────────────────────────────────────────────────────────────
|
|
102
|
+
// Un hallazgo que no apunta al diff NO se postea. Se reporta, no se descarta en silencio.
|
|
103
|
+
|
|
104
|
+
export function validateFindings(findings, positions) {
|
|
105
|
+
const valid = [], rejected = [];
|
|
106
|
+
for (const f of findings) {
|
|
107
|
+
const pos = positions.get(f.path);
|
|
108
|
+
if (!pos) {
|
|
109
|
+
rejected.push({ finding: f, reason: "el archivo no aparece en el diff" });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const lines = f.side === "LEFT" ? pos.left : pos.right;
|
|
113
|
+
if (!lines.has(f.line)) {
|
|
114
|
+
rejected.push({ finding: f, reason: `la línea ${f.line} no es parte del diff (lado ${f.side})` });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
valid.push(f);
|
|
118
|
+
}
|
|
119
|
+
return { valid, rejected };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Filtros ──────────────────────────────────────────────────────────────────
|
|
123
|
+
// Lo que habilita el modo desatendido con red: por debajo del umbral, el hallazgo no se
|
|
124
|
+
// postea pero se lista en el resumen. Nada se cae en silencio.
|
|
125
|
+
|
|
126
|
+
export function filterFindings(findings, { minSeverity = "low", minConfidence = 0, maxComments = Infinity } = {}) {
|
|
127
|
+
if (!SEVERITIES.includes(minSeverity)) throw new Error(`--min-severity: valores ${SEVERITIES.join(" | ")}, vino '${minSeverity}'.`);
|
|
128
|
+
const kept = [], suppressed = [];
|
|
129
|
+
// Ordenado por severidad y después por confianza: si hay tope, se cortan los menos graves.
|
|
130
|
+
const sorted = [...findings].sort((a, b) => rank(b.severity) - rank(a.severity) || b.confidence - a.confidence);
|
|
131
|
+
for (const f of sorted) {
|
|
132
|
+
if (rank(f.severity) < rank(minSeverity)) { suppressed.push({ finding: f, reason: `severidad ${f.severity} < ${minSeverity}` }); continue; }
|
|
133
|
+
if (f.confidence < minConfidence) { suppressed.push({ finding: f, reason: `confianza ${f.confidence} < ${minConfidence}` }); continue; }
|
|
134
|
+
if (kept.length >= maxComments) { suppressed.push({ finding: f, reason: `tope de --max-comments (${maxComments})` }); continue; }
|
|
135
|
+
kept.push(f);
|
|
136
|
+
}
|
|
137
|
+
return { kept, suppressed };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
// El cuerpo de un comentario inline. Lleva marca de dai a propósito: el comentario se
|
|
143
|
+
// postea con el token del humano, así que el forge lo atribuye a él SIN badge de bot. Sin
|
|
144
|
+
// esta línea, el compañero ve un juicio sobre su código firmado por una persona, sin
|
|
145
|
+
// forma de saber que lo escribió una máquina.
|
|
146
|
+
export function renderFindingBody(f) {
|
|
147
|
+
const { emoji, label } = SEV[f.severity];
|
|
148
|
+
return `${emoji} **${label}** — ${f.body}\n\n<sub>🤖 <code>dai-review</code> · revisión asistida por IA, revisada y firmada por un humano</sub>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const bullets = (arr) => (arr.length ? arr.map((x) => `- ${x}`).join("\n") : "- — ninguno —");
|
|
152
|
+
|
|
153
|
+
function detailsList(title, items) {
|
|
154
|
+
if (!items.length) return null;
|
|
155
|
+
const rows = items.map(({ finding: f, reason }) => `- \`${f.path}:${f.line}\` (${f.severity}) — ${reason}\n > ${f.body.split("\n")[0]}`);
|
|
156
|
+
return `<details>\n<summary>${title} (${items.length})</summary>\n\n${rows.join("\n")}\n\n</details>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// El cuerpo del review (el "Pull request overview"). Mismo encabezado de metodología que
|
|
160
|
+
// renderReviewComment — los reviews del equipo se siguen leyendo igual.
|
|
161
|
+
export function renderReviewSummary(r, { kept = [], suppressed = [], rejected = [] } = {}) {
|
|
162
|
+
const head = r.us
|
|
163
|
+
? `**US:** \`${r.us}\`${r.version ? ` @ ${r.version}` : ""} · \`dai check\`: ${r.checkStatus || "?"}`
|
|
164
|
+
: "**US:** _(este PR no declara implementar una US)_";
|
|
165
|
+
|
|
166
|
+
const counts = SEVERITIES.slice().reverse()
|
|
167
|
+
.map((s) => ({ s, n: kept.filter((f) => f.severity === s).length }))
|
|
168
|
+
.filter(({ n }) => n > 0)
|
|
169
|
+
.map(({ s, n }) => `${n} ${SEV[s].emoji} ${SEV[s].label}`);
|
|
170
|
+
|
|
171
|
+
const hallazgos = kept.length
|
|
172
|
+
? `Dejé **${kept.length}** ${kept.length === 1 ? "comentario" : "comentarios"} en línea: ${counts.join(" · ")}.`
|
|
173
|
+
: "Sin comentarios en línea: no encontré nada concreto que marcar.";
|
|
174
|
+
|
|
175
|
+
return [
|
|
176
|
+
"## 🤖 dai-review",
|
|
177
|
+
"",
|
|
178
|
+
head,
|
|
179
|
+
r.dod ? `**Definition of Done:** ${r.dod}` : null,
|
|
180
|
+
"",
|
|
181
|
+
r.summary || null,
|
|
182
|
+
r.summary ? "" : null,
|
|
183
|
+
"### Hallazgos",
|
|
184
|
+
hallazgos,
|
|
185
|
+
"",
|
|
186
|
+
"### ✅ Lo que está bien",
|
|
187
|
+
bullets(r.good),
|
|
188
|
+
"",
|
|
189
|
+
detailsList("Suprimidos por el filtro", suppressed),
|
|
190
|
+
detailsList("Descartados: no apuntan al diff", rejected),
|
|
191
|
+
"",
|
|
192
|
+
"---",
|
|
193
|
+
"_Revisión asistida por dai. La aprobación la firma un humano (Art. 5 del manifiesto)._",
|
|
194
|
+
].filter((l) => l !== null).join("\n").replace(/\n{3,}/g, "\n\n") + "\n";
|
|
195
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// dai · la URL de la US en el tracker. Puro, sync, sin red (ADR-0007: config por .env).
|
|
2
|
+
//
|
|
3
|
+
// Cadena de resolución, del más específico al más general:
|
|
4
|
+
// 1. DAI_TRACKER_URL_TEMPLATE — override explícito del usuario: siempre gana.
|
|
5
|
+
// 2. La URL que devolvió el tracker en `fetchUS` — la canónica (ClickUp la trae con
|
|
6
|
+
// el team_id; la derivada no puede saberlo). Solo existe si hubo red.
|
|
7
|
+
// 3. Derivada del backend + su config — determinística y offline.
|
|
8
|
+
// 4. `null` — dai NO sabe la URL.
|
|
9
|
+
//
|
|
10
|
+
// El paso 4 es el que importa. Antes devolvíamos el `id` pelado, y como un string es
|
|
11
|
+
// truthy, quien consumía esto lo escribía igual — un id disfrazado de enlace, sin un
|
|
12
|
+
// solo aviso. Preferimos no decir nada antes que mentir: quien llama decide si omite
|
|
13
|
+
// la línea o avisa.
|
|
14
|
+
|
|
15
|
+
// URL web de la US deducida del backend, sin salir a la red.
|
|
16
|
+
export function deriveTrackerUrl(id, env = {}) {
|
|
17
|
+
if (!id) return null;
|
|
18
|
+
const kind = String(env.DAI_PM || "md").toLowerCase();
|
|
19
|
+
if (kind === "clickup") {
|
|
20
|
+
// /t/<id> redirige a la canónica /t/<team_id>/<id>. Sin token no sabemos el team.
|
|
21
|
+
return `https://app.clickup.com/t/${encodeURIComponent(id)}`;
|
|
22
|
+
}
|
|
23
|
+
if (kind === "jira") {
|
|
24
|
+
const base = String(env.DAI_JIRA_BASE_URL || "").replace(/\/+$/, "");
|
|
25
|
+
return base ? `${base}/browse/${encodeURIComponent(id)}` : null;
|
|
26
|
+
}
|
|
27
|
+
return null; // md: la US es un archivo local, no tiene URL web
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// La URL final, o null si no hay forma de saberla. `liveUrl` es la que trajo fetchUS.
|
|
31
|
+
export function trackerUrl(id, { env = {}, liveUrl = null } = {}) {
|
|
32
|
+
if (!id) return null;
|
|
33
|
+
const tpl = env.DAI_TRACKER_URL_TEMPLATE;
|
|
34
|
+
if (tpl) return String(tpl).replace(/\{id\}/g, id);
|
|
35
|
+
return liveUrl || deriveTrackerUrl(id, env);
|
|
36
|
+
}
|
package/docs/PROBAR.md
CHANGED
|
@@ -65,8 +65,9 @@ Si esto anda, el flujo está bien. Pasa al tracker real.
|
|
|
65
65
|
cat > .env <<'EOF'
|
|
66
66
|
DAI_PM=clickup
|
|
67
67
|
DAI_CLICKUP_TOKEN=pk_XXXXXXXX
|
|
68
|
-
DAI_TRACKER_URL_TEMPLATE=https://app.clickup.com/t/{id}
|
|
69
68
|
EOF
|
|
69
|
+
# El link a la tarea lo deduce dai solo. Solo si tu tracker vive en otra URL:
|
|
70
|
+
# DAI_TRACKER_URL_TEMPLATE=https://mi-tracker/t/{id}
|
|
70
71
|
dai doctor # confirma DAI_PM=clickup y el token
|
|
71
72
|
|
|
72
73
|
dai link-us 86cxyz # trae la US de ClickUp → branch + implements.yaml
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# ADR-0016 — Review inline: el `review.json` como puerta humana, y el CLI como validador
|
|
2
|
+
|
|
3
|
+
- **Estado:** aceptado
|
|
4
|
+
- **Fecha:** 2026-07-17
|
|
5
|
+
- **Decide:** lead / arquitecto de la metodología
|
|
6
|
+
|
|
7
|
+
## Contexto
|
|
8
|
+
|
|
9
|
+
`dai-review` dejaba **un comentario al final del hilo** de la PR con todos los hallazgos
|
|
10
|
+
en una lista. El reviewer humano tenía que leer "`src/checkout.ts:42` — el guard falla
|
|
11
|
+
abierto", abrir el archivo, buscar la línea, y reconstruir el contexto a mano. Por cada
|
|
12
|
+
hallazgo.
|
|
13
|
+
|
|
14
|
+
El review de Copilot, con todos sus defectos, hace algo mejor: deja **un comentario
|
|
15
|
+
anclado a cada `archivo:línea`**, clasificado `Low`/`Medium`/`High`, más un resumen
|
|
16
|
+
arriba. El hallazgo aparece **al lado del código del que habla**.
|
|
17
|
+
|
|
18
|
+
Técnicamente el gap era chico y estaba en un solo lugar: `dai forge comment` postea al
|
|
19
|
+
endpoint de **issues** (el hilo). El review inline es otro endpoint. No hacía falta una
|
|
20
|
+
arquitectura nueva.
|
|
21
|
+
|
|
22
|
+
Pero al abrirlo aparecieron tres problemas que sí son de diseño:
|
|
23
|
+
|
|
24
|
+
1. **El modelo inventa líneas.** Un LLM revisando código produce `path:line` que no
|
|
25
|
+
existen en el diff con una facilidad pasmosa. El forge responde `422` sin decir cuál
|
|
26
|
+
de los seis comentarios falló, y en GitHub el review es atómico: **un hallazgo
|
|
27
|
+
inventado tira los cinco buenos**.
|
|
28
|
+
2. **La skill posteaba sin gate** (ver 0.8.2). Con un comentario suelto ya era grave.
|
|
29
|
+
Con seis comentarios inline **firmados con el token del humano** —el forge los
|
|
30
|
+
atribuye a él como usuario, sin badge de bot— pesa mucho más.
|
|
31
|
+
3. **"Desatendido" no puede significar "sin criterio".** Hacía falta una forma de que un
|
|
32
|
+
review simple salga solo sin que eso implique postear cualquier cosa.
|
|
33
|
+
|
|
34
|
+
## Decisión
|
|
35
|
+
|
|
36
|
+
### 1. El contrato es un archivo, y ese archivo es la puerta humana
|
|
37
|
+
|
|
38
|
+
La skill deja de componer markdown y escribe un **`review.json`** en `.dai/reviews/<n>.json`.
|
|
39
|
+
El CLI lo valida y lo postea.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
skill (criterio) → .dai/reviews/22.json → dai forge review (mecánico)
|
|
43
|
+
hallazgos + severidad el humano lo edita valida vs. diff, filtra, postea
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Es la [ADR-0002](0002-agnostico-del-asistente.md) aplicada al review: el criterio es de
|
|
47
|
+
la skill, lo determinista es del CLI.
|
|
48
|
+
|
|
49
|
+
**Por qué un archivo y no una TUI.** Evaluamos [hunk](https://github.com/modem-dev/hunk),
|
|
50
|
+
que resuelve esto con una sesión interactiva en la terminal donde el humano navega los
|
|
51
|
+
hunks y el agente le anota comentarios. Lo bueno de robar es el **contrato**: el agente
|
|
52
|
+
emite un lote estructurado y la publicación es un paso aparte. Lo que no robamos es la
|
|
53
|
+
TUI: construirla es un proyecto entero y **ata el flujo a que el humano esté en tu
|
|
54
|
+
terminal** — justo lo contrario de la 0002. Un JSON es diff-eable, editable a mano,
|
|
55
|
+
auditable, y anda igual en Claude, Copilot o Cursor.
|
|
56
|
+
|
|
57
|
+
### 2. El CLI valida contra el diff antes de salir a la red
|
|
58
|
+
|
|
59
|
+
`dai forge review` trae el diff con **git** (local, por SSH — no gasta rate limit ni
|
|
60
|
+
depende de la API) y verifica que cada `path:line` **exista de verdad en el hunk**, del
|
|
61
|
+
lado declarado. Lo que no apunta al diff **no se postea**.
|
|
62
|
+
|
|
63
|
+
Esto es lo que más valor tiene del comando, más que postear. Es la diferencia entre un
|
|
64
|
+
review que entra y un `422` críptico que se lleva puestos los hallazgos buenos.
|
|
65
|
+
|
|
66
|
+
### 3. Nada se cae en silencio
|
|
67
|
+
|
|
68
|
+
Un hallazgo descartado o filtrado **se reporta**: en el preview del CLI y en un
|
|
69
|
+
`<details>` del propio resumen posteado. Un tope (`--max-comments`) que corta en silencio
|
|
70
|
+
se lee como "revisé todo" cuando no. Si dai suprime algo, lo dice.
|
|
71
|
+
|
|
72
|
+
### 4. `--yes` explícito; el desatendido es una excepción que se pide
|
|
73
|
+
|
|
74
|
+
Sin `--yes` no se postea nada, nunca: se muestra el preview y se corta. El modo
|
|
75
|
+
desatendido existe —`--yes --min-severity medium --min-confidence 0.8 --max-comments N`—
|
|
76
|
+
pero es una **excepción que el humano tipea**, no un default al que se llega por descuido.
|
|
77
|
+
|
|
78
|
+
`confidence` es lo que lo hace usable: por debajo del umbral el hallazgo no se postea y
|
|
79
|
+
queda listado. Es el equivalente honesto del *"Comments suppressed due to low confidence"*
|
|
80
|
+
de Copilot.
|
|
81
|
+
|
|
82
|
+
### 5. `event: COMMENT`, nunca `APPROVE`
|
|
83
|
+
|
|
84
|
+
Ni siquiera en desatendido, ni por configuración. dai comenta; la aprobación la firma un
|
|
85
|
+
humano ([Art. 5](../MANIFIESTO.md#art-5)).
|
|
86
|
+
|
|
87
|
+
### 6. Cada comentario inline lleva marca de dai
|
|
88
|
+
|
|
89
|
+
El review sale con el token del humano, así que el forge lo atribuye a él **sin badge de
|
|
90
|
+
bot** — a diferencia de Copilot, que postea vía GitHub App y muestra el badge `AI`. Sin
|
|
91
|
+
una marca en el cuerpo, el compañero ve seis juicios sobre su código firmados por una
|
|
92
|
+
persona, sin forma de saber que los escribió una máquina. Cada comentario cierra con una
|
|
93
|
+
línea que lo dice.
|
|
94
|
+
|
|
95
|
+
## Consecuencias
|
|
96
|
+
|
|
97
|
+
**A favor**
|
|
98
|
+
|
|
99
|
+
- El hallazgo aparece al lado del código. El reviewer humano deja de reconstruir contexto.
|
|
100
|
+
- El validador de posiciones convierte el error más común del LLM en un descarte legible,
|
|
101
|
+
antes de la red.
|
|
102
|
+
- El `review.json` es auditable y editable: el humano puede bajar una severidad o borrar
|
|
103
|
+
un hallazgo sin pelearse con un prompt.
|
|
104
|
+
- Sirve igual en GitHub y GitLab, y con cualquier asistente.
|
|
105
|
+
|
|
106
|
+
**En contra / a asumir**
|
|
107
|
+
|
|
108
|
+
- **GitLab no es atómico, y no lo podemos fingir.** GitHub postea resumen + N inline en
|
|
109
|
+
un solo `POST` (o entra todo o no entra nada). GitLab necesita 1 nota para el resumen y
|
|
110
|
+
N `discussions` separadas: si la tercera falla, las dos primeras **ya están
|
|
111
|
+
publicadas**. Se mitiga validando todo antes de la primera llamada, pero cuando falla
|
|
112
|
+
igual, el CLI **reporta qué entró y qué no** en vez de prometer una atomicidad que no
|
|
113
|
+
tiene.
|
|
114
|
+
- GitLab exige los tres shas de `diff_refs` en cada comentario: `getPR` tuvo que dejar de
|
|
115
|
+
descartarlos.
|
|
116
|
+
- Un `review.json` a medio editar no debe commitearse: `dai init` agrega `.dai/reviews/`
|
|
117
|
+
al `.gitignore` del repo (`.dai/` sigue versionándose — ahí vive `jira-fields.json`).
|
|
118
|
+
|
|
119
|
+
**Fuera de alcance, a propósito**
|
|
120
|
+
|
|
121
|
+
- **Bloques `suggestion`** (el botón "Apply suggestion"). Son valiosos pero la sintaxis
|
|
122
|
+
difiere entre GitHub y GitLab; el schema queda abierto para sumarlos después.
|
|
123
|
+
- **Cuenta bot / GitHub App** para que los reviews salgan marcados como IA como los de
|
|
124
|
+
Copilot. Una cuenta bot es solo **otro token en el `.env`** (cero código); una GitHub
|
|
125
|
+
App sí es trabajo real (JWT + installation token). Es una decisión de operación del
|
|
126
|
+
equipo, no del CLI, y por eso no la cierra esta ADR.
|
|
127
|
+
- **La calidad del review en sí.** dai no compite en catálogos de bugs ni reglas: eso no
|
|
128
|
+
es su dominio. Lo suyo es la trazabilidad y que el hallazgo aterrice donde sirve.
|
package/docs/adr/README.md
CHANGED
|
@@ -21,6 +21,7 @@ decisión cambia, se escribe un ADR nuevo que supersede al viejo. Molde en
|
|
|
21
21
|
| [0013](0013-skills-externas-install-from.md) | `dai skills install --from`: skills externas por-stack, sin registro ni gatekeeping | aceptado |
|
|
22
22
|
| [0014](0014-copilot-agent-skills.md) | Copilot lee `SKILL.md` nativo (Agent Skills): se elimina el adaptador `.prompt.md` — modifica la 0002 | aceptado |
|
|
23
23
|
| [0015](0015-jira-corporativo.md) | `dai publish` en Jira corporativo: campos propios declarados, `--parent`/`--issuetype`, TLS con CA (nunca apagar la verificación) | aceptado |
|
|
24
|
+
| [0016](0016-review-inline.md) | Review inline: `review.json` como contrato y puerta humana, el CLI valida las posiciones contra el diff, `--yes` explícito, nunca `APPROVE` | aceptado |
|
|
24
25
|
|
|
25
26
|
> Estas son las decisiones que cierran las "Decisiones abiertas" de
|
|
26
27
|
> [`METODOLOGIA.md §7`](../METODOLOGIA.md) y las enmiendas al
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforce2055/dai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Metodología de desarrollo asistido por IA — CLI de acciones deterministas (trazabilidad QUÉ↔CÓMO).",
|
|
5
5
|
"repository": { "type": "git", "url": "git+https://github.com/dforce2055/dai.git" },
|
|
6
6
|
"homepage": "https://dforce2055.github.io/dai/",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dai-review
|
|
3
|
-
description: "Revisa una Pull/Merge Request de un repo remoto (GitHub o GitLab) de forma consciente de la metodología dai
|
|
3
|
+
description: "Revisa una Pull/Merge Request de un repo remoto (GitHub o GitLab) de forma consciente de la metodología dai y deja un REVIEW INLINE en español: un comentario de resumen más un comentario anclado a cada archivo:línea, clasificado low/medium/high. Corre `dai check` (¿la US está atrasada?), valida el Definition of Done, hace el review de código (correctitud + calidad), escribe un review.json, lo valida contra el diff con `dai forge review --dry-run` (descarta las líneas que el modelo inventó), TE MUESTRA EL PREVIEW Y ESPERA TU OK, y recién entonces postea con `dai forge review --yes`. Nunca postea sin aprobación explícita: sale con tu nombre y tu token, sin badge de bot. Hay modo desatendido para reviews simples (--yes --min-severity --min-confidence), pero se pide, no es el default. Invocar como /dai-review <URL-de-la-PR o número>. Usar en el paso 6 de SCRUM-CON-IA (code review), antes de que un partner humano firme."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# dai-review — review de PR consciente de la metodología
|
|
@@ -9,16 +9,33 @@ Es el **primer pase** del paso 6 (code review). No reemplaza al partner humano:
|
|
|
9
9
|
saca el ruido para que firme lo que importa ([Art. 5](../../docs/MANIFIESTO.md#art-5) del manifiesto). Funciona igual
|
|
10
10
|
en **GitHub y GitLab**.
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## El reparto: tú traes el criterio, el CLI hace lo mecánico
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
No compones markdown y lo posteas. Escribes un **`review.json`** y el CLI lo valida y lo
|
|
15
|
+
postea (ADR-0002 / [ADR-0016](../../docs/adr/0016-review-inline.md)):
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
```
|
|
18
|
+
tú (criterio) → .dai/reviews/<n>.json → dai forge review (mecánico)
|
|
19
|
+
hallazgos + severidad el humano lo edita valida vs. el diff, filtra, postea
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Ese archivo **es la puerta humana**: es diff-eable, editable a mano y auditable, y no ata
|
|
23
|
+
el flujo a ningún asistente. No hay TUI que aprender.
|
|
24
|
+
|
|
25
|
+
**Por qué el CLI y no tú directamente:** `dai forge review` verifica que cada `path:line`
|
|
26
|
+
**exista de verdad en el diff** antes de salir a la red. Inventar números de línea es el
|
|
27
|
+
error más común de un LLM revisando código, y el forge responde `422` sin decir cuál
|
|
28
|
+
falló. El CLI lo caza antes.
|
|
19
29
|
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
### Cómo postear (elige la primera disponible)
|
|
31
|
+
|
|
32
|
+
1. **`dai forge review <ref> --from <archivo> --yes`** — la forma. Review inline:
|
|
33
|
+
resumen + un comentario por línea. Usa `GITHUB_TOKEN`/`GITLAB_TOKEN` del `.env`.
|
|
34
|
+
2. **MCP del forge / `dai forge comment`** — fallback si no hay token, o si necesitas
|
|
35
|
+
dejar un comentario suelto sin anclar. Pierdes el inline y la validación.
|
|
36
|
+
|
|
37
|
+
> **Auth:** git (traer el diff) usa **SSH**; postear usa **token del forge** (postear no
|
|
38
|
+
> se puede por SSH). Cero contraseñas.
|
|
22
39
|
|
|
23
40
|
## Input
|
|
24
41
|
|
|
@@ -35,44 +52,93 @@ Mismo comentario estándar, dos formas de dejarlo (elige la disponible, en este
|
|
|
35
52
|
- ¿existe `implements.yaml`? (si es un PR de producto, es obligatorio)
|
|
36
53
|
- **Definition of Done** (`templates/definition-of-done.md`): cuenta cuántos ítems cumple.
|
|
37
54
|
4. **Review de código (criterio, no mecánico):** busca
|
|
38
|
-
- 🔴 **
|
|
39
|
-
- 🟡 **
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
**
|
|
55
|
+
- 🔴 **high** — errores de correctitud: bugs, casos borde, seguridad.
|
|
56
|
+
- 🟡 **medium** — riesgo real pero no roto: contratos que mienten, races, deuda que muerde.
|
|
57
|
+
- 🔵 **low** — calidad: reuso, simplicidad, nombres.
|
|
58
|
+
- ✅ Lo que está **bien** (refuerza lo bueno) → va en `good`.
|
|
59
|
+
5. **Escribir el `review.json`** en `.dai/reviews/<número-de-PR>.json` (ver el schema abajo).
|
|
60
|
+
Cada hallazgo va anclado a `path` + `line` **del diff que trajiste en el paso 2** —
|
|
61
|
+
no de tu memoria del archivo.
|
|
62
|
+
6. **Validar sin postear** — `dai forge review <ref> --from .dai/reviews/<n>.json --dry-run`.
|
|
63
|
+
Te dice qué se postea, qué se filtra y qué se **descarta por no apuntar al diff**. Si
|
|
64
|
+
hay descartados, corregí el `path`/`line` y repetí. **No pases al paso 7 con descartados
|
|
65
|
+
que puedas arreglar.**
|
|
66
|
+
7. **Mostrarlo y esperar el OK.** Mostrá el preview **entero** y preguntá: _"¿lo posteo
|
|
67
|
+
así, lo edito, o lo descarto?"_ **Frená ahí.** Si te piden cambios (bajar una severidad,
|
|
68
|
+
borrar un hallazgo, reescribir un texto), editás el JSON y volvés a mostrarlo. Sin un
|
|
69
|
+
"sí" explícito **en este turno**, no se postea. Un "sí" de una PR anterior no cuenta.
|
|
70
|
+
8. **Postear** — `dai forge review <ref> --from .dai/reviews/<n>.json --yes`. Confirmá el link.
|
|
71
|
+
|
|
72
|
+
## El `review.json`
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"us": "ABC-482",
|
|
77
|
+
"version": "v1",
|
|
78
|
+
"checkStatus": "✅ al día",
|
|
79
|
+
"dod": "4/5",
|
|
80
|
+
"summary": "Prosa breve: el juicio general del PR. Es el cuerpo del review.",
|
|
81
|
+
"good": ["Algo real y específico que está bien."],
|
|
82
|
+
"findings": [
|
|
83
|
+
{
|
|
84
|
+
"path": "src/checkout.ts",
|
|
85
|
+
"line": 42,
|
|
86
|
+
"side": "RIGHT",
|
|
87
|
+
"severity": "high",
|
|
88
|
+
"confidence": 0.9,
|
|
89
|
+
"body": "El hallazgo, en prosa. Qué está mal, por qué, y qué harías."
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
53
94
|
|
|
54
|
-
|
|
55
|
-
|
|
95
|
+
| campo | obligatorio | qué |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `path` | sí | ruta **relativa a la raíz del repo**, tal cual sale en el diff |
|
|
98
|
+
| `line` | sí | línea **del lado que declares**; tiene que ser parte del diff |
|
|
99
|
+
| `side` | no (`RIGHT`) | `RIGHT` = archivo nuevo · `LEFT` = línea borrada |
|
|
100
|
+
| `severity` | sí | `low` \| `medium` \| `high` |
|
|
101
|
+
| `confidence` | no (`1`) | 0–1. **Sé honesto**: por debajo de `--min-confidence` el hallazgo no se postea y queda listado como suprimido. Es lo que hace usable el modo desatendido. |
|
|
102
|
+
| `body` | sí | el hallazgo, en prosa. Sin `**High**` ni emoji: lo pone el CLI. |
|
|
56
103
|
|
|
57
|
-
|
|
58
|
-
- <sugerencia concreta>
|
|
104
|
+
## Modo desatendido
|
|
59
105
|
|
|
60
|
-
|
|
61
|
-
- <algo real y específico>
|
|
106
|
+
Para reviews simples que no necesitan supervisión, el humano puede pedirlo explícito:
|
|
62
107
|
|
|
63
|
-
|
|
64
|
-
|
|
108
|
+
```bash
|
|
109
|
+
dai forge review <ref> --from <archivo> --yes --min-severity medium --min-confidence 0.8 --max-comments 10
|
|
65
110
|
```
|
|
66
111
|
|
|
67
|
-
|
|
112
|
+
Es una **excepción que se pide**, no el default. Sin `--yes` no se postea nada, nunca.
|
|
113
|
+
Y el `--yes` lo tipea el humano: la skill no lo agrega por su cuenta.
|
|
114
|
+
|
|
115
|
+
## Cuatro cortes duros
|
|
68
116
|
|
|
69
117
|
1. **No aprobar.** La skill **comenta**, no firma la aprobación. Eso es de un humano.
|
|
70
|
-
|
|
118
|
+
`dai forge review` postea siempre con `event: COMMENT`, nunca `APPROVE` — ni siquiera
|
|
119
|
+
en desatendido. No es configurable.
|
|
120
|
+
2. **No postear sin OK.** El review sale **con el token del humano y con su nombre**
|
|
121
|
+
(`GITHUB_TOKEN`/`GITLAB_TOKEN` son suyos): el forge lo atribuye a él como usuario, sin
|
|
122
|
+
badge de bot, así que en la PR de un compañero es indistinguible de haberlo escrito a
|
|
123
|
+
mano. Publicar un juicio sobre el código de otro, firmado por alguien que no lo leyó,
|
|
124
|
+
es tan grave como aprobar sin mirar. **El paso 7 no es opcional**, y pesa más que
|
|
125
|
+
antes: un review con 6 comentarios inline firmados por alguien es mucho más que un
|
|
126
|
+
comentario suelto.
|
|
127
|
+
3. **Las líneas se sacan del diff, no de la memoria.** Inventar un `path:line` es el
|
|
128
|
+
error más común de un LLM revisando código. El CLI lo caza y lo descarta, pero un
|
|
129
|
+
hallazgo descartado es un hallazgo perdido: si el paso 6 marca descartes, **arreglalos**
|
|
130
|
+
en vez de postear igual.
|
|
131
|
+
4. **Hallazgos concretos.** Nada de "mejorar la calidad" en abstracto: archivo, línea,
|
|
71
132
|
y el porqué. Si no es accionable, no va.
|
|
72
133
|
|
|
134
|
+
> **Por qué el corte 2 existe:** esta skill posteaba directo. El Art. 5 estaba bien
|
|
135
|
+
> leído en la letra —no clickeaba Approve— y mal puesto en la práctica: te dejaba
|
|
136
|
+
> firmar en público un review que nunca viste. Que salga bueno era suerte, no diseño.
|
|
137
|
+
|
|
73
138
|
## Relación con el modelo
|
|
74
139
|
|
|
75
140
|
- Es el paso 6 de [`SCRUM-CON-IA.md`](../../docs/SCRUM-CON-IA.md).
|
|
76
141
|
- Se apoya en `dai check` (ADR-0003) y en el forge adapter (`dai forge`, ADR-0002:
|
|
77
142
|
lo mecánico en el CLI, la inteligencia en la skill).
|
|
78
|
-
- El
|
|
143
|
+
- El review inline y el contrato del `review.json`: [ADR-0016](../../docs/adr/0016-review-inline.md).
|
|
144
|
+
- El formato estándar hace que todos los reviews del equipo se lean igual.
|
|
@@ -42,7 +42,11 @@
|
|
|
42
42
|
|
|
43
43
|
## Enlaces relacionados
|
|
44
44
|
|
|
45
|
-
<!--
|
|
45
|
+
<!--
|
|
46
|
+
La US, la branch y el commit ancla los precarga `dai pr` en el bloque `dai:links`
|
|
47
|
+
de abajo: NO los escribas a mano ni reescribas ese bloque (se regenera y te lo pisa).
|
|
48
|
+
Acá abajo sumá solo lo que dai no sabe: docs, issues, PRs relacionadas, dependencias.
|
|
49
|
+
-->
|
|
46
50
|
|
|
47
51
|
---
|
|
48
52
|
|