@dforce2055/dai 0.8.2 → 0.10.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.
Files changed (55) hide show
  1. package/{.env.example → .env.dai.example} +3 -3
  2. package/CHANGELOG.md +81 -0
  3. package/README.md +41 -29
  4. package/VERSION +1 -1
  5. package/cli/dai.mjs +175 -44
  6. package/cli/lib/bootstrap.mjs +42 -7
  7. package/cli/lib/env.mjs +12 -0
  8. package/cli/lib/forge-api.mjs +89 -2
  9. package/cli/lib/pm-clickup.mjs +2 -2
  10. package/cli/lib/pm-jira.mjs +2 -2
  11. package/cli/lib/review-findings.mjs +195 -0
  12. package/cli/lib/skills-source.mjs +8 -0
  13. package/docs/EJEMPLO-END-TO-END.md +52 -43
  14. package/docs/MANIFIESTO.md +2 -2
  15. package/docs/METODOLOGIA.md +20 -15
  16. package/docs/PROBAR.md +13 -14
  17. package/docs/SCRUM-CON-IA.md +10 -10
  18. package/docs/adr/0003-deteccion-y-estampado-son-comandos.md +1 -1
  19. package/docs/adr/0006-distribucion-y-licencia.md +1 -1
  20. package/docs/adr/0013-skills-externas-install-from.md +11 -4
  21. package/docs/adr/0015-jira-corporativo.md +1 -1
  22. package/docs/adr/0016-review-inline.md +128 -0
  23. package/docs/adr/0017-env-dai.md +64 -0
  24. package/docs/adr/README.md +2 -0
  25. package/docs/detalle/01-refinamiento.md +6 -5
  26. package/docs/detalle/03-ramas.md +2 -2
  27. package/docs/detalle/04-tdd.md +15 -9
  28. package/docs/detalle/06-code-review.md +8 -5
  29. package/docs/detalle/08-daily.md +1 -1
  30. package/docs/detalle/README.md +1 -1
  31. package/docs/glosario.md +2 -2
  32. package/docs/guias/dev.md +10 -7
  33. package/docs/guias/index.md +12 -0
  34. package/docs/guias/lead.md +1 -1
  35. package/docs/guias/po.md +13 -7
  36. package/docs/index.md +35 -0
  37. package/docs/public/favicon.svg +12 -0
  38. package/docs/public/logo-link.svg +12 -0
  39. package/docs/public/logo.svg +12 -0
  40. package/docs/public/tutoriales/clickup-1-settings.png +0 -0
  41. package/docs/public/tutoriales/clickup-2-api.png +0 -0
  42. package/docs/public/tutoriales/clickup-3-generate-copy.png +0 -0
  43. package/docs/public/tutoriales/jira-1-avatar.png +0 -0
  44. package/docs/public/tutoriales/jira-2-seguridad-tokens.png +0 -0
  45. package/docs/public/tutoriales/jira-3-crear-token.png +0 -0
  46. package/docs/public/tutoriales/jira-4-nombre-vencimiento.png +0 -0
  47. package/docs/public/tutoriales/jira-5-copiar.png +0 -0
  48. package/docs/tutoriales/claves-ssh.md +93 -0
  49. package/docs/tutoriales/configurar-git.md +53 -0
  50. package/docs/tutoriales/index.md +18 -0
  51. package/docs/tutoriales/instalar-glab.md +74 -0
  52. package/docs/tutoriales/token-clickup.md +73 -0
  53. package/docs/tutoriales/token-jira.md +74 -0
  54. package/package.json +9 -3
  55. package/skills/dai-review/SKILL.md +96 -42
package/cli/dai.mjs CHANGED
@@ -21,11 +21,12 @@ import { createInterface } from "node:readline/promises";
21
21
  import { acHash } from "./lib/ac-hash.mjs";
22
22
  import { discoverImplements, isPlaceholderId } from "./lib/implements.mjs";
23
23
  import { isValidKey, slugify, branchName, extractTitle, renderImplementsYaml } from "./lib/link-us.mjs";
24
- import { loadEnv } from "./lib/env.mjs";
24
+ import { loadDaiEnv } 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
28
  import { trackerUrl } from "./lib/tracker-url.mjs";
29
+ import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
29
30
  import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
30
31
  import { dirsEqual } from "./lib/fsutil.mjs";
31
32
  import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
@@ -47,8 +48,33 @@ const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
47
48
  // Color ANSI mínimo — solo si es TTY y no está NO_COLOR (así no ensucia pipes/CI).
48
49
  const _color = process.stdout.isTTY && !process.env.NO_COLOR;
49
50
  const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
50
- const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m) };
51
+ const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m), dim: (m) => paint("2", m) };
51
52
  const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
53
+
54
+ // Banner de bienvenida de `dai init`: el Sol de Mayo en bloques (cuerpo y rayos rectos en
55
+ // oro; rayos ondulados en celeste) al lado del título, más el preview de lo que se configura.
56
+ // Todo con caracteres — cero-dep. Degrada a ASCII sin color si no hay TTY o con NO_COLOR.
57
+ const initBanner = () => {
58
+ const sun = [
59
+ " █ ",
60
+ " ▒ ▄███▄ ▒ ",
61
+ "██ █████ ██",
62
+ " ▒ ▀███▀ ▒ ",
63
+ " █ ",
64
+ ];
65
+ const paintSun = (line) => [...line].map((ch) => (ch === "▒" ? C.cy(ch) : ch === " " ? " " : C.y(ch))).join("");
66
+ const aside = ["", paint("1;33", "dai") + " · Desarrollo Asistido por IA", C.dim("La IA asiste; la persona firma."), "", ""];
67
+ let out = "\n";
68
+ for (let i = 0; i < sun.length; i++) out += " " + paintSun(sun[i]) + (aside[i] ? " " + aside[i] : "") + "\n";
69
+ out += "\n " + C.b("Esto va a configurar el repo:") + "\n";
70
+ for (const b of [
71
+ "Skills del método (grill · link-us · tdd · dai-review) en tu asistente",
72
+ "La constitución del proyecto — las reglas del trabajo",
73
+ "OpenSpec para el CÓMO (design / tasks) — opcional",
74
+ "Plantilla de PR + .env.dai para el tracker",
75
+ ]) out += " " + C.cy("▸") + " " + b + "\n";
76
+ return out;
77
+ };
52
78
  const CLAUDE_SKILLS_DIR = process.env.CLAUDE_SKILLS_DIR || join(homedir(), ".claude", "skills");
53
79
  const CURSOR_SKILLS_DIR = process.env.CURSOR_SKILLS_DIR || join(homedir(), ".cursor", "skills");
54
80
  // Copilot lee las skills personales de ~/.copilot/skills (NO de ~/.claude/skills, que
@@ -118,10 +144,10 @@ async function cmdLinkUs(key, opts) {
118
144
  title = opts.title || extractTitle(md);
119
145
  } else {
120
146
  // Fuente tracker: traer la US del adaptador (mismo hash que usará `dai check`).
121
- loadEnv();
147
+ loadDaiEnv();
122
148
  const adapter = getAdapter(process.env);
123
149
  const us = await adapter.fetchUS(key);
124
- if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.`, 2);
150
+ if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.dai.`, 2);
125
151
  hash = us.ac_hash;
126
152
  if (hash == null) fail(`la US ${key} no tiene una sección 'Criterios de aceptación' con criterios testeables → sin ac_hash, no se puede linkear.\n Agregá la sección en el tracker, o corré /grill-user-story ${key} para pulir la US (te interroga y la re-publica).`, 2);
127
153
  title = opts.title || us.title;
@@ -179,7 +205,7 @@ function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return
179
205
 
180
206
  // ── check ──────────────────────────────────────────────────────────────────
181
207
  async function cmdCheck() {
182
- loadEnv();
208
+ loadDaiEnv();
183
209
  const adapter = getAdapter(process.env);
184
210
  const found = discoverImplements(process.cwd(), { includeArchived: false });
185
211
  let worst = 0, n = 0;
@@ -210,7 +236,7 @@ async function cmdCheck() {
210
236
 
211
237
  // ── stamp ──────────────────────────────────────────────────────────────────
212
238
  async function cmdStamp() {
213
- loadEnv();
239
+ loadDaiEnv();
214
240
  const adapter = getAdapter(process.env);
215
241
  const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
216
242
  const found = discoverImplements(process.cwd());
@@ -232,7 +258,7 @@ async function cmdStamp() {
232
258
 
233
259
  // ── forge (review) ───────────────────────────────────────────────────────────
234
260
  async function cmdForge(sub, ref, opts) {
235
- loadEnv();
261
+ loadDaiEnv();
236
262
  const pr = parsePrRef(ref, gitRemote());
237
263
  if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
238
264
  if (sub === "pr") {
@@ -242,11 +268,82 @@ async function cmdForge(sub, ref, opts) {
242
268
  if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
243
269
  const res = await postComment(pr, body, process.env);
244
270
  process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
271
+ } else if (sub === "review") {
272
+ await cmdForgeReview(pr, opts);
245
273
  } else {
246
- fail("uso: dai forge <pr|comment> <ref> [--body-file f | --body t]", 1);
274
+ fail("uso: dai forge <pr|comment|review> <ref> [--body-file f | --body t | --from review.json]", 1);
247
275
  }
248
276
  }
249
277
 
278
+ // dai forge review <ref> --from review.json [--dry-run | --yes]
279
+ //
280
+ // El reparto del ADR-0002 en una función: la skill trajo el CRITERIO (el review.json),
281
+ // el CLI hace lo MECÁNICO — validar que cada hallazgo apunte al diff de verdad, filtrar,
282
+ // y postear. Lo que más valor tiene acá no es postear: es RECHAZAR lo que un LLM inventó
283
+ // antes de que el forge conteste 422 sin decir cuál falló.
284
+ async function cmdForgeReview(pr, opts) {
285
+ if (!opts.from) fail("falta --from <review.json>. Lo escribe la skill dai-review; revisalo antes de postear.", 1);
286
+ const review = parseFindings(readFileSync(opts.from, "utf8"));
287
+
288
+ // El diff sale de git (local, por SSH), no de la API: es la fuente de verdad de qué
289
+ // línea es comentable, y no gasta rate limit.
290
+ const remote = await Promise.resolve(getPR(pr, process.env)).catch(() => null);
291
+ if (!remote) fail("no pude leer la PR/MR del forge (¿token? ¿ref correcta?).", 1);
292
+ const base = opts.base || remote.baseRef;
293
+ if (!base) fail("no pude saber la branch base de la PR. Pasala con --base <branch>.", 1);
294
+ let diff = "";
295
+ try {
296
+ git(["fetch", "origin", base, remote.branch], { stdio: ["inherit", "pipe", "pipe"] });
297
+ diff = git(["diff", `origin/${base}...origin/${remote.branch}`]);
298
+ } catch (e) {
299
+ const err = String(e.stderr || e.message).trim();
300
+ if (/couldn't find remote ref|no such ref/i.test(err)) {
301
+ fail(`la branch '${remote.branch}' ya no está en origin (¿la PR se mergeó y se borró la branch?). ` +
302
+ `Un review inline necesita el diff vivo; sobre una PR cerrada no hay dónde anclar.`, 1);
303
+ }
304
+ fail(`no pude traer el diff de ${base}...${remote.branch}: ${err}`, 1);
305
+ }
306
+
307
+ // 1. Validar contra el diff. 2. Filtrar. Nada se cae en silencio: todo se reporta.
308
+ const { valid, rejected } = validateFindings(review.findings, diffPositions(diff));
309
+ const { kept, suppressed } = filterFindings(valid, {
310
+ minSeverity: opts.minSeverity || "low",
311
+ minConfidence: opts.minConfidence ? Number(opts.minConfidence) : 0,
312
+ maxComments: opts.maxComments ? Number(opts.maxComments) : Infinity,
313
+ });
314
+
315
+ const body = renderReviewSummary(review, { kept, suppressed, rejected });
316
+ const comments = kept.map((f) => ({ path: f.path, line: f.line, side: f.side, body: renderFindingBody(f) }));
317
+
318
+ // ── Preview (acción hacia afuera: se muestra SIEMPRE, se postea solo con --yes) ──
319
+ process.stdout.write(`\n ── Review a postear en ${remote.url || `#${pr.number}`} ──────────\n`);
320
+ process.stdout.write(` forge: ${pr.forge}${pr.forge === "gitlab" ? " (no atómico: son N llamadas)" : " (atómico: 1 llamada)"}\n`);
321
+ process.stdout.write(` diff: ${base}...${remote.branch}\n`);
322
+ process.stdout.write(` hallazgos: ${review.findings.length} en el archivo · ${kept.length} a postear · ${suppressed.length} filtrados · ${rejected.length} descartados\n\n`);
323
+ for (const f of kept) process.stdout.write(` ✓ ${f.path}:${f.line} [${f.severity}] ${f.body.split("\n")[0].slice(0, 60)}\n`);
324
+ for (const { finding: f, reason } of suppressed) process.stdout.write(` ~ ${f.path}:${f.line} [${f.severity}] filtrado — ${reason}\n`);
325
+ for (const { finding: f, reason } of rejected) process.stdout.write(` ✗ ${f.path}:${f.line} [${f.severity}] DESCARTADO — ${reason}\n`);
326
+ process.stdout.write(`\n ───────────────────────────────────────────────\n${body}\n ───────────────────────────────────────────────\n`);
327
+
328
+ if (rejected.length) {
329
+ warn(`${rejected.length} hallazgo(s) NO apuntan al diff y no se postean. Corregí el 'path'/'line' en ${opts.from} o borralos.`);
330
+ }
331
+ if (opts.dryRun) { info("[dry-run] no se posteó nada."); return; }
332
+ if (!opts.yes) {
333
+ info(`Nada posteado. Revisá el preview y, si está bien: dai forge review ${pr.number} --from ${opts.from} --yes`);
334
+ return;
335
+ }
336
+ if (!kept.length && !review.summary) fail("no hay nada que postear (0 comentarios y resumen vacío).", 1);
337
+
338
+ const res = await postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env);
339
+ ok(`review posteado${res.url ? `: ${res.url}` : ""} — ${res.posted} comentario(s) en línea.`);
340
+ if (res.failed.length) {
341
+ warn(`${res.failed.length} comentario(s) NO entraron (gitlab no es atómico: el resumen y el resto SÍ están posteados):`);
342
+ for (const f of res.failed) process.stdout.write(` ✗ ${f.path}:${f.line} — ${f.error}\n`);
343
+ }
344
+ info("La aprobación la firma un humano: dai comentó, no aprobó (Art. 5).");
345
+ }
346
+
250
347
  // ── publish: crea la US en el tracker desde un .md (fallback del MCP) ──────────
251
348
 
252
349
  // Los campos propios que exige el proyecto, declarados en .dai/jira-fields.json
@@ -260,7 +357,7 @@ function loadJiraFieldsSpec() {
260
357
 
261
358
  async function cmdPublish(file, opts = {}) {
262
359
  if (!file) fail("uso: dai publish <archivo-us.md> [--parent KEY] [--issuetype T] [--field alias=valor]", 1);
263
- loadEnv();
360
+ loadDaiEnv();
264
361
  const md = readFileSync(file, "utf8");
265
362
  const title = extractTitle(md);
266
363
  if (!title) fail("no pude extraer el título de la US (falta un '# Título').", 1);
@@ -337,7 +434,7 @@ function cmdDone(opts) {
337
434
  // ── pr: crea TU PROPIA PR/MR precargada desde el template + el link ────────────
338
435
  // (Distinto de dai-review, que revisa la PR de OTRO. Tu PR la creas y revisas tú.)
339
436
  async function cmdPr(opts) {
340
- loadEnv();
437
+ loadDaiEnv();
341
438
  const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
342
439
  if (!remote) fail("no hay remoto git 'origin'. Configúralo para crear la PR.", 1);
343
440
  if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
@@ -375,7 +472,7 @@ async function cmdPr(opts) {
375
472
  for (const f of found) for (const im of f.implements || []) {
376
473
  if (!isPlaceholderId(im.id)) { entry = { f, im }; break; }
377
474
  }
378
- if (!entry) fail("no encontré un implements.yaml con una US real. Ejecuta `dai link-us` primero.", 1);
475
+ if (!entry) fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
379
476
  const { id, version, ac_hash } = entry.im;
380
477
 
381
478
  // 2. Estado de trazabilidad (dai check) contra la US viva.
@@ -398,7 +495,7 @@ async function cmdPr(opts) {
398
495
  const usUrl = usUrlFor(id, live?.url);
399
496
  if (!usUrl) {
400
497
  warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
401
- warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env (p. ej. https://tu-tracker/browse/{id}).`);
498
+ warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
402
499
  }
403
500
  const body = composePrBody(readFileSync(tplPath, "utf8"), {
404
501
  id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
@@ -560,7 +657,7 @@ async function cmdInstall(opts) {
560
657
  // dai (colisión → warn + skip). `dai sync` NO las toca: es solo de dai.
561
658
  function cmdInstallFrom(opts) {
562
659
  if (typeof opts.from !== "string" || !opts.from.trim())
563
- fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]) o un path local", 2);
660
+ fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]), un paquete npm (npm:@scope/pkg) o un path local", 2);
564
661
  let want;
565
662
  try { want = parseAssistants(typeof opts.for === "string" ? opts.for : "all"); }
566
663
  catch (e) { fail(`--for ${e.message}`); }
@@ -579,6 +676,33 @@ function cmdInstallFrom(opts) {
579
676
  try { git(args); }
580
677
  catch (e) { rmSync(tmp, { recursive: true, force: true }); fail(`no pude clonar la fuente: ${String(e.message).split("\n")[0]}`, 1); }
581
678
  root = tmp;
679
+ } else if (src.type === "npm") {
680
+ tmp = mkdtempSync(join(tmpdir(), "dai-skills-"));
681
+ info(`bajando el paquete npm ${src.location} …`);
682
+ try {
683
+ // `npm install` (no `npm pack`) en el temp. Dos decisiones a propósito:
684
+ // 1) install en vez de pack: los registries de GRUPO de GitLab devuelven una
685
+ // `dist.tarball` malformada (el scope duplicado en el nombre) que `npm pack` sigue
686
+ // literal y da 404; `npm install` —como `npx`— reconstruye la URL y resuelve.
687
+ // 2) copiamos el `.npmrc` del repo al temp y corremos con cwd=temp: así npm ve el
688
+ // registry/scope privado (con `--prefix` npm NO lee el `.npmrc` del cwd y se va al
689
+ // registry público). `--ignore-scripts`: no corremos scripts de un paquete de 3ros.
690
+ const repoNpmrc = join(process.cwd(), ".npmrc");
691
+ if (existsSync(repoNpmrc)) cpSync(repoNpmrc, join(tmp, ".npmrc"));
692
+ runNpmTool("npm", ["install", src.location,
693
+ "--no-save", "--no-package-lock", "--ignore-scripts", "--no-audit", "--no-fund"],
694
+ { cwd: tmp, stdio: ["ignore", "ignore", "pipe"] });
695
+ // El paquete queda en <tmp>/node_modules/<name>. name = spec sin la versión
696
+ // (@scope/pkg@1.2.3 → @scope/pkg; el `@` inicial del scope no cuenta).
697
+ let name = src.location; const at = name.lastIndexOf("@");
698
+ if (at > 0) name = name.slice(0, at);
699
+ root = join(tmp, "node_modules", name);
700
+ if (!existsSync(root)) throw new Error(`npm install no dejó '${name}' en node_modules`);
701
+ } catch (e) {
702
+ rmSync(tmp, { recursive: true, force: true });
703
+ const detail = String(e.stderr || e.stdout || e.message || "").split("\n").map((s) => s.trim()).filter(Boolean).slice(0, 3).join("\n ");
704
+ fail(`no pude bajar el paquete npm '${src.location}':\n ${detail || "npm install falló — revisá el spec, el registry y el .npmrc del repo"}`, 1);
705
+ }
582
706
  } else {
583
707
  root = src.location;
584
708
  if (!existsSync(root)) fail(`no existe la fuente: ${root}`, 2);
@@ -666,7 +790,7 @@ async function cmdInit(repo, opts) {
666
790
  }
667
791
  const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
668
792
 
669
- process.stdout.write("\n dai · configurar este repo para desarrollo asistido por IA\n");
793
+ process.stdout.write(initBanner());
670
794
 
671
795
  // Preguntas primero (después cerramos readline para liberar stdin a los instaladores).
672
796
  let forOpt = typeof opts.for === "string" ? opts.for.toLowerCase() : null;
@@ -717,28 +841,32 @@ async function cmdInit(repo, opts) {
717
841
  writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
718
842
  ok(".dai/ moldes (templates) + reglas (governance) del método");
719
843
 
720
- // .env.example aditivo, reflejando el --pm elegido: mismas claves que el .env
721
- // (con valores VACÍOS, sin secretos) para que el token del tracker esté presente.
722
- const exSrc = envFor(pm);
723
- const exPath = join(repo, ".env.example");
844
+ // Config de dai en SUS PROPIOS archivos, sin tocar el `.env`/`.env.example` del equipo:
845
+ // muchas orgs versionan el `.env` como política, así que dai lo deja en paz (solo lo lee,
846
+ // por compat) y pone lo suyo en `.env.dai` (ver ADR-0017). `.env.dai.example` se versiona
847
+ // como plantilla; `.env.dai` (gitignored) es donde cada dev completa token y datos propios.
848
+ const envBlock = envFor(pm);
849
+
850
+ // .env.dai.example — plantilla VERSIONADA, mismas claves con valores VACÍOS (sin secretos).
851
+ const exPath = join(repo, ".env.dai.example");
724
852
  if (existsSync(exPath)) {
725
- const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur, exSrc);
726
- if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.example claves de dai agregadas (aditivo)"); }
727
- else ok(".env.example ya tenía la config de dai");
728
- } else { writeFileSync(exPath, exSrc); ok(".env.example creado"); }
853
+ const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur, envBlock);
854
+ if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.dai.example claves de dai agregadas (aditivo)"); }
855
+ else ok(".env.dai.example ya tenía la config de dai");
856
+ } else { writeFileSync(exPath, envBlock); ok(".env.dai.example creado (plantilla versionada)"); }
729
857
 
730
- // .env — aditivo: agrega las claves de dai que falten; si no existe, lo crea.
731
- const envPath = join(repo, ".env"), envBlock = envFor(pm);
858
+ // .env.daiel real de cada dev (gitignored): aditivo; si no existe, lo crea.
859
+ const envPath = join(repo, ".env.dai");
732
860
  if (existsSync(envPath)) {
733
861
  const cur = readFileSync(envPath, "utf8"), merged = mergeEnv(cur, envBlock);
734
- if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env claves de dai agregadas (aditivo, DAI_PM=${pm}${pm === "md" ? "" : " — completa el token"})`); }
735
- else ok(".env ya tenía la config de dai");
736
- } else { writeFileSync(envPath, envBlock); ok(`.env listo, DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
862
+ if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env.dai claves de dai agregadas (aditivo, DAI_PM=${pm}${pm === "md" ? "" : " — completa el token"})`); }
863
+ else ok(".env.dai ya tenía la config de dai");
864
+ } else { writeFileSync(envPath, envBlock); ok(`.env.dai creado (no versionado), DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
737
865
 
738
866
  // .gitignore — versiona los artefactos de dai (según --for), deja fuera solo lo personal.
739
867
  const giPath = join(repo, ".gitignore");
740
868
  const gi = reconcileGitignore(existsSync(giPath) ? readFileSync(giPath, "utf8") : "", want);
741
- if (gi.changed) { writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"); ok(".gitignore ajustado (skills/constitución versionadas; .env y settings.local.json fuera)"); }
869
+ if (gi.changed) { writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"); ok(".gitignore ajustado (skills/constitución versionadas; .env.dai y settings.local.json fuera)"); }
742
870
 
743
871
  mkdirSync(join(repo, ".github"), { recursive: true });
744
872
  cpSync(join(ROOT, "templates", "pull-request.md"), join(repo, ".github", "pull_request_template.md"));
@@ -822,12 +950,12 @@ async function cmdInit(repo, opts) {
822
950
  }
823
951
 
824
952
  // ── Próximos pasos ─────────────────────────────────────────────────────────
825
- process.stdout.write("\n ✔ Repo configurado. Próximos pasos:\n");
953
+ process.stdout.write("\n " + C.y("") + " " + C.b("Repo configurado.") + " Próximos pasos:\n");
826
954
  process.stdout.write(pm === "md"
827
- ? " 1. Crea tu primera US en .dai/us/<ID>.md (criterios bajo '## Criterios de aceptación')\n"
828
- : ` 1. Completa el token de ${pm} en .env, y verifica con: dai doctor\n`);
829
- process.stdout.write(" 2. dai link-us <ID> → crea la branch + el link a la US\n");
830
- process.stdout.write(" 3. Implementa con test primero, después: dai check\n");
955
+ ? ` 1. Crea tu primera US en ${C.cy(".dai/us/<ID>.md")} (criterios bajo '## Criterios de aceptación')\n`
956
+ : ` 1. Copia ${C.cy(".env.dai.example")} → ${C.cy(".env.dai")} y completa el token de ${pm}; verifica con ${C.y("dai doctor")}\n`);
957
+ process.stdout.write(` 2. ${C.y("dai link-us <ID>")} → crea la branch + el link a la US\n`);
958
+ process.stdout.write(` 3. Implementa con test primero, después: ${C.y("dai check")}\n`);
831
959
  process.stdout.write(" Guía paso a paso: https://github.com/dforce2055/dai/blob/main/docs/PROBAR.md\n\n");
832
960
  }
833
961
 
@@ -959,7 +1087,7 @@ function cmdSync(repo, opts) {
959
1087
  () => writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"));
960
1088
 
961
1089
  if (dry) info("dry-run: nada escrito. Quitá --dry-run para aplicar.");
962
- else { process.stdout.write("\n"); ok(`sync completo — .dai/ ahora en v${cliV}`); process.stdout.write(" (El .env y OpenSpec no se tocan: OpenSpec se actualiza aparte con `openspec`.)\n"); }
1090
+ else { process.stdout.write("\n"); ok(`sync completo — .dai/ ahora en v${cliV}`); process.stdout.write(" (El .env.dai y OpenSpec no se tocan: OpenSpec se actualiza aparte con `openspec`.)\n"); }
963
1091
  }
964
1092
 
965
1093
  // Imprime el estado de version-drift del scaffold (ADR-0010) con color + ícono.
@@ -1025,7 +1153,7 @@ function cmdUpgrade(opts) {
1025
1153
 
1026
1154
  // ── doctor: diagnóstico ───────────────────────────────────────────────────────
1027
1155
  function cmdDoctor() {
1028
- loadEnv();
1156
+ loadDaiEnv();
1029
1157
  info(`dai doctor — versión v${readFileSync(join(ROOT, "VERSION"), "utf8").trim()}`);
1030
1158
 
1031
1159
  // Una skill sirve si está en el repo actual (la puso `dai init`) o global (la puso
@@ -1079,11 +1207,11 @@ function cmdDoctor() {
1079
1207
  const pm = process.env.DAI_PM || "md";
1080
1208
  ok(`DAI_PM=${pm}`);
1081
1209
  if (pm === "jira") {
1082
- if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en el .env");
1083
- if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en el .env");
1210
+ if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en .env.dai");
1211
+ if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en .env.dai");
1084
1212
  // Ojo: solo miramos que el token ESTÉ, no que sirva — uno vencido pasa este chequeo
1085
1213
  // y recién falla al publicar. Verificarlo de verdad es pegarle a la red.
1086
- if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en el .env"); else ok("token de Jira presente (no verificado: eso lo dice `dai publish`)");
1214
+ if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en .env.dai"); else ok("token de Jira presente (no verificado: eso lo dice `dai publish`)");
1087
1215
  if (!process.env.DAI_JIRA_PROJECT) warn("DAI_JIRA_PROJECT vacío — solo hace falta para `dai publish` (crear issues)");
1088
1216
  else {
1089
1217
  try { ok(`proyecto=${assertProjectKey(process.env.DAI_JIRA_PROJECT)} (para dai publish)`); }
@@ -1101,7 +1229,7 @@ function cmdDoctor() {
1101
1229
  }
1102
1230
  }
1103
1231
  if (pm === "clickup") {
1104
- if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en el .env"); else ok("token de ClickUp presente");
1232
+ if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en .env.dai"); else ok("token de ClickUp presente");
1105
1233
  process.env.DAI_CLICKUP_LIST_ID ? ok(`lista=${process.env.DAI_CLICKUP_LIST_ID} (para dai publish)`)
1106
1234
  : warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
1107
1235
  }
@@ -1160,19 +1288,22 @@ switch (cmd) {
1160
1288
  " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
1161
1289
  " 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" +
1162
1290
  " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1163
- " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n\n" +
1291
+ " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
1292
+ " forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
1293
+ " --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
1294
+ " Sin --yes no postea nada: muestra el preview y valida que cada hallazgo apunte al diff.\n\n" +
1164
1295
  "Instalación:\n" +
1165
1296
  " skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
1166
- " skills install --from <git-url|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
1297
+ " skills install --from <git-url|npm:pkg|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
1167
1298
  " init [<repo>] scaffolder interactivo del repo (asistente, gestor, OpenSpec)\n" +
1168
1299
  " --for <asistentes> claude|copilot|cursor (combinables con coma) · o both|all (default all)\n" +
1169
1300
  " ej: --for claude,cursor · --for copilot · --for all\n" +
1170
1301
  " --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
1171
- " sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env ni OpenSpec)\n" +
1302
+ " sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env.dai ni OpenSpec)\n" +
1172
1303
  " upgrade [--check] [--dry-run] (alias: update) actualiza el CLI global a la última (npm i -g …@latest) y avisa si el repo quedó atrasado (ADR-0012)\n" +
1173
1304
  " docs <destino> documentación conceptual → <destino>\n" +
1174
1305
  " doctor diagnóstico del entorno\n\n" +
1175
- " (config: .env — ver .env.example)\n"
1306
+ " (config: .env.dai — ver .env.dai.example)\n"
1176
1307
  );
1177
1308
  process.exit(cmd && cmd !== "help" ? 1 : 0);
1178
1309
  }
@@ -47,13 +47,38 @@ function unquoteScalar(s) {
47
47
  // Parsea el frontmatter YAML de un SKILL.md → { name, description, body }.
48
48
  // Devuelve los valores YA sin comillas, para que quien los reserialice (skillToCursor)
49
49
  // no los cite dos veces.
50
+ // Lee un campo del frontmatter. Soporta escalar de una línea (con/sin comillas) y BLOQUE
51
+ // YAML (`|` literal, `>` plegado, con chomp `-`/`+`): junta las líneas indentadas siguientes
52
+ // hasta la próxima clave (columna 0). El parser de dai es regex, no un YAML completo — esto
53
+ // cubre lo común: descripciones multilínea, que es como se escriben las skills reales.
54
+ function readFmField(fm, key) {
55
+ const lines = fm.split(/\r?\n/);
56
+ const keyRe = new RegExp(`^${key}:\\s*(.*)$`);
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const m = lines[i].match(keyRe);
59
+ if (!m) continue;
60
+ const inline = m[1].trim();
61
+ const blk = inline.match(/^([|>])[-+]?\s*$/); // |, >, |-, |+, >-, >+
62
+ if (!blk) return unquoteScalar(inline) || null; // escalar de una línea
63
+ const buf = [];
64
+ for (let j = i + 1; j < lines.length; j++) {
65
+ if (lines[j].trim() === "") { buf.push(""); continue; }
66
+ if (!/^\s/.test(lines[j])) break; // sin indentar → empezó otra clave
67
+ buf.push(lines[j]);
68
+ }
69
+ const indent = ((buf.find((l) => l.trim() !== "") || "").match(/^\s*/) || [""])[0].length;
70
+ const text = buf.map((l) => l.slice(indent)).join("\n").replace(/\s+$/, "");
71
+ // `>` plegado: un salto simple → espacio; los dobles (párrafo) se conservan.
72
+ return (blk[1] === ">" ? text.replace(/([^\n])\n(?!\n)/g, "$1 ") : text) || null;
73
+ }
74
+ return null;
75
+ }
76
+
50
77
  export function parseFrontmatter(md) {
51
78
  const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
52
79
  if (!m) return { name: null, description: null, body: md.trim() };
53
80
  const fm = m[1];
54
- const name = unquoteScalar((fm.match(/^name:\s*(.+)$/m) || [])[1]) || null;
55
- const description = unquoteScalar((fm.match(/^description:\s*(.+)$/m) || [])[1]) || null;
56
- return { name, description, body: m[2].trim() };
81
+ return { name: readFmField(fm, "name"), description: readFmField(fm, "description"), body: m[2].trim() };
57
82
  }
58
83
 
59
84
  // Valida el contrato MÍNIMO de un SKILL.md para que dai lo ingiera y los asistentes lo
@@ -66,7 +91,11 @@ export function validateSkill(md) {
66
91
  if (!name) return "falta 'name' en el frontmatter";
67
92
  if (!description) return "falta 'description' en el frontmatter";
68
93
  for (const key of ["name", "description"]) {
69
- const issue = yamlScalarIssue(rawFrontmatterValue(md, key));
94
+ const raw = rawFrontmatterValue(md, key);
95
+ // Un bloque YAML (`|`, `>`, con chomp) es literal → siempre válido; su contenido va en las
96
+ // líneas indentadas, no en el valor de la clave. No lo pasamos por el scalar-check.
97
+ if (/^[|>][-+]?\s*$/.test(String(raw ?? "").trim())) continue;
98
+ const issue = yamlScalarIssue(raw);
70
99
  if (issue) return `'${key}' no es YAML válido: ${issue} — citá el valor con comillas dobles`;
71
100
  }
72
101
  return null;
@@ -98,7 +127,7 @@ export function skillToCursor(md) {
98
127
  // la URL canónica que devuelve el tracker, así que el `/t/{id}` que escribíamos acá
99
128
  // tapaba la de ClickUp con team_id. Queda como override manual para trackers raros.
100
129
  export function envFor(pm) {
101
- const head = "# Config de dai — completá lo que falte. NUNCA commitees tokens (.env está gitignored).\n";
130
+ const head = "# Config de dai — va en .env.dai (NO versionado), no en el .env del equipo.\n# Completá lo que falte. NUNCA commitees tokens.\n";
102
131
  if (pm === "clickup") {
103
132
  return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n";
104
133
  }
@@ -153,7 +182,13 @@ export function upsertBlock(existing, block, marker = "dai") {
153
182
  export function reconcileGitignore(text, want) {
154
183
  const norm = (s) => { let t = s.trim(); while (t.startsWith("/")) { t = t.slice(1); } while (t.endsWith("/")) { t = t.slice(0, -1); } return t; };
155
184
  const broad = new Set();
156
- const ensure = [".env"];
185
+ // `.dai/` NO va acá: ahí vive config que SÍ se versiona (jira-fields.json). Solo se
186
+ // ignora `.dai/reviews/`, que son borradores de `dai forge review` — efímeros, con
187
+ // hallazgos a medio editar, y no tienen por qué viajar en un commit.
188
+ // `.env.dai` (config y secretos de dai) SÍ se ignora; el `.env` del equipo NO lo tocamos:
189
+ // es suyo y muchas orgs lo versionan como política (ADR-0017). La plantilla
190
+ // `.env.dai.example` sí se versiona (no matchea `.env.dai` exacto, así que no se ignora).
191
+ const ensure = [".env.dai", ".dai/reviews/"];
157
192
  if (want.claude) { broad.add("CLAUDE.md"); broad.add(".claude"); ensure.push(".claude/settings.local.json"); }
158
193
  if (want.cursor) { broad.add(".cursor"); }
159
194
  let changed = false;
@@ -196,7 +231,7 @@ export function constitution(kind) {
196
231
  - **El link se autora una vez** (\`implements.yaml\`); la cobertura se **deriva** (nunca a mano).
197
232
  - **Verifica el comportamiento, no solo que compile:** que pase el chequeo estático o el build no prueba que funcione; ejercita el flujo real antes de darlo por hecho.
198
233
  - **La IA confirma antes de construir:** el asistente declara que entendió esta constitución y la va a obedecer antes de generar código.
199
- - **Secretos:** en \`.env\` (nunca commiteados). git por **SSH**, APIs por **token scopeado**.
234
+ - **Secretos:** en \`.env.dai\` (NO versionado; el \`.env\` del equipo no se toca). git por **SSH**, APIs por **token scopeado**.
200
235
  - **No bajes la seguridad para avanzar:** si una llamada falla por el certificado, declara la CA (\`NODE_EXTRA_CA_CERTS\`). **Nunca** \`NODE_TLS_REJECT_UNAUTHORIZED=0\`, \`verify=False\`, \`-k\` ni equivalentes: apagan la verificación de toda la conexión, y por ahí viajan los tokens.
201
236
  - **Si el CLI no llega, para y dilo:** cuando \`dai\` no cubre un caso, repórtalo — no improvises una llamada a la API por fuera. El atajo publica igual, pero rompe el link QUÉ↔CÓMO en silencio y nadie se entera hasta que la trazabilidad ya está mal.
202
237
  - **Docs vivas:** una constitución o arquitectura desactualizada es un defecto, no documentación.
package/cli/lib/env.mjs CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  import { readFileSync } from "node:fs";
6
6
 
7
+ // Config de dai: `.env.dai` (propio, nunca versionado) tiene prioridad sobre `.env`.
8
+ // Pensado para equipos que versionan el `.env` (política de empresa): dai deja ese
9
+ // archivo en paz y pone sus claves y secretos en `.env.dai` (gitignored). Se carga
10
+ // `.env.dai` PRIMERO porque el loader es "primero-gana" (ver más abajo), así la
11
+ // precedencia queda: entorno (shell/CI) > .env.dai > .env. Leer también `.env`
12
+ // mantiene compatibilidad con repos previos que tienen los DAI_* ahí.
13
+ export function loadDaiEnv(env = process.env) {
14
+ loadEnv(".env.dai", env);
15
+ loadEnv(".env", env);
16
+ return env;
17
+ }
18
+
7
19
  export function loadEnv(path = ".env", env = process.env) {
8
20
  let text;
9
21
  try { text = readFileSync(path, "utf8"); } catch { return env; } // sin .env: no pasa nada
@@ -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
+ }
@@ -22,7 +22,7 @@ export function clickupTaskToText(json) {
22
22
  }
23
23
 
24
24
  export function clickupAdapter(env) {
25
- if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env (backend clickup).");
25
+ if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env.dai (backend clickup).");
26
26
  return {
27
27
  kind: "clickup",
28
28
  async fetchUS(id) {
@@ -43,7 +43,7 @@ export function clickupAdapter(env) {
43
43
  },
44
44
  async createUS({ title, descriptionMarkdown }) {
45
45
  const list = env.DAI_CLICKUP_LIST_ID;
46
- if (!list) throw new Error("falta DAI_CLICKUP_LIST_ID en el .env (la lista donde crear la tarea).");
46
+ if (!list) throw new Error("falta DAI_CLICKUP_LIST_ID en el .env.dai (la lista donde crear la tarea).");
47
47
  const res = await fetch(`${API}/list/${encodeURIComponent(list)}/task`, {
48
48
  method: "POST", headers: clickupAuthHeaders(env),
49
49
  body: JSON.stringify({ name: title, markdown_content: descriptionMarkdown }),