@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 CHANGED
@@ -6,8 +6,11 @@
6
6
  # md | jira | clickup
7
7
  DAI_PM=md
8
8
 
9
- # Plantilla del link al ticket (para `dai ls` / stamp). {id} se reemplaza.
10
- DAI_TRACKER_URL_TEMPLATE=https://jira.miempresa.com/browse/{id}
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 estándar; un humano aprueba
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 estándar; un humano aprueba | partner |
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 comment <ref> --body-file <f>` · `dai forge pr <ref>` | comentar / leer una PR/MR (GitHub/GitLab) |
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–0009)
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.8.1
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
- function trackerUrl(id) {
70
- const tpl = process.env.DAI_TRACKER_URL_TEMPLATE;
71
- return tpl ? tpl.replace("{id}", id) : id;
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: trackerUrl(im.id) });
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: trackerUrl(id), usTitle: live?.title, commits,
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\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" +
@@ -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=\nDAI_TRACKER_URL_TEMPLATE=https://app.clickup.com/t/{id}\n";
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
- const ensure = [".env"];
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;
@@ -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
- : { title: j.title, state: j.state, body: j.description, branch: j.source_branch, url: j.web_url };
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
+ }
@@ -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";
@@ -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
- return { id, ...parseUS(clickupTaskToText(await res.json())) };
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), {
@@ -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
- // Bloque de enlaces (lo agrega dai; el humano completa el resto del template).
33
- const links = ["", "<!-- Enlaces precargados por `dai pr` -->"];
34
- if (d.usUrl) links.push(`- US: ${d.usUrl}`);
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
- return b.replace(/(##\s*Enlaces relacionados\s*\n)(<!--[\s\S]*?-->)?/i,
38
- (m, h) => `${h}${links.join("\n")}\n`) === b
39
- ? b + "\n" + links.join("\n") + "\n" // si no había sección Enlaces, apéndela
40
- : b.replace(/(##\s*Enlaces relacionados\s*\n)(<!--[\s\S]*?-->)?/i, (m, h) => `${h}${links.join("\n")}\n`);
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.
@@ -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.8.1",
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, y deja un comentario estándar en español con errores y mejoras. Corre `dai check` (¿la US está atrasada?), valida el Definition of Done, hace el review de código (correctitud + calidad), compone el comentario estándar y lo postea vía el MCP del forge si está disponible, o vía `dai forge comment` (token) si no. 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."
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
- ## Las dos caras (cómo postea el comentario)
12
+ ## El reparto: traes el criterio, el CLI hace lo mecánico
13
13
 
14
- Mismo comentario estándar, dos formas de dejarlo (elige la disponible, en este orden):
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
- 1. **MCP del forge** — si hay un MCP de GitHub/GitLab conectado, postea por ahí.
17
- 2. **CLI con token** — si no, `dai forge comment <ref> --body-file <archivo>`. Usa
18
- `GITHUB_TOKEN`/`GITLAB_TOKEN` del `.env` (token scopeado, **nunca** contraseña).
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
- > **Auth:** git (traer la branch) usa **SSH**; comentar la PR usa **token del forge**
21
- > (comentar no se puede por SSH). Cero contraseñas.
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
- - 🔴 **Errores** de correctitud (bugs, casos borde, seguridad).
39
- - 🟡 **Mejoras** de calidad (reuso, simplicidad, eficiencia).
40
- - Lo que está **bien** (refuerza lo bueno).
41
- 5. **Componer el comentario estándar** (ver formato abajo).
42
- 6. **Postear** por MCP o `dai forge comment`. Confirmar el link al comentario.
43
-
44
- ## El comentario estándar
45
-
46
- Es el mismo que emite `renderReviewComment` del CLI respeta esta forma:
47
-
48
- ```markdown
49
- ## 🤖 dai-review
50
-
51
- **US:** `ABC-482` @ v1 · `dai check`: al día
52
- **Definition of Done:** 4/5
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
- ### 🔴 Errores (correctitud)
55
- - <hallazgo concreto con archivo:línea>
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
- ### 🟡 Mejoras (calidad, reuso, simplicidad)
58
- - <sugerencia concreta>
104
+ ## Modo desatendido
59
105
 
60
- ### Lo que está bien
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
- _Revisión asistida por dai. La aprobación la firma un humano (Art. 5 del manifiesto)._
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
- ## Dos cortes duros
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
- 2. **Hallazgos concretos.** Nada de "mejorar la calidad" en abstracto: archivo, línea,
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 comentario estándar hace que todos los reviews del equipo se lean igual.
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
- <!-- US en el tracker, commit ancla, docs, issues. -->
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