@dforce2055/dai 0.15.0 → 0.15.1

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/CHANGELOG.md CHANGED
@@ -3,6 +3,61 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.15.1] — 2026-09-10
7
+
8
+ **Una US puede estar impecable y dai igual no leerla. El molde de trazabilidad pide que la
9
+ metadata viva en una tabla, y cuando la US se escribe a mano en Jira esa tabla es una tabla
10
+ de verdad — que dai no sabía leer. El `spec_version` declarado se perdía en el camino y el
11
+ link quedaba estampado en `pendiente`, con un mensaje que mandaba a agregar una fila que ya
12
+ estaba ahí. Esta versión le enseña a dai a leer y escribir tablas.**
13
+
14
+ ### Corregido
15
+ - **El `spec_version` que la US declaraba y dai no veía.** `adfToMarkdown` no tenía caso
16
+ para las tablas de Jira Cloud, así que caían al `default` y cada celda terminaba en su
17
+ propia línea: `spec_version` en una, `v1` en la siguiente. Como el regex que lee la
18
+ versión no cruza saltos de línea —a propósito, para no capturar un `v2` de veinte líneas
19
+ más abajo— no encontraba nada, y el link se estampaba en `version: pendiente`. El aviso
20
+ era el peor de todos: uno que **acusa al usuario** de no haber hecho algo que sí hizo.
21
+ Ahora una fila de tabla llega como una fila de markdown. El `ac_hash` **no se mueve** —la
22
+ metadata vive arriba del bloque de criterios—, así que ningún `implements.yaml` ya
23
+ estampado queda atrasado por este cambio: lo único que cambia es que `pendiente` pasa a
24
+ ser la versión que la US declara. Es el hermano del #46: aquel era el campo escrito
25
+ `specversion`, este es el campo escrito en la tabla que el propio molde pide.
26
+ - **La otra mitad del mismo bug: dai tampoco escribía tablas.** `markdownToAdf` mandaba la
27
+ metadata del molde —y el comentario de cobertura de `dai release stamp`, que también es
28
+ una tabla— como párrafos con pipes adentro. Ilegibles, y encima invitaban a rehacerlos
29
+ como tabla de Jira a mano, que es justo lo que del otro lado no se sabía leer. Ahora
30
+ viajan como nodo `table` y el ida y vuelta de `dai edit-us` no destruye la metadata.
31
+ - **`dai release done` avisaba de un problema inexistente.** Miraba `origin/release/X.Y.Z`,
32
+ una ref local que sobrevive desactualizada hasta el próximo prune, en vez de preguntarle
33
+ al remoto. Cuando GitHub ya había borrado la rama al mergear —su comportamiento por
34
+ default— intentaba borrar algo que no estaba y reportaba un ⚠ sobre un estado que era el
35
+ correcto. Ahora pregunta con `git ls-remote --heads`, que es autoritativo, limpia la ref
36
+ fantasma y explica en una línea por qué no hubo nada que borrar. Un aviso falso es caro:
37
+ enseña a ignorar los avisos.
38
+
39
+ ### Cambiado
40
+ - **`dai release plan --json` expone `counts.stale`, antes `counts.atrasadas`.** Era una
41
+ clave en español en una salida que lee un script — la única que quedaba — y sigue el
42
+ glosario que ADR-0008 ya había fijado (*atrasado* → *stale*). Si escribiste algo contra
43
+ el `--json` de la 0.15.0 (publicada un día antes), es el único nombre que tenés que
44
+ cambiar.
45
+
46
+ ### Interno
47
+ - **Convención de idioma, ahora escrita.** El repo siempre fue código en inglés y
48
+ comentarios en español, pero no estaba en ningún lado: al escribir el ciclo de release se
49
+ rompió sistemáticamente (25% de identificadores en español en lo nuevo, contra ~2%
50
+ preexistente). Se renombraron 209 identificadores al inglés y la regla quedó como sexta
51
+ regla de oro en `CONTRIBUTING.md`, con una excepción deliberada: **`autor` se queda**. No
52
+ es código, es un campo del `implements.yaml` y parte del contrato del método (ADR-0004);
53
+ renombrarlo sería romper un contrato para ganar consistencia interna.
54
+ - **ADR-0008** documenta el relevamiento que falta para poder ejecutar el i18n.
55
+ - **484 tests** (+9 desde la 0.15.0): las tablas ADF en las dos direcciones —incluido el
56
+ ida y vuelta markdown → ADF → markdown, que es el que protege la metadata de `dai
57
+ edit-us`— y la primera cobertura de integración de `dai release done`, que era casi todo
58
+ efecto y no tenía ninguna: el ciclo completo contra un repo con remoto de verdad, el caso
59
+ del forge que se adelantó, y las dos negativas a taguear.
60
+
6
61
  ## [0.15.0] — 2026-09-09
7
62
 
8
63
  **Un equipo puede tener el link QUÉ↔CÓMO perfecto y seguir sin poder contestar la pregunta
@@ -988,6 +1043,7 @@ ClickUp y Jira Cloud.
988
1043
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
989
1044
  `files` de npm sin tests ni secretos.
990
1045
 
1046
+ [0.15.1]: https://github.com/dforce2055/dai/releases/tag/v0.15.1
991
1047
  [0.15.0]: https://github.com/dforce2055/dai/releases/tag/v0.15.0
992
1048
  [0.14.0]: https://github.com/dforce2055/dai/releases/tag/v0.14.0
993
1049
  [0.13.3]: https://github.com/dforce2055/dai/releases/tag/v0.13.3
package/CONTRIBUTING.md CHANGED
@@ -41,6 +41,28 @@ git config core.hooksPath .githooks # valida convención de commits + autoría
41
41
  (Cursor, Copilot, Claude, …). Un agente puede ayudarte, pero el cambio lo firmas
42
42
  tú: con tu identidad y sin el trailer del agente. Un check de CI lo bloquea en cada
43
43
  PR (ver [`governance/human-authorship.md`](governance/human-authorship.md)).
44
+ 6. **Código en inglés, comentarios en español.** Los identificadores —variables,
45
+ funciones, constantes, claves de objeto— van en **inglés**; los comentarios y la
46
+ documentación, en **español**. Y todo lo que sale por una interfaz de máquina
47
+ (`--json`, el payload de un webhook, los códigos de estado internos) va en inglés
48
+ también: es una API, la lee un script, y traducirla después rompe a quien la consume.
49
+
50
+ ```js
51
+ // El endpoint ES la credencial: quien lo tiene, postea. ← comentario en español
52
+ export function describeTarget(cfg) { ← código en inglés
53
+ const target = new URL(cfg.endpoint).host;
54
+ ```
55
+
56
+ **La excepción, y es una sola:** los nombres de campo del `implements.yaml` son
57
+ parte del contrato del método, no del código. `autor:` se llama así en el schema
58
+ (ADR-0004) y en todos los repos que ya lo usan; renombrarlo sería romper el
59
+ contrato para ganar consistencia interna, que es el peor cambio posible.
60
+
61
+ Lo que **todavía no** cumple esta regla son los **mensajes al usuario**, que hoy son
62
+ literales en español repartidos por todo el CLI. Eso no es un descuido: es el
63
+ refactor que [ADR-0008](docs/adr/0008-estrategia-de-i18n.md) tiene planificado como
64
+ fase 3 (`DAI_LANG` + un catálogo `t(key)` sin dependencias). Mientras tanto, escribí
65
+ los mensajes en español y **no inventes un mecanismo de traducción propio**.
44
66
 
45
67
  ## Flujo (la propia metodología)
46
68
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.15.0
1
+ 0.15.1
package/cli/dai.mjs CHANGED
@@ -33,7 +33,7 @@ import { listPrCmd, parsePrList, updatePrCmd, updatePrApiCmd, isAlreadyExistsErr
33
33
  import { parseCommitLog, proposeBump, nextVersion, buildManifest, renderManifest, BUMP_CAVEAT } from "./lib/release-plan.mjs";
34
34
  import { bumpPackageJson, bumpVersionFile, changelogEntry, insertChangelogEntry, changelogSection, changelogGaps, releaseBranch, tagName, normalizeVersion } from "./lib/release-files.mjs";
35
35
  import { notifyConfig, describeTarget, renderNotice, sendNotice, formatFecha } from "./lib/notify.mjs";
36
- import { releaseMarker, alreadyStamped, renderReleaseStamp, stampPlan, renderStampPlan, stampWarning, SIN_ESTAMPAR } from "./lib/release-stamp.mjs";
36
+ import { releaseMarker, alreadyStamped, renderReleaseStamp, stampPlan, renderStampPlan, stampWarning, NOT_STAMPED_NOTE } from "./lib/release-stamp.mjs";
37
37
  import { parseImplements } from "./lib/implements.mjs";
38
38
  import { absolutizeSiteLinks } from "./lib/docs-links.mjs";
39
39
  import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
@@ -353,9 +353,9 @@ function ciBranch() {
353
353
  // lo hace pasar por verificado (issue #46). No cambia el exit code — el hash manda.
354
354
  function versionDriftHint(im, live) {
355
355
  const declared = String(im?.version ?? "").trim();
356
- const real = String(live?.spec_version ?? "").trim();
357
- if (!real || declared === real) return;
358
- warn(`${im.id}: el link declara version '${declared || "(vacío)"}' y la US viva dice '${real}'.`);
356
+ const liveVersion = String(live?.spec_version ?? "").trim();
357
+ if (!liveVersion || declared === liveVersion) return;
358
+ warn(`${im.id}: el link declara version '${declared || "(vacío)"}' y la US viva dice '${liveVersion}'.`);
359
359
  process.stdout.write(` Ese número se publica en la PR y se estampa en el tracker. Corregilo: dai link-us ${im.id} --resync\n`);
360
360
  }
361
361
 
@@ -366,7 +366,7 @@ async function cmdCheck() {
366
366
  const adapter = getAdapter(process.env);
367
367
  const found = discoverImplements(process.cwd(), { includeArchived: false });
368
368
  let worst = 0, n = 0;
369
- const atrasadas = [];
369
+ const stale = [];
370
370
  for (const f of found) for (const im of f.implements || []) {
371
371
  if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
372
372
  n++;
@@ -378,7 +378,7 @@ async function cmdCheck() {
378
378
  }
379
379
  else if (status === "atrasado") {
380
380
  process.stdout.write(`⚠️ ${im.id} ATRASADO: implementaste ${im.ac_hash}, la US viva es ${live.ac_hash}${live.spec_version ? ` (${live.spec_version})` : ""}\n`);
381
- atrasadas.push(im.id);
381
+ stale.push(im.id);
382
382
  worst = Math.max(worst, 1);
383
383
  } else if (status === "sin-respuesta") {
384
384
  // No es "no hay US": es "no pude preguntar". Antes moría acá con `fetch failed` y sin
@@ -391,9 +391,9 @@ async function cmdCheck() {
391
391
  }
392
392
  }
393
393
  if (n === 0) process.stdout.write("No hay implements.yaml para chequear.\n");
394
- if (atrasadas.length) {
394
+ if (stale.length) {
395
395
  process.stdout.write("\n El QUÉ cambió desde que lo implementaste. Para resincronizar:\n");
396
- for (const id of atrasadas) process.stdout.write(` dai link-us ${id} --resync # re-estampa el ac_hash contra la US viva\n`);
396
+ for (const id of stale) process.stdout.write(` dai link-us ${id} --resync # re-estampa el ac_hash contra la US viva\n`);
397
397
  process.stdout.write(" Después, revisa si tu implementación cubre el criterio nuevo.\n");
398
398
  }
399
399
  process.exitCode = worst;
@@ -1091,8 +1091,8 @@ async function cmdPr(opts) {
1091
1091
  const existing = findExistingPr(tool, branch);
1092
1092
 
1093
1093
  // 4. Mostrar y pedir confirmación (acción hacia afuera).
1094
- const accion = existing ? `a ACTUALIZAR (#${existing.number})` : "a crear";
1095
- process.stdout.write(`\n ── Pull Request ${accion} ──────────────────────────────\n`);
1094
+ const action = existing ? `a ACTUALIZAR (#${existing.number})` : "a crear";
1095
+ process.stdout.write(`\n ── Pull Request ${action} ──────────────────────────────\n`);
1096
1096
  process.stdout.write(` título: ${title}\n de: ${branch}\n`);
1097
1097
  process.stdout.write(` a: ${base} ${C.dim(`(${baseSource}${baseWhy ? ` — ${baseWhy}` : ""})`)}${toProd ? ` ${C.b("⚠️ DESPLIEGA A PRODUCCIÓN")}` : ""}\n`);
1098
1098
  process.stdout.write(` US: ${id || C.dim("(sin US)")} ${C.dim(`— ${usWhy}`)}\n`);
@@ -1284,16 +1284,16 @@ function linkedInRange(range) {
1284
1284
  raw = git(["log", "--format=\x1e%H", "--name-only", "--diff-filter=AMR", range, "--", "*implements.yaml"]);
1285
1285
  } catch { return rows; }
1286
1286
  let order = 0;
1287
- for (const bloque of raw.split("\x1e")) {
1288
- const lineas = bloque.split("\n").map((l) => l.trim()).filter(Boolean);
1289
- if (lineas.length === 0) continue;
1290
- const sha = lineas[0];
1291
- for (const path of lineas.slice(1)) {
1287
+ for (const chunk of raw.split("\x1e")) {
1288
+ const outLines = chunk.split("\n").map((l) => l.trim()).filter(Boolean);
1289
+ if (outLines.length === 0) continue;
1290
+ const sha = outLines[0];
1291
+ for (const path of outLines.slice(1)) {
1292
1292
  if (!path.endsWith("implements.yaml")) continue;
1293
- let texto;
1294
- try { texto = git(["show", `${sha}:${path}`]); } catch { continue; } // borrado después
1293
+ let text;
1294
+ try { text = git(["show", `${sha}:${path}`]); } catch { continue; } // borrado después
1295
1295
  let parsed;
1296
- try { parsed = parseImplements(texto); } catch { continue; }
1296
+ try { parsed = parseImplements(text); } catch { continue; }
1297
1297
  for (const im of parsed.implements || []) {
1298
1298
  if (isPlaceholderId(im.id)) continue;
1299
1299
  rows.push({ ...im, change: parsed.change, repo: parsed.repo, path, sha, order: order++ });
@@ -1307,11 +1307,11 @@ function linkedInRange(range) {
1307
1307
  // contestar "qué entra en esta versión" — si cada comando lo calculara a su manera,
1308
1308
  // tarde o temprano dirían cosas distintas y le creerías al que tenés más a mano.
1309
1309
  async function releaseManifest(opts = {}, { from, to } = {}) {
1310
- const desde = from ?? (textOpt(opts, "from") || lastTag());
1311
- const hasta = to ?? (textOpt(opts, "to") || branchFlow(process.env).dev || gitBranch());
1312
- const toRev = resolveRev(hasta) || resolveRev(`origin/${hasta}`);
1313
- if (!toRev) fail(`no encuentro '${hasta}' (ni local ni en origin/${hasta}).\n Traelo primero: git fetch origin ${hasta}`, 1);
1314
- const range = desde ? `${desde}..${toRev}` : toRev;
1310
+ const fromRef = from ?? (textOpt(opts, "from") || lastTag());
1311
+ const toRef = to ?? (textOpt(opts, "to") || branchFlow(process.env).dev || gitBranch());
1312
+ const toRev = resolveRev(toRef) || resolveRev(`origin/${toRef}`);
1313
+ if (!toRev) fail(`no encuentro '${toRef}' (ni local ni en origin/${toRef}).\n Traelo primero: git fetch origin ${toRef}`, 1);
1314
+ const range = fromRef ? `${fromRef}..${toRev}` : toRev;
1315
1315
 
1316
1316
  let commits = [];
1317
1317
  try { commits = parseCommitLog(git(["log", "--format=%H\x1f%s", range])); }
@@ -1335,7 +1335,7 @@ async function releaseManifest(opts = {}, { from, to } = {}) {
1335
1335
  // declararla nunca. Se mira el repo entero, no el rango.
1336
1336
  const repoUsesStories = flattenImplements(discoverImplements(process.cwd())).length > 0;
1337
1337
  const m = buildManifest({ commits, linked, live, unreachable: unreachable || Boolean(opts.noNetwork), repoUsesStories });
1338
- return { manifest: m, commits, from: desde, to: hasta, range };
1338
+ return { manifest: m, commits, from: fromRef, to: toRef, range };
1339
1339
  }
1340
1340
 
1341
1341
  // La versión que este repo declara hoy. `null` si no espeja el número en ningún archivo
@@ -1386,31 +1386,31 @@ async function cmdReleaseCut(versionArg, opts = {}) {
1386
1386
  const { manifest: m, commits, from, to } = await releaseManifest(opts);
1387
1387
  const { version: current, file: versionFile } = currentVersion();
1388
1388
  const { bump, reason } = proposeBump(commits);
1389
- const sugerida = current ? nextVersion(current, bump) : null;
1389
+ const suggested = current ? nextVersion(current, bump) : null;
1390
1390
 
1391
1391
  // Qué archivos espejan el número en ESTE repo. Puede no haber ninguno (un repo .NET, un
1392
1392
  // frontend corporativo): el tag sigue siendo la versión.
1393
- const espejos = [];
1393
+ const mirrors = [];
1394
1394
  for (const [file, fn] of [["VERSION", bumpVersionFile], ["package.json", bumpPackageJson]]) {
1395
1395
  const p = join(process.cwd(), file);
1396
1396
  if (!existsSync(p)) continue;
1397
1397
  const r = fn(readFileSync(p, "utf8"), version);
1398
- espejos.push({ file, path: p, ...r });
1398
+ mirrors.push({ file, path: p, ...r });
1399
1399
  }
1400
1400
 
1401
- const crearBranch = opts.branch !== false && !opts.noBranch;
1402
- const nombreBranch = releaseBranch(version);
1401
+ const makeBranch = opts.branch !== false && !opts.noBranch;
1402
+ const releaseBranchName = releaseBranch(version);
1403
1403
 
1404
1404
  process.stdout.write("\n" + renderManifest(m, { from, to, current: current || "(sin archivo)", proposed: version, bump: `pediste ${version}`, reason }) + "\n");
1405
1405
  process.stdout.write(`\n ── Se va a preparar ─────────────────────────────────\n`);
1406
- process.stdout.write(` versión: ${version}${sugerida && sugerida !== version ? C.dim(` (dai habría propuesto ${sugerida} — ${bump})`) : ""}\n`);
1407
- process.stdout.write(` branch: ${crearBranch ? `${nombreBranch} (desde ${branch})` : C.dim("ninguna — se taguea desde la rama de integración")}\n`);
1408
- for (const e of espejos) process.stdout.write(` ${e.file.padEnd(9)} ${e.changed ? `${e.from} → ${version}` : C.dim(`ya está en ${version}`)}\n`);
1409
- if (espejos.length === 0) process.stdout.write(` ${C.dim("este repo no espeja el número en ningún archivo que dai reconozca: la versión es el tag")}\n`);
1406
+ process.stdout.write(` versión: ${version}${suggested && suggested !== version ? C.dim(` (dai habría propuesto ${suggested} — ${bump})`) : ""}\n`);
1407
+ process.stdout.write(` branch: ${makeBranch ? `${releaseBranchName} (desde ${branch})` : C.dim("ninguna — se taguea desde la rama de integración")}\n`);
1408
+ for (const e of mirrors) process.stdout.write(` ${e.file.padEnd(9)} ${e.changed ? `${e.from} → ${version}` : C.dim(`ya está en ${version}`)}\n`);
1409
+ if (mirrors.length === 0) process.stdout.write(` ${C.dim("este repo no espeja el número en ningún archivo que dai reconozca: la versión es el tag")}\n`);
1410
1410
  process.stdout.write(` CHANGELOG entrada nueva para ${version} (con el manifiesto para repartir)\n`);
1411
1411
  process.stdout.write(` commit: chore(release): v${version}\n`);
1412
1412
  process.stdout.write(` ─────────────────────────────────────────────────────\n`);
1413
- if (m.counts.atrasadas > 0) warn(`vas a cortar una versión con ${m.counts.atrasadas} US ATRASADA(S). Revisalas o resincronizá antes.`);
1413
+ if (m.counts.stale > 0) warn(`vas a cortar una versión con ${m.counts.stale} US ATRASADA(S). Revisalas o resincronizá antes.`);
1414
1414
  if (m.orphans.length) warn(`hay ${m.orphans.length} branch(es) sin US ni prefijo exento en el rango.`);
1415
1415
 
1416
1416
  if (opts.dryRun) { info("[dry-run] no se tocó nada."); return; }
@@ -1420,35 +1420,35 @@ async function cmdReleaseCut(versionArg, opts = {}) {
1420
1420
  if (!["s", "si", "sí", "y", "yes"].includes(a)) { info("Cancelado — no se tocó nada."); return; }
1421
1421
  }
1422
1422
 
1423
- if (crearBranch) {
1424
- try { git(["checkout", "-b", nombreBranch]); ok(`branch ${nombreBranch}`); }
1425
- catch (e) { fail(`no pude crear '${nombreBranch}': ${String(e.message).split("\n")[0]}`, 1); }
1423
+ if (makeBranch) {
1424
+ try { git(["checkout", "-b", releaseBranchName]); ok(`branch ${releaseBranchName}`); }
1425
+ catch (e) { fail(`no pude crear '${releaseBranchName}': ${String(e.message).split("\n")[0]}`, 1); }
1426
1426
  }
1427
- const tocados = [];
1428
- for (const e of espejos) {
1427
+ const touched = [];
1428
+ for (const e of mirrors) {
1429
1429
  if (!e.changed) continue;
1430
1430
  writeFileSync(e.path, e.text);
1431
- tocados.push(e.file);
1431
+ touched.push(e.file);
1432
1432
  ok(`${e.file}: ${e.from} → ${version}`);
1433
1433
  }
1434
1434
 
1435
1435
  // CHANGELOG: el andamio y el material. La prosa la escribe una persona (o la skill).
1436
1436
  const chPath = join(process.cwd(), "CHANGELOG.md");
1437
1437
  const entry = changelogEntry({ version, date: new Date().toISOString().slice(0, 10), manifest: m });
1438
- const previo = existsSync(chPath) ? readFileSync(chPath, "utf8") : "# Changelog\n";
1438
+ const previousChangelog = existsSync(chPath) ? readFileSync(chPath, "utf8") : "# Changelog\n";
1439
1439
  const repoUrl = (() => { const p = parseRemote(gitRemote()); return p ? `https://${p.host}/${p.path}` : null; })();
1440
- const res = insertChangelogEntry(previo, entry, { version, repoUrl });
1441
- if (res.changed) { writeFileSync(chPath, res.text); tocados.push("CHANGELOG.md"); ok(`CHANGELOG.md: entrada para ${version}`); }
1440
+ const res = insertChangelogEntry(previousChangelog, entry, { version, repoUrl });
1441
+ if (res.changed) { writeFileSync(chPath, res.text); touched.push("CHANGELOG.md"); ok(`CHANGELOG.md: entrada para ${version}`); }
1442
1442
  else warn(`CHANGELOG.md sin tocar — ${res.reason}.`);
1443
1443
 
1444
- if (tocados.length === 0) { warn("no hubo nada que cambiar: no hago un commit vacío."); return; }
1445
- git(["add", ...tocados]);
1444
+ if (touched.length === 0) { warn("no hubo nada que cambiar: no hago un commit vacío."); return; }
1445
+ git(["add", ...touched]);
1446
1446
  git(["commit", "-m", `chore(release): v${version}`, "-m", `Manifiesto: ${m.counts.stories} US · ${m.counts.commits} commits desde ${from || "el principio"}.`]);
1447
1447
  ok(`commit chore(release): v${version}`);
1448
1448
 
1449
1449
  process.stdout.write("\n");
1450
1450
  info("Falta lo que dai no puede escribir por vos: repartí el manifiesto del CHANGELOG y contá el porqué.");
1451
- info(`Después: dai pr${crearBranch ? "" : ""} → se mergea → dai release done ${version}`);
1451
+ info(`Después: dai pr${makeBranch ? "" : ""} → se mergea → dai release done ${version}`);
1452
1452
  }
1453
1453
 
1454
1454
  // ── release done: cerrar la versión ────────────────────────────────────────
@@ -1481,20 +1481,24 @@ async function cmdReleaseDone(versionArg, opts = {}) {
1481
1481
  const notes = existsSync(join(process.cwd(), "CHANGELOG.md"))
1482
1482
  ? changelogSection(readFileSync(join(process.cwd(), "CHANGELOG.md"), "utf8"), version) : null;
1483
1483
  const cfg = (() => { try { return notifyConfig(process.env); } catch (e) { warn(String(e.message)); return null; } })();
1484
- const avisar = cfg && !opts.noNotify;
1484
+ const willNotify = cfg && !opts.noNotify;
1485
1485
 
1486
1486
  process.stdout.write(`\n ── Cerrar la versión ${version} ──────────────────────────\n`);
1487
1487
  process.stdout.write(` tag: ${tag} → ${prod} @ ${String(head).slice(0, 8)}\n`);
1488
1488
  process.stdout.write(` release: ${opts.noRelease ? C.dim("no (--no-release)") : `nota en el forge${notes ? "" : C.dim(" (sin sección del CHANGELOG: sale con las notas del forge)")}`}\n`);
1489
1489
  process.stdout.write(` back-merge: ${dev ? `${prod} → ${dev}` : C.dim("no (DAI_BRANCH_DEV no declarada)")}\n`);
1490
1490
  const relBranch = releaseBranch(version);
1491
- const hayLocal = Boolean(safeGit(["rev-parse", "--verify", "--quiet", relBranch]));
1492
- const hayRemota = Boolean(safeGit(["rev-parse", "--verify", "--quiet", `origin/${relBranch}`]));
1493
- const limpiar = (hayLocal || hayRemota) && !opts.keepBranch;
1494
- process.stdout.write(` limpieza: ${limpiar
1495
- ? `borrar ${relBranch}${hayLocal ? " (local)" : ""}${hayLocal && hayRemota ? " +" : ""}${hayRemota ? " (remota)" : ""}`
1496
- : C.dim(hayLocal || hayRemota ? "no (--keep-branch)" : "nada que borrar")}\n`);
1497
- process.stdout.write(` aviso: ${avisar ? describeTarget(cfg) : C.dim(cfg ? "no (--no-notify)" : "no (DAI_NOTIFY no declarado)")}\n`);
1491
+ const hasLocal = Boolean(safeGit(["rev-parse", "--verify", "--quiet", relBranch]));
1492
+ // Se pregunta al REMOTO, no a `origin/<branch>`: esa ref local queda desactualizada
1493
+ // cuando el forge borra la rama al mergear (auto-delete on merge, que es lo normal), y
1494
+ // dai terminaba intentando borrar algo que ya no estaba y reportando un ⚠ por un estado
1495
+ // que en realidad era el correcto. Avisar de un problema inexistente enseña a ignorar los avisos.
1496
+ const hasRemote = Boolean(safeGit(["ls-remote", "--heads", "origin", relBranch])?.trim());
1497
+ const willCleanup = (hasLocal || hasRemote) && !opts.keepBranch;
1498
+ process.stdout.write(` limpieza: ${willCleanup
1499
+ ? `borrar ${relBranch}${hasLocal ? " (local)" : ""}${hasLocal && hasRemote ? " +" : ""}${hasRemote ? " (remota)" : ""}`
1500
+ : C.dim(hasLocal || hasRemote ? "no (--keep-branch)" : "nada que borrar")}\n`);
1501
+ process.stdout.write(` aviso: ${willNotify ? describeTarget(cfg) : C.dim(cfg ? "no (--no-notify)" : "no (DAI_NOTIFY no declarado)")}\n`);
1498
1502
  process.stdout.write(` ─────────────────────────────────────────────────────\n`);
1499
1503
  if (opts.dryRun) { info("[dry-run] no se tocó nada."); return; }
1500
1504
  if (!opts.yes) {
@@ -1552,33 +1556,40 @@ async function cmdReleaseDone(versionArg, opts = {}) {
1552
1556
  // no pasa acá, se acumulan — y una rama de release que sobrevive a su release es un fork.
1553
1557
  // Misma disciplina que `dai done`: `git branch -d` (minúscula) se niega si no está
1554
1558
  // mergeada, así que la red de seguridad la pone git, no una suposición nuestra.
1555
- if (limpiar) {
1556
- if (hayLocal) {
1559
+ if (willCleanup) {
1560
+ if (hasLocal) {
1557
1561
  try { git(["branch", "-d", relBranch]); ok(`borrada la branch local ${relBranch}`); }
1558
1562
  catch (e) {
1559
1563
  warn(`no borré '${relBranch}' local: ${String(e.message).split("\n")[0]}`);
1560
1564
  process.stdout.write(` git se niega a borrar una branch sin mergear. Revisala: quizá tiene commits que no llegaron a '${prod}'.\n`);
1561
1565
  }
1562
1566
  }
1563
- if (hayRemota) {
1567
+ if (hasRemote) {
1564
1568
  try {
1565
1569
  git(["push", "origin", "--delete", relBranch], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
1566
1570
  ok(`borrada la branch remota origin/${relBranch}`);
1567
1571
  } catch (e) {
1568
1572
  warn(`no borré 'origin/${relBranch}': ${String(e.message).split("\n")[0]}`);
1569
- process.stdout.write(` Puede que ya la haya borrado el forge al mergear. Si no: git push origin --delete ${relBranch}\n`);
1573
+ process.stdout.write(` Borrala a mano: git push origin --delete ${relBranch}\n`);
1574
+ }
1575
+ } else if (hasLocal) {
1576
+ // La ref local puede seguir apuntando a una rama que el forge ya borró. Se limpia
1577
+ // para que el próximo `git branch -r` no muestre un fantasma.
1578
+ if (safeGit(["rev-parse", "--verify", "--quiet", `origin/${relBranch}`])) {
1579
+ safeGit(["branch", "-dr", `origin/${relBranch}`]);
1580
+ info(`la branch remota ya no estaba (la borró el forge al mergear); limpié la referencia local`);
1570
1581
  }
1571
1582
  }
1572
1583
  }
1573
1584
 
1574
1585
  // 5. El aviso. Lo último a propósito: anunciar algo que después falla es peor que no anunciar.
1575
- if (avisar) {
1586
+ if (willNotify) {
1576
1587
  const ev = {
1577
1588
  event: "released", app: releaseApp(opts), version, environment: null,
1578
1589
  author: gitUser(), date: formatFecha(), url: releaseUrl,
1579
1590
  stories: m.stories.map((s) => ({ id: s.id, title: s.title })),
1580
1591
  };
1581
- await confirmarYAvisar(cfg, ev, opts);
1592
+ await confirmAndNotify(cfg, ev, opts);
1582
1593
  }
1583
1594
  process.stdout.write("\n");
1584
1595
  ok(`versión ${version} cerrada.`);
@@ -1609,11 +1620,11 @@ async function cmdReleaseStamp(versionArg, opts = {}) {
1609
1620
 
1610
1621
  // El manifiesto de ESA versión: lo que entró entre el tag anterior y este. Se pide por
1611
1622
  // tag, no por rama, porque estampar es contar qué se desplegó — y lo desplegado es el tag.
1612
- const previo = safeGit(["describe", "--tags", "--abbrev=0", `${tag}^`])?.trim() || null;
1623
+ const previousTag = safeGit(["describe", "--tags", "--abbrev=0", `${tag}^`])?.trim() || null;
1613
1624
  if (!safeGit(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`])) {
1614
1625
  fail(`no existe el tag ${tag} en este repo.\n Cerrá la versión primero: dai release done ${version}\n (o traé los tags: git fetch --tags)`, 1);
1615
1626
  }
1616
- const { manifest: m } = await releaseManifest(opts, { from: previo, to: tag });
1627
+ const { manifest: m } = await releaseManifest(opts, { from: previousTag, to: tag });
1617
1628
 
1618
1629
  // ¿Cuáles ya están estampadas? Sin poder leer los comentarios dai NO afirma que no las
1619
1630
  // estampó: lo dice, y quien decide es la persona. Afirmar de más acá se paga en duplicados.
@@ -1631,42 +1642,42 @@ async function cmdReleaseStamp(versionArg, opts = {}) {
1631
1642
  const plan = stampPlan({ stories: m.stories, stamped, unknown });
1632
1643
  process.stdout.write("\n" + renderStampPlan(plan, { app, version, environment, tracker: adapter.kind }) + "\n");
1633
1644
  warn(stampWarning(plan));
1634
- if (plan.pendientes.length === 0) { info("Nada que hacer."); return; }
1645
+ if (plan.pending.length === 0) { info("Nada que hacer."); return; }
1635
1646
 
1636
1647
  if (opts.dryRun) { info("[dry-run] no se escribió nada."); return; }
1637
1648
  if (!opts.yes) {
1638
1649
  if (!process.stdin.isTTY) {
1639
- info(`Sin TTY y sin --yes: no estampo. Re-ejecutá con --yes si querés los ${plan.pendientes.length} comentarios.`);
1650
+ info(`Sin TTY y sin --yes: no estampo. Re-ejecutá con --yes si querés los ${plan.pending.length} comentarios.`);
1640
1651
  return;
1641
1652
  }
1642
- const a = (await askOrCancel(` ¿Estampo ${plan.pendientes.length} comentario(s)? (s/N) `) || "").toLowerCase();
1653
+ const a = (await askOrCancel(` ¿Estampo ${plan.pending.length} comentario(s)? (s/N) `) || "").toLowerCase();
1643
1654
  if (!["s", "si", "sí", "y", "yes"].includes(a)) {
1644
1655
  info("No se estampó nada — la release está hecha igual.");
1645
- info(SIN_ESTAMPAR);
1656
+ info(NOT_STAMPED_NOTE);
1646
1657
  return; // salida 0: decir que no es una respuesta válida, no un error
1647
1658
  }
1648
1659
  }
1649
1660
 
1650
- const cuerpo = renderReleaseStamp({
1661
+ const body = renderReleaseStamp({
1651
1662
  app, version, environment, date: formatFecha(), commit: safeGit(["rev-list", "-n", "1", tag])?.trim(),
1652
1663
  url: textOpt(opts, "url") || null,
1653
1664
  });
1654
- let hechos = 0;
1655
- const fallados = [];
1656
- for (const s of plan.pendientes) {
1657
- try { await adapter.comment(s.id, cuerpo); hechos++; ok(`${s.id} estampada`); }
1658
- catch (e) { fallados.push(s.id); warn(`${s.id}: ${String(e.message).split("\n")[0]}`); }
1659
- }
1660
- if (fallados.length) {
1661
- warn(`quedaron ${fallados.length} sin estampar: ${fallados.join(", ")}. Volvé a correr el comando — las ya hechas se saltean.`);
1665
+ let stampedCount = 0;
1666
+ const failed = [];
1667
+ for (const s of plan.pending) {
1668
+ try { await adapter.comment(s.id, body); stampedCount++; ok(`${s.id} estampada`); }
1669
+ catch (e) { failed.push(s.id); warn(`${s.id}: ${String(e.message).split("\n")[0]}`); }
1670
+ }
1671
+ if (failed.length) {
1672
+ warn(`quedaron ${failed.length} sin estampar: ${failed.join(", ")}. Volvé a correr el comando — las ya hechas se saltean.`);
1662
1673
  process.exitCode = 1;
1663
- } else ok(`${hechos} US estampada(s) con ${app} ${tag} → ${environment.toUpperCase()}.`);
1674
+ } else ok(`${stampedCount} US estampada(s) con ${app} ${tag} → ${environment.toUpperCase()}.`);
1664
1675
 
1665
1676
  // El aviso al canal es del EVENTO de despliegue, no del estampado: sale aunque no se
1666
1677
  // haya estampado nada (para eso está `--only-notify`), y no sale si el repo no lo declaró.
1667
1678
  const cfg = (() => { try { return notifyConfig(process.env); } catch (e) { warn(String(e.message)); return null; } })();
1668
1679
  if (cfg && !opts.noNotify) {
1669
- await confirmarYAvisar(cfg, {
1680
+ await confirmAndNotify(cfg, {
1670
1681
  event: "deployed", app, version, environment, author: gitUser(), date: formatFecha(),
1671
1682
  url: textOpt(opts, "url") || null, stories: m.stories.map((x) => ({ id: x.id, title: x.title })),
1672
1683
  }, opts);
@@ -1703,21 +1714,21 @@ async function cmdReleaseStatus(opts = {}) {
1703
1714
 
1704
1715
  // Back-merge pendiente: producción adelante de integración es el olvido clásico.
1705
1716
  if (prod && flow.dev) {
1706
- const pendiente = safeGit(["rev-list", "--count", `${flow.dev}..${prod}`])?.trim();
1707
- if (pendiente && Number(pendiente) > 0) {
1708
- warn(`'${prod}' está ${pendiente} commit(s) adelante de '${flow.dev}': falta el back-merge.`);
1717
+ const behind = safeGit(["rev-list", "--count", `${flow.dev}..${prod}`])?.trim();
1718
+ if (behind && Number(behind) > 0) {
1719
+ warn(`'${prod}' está ${behind} commit(s) adelante de '${flow.dev}': falta el back-merge.`);
1709
1720
  process.stdout.write(` git checkout ${flow.dev} && git merge ${prod} && git push origin ${flow.dev}\n`);
1710
1721
  }
1711
1722
  }
1712
1723
 
1713
1724
  // Branches de release abiertas (model B): una que sobrevive a su release es un fork.
1714
- const abiertas = (safeGit(["branch", "--list", "release/*", "--format=%(refname:short)"]) || "")
1725
+ const openBranches = (safeGit(["branch", "--list", "release/*", "--format=%(refname:short)"]) || "")
1715
1726
  .split("\n").map((x) => x.trim()).filter(Boolean);
1716
- if (abiertas.length) {
1717
- warn(`${abiertas.length} branch(es) de release sin borrar: ${abiertas.join(", ")}`);
1727
+ if (openBranches.length) {
1728
+ warn(`${openBranches.length} branch(es) de release sin borrar: ${openBranches.join(", ")}`);
1718
1729
  process.stdout.write(` Una rama de release que sobrevive a su release es un fork. Desde 0.15.0 las borra\n`);
1719
1730
  process.stdout.write(` \`dai release done\`; las de antes se limpian a mano (git se niega si alguna no está mergeada):\n`);
1720
- process.stdout.write(` git branch -d ${abiertas.join(" ")}\n`);
1731
+ process.stdout.write(` git branch -d ${openBranches.join(" ")}\n`);
1721
1732
  }
1722
1733
 
1723
1734
  const cfg = (() => { try { return notifyConfig(process.env); } catch { return null; } })();
@@ -1730,8 +1741,8 @@ async function cmdReleaseStatus(opts = {}) {
1730
1741
  try {
1731
1742
  const out = execFileSync(tool, ["release", "list", ...(tool === "gh" ? ["--limit", "3"] : ["--per-page", "3"])],
1732
1743
  { encoding: "utf8", cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
1733
- const lineas = String(out).split("\n").filter(Boolean).slice(0, 3);
1734
- if (lineas.length) { info("últimos releases publicados:"); for (const l of lineas) process.stdout.write(` ${l}\n`); }
1744
+ const outLines = String(out).split("\n").filter(Boolean).slice(0, 3);
1745
+ if (outLines.length) { info("últimos releases publicados:"); for (const l of outLines) process.stdout.write(` ${l}\n`); }
1735
1746
  } catch { /* sin binario, sin auth o sin releases: no es un problema que resolver acá */ }
1736
1747
  }
1737
1748
  }
@@ -1745,7 +1756,7 @@ async function cmdReleaseNotify(opts = {}) {
1745
1756
  try { cfg = notifyConfig(process.env); } catch (e) { fail(String(e.message), 1); }
1746
1757
  if (!cfg) fail("no hay canal declarado: poné DAI_NOTIFY (y DAI_NOTIFY_WEBHOOK) en el .env.dai.", 1);
1747
1758
  if (!opts.test) fail("por ahora `dai release notify` solo sabe probar el canal: dai release notify --test", 2);
1748
- await confirmarYAvisar(cfg, { event: "test" }, opts);
1759
+ await confirmAndNotify(cfg, { event: "test" }, opts);
1749
1760
  }
1750
1761
 
1751
1762
  // El nombre de la app en los avisos y en el stamp. Sale del repo, no de una variable
@@ -1756,7 +1767,7 @@ function releaseApp(opts) {
1756
1767
 
1757
1768
  // Mostrar el mensaje EXACTO que va a salir y pedir confirmación. Un mensaje a un canal de
1758
1769
  // equipo no se desmanda: se muestra antes, siempre, aunque el aviso sea de una sola línea.
1759
- async function confirmarYAvisar(cfg, ev, opts = {}) {
1770
+ async function confirmAndNotify(cfg, ev, opts = {}) {
1760
1771
  const msg = renderNotice(ev);
1761
1772
  process.stdout.write(`\n ── Aviso a ${describeTarget(cfg)} ──\n`);
1762
1773
  process.stdout.write(msg.split("\n").map((l) => ` │ ${l}`).join("\n") + "\n");
@@ -2169,14 +2180,14 @@ function cmdDocs(dest) {
2169
2180
  // repo de nadie: las capturas de los tutoriales ni siquiera viajan en el paquete npm
2170
2181
  // (issue #37). Se saltea, y los links que las nombran se absolutizan contra el sitio.
2171
2182
  cpSync(join(ROOT, "docs"), dest, { recursive: true, filter: (src) => !/[/\\]public([/\\]|$)/.test(src) });
2172
- let reescritos = 0;
2183
+ let rewritten = 0;
2173
2184
  for (const f of walkMd(dest)) {
2174
2185
  const md = readFileSync(f, "utf8");
2175
2186
  const out = absolutizeSiteLinks(md);
2176
- if (out !== md) { writeFileSync(f, out); reescritos++; }
2187
+ if (out !== md) { writeFileSync(f, out); rewritten++; }
2177
2188
  }
2178
2189
  ok(`documentación copiada a ${dest}`);
2179
- if (reescritos) info(`${reescritos} documento(s) con capturas: los links apuntan al sitio publicado.`);
2190
+ if (rewritten) info(`${rewritten} documento(s) con capturas: los links apuntan al sitio publicado.`);
2180
2191
  }
2181
2192
 
2182
2193
  // Los .md de un árbol, para la reescritura de links de cmdDocs.
@@ -2437,13 +2448,13 @@ function cmdDoctor() {
2437
2448
  if (localActive.length && existsSync(join(cwd, "openspec"))) {
2438
2449
  info("OpenSpec — los comandos van en el chat del asistente, no en la terminal:");
2439
2450
  for (const a of localActive) {
2440
- const faltan = OPSX_IDS.filter((id) => !existsSync(opsxPath(a.kind, id)));
2441
- if (faltan.length === OPSX_IDS.length) {
2451
+ const missing = OPSX_IDS.filter((id) => !existsSync(opsxPath(a.kind, id)));
2452
+ if (missing.length === OPSX_IDS.length) {
2442
2453
  warn(`${a.label}: no hay comandos opsx. Generalos: openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force`);
2443
2454
  } else {
2444
2455
  const hay = OPSX_IDS.filter((id) => existsSync(opsxPath(a.kind, id)));
2445
2456
  ok(`${a.label}: ${hay.map((id) => opsxCommand(a.kind, id)).join(" · ")}`);
2446
- if (faltan.length) warn(`${a.label}: faltan ${faltan.join(", ")} — regenerá con \`openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force\``);
2457
+ if (missing.length) warn(`${a.label}: faltan ${missing.join(", ")} — regenerá con \`openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force\``);
2447
2458
  }
2448
2459
  }
2449
2460
  process.stdout.write(" (el nombre sale del archivo que genera OpenSpec: solo Claude usa los dos puntos)\n");
@@ -169,7 +169,7 @@ export function envFor(pm) {
169
169
  // Flujo de branches: sin esto, `dai pr` tiene que adivinar la base, y en un repo con
170
170
  // ramas de ambiente adivinar significa proponer un merge a producción (issue #46).
171
171
  // Van vacías a propósito: vacío = "no declarada", y dai cae a la default del remoto.
172
- const flujo =
172
+ const flowBlock =
173
173
  "\n# ── Flujo de branches (dai pr · dai done) ─────────────────────────────────\n" +
174
174
  "# Las DOS ramas de vida larga del repo. La base de una PR sale del TIPO de branch:\n" +
175
175
  "# feature/ · fix/ → PR contra DAI_BRANCH_DEV\n" +
@@ -178,7 +178,7 @@ export function envFor(pm) {
178
178
  "DAI_BRANCH_DEV=\n" +
179
179
  "DAI_BRANCH_PROD=\n";
180
180
  if (pm === "clickup") {
181
- return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n" + flujo;
181
+ return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n" + flowBlock;
182
182
  }
183
183
  if (pm === "jira") {
184
184
  return head +
@@ -192,9 +192,9 @@ export function envFor(pm) {
192
192
  "# Solo si tu Jira exige campos propios AL CREAR una US (lo usa grill-user-story,\n" +
193
193
  "# no hace falta para leerlas). El default ya es .dai/jira-fields.json; descomentá\n" +
194
194
  "# solo para apuntar a otra ruta. Si el archivo no existe, se ignora.\n" +
195
- "# DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n" + flujo;
195
+ "# DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n" + flowBlock;
196
196
  }
197
- return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n" + flujo;
197
+ return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n" + flowBlock;
198
198
  }
199
199
 
200
200
  // ── Helpers aditivos para `dai init` — no destruir la config de un repo vivo ──
@@ -29,7 +29,7 @@ export function branchFlow(env = {}) {
29
29
 
30
30
  // Los tipos de branch que van contra producción: una release que se corta y un hotfix
31
31
  // que sale del tag que está en PRO. El resto integra.
32
- const HACIA_PROD = new Set(["release", "hotfix"]);
32
+ const PROD_BOUND_TYPES = new Set(["release", "hotfix"]);
33
33
 
34
34
  // Resuelve la base de una PR y —tan importante como el valor— POR QUÉ es esa.
35
35
  // --base > el mapa de ramas según el tipo de branch > rama default del remoto > main
@@ -38,9 +38,9 @@ export function resolveBase({ flag, branch = null, env = {}, originHead = null }
38
38
  if (explicit) return { base: explicit, source: "--base", reason: null };
39
39
 
40
40
  const flow = branchFlow(env);
41
- const tipo = branchType(branch);
42
- if (HACIA_PROD.has(tipo) && flow.prod) {
43
- return { base: flow.prod, source: "DAI_BRANCH_PROD (.env.dai)", reason: `la branch es ${tipo}/` };
41
+ const branchKind = branchType(branch);
42
+ if (PROD_BOUND_TYPES.has(branchKind) && flow.prod) {
43
+ return { base: flow.prod, source: "DAI_BRANCH_PROD (.env.dai)", reason: `la branch es ${branchKind}/` };
44
44
  }
45
45
  if (flow.dev) {
46
46
  return { base: flow.dev, source: "DAI_BRANCH_DEV (.env.dai)", reason: null };
@@ -61,12 +61,12 @@ export function isProdBranch(base, env = {}) {
61
61
  }
62
62
 
63
63
  // Las fuentes que YA son una decisión de alguien: no hay nada que avisar.
64
- const DECIDIDAS = new Set(["--base", "DAI_BRANCH_DEV (.env.dai)", "DAI_BRANCH_PROD (.env.dai)", "lo respondiste vos"]);
64
+ const DECIDED_SOURCES = new Set(["--base", "DAI_BRANCH_DEV (.env.dai)", "DAI_BRANCH_PROD (.env.dai)", "lo respondiste vos"]);
65
65
 
66
66
  // El aviso que va debajo del preview cuando la base salió de un default. Desaparece en
67
67
  // cuanto el repo declara su mapa de ramas, que es justo lo que se le pide.
68
68
  export function baseHint(source, base) {
69
- if (DECIDIDAS.has(source)) return null;
69
+ if (DECIDED_SOURCES.has(source)) return null;
70
70
  return `la base '${base}' salió de ${source} — dai no sabe cuáles son las ramas de vida larga de este repo. Declaralas una vez en el .env.dai:\n` +
71
71
  ` DAI_BRANCH_DEV=<rama-que-integra> · DAI_BRANCH_PROD=<rama-que-despliega-a-PRO>\n` +
72
72
  ` Con eso: feature/ y fix/ van contra DEV; release/ y hotfix/ contra PROD (con confirmación).`;
@@ -26,7 +26,7 @@
26
26
  import { daiFetch } from "./http.mjs";
27
27
 
28
28
  // El envelope de cada canal. Esto es el adaptador entero.
29
- const CANALES = {
29
+ const CHANNELS = {
30
30
  discord: (msg) => ({ content: msg }),
31
31
  slack: (msg) => ({ text: msg }),
32
32
  webex: (msg) => ({ markdown: msg }),
@@ -37,7 +37,7 @@ const CANALES = {
37
37
  webhook: (msg, cfg, ev) => ({ ...eventFields(ev), text: msg }),
38
38
  };
39
39
 
40
- export const CANALES_VALIDOS = Object.keys(CANALES);
40
+ export const VALID_CHANNELS = Object.keys(CHANNELS);
41
41
 
42
42
  // ── Config ───────────────────────────────────────────────────────────────────
43
43
  // Devuelve null cuando el repo no declaró canal: no avisar es el default, y un default
@@ -45,9 +45,9 @@ export const CANALES_VALIDOS = Object.keys(CANALES);
45
45
  export function notifyConfig(env = {}) {
46
46
  const channel = String(env.DAI_NOTIFY ?? "").trim().toLowerCase();
47
47
  if (channel === "" || channel === "none") return null;
48
- if (!CANALES[channel]) {
48
+ if (!CHANNELS[channel]) {
49
49
  throw new Error(
50
- `DAI_NOTIFY='${channel}' no es un canal que dai conozca (${CANALES_VALIDOS.join(" | ")} | none).\n` +
50
+ `DAI_NOTIFY='${channel}' no es un canal que dai conozca (${VALID_CHANNELS.join(" | ")} | none).\n` +
51
51
  ` Para un destino propio —Teams, Mattermost, un sistema interno— usá 'webhook':\n` +
52
52
  ` manda los campos del release en JSON y tu endpoint hace lo que quiera con ellos.`,
53
53
  );
@@ -121,10 +121,10 @@ function eventFields(ev = {}) {
121
121
  // diferencia entre el QUÉ y el CÓMO que sostiene todo el método.
122
122
  const MAX_BULLETS = 8;
123
123
 
124
- const dosDigitos = (n) => String(n).padStart(2, "0");
124
+ const pad2 = (n) => String(n).padStart(2, "0");
125
125
  export function formatFecha(d = new Date()) {
126
- return `${dosDigitos(d.getDate())}/${dosDigitos(d.getMonth() + 1)}/${d.getFullYear()} ` +
127
- `${dosDigitos(d.getHours())}:${dosDigitos(d.getMinutes())}`;
126
+ return `${pad2(d.getDate())}/${pad2(d.getMonth() + 1)}/${d.getFullYear()} ` +
127
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
128
128
  }
129
129
 
130
130
  export function renderNotice(ev = {}) {
@@ -161,7 +161,7 @@ export function renderNotice(ev = {}) {
161
161
 
162
162
  // El cuerpo que se le manda al canal.
163
163
  export function payloadFor(cfg, ev) {
164
- const envelope = CANALES[cfg.channel];
164
+ const envelope = CHANNELS[cfg.channel];
165
165
  if (!envelope) throw new Error(`canal desconocido: ${cfg.channel}`);
166
166
  return envelope(renderNotice(ev), cfg, ev);
167
167
  }
@@ -190,16 +190,16 @@ export async function sendNotice(cfg, ev) {
190
190
  }
191
191
 
192
192
  export function explainNotifyError(cfg, status, body = "") {
193
- const donde = describeTarget(cfg);
193
+ const target = describeTarget(cfg);
194
194
  if (status === 401 || status === 403) {
195
- return `${donde} rechazó el aviso (${status}): el endpoint existe pero no autoriza.\n` +
195
+ return `${target} rechazó el aviso (${status}): el endpoint existe pero no autoriza.\n` +
196
196
  " Revisá DAI_NOTIFY_WEBHOOK en el .env.dai — puede estar revocado o ser de otro espacio.";
197
197
  }
198
198
  if (status === 404) {
199
- return `${donde} no existe (404). El webhook fue borrado, o la URL está mal copiada.`;
199
+ return `${target} no existe (404). El webhook fue borrado, o la URL está mal copiada.`;
200
200
  }
201
201
  if (status === 429) {
202
- return `${donde} te frenó por rate limit (429). El aviso no salió; el release sí está hecho.`;
202
+ return `${target} te frenó por rate limit (429). El aviso no salió; el release sí está hecho.`;
203
203
  }
204
- return `${donde} respondió ${status}.${body ? `\n ${body}` : ""}`;
204
+ return `${target} respondió ${status}.${body ? `\n ${body}` : ""}`;
205
205
  }
@@ -65,8 +65,8 @@ function mdAdapter(env) {
65
65
  comment(id, markdown) {
66
66
  const p = join(dir, `${id}.deploys.md`);
67
67
  mkdirSync(dirname(p), { recursive: true });
68
- const previo = existsSync(p) ? readFileSync(p, "utf8") : `# Despliegues de ${id}\n`;
69
- writeFileSync(p, `${previo.replace(/\s*$/, "")}\n\n${markdown}\n`);
68
+ const previousTag = existsSync(p) ? readFileSync(p, "utf8") : `# Despliegues de ${id}\n`;
69
+ writeFileSync(p, `${previousTag.replace(/\s*$/, "")}\n\n${markdown}\n`);
70
70
  return p;
71
71
  },
72
72
  listComments(id) {
@@ -43,6 +43,12 @@ export function jiraAuthHeaders(env) {
43
43
  }
44
44
 
45
45
  // ── ADF → markdown (para leer la descripción) ────────────────────────────────
46
+
47
+ // Una celda puede tener varios párrafos, y una fila de markdown no sobrevive un salto de
48
+ // línea en el medio: se aplana a UNA línea y se escapan los `|` que traiga el texto.
49
+ const inlineCell = (cell) => adfToMarkdown(cell).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
50
+ const mdRow = (cells) => `| ${cells.join(" | ")} |`;
51
+
46
52
  export function adfToMarkdown(node) {
47
53
  if (node == null) return "";
48
54
  if (Array.isArray(node)) return node.map(adfToMarkdown).join("");
@@ -53,6 +59,25 @@ export function adfToMarkdown(node) {
53
59
  case "bulletList":
54
60
  case "orderedList": return (node.content || []).map(adfToMarkdown).join("");
55
61
  case "listItem": return "- " + (node.content || []).map(adfToMarkdown).join("").trim() + "\n";
62
+ // Jira Cloud tiene tablas de verdad, y el molde de US pone la metadata —`spec_version`
63
+ // incluido— en una. Sin estos casos caían al `default`, que aplana cada celda a su
64
+ // propia línea: `spec_version` quedaba en una y `v1` en la siguiente, y SPEC_VERSION_RE
65
+ // —que no cruza saltos de línea a propósito— no encontraba nada. La US declaraba `v1`
66
+ // en el tracker y dai estampaba `version: pendiente` sin que nada explicara por qué.
67
+ case "table": {
68
+ const rows = (node.content || []).filter((r) => r?.type === "tableRow");
69
+ if (rows.length === 0) return "";
70
+ const out = rows.map((r) => mdRow((r.content || []).map(inlineCell)));
71
+ // El separador va solo si la primera fila es de encabezados: es lo que hace que esto
72
+ // se RENDERICE como tabla donde el markdown importa (el cuerpo de una PR, el .md que
73
+ // baja `dai edit-us`). Para el parseo no cambia nada.
74
+ const head = rows[0].content || [];
75
+ if (head.length && head.every((c) => c?.type === "tableHeader")) {
76
+ out.splice(1, 0, mdRow(head.map(() => "---")));
77
+ }
78
+ return out.join("\n") + "\n";
79
+ }
80
+ case "tableRow": return mdRow((node.content || []).map(inlineCell)) + "\n";
56
81
  case "text": return node.text || "";
57
82
  case "hardBreak": return "\n";
58
83
  default: return (node.content || []).map(adfToMarkdown).join("");
@@ -60,23 +85,62 @@ export function adfToMarkdown(node) {
60
85
  }
61
86
 
62
87
  // ── markdown → ADF (para CREAR el issue: la descripción va en ADF) ────────────
63
- // Parser de bloques: headings, párrafos y bullets. Suficiente para el formato de US.
88
+ // Parser de bloques: headings, párrafos, bullets y tablas. Suficiente para el formato de US.
89
+
90
+ // Una fila de markdown: `| a | b |`. El separador (`|---|---|`) marca que la fila de
91
+ // arriba era el encabezado; no es una fila de datos.
92
+ const MD_ROW_RE = /^\s*\|.*\|\s*$/;
93
+ const MD_SEP_RE = /^\s*\|[\s:|-]+\|\s*$/;
94
+ const splitCells = (line) =>
95
+ line.trim().replace(/^\||\|$/g, "").split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, "|"));
96
+
64
97
  export function markdownToAdf(md) {
65
- const clean = (s) => s.replace(/[*_`]+/g, "").trim();
98
+ // El `_` se saca solo cuando hace de énfasis (_así_), no cuando vive DENTRO de una
99
+ // palabra: sacarlo siempre publicaba la fila del molde como `specversion`, un nombre de
100
+ // campo que nadie escribió y que del otro lado hubo que aprender a leer (issue #46).
101
+ const clean = (s) => s.replace(/[*`]+/g, "").replace(/(?<![A-Za-z0-9])_+|_+(?![A-Za-z0-9])/g, "").trim();
66
102
  const content = [];
67
- let para = [], bullets = null;
103
+ let para = [], bullets = null, rows = null;
68
104
  const flushPara = () => { if (para.length) { const t = clean(para.join(" ")); if (t) content.push({ type: "paragraph", content: [{ type: "text", text: t }] }); para = []; } };
69
105
  const flushBullets = () => { if (bullets) { if (bullets.length) content.push({ type: "bulletList", content: bullets }); bullets = null; } };
106
+ // La metadata de trazabilidad del molde de US es una TABLA, y el comentario de cobertura
107
+ // también. Sin este caso viajaban a Jira como párrafos con pipes adentro: ilegibles, y
108
+ // encima invitaban a rehacerlos como tabla de Jira a mano — que es justo lo que del otro
109
+ // lado dai no sabía leer.
110
+ const flushTable = () => {
111
+ if (!rows) return;
112
+ const data = rows.filter((r) => !r.sep);
113
+ if (data.length) {
114
+ const headed = rows.length > 1 && rows[1].sep;
115
+ const cell = (raw, header) => {
116
+ const text = clean(raw);
117
+ return {
118
+ type: header ? "tableHeader" : "tableCell",
119
+ attrs: {},
120
+ content: [{ type: "paragraph", content: text ? [{ type: "text", text }] : [] }],
121
+ };
122
+ };
123
+ content.push({
124
+ type: "table",
125
+ attrs: { isNumberColumnEnabled: false, layout: "default" },
126
+ content: data.map((r, i) => ({ type: "tableRow", content: r.cells.map((c) => cell(c, headed && i === 0)) })),
127
+ });
128
+ }
129
+ rows = null;
130
+ };
70
131
  for (const raw of String(md || "").split(/\r?\n/)) {
71
132
  const line = raw.replace(/\s+$/, "");
72
133
  const h = line.match(/^(#{1,6})\s+(.*)$/);
73
134
  const b = line.match(/^\s*[-*+]\s+(?:\[[ xX]\]\s+)?(.*)$/);
74
- if (h) { flushPara(); flushBullets(); const t = clean(h[2]); if (t) content.push({ type: "heading", attrs: { level: h[1].length }, content: [{ type: "text", text: t }] }); }
75
- else if (b) { flushPara(); if (!bullets) bullets = []; const t = clean(b[1]); if (t) bullets.push({ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: t }] }] }); }
76
- else if (line.trim() === "") { flushPara(); flushBullets(); }
77
- else { flushBullets(); para.push(line.trim()); }
135
+ if (h) { flushPara(); flushBullets(); flushTable(); const t = clean(h[2]); if (t) content.push({ type: "heading", attrs: { level: h[1].length }, content: [{ type: "text", text: t }] }); }
136
+ // Una fila se reconoce por abrir Y cerrar con `|`; el separador entra acá también,
137
+ // marcado, porque es lo único que distingue un encabezado de una fila más.
138
+ else if (MD_ROW_RE.test(line)) { flushPara(); flushBullets(); if (!rows) rows = []; rows.push({ sep: MD_SEP_RE.test(line), cells: splitCells(line) }); }
139
+ else if (b) { flushPara(); flushTable(); if (!bullets) bullets = []; const t = clean(b[1]); if (t) bullets.push({ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: t }] }] }); }
140
+ else if (line.trim() === "") { flushPara(); flushBullets(); flushTable(); }
141
+ else { flushBullets(); flushTable(); para.push(line.trim()); }
78
142
  }
79
- flushPara(); flushBullets();
143
+ flushPara(); flushBullets(); flushTable();
80
144
  if (content.length === 0) content.push({ type: "paragraph", content: [{ type: "text", text: " " }] });
81
145
  return { type: "doc", version: 1, content };
82
146
  }
@@ -63,10 +63,10 @@ export function changelogEntry({ version, date, manifest = {}, secciones = ["Agr
63
63
  // Acá es un AVISO, no un bloqueo: cortar la versión no debe frenarse por la redacción.
64
64
  export function changelogGaps(entry) {
65
65
  const gaps = [];
66
- const cuerpo = String(entry ?? "").replace(/<!--[\s\S]*?-->/g, "");
66
+ const body = String(entry ?? "").replace(/<!--[\s\S]*?-->/g, "");
67
67
  if (String(entry ?? "").includes(CHANGELOG_MARK)) gaps.push("el manifiesto de dai sigue sin repartir");
68
- const conTexto = cuerpo.split("\n").some((l) => /^\s*[-*]\s+\S/.test(l));
69
- if (!conTexto) gaps.push("no hay ni un ítem en las secciones");
68
+ const hasItems = body.split("\n").some((l) => /^\s*[-*]\s+\S/.test(l));
69
+ if (!hasItems) gaps.push("no hay ni un ítem en las secciones");
70
70
  return gaps;
71
71
  }
72
72
 
@@ -99,9 +99,9 @@ export function changelogSection(text, version) {
99
99
  const m = s.match(re);
100
100
  if (!m) return null;
101
101
  const ini = m.index + m[0].length;
102
- const resto = s.slice(ini);
103
- const j = resto.search(/^## \[/m);
104
- return (j === -1 ? resto : resto.slice(0, j)).replace(/<!--[\s\S]*?-->/g, "").trim() || null;
102
+ const rest = s.slice(ini);
103
+ const j = rest.search(/^## \[/m);
104
+ return (j === -1 ? rest : rest.slice(0, j)).replace(/<!--[\s\S]*?-->/g, "").trim() || null;
105
105
  }
106
106
 
107
107
  // ── Nombres ──────────────────────────────────────────────────────────────────
@@ -67,20 +67,20 @@ export function mergedBranch(subject) {
67
67
  // observable. Cualquier derivación automática habría cortado un patch equivocado.
68
68
  // Por eso esto devuelve un PISO y su justificación, y quien firma es una persona.
69
69
  export function proposeBump(commits = []) {
70
- const reales = commits.filter((c) => !c.merge);
71
- if (reales.some((c) => c.breaking)) {
72
- const cual = reales.find((c) => c.breaking);
73
- return { bump: "major", floor: true, reason: `hay un commit marcado como breaking (${cual.subject})` };
70
+ const realCommits = commits.filter((c) => !c.merge);
71
+ if (realCommits.some((c) => c.breaking)) {
72
+ const offender = realCommits.find((c) => c.breaking);
73
+ return { bump: "major", floor: true, reason: `hay un commit marcado como breaking (${offender.subject})` };
74
74
  }
75
- if (reales.some((c) => c.type === "feat")) {
76
- const n = reales.filter((c) => c.type === "feat").length;
75
+ if (realCommits.some((c) => c.type === "feat")) {
76
+ const n = realCommits.filter((c) => c.type === "feat").length;
77
77
  return { bump: "minor", floor: true, reason: `${n} commit(s) feat: agregan funcionalidad` };
78
78
  }
79
79
  return {
80
80
  bump: "patch",
81
81
  floor: true,
82
- reason: reales.length
83
- ? `solo hay ${[...new Set(reales.map((c) => c.type || "sin-tipo"))].sort().join(", ")}: ningún feat ni breaking`
82
+ reason: realCommits.length
83
+ ? `solo hay ${[...new Set(realCommits.map((c) => c.type || "sin-tipo"))].sort().join(", ")}: ningún feat ni breaking`
84
84
  : "no hay commits nuevos",
85
85
  };
86
86
  }
@@ -118,7 +118,7 @@ export function buildManifest({ commits = [], linked = [], live = {}, unreachabl
118
118
 
119
119
  const stories = [...porId.values()].map((r) => {
120
120
  const l = live[r.id];
121
- const estado = unreachable || !l ? (unreachable ? "sin-respuesta" : "sin-us")
121
+ const storyStatus = unreachable || !l ? (unreachable ? "sin-respuesta" : "sin-us")
122
122
  : l.ac_hash === r.ac_hash ? "al-dia" : "atrasado";
123
123
  return {
124
124
  id: r.id,
@@ -127,7 +127,7 @@ export function buildManifest({ commits = [], linked = [], live = {}, unreachabl
127
127
  ac_hash: r.ac_hash ?? null,
128
128
  spec_version: l?.spec_version ?? null,
129
129
  change: r.change ?? null,
130
- status: estado,
130
+ status: storyStatus,
131
131
  };
132
132
  }).sort((a, b) => String(a.id).localeCompare(String(b.id)));
133
133
 
@@ -136,11 +136,11 @@ export function buildManifest({ commits = [], linked = [], live = {}, unreachabl
136
136
  // trabajo de producto sin link, y esta es la última oportunidad de verlo.
137
137
  const ids = new Set(stories.map((s) => String(s.id).toLowerCase()));
138
138
  const branches = commits.map((c) => mergedBranch(c.subject)).filter(Boolean);
139
- const EXENTAS = new Set(["chore", "docs", "ci", "build", "test", "refactor", "style", "release", "hotfix", "revert"]);
139
+ const EXEMPT_TYPES = new Set(["chore", "docs", "ci", "build", "test", "refactor", "style", "release", "hotfix", "revert"]);
140
140
  const chores = [], orphans = [];
141
141
  for (const b of branches) {
142
- if (nombraAlguna(b, ids)) continue; // ya está contada como US
143
- if (EXENTAS.has(branchType(b))) { chores.push(b); continue; }
142
+ if (namesAnyStory(b, ids)) continue; // ya está contada como US
143
+ if (EXEMPT_TYPES.has(branchType(b))) { chores.push(b); continue; }
144
144
  // "Entró trabajo sin link" solo es un hallazgo si el repo trabaja con User Stories. En
145
145
  // un repo de tooling —el de dai, sin ir más lejos— NINGUNA branch va a declarar una, y
146
146
  // marcarlas todas convierte el aviso en ruido que se aprende a ignorar. La excepción es
@@ -158,22 +158,22 @@ export function buildManifest({ commits = [], linked = [], live = {}, unreachabl
158
158
  commits: commits.filter((c) => !c.merge).length,
159
159
  merges: commits.filter((c) => c.merge).length,
160
160
  stories: stories.length,
161
- atrasadas: stories.filter((s) => s.status === "atrasado").length,
161
+ stale: stories.filter((s) => s.status === "atrasado").length,
162
162
  },
163
163
  };
164
164
  }
165
165
 
166
166
  // ¿El nombre de la branch nombra alguna de las US del manifiesto? Comparación en
167
167
  // minúsculas: el slug de la branch va en minúscula y el key del tracker en mayúscula.
168
- function nombraAlguna(branch, ids) {
168
+ function namesAnyStory(branch, ids) {
169
169
  const b = String(branch).toLowerCase();
170
170
  for (const id of ids) if (b.includes(id)) return true;
171
171
  return false;
172
172
  }
173
173
 
174
174
  // ── Render ───────────────────────────────────────────────────────────────────
175
- const ICONO = { "al-dia": "✅", atrasado: "⚠️ ", "sin-us": "❓", "sin-respuesta": "⚠️ " };
176
- const ETIQUETA = {
175
+ const ICON = { "al-dia": "✅", atrasado: "⚠️ ", "sin-us": "❓", "sin-respuesta": "⚠️ " };
176
+ const LABEL = {
177
177
  "al-dia": "al día", atrasado: "ATRASADA", "sin-us": "no está en el tracker",
178
178
  "sin-respuesta": "no verificada",
179
179
  };
@@ -195,13 +195,13 @@ export function renderManifest(m, ctx = {}) {
195
195
  const w = Math.max(...m.stories.map((s) => String(s.id).length), 4);
196
196
  for (const s of m.stories) {
197
197
  const t = s.title ? ` ${s.title}` : "";
198
- L.push(` ${ICONO[s.status] || " "} ${String(s.id).padEnd(w)} ${s.version || "?"}${t}`);
199
- if (s.status !== "al-dia") L.push(` ${" ".repeat(w)} ${ETIQUETA[s.status]}`);
198
+ L.push(` ${ICON[s.status] || " "} ${String(s.id).padEnd(w)} ${s.version || "?"}${t}`);
199
+ if (s.status !== "al-dia") L.push(` ${" ".repeat(w)} ${LABEL[s.status]}`);
200
200
  }
201
201
  }
202
- if (m.counts.atrasadas > 0) {
202
+ if (m.counts.stale > 0) {
203
203
  L.push(``);
204
- L.push(` ⚠ ${m.counts.atrasadas} US ATRASADA(S): el QUÉ cambió después de implementarlo.`);
204
+ L.push(` ⚠ ${m.counts.stale} US ATRASADA(S): el QUÉ cambió después de implementarlo.`);
205
205
  L.push(` Esta release las llevaría sin cubrir el criterio nuevo. Revisalas antes de cortar.`);
206
206
  }
207
207
  if (m.chores.length) {
@@ -32,8 +32,8 @@ export function alreadyStamped(comments = [], marker) {
32
32
  // y el ticket solo necesita saber qué salió, dónde y cuándo.
33
33
  export function renderReleaseStamp(ev = {}) {
34
34
  const v = `v${String(ev.version ?? "").replace(/^v/, "")}`;
35
- const amb = String(ev.environment ?? "").toUpperCase();
36
- const L = [`**${ev.app || "app"} ${v}** desplegada en **${amb}** — ${ev.date || ""}`.trim(), ""];
35
+ const envLabel = String(ev.environment ?? "").toUpperCase();
36
+ const L = [`**${ev.app || "app"} ${v}** desplegada en **${envLabel}** — ${ev.date || ""}`.trim(), ""];
37
37
  if (ev.commit) L.push(`- commit: \`${String(ev.commit).slice(0, 8)}\``);
38
38
  if (ev.url) L.push(`- release: ${ev.url}`);
39
39
  L.push("");
@@ -48,9 +48,9 @@ export function renderReleaseStamp(ev = {}) {
48
48
  // stories → las US del manifiesto
49
49
  // stamped → Set de ids que YA tienen la marca de este (app, versión, ambiente)
50
50
  export function stampPlan({ stories = [], stamped = new Set(), unknown = false } = {}) {
51
- const pendientes = [], repetidas = [];
52
- for (const s of stories) (stamped.has(s.id) ? repetidas : pendientes).push(s);
53
- return { pendientes, repetidas, unknown, total: stories.length };
51
+ const pending = [], alreadyDone = [];
52
+ for (const s of stories) (stamped.has(s.id) ? alreadyDone : pending).push(s);
53
+ return { pending, alreadyDone, unknown, total: stories.length };
54
54
  }
55
55
 
56
56
  // El aviso de alcance. Es la pantalla que pidió existir: cuántos comentarios, en qué
@@ -62,8 +62,8 @@ export function renderStampPlan(plan, { app, version, environment, tracker } = {
62
62
  L.push(` versión: ${v} app: ${app || "?"} ambiente: ${String(environment ?? "").toUpperCase()}`);
63
63
  L.push(` tracker: ${tracker || "?"} · ${plan.total} User Storie(s) en el release`);
64
64
  L.push(` ─────────────────────────────────────────────────────`);
65
- for (const s of plan.pendientes) L.push(` ${s.id}${s.title ? ` ${s.title}` : ""}`);
66
- for (const s of plan.repetidas) L.push(` ${s.id}${s.title ? ` ${s.title}` : ""} ← ya estampada, se saltea`);
65
+ for (const s of plan.pending) L.push(` ${s.id}${s.title ? ` ${s.title}` : ""}`);
66
+ for (const s of plan.alreadyDone) L.push(` ${s.id}${s.title ? ` ${s.title}` : ""} ← ya estampada, se saltea`);
67
67
  if (plan.total === 0) L.push(` (ninguna: el manifiesto de esta versión no declara US)`);
68
68
  L.push(` ─────────────────────────────────────────────────────`);
69
69
  if (plan.unknown) {
@@ -75,13 +75,13 @@ export function renderStampPlan(plan, { app, version, environment, tracker } = {
75
75
 
76
76
  // La frase que decide. Dice el número REAL de escrituras y que no hay vuelta atrás.
77
77
  export function stampWarning(plan) {
78
- const n = plan.pendientes.length;
78
+ const n = plan.pending.length;
79
79
  if (n === 0) return "no hay nada para estampar: todas las US ya tienen esta versión en este ambiente.";
80
- const saltea = plan.repetidas.length ? ` (${plan.repetidas.length} ya estampada(s), se saltean)` : "";
81
- return `esto escribe ${n} comentario(s) en el tracker de todo el equipo${saltea}. No se deshace.`;
80
+ const skipped = plan.alreadyDone.length ? ` (${plan.alreadyDone.length} ya estampada(s), se saltean)` : "";
81
+ return `esto escribe ${n} comentario(s) en el tracker de todo el equipo${skipped}. No se deshace.`;
82
82
  }
83
83
 
84
84
  // Lo que se pierde al decir que no. Se dice UNA vez, sin insistir: elegir menos ruido es
85
85
  // una decisión legítima, no un error a corregir.
86
- export const SIN_ESTAMPAR =
86
+ export const NOT_STAMPED_NOTE =
87
87
  "sin estampar, el ticket no va a decir en qué versión salió: el registro queda solo en el release note.";
@@ -168,7 +168,7 @@ export function renderReviewSummary(r, { kept = [], suppressed = [], rejected =
168
168
  .filter(({ n }) => n > 0)
169
169
  .map(({ s, n }) => `${n} ${SEV[s].emoji} ${SEV[s].label}`);
170
170
 
171
- const hallazgos = kept.length
171
+ const findings = kept.length
172
172
  ? `Dejé **${kept.length}** ${kept.length === 1 ? "comentario" : "comentarios"} en línea: ${counts.join(" · ")}.`
173
173
  : "Sin comentarios en línea: no encontré nada concreto que marcar.";
174
174
 
@@ -181,7 +181,7 @@ export function renderReviewSummary(r, { kept = [], suppressed = [], rejected =
181
181
  r.summary || null,
182
182
  r.summary ? "" : null,
183
183
  "### Hallazgos",
184
- hallazgos,
184
+ findings,
185
185
  "",
186
186
  "### ✅ Lo que está bien",
187
187
  bullets(r.good),
@@ -93,8 +93,8 @@ export function validateUS(md) {
93
93
 
94
94
  for (const c of criteria) {
95
95
  if (!isGherkin(c)) {
96
- const falta = [!c.dado && "Dado", !c.cuando && "Cuando", !c.entonces && "Entonces"].filter(Boolean).join(" / ");
97
- warnings.push(`${c.label}: no es Gherkin completo — falta ${falta}. Un criterio sin las tres partes es difícil de volver un test.`);
96
+ const missingParts = [!c.dado && "Dado", !c.cuando && "Cuando", !c.entonces && "Entonces"].filter(Boolean).join(" / ");
97
+ warnings.push(`${c.label}: no es Gherkin completo — falta ${missingParts}. Un criterio sin las tres partes es difícil de volver un test.`);
98
98
  }
99
99
  if (TECNICAS.test(c.text)) {
100
100
  warnings.push(`${c.label}: menciona implementación (${c.text.match(TECNICAS)[0]}). El QUÉ describe comportamiento observable; el CÓMO lo decide el dev.`);
package/cli/lib/us.mjs CHANGED
@@ -55,17 +55,17 @@ export function renderCoverage(id, r) {
55
55
  // llega. Acá se le pone alrededor qué, contra qué, y qué mirar.
56
56
  export function explainFetchError(err, { kind, endpoint, id } = {}) {
57
57
  const raw = String(err?.message ?? err ?? "").split("\n")[0] || "error desconocido";
58
- const donde = [kind, endpoint].filter(Boolean).join(" · ");
59
- const cabeza = `no pude consultar ${id ? `la US ${id}` : "el tracker"}${donde ? ` en ${donde}` : ""}: ${raw}`;
58
+ const where = [kind, endpoint].filter(Boolean).join(" · ");
59
+ const head = `no pude consultar ${id ? `la US ${id}` : "el tracker"}${where ? ` en ${where}` : ""}: ${raw}`;
60
60
  if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|certificate|self.signed/i.test(raw)) {
61
- return `${cabeza}\n No llegó a haber respuesta. Revisá la red y el host del backend; si estás detrás de un\n` +
61
+ return `${head}\n No llegó a haber respuesta. Revisá la red y el host del backend; si estás detrás de un\n` +
62
62
  ` proxy corporativo, declará la CA con NODE_EXTRA_CA_CERTS (nunca NODE_TLS_REJECT_UNAUTHORIZED=0).\n` +
63
63
  ` Diagnóstico: dai doctor`;
64
64
  }
65
65
  if (/\b40[13]\b|unauthorized|forbidden/i.test(raw)) {
66
- return `${cabeza}\n El tracker rechazó las credenciales: revisá el token del .env.dai (¿venció?) y sus permisos.\n` +
66
+ return `${head}\n El tracker rechazó las credenciales: revisá el token del .env.dai (¿venció?) y sus permisos.\n` +
67
67
  ` Diagnóstico: dai doctor`;
68
68
  }
69
- if (/\b5\d\d\b/.test(raw)) return `${cabeza}\n El error es del tracker, no tuyo: probá de nuevo en un rato.`;
70
- return cabeza;
69
+ if (/\b5\d\d\b/.test(raw)) return `${head}\n El error es del tracker, no tuyo: probá de nuevo en un rato.`;
70
+ return head;
71
71
  }
@@ -46,6 +46,64 @@ Adoptamos un modelo de **fuente única + traducciones derivadas**, por superfici
46
46
  core (MANIFIESTO, METODOLOGIA, glosario, EJEMPLO, guías) · (3) CLI i18n + skills · (4) el
47
47
  resto (detalle/, ADRs, templates). Se prioriza por alcance, no por completitud.
48
48
 
49
+ ## Estado medido — 2026-09-10
50
+
51
+ El ADR sigue en **propuesto**; esto es el relevamiento que hace falta para poder ejecutarlo,
52
+ tomado al auditar la convención de naming después de la 0.15.0.
53
+
54
+ ### Cuánto es el trabajo de la fase 3 (CLI)
55
+
56
+ | Superficie | Volumen |
57
+ |---|---|
58
+ | `ok()` / `info()` / `warn()` / `fail()` en `cli/dai.mjs` | 348 llamadas |
59
+ | `process.stdout.write` con texto en `cli/dai.mjs` | 99 |
60
+ | `cli/lib/help.mjs` (es **todo** texto al usuario) | 524 líneas |
61
+ | `throw new Error(...)` / `fail(...)` en `cli/lib/` | 67 |
62
+
63
+ Del orden de **mil literales**. Es mecánico, pero confirma lo que el ADR ya anticipaba: se
64
+ hace de una sola vez, no a pedazos. El ciclo de release (0.15.0) le sumó volumen: los
65
+ comandos nuevos, su ayuda y el manifiesto son texto al usuario de punta a punta.
66
+
67
+ ### Lo que ya está resuelto y no hay que rehacer
68
+
69
+ - **Los identificadores del código están en inglés** (auditado y corregido en la 0.15.x). El
70
+ refactor de i18n toca strings, no nombres.
71
+ - **Las salidas de máquina ya usan claves en inglés**: el manifiesto de `dai release plan
72
+ --json` y el payload del webhook genérico.
73
+ - **El glosario ES→EN de §5 ya existe** y se usó: `atrasado → stale` fue el término que se
74
+ aplicó al renombrar `counts.atrasadas`.
75
+
76
+ ### La decisión de contrato que falta tomar, y su costo real
77
+
78
+ `coverageStatus()` devuelve valores **en español** —`"al-dia"`, `"atrasado"`, `"sin-us"`,
79
+ `"sin-respuesta"`— y esos valores **salen por `--json`**: son parte de la API que puede estar
80
+ parseando el CI de alguien.
81
+
82
+ Lo interesante es que el costo interno de cambiarlos es **casi cero**: ya están separados de
83
+ lo que se muestra (`statusLabel()` los traduce a `✅ al día` y compañía), así que el i18n de
84
+ la *presentación* no necesita tocarlos. La única razón para renombrarlos es que una API en
85
+ inglés se lea en inglés.
86
+
87
+ Entonces la decisión es puramente de contrato, y hay tres caminos:
88
+
89
+ 1. **Dejarlos.** Son un enum opaco; el consumidor los compara, no los lee. Costo cero,
90
+ inconsistencia visible en cada `--json`.
91
+ 2. **Renombrarlos en una major** (`up-to-date`, `stale`, `no-story`, `unreachable`). Limpio,
92
+ pero obliga a una major solo por esto.
93
+ 3. **Emitir los dos** por un tiempo (`status` en inglés + `status_es` deprecado), y sacar el
94
+ viejo en la próxima major. Es el camino habitual para no romper, y el que menos duele si
95
+ ya hay alguien parseando.
96
+
97
+ No se decide acá: se decide **junto con** la fase 3, porque hacer dos cambios de la salida
98
+ `--json` en versiones distintas es peor que hacer uno solo.
99
+
100
+ ### Recomendación de orden
101
+
102
+ Mantener el orden del ADR y **no empezar por el CLI**. Un CLI traducido con un README en
103
+ español no lo encuentra nadie: lo que abre la herramienta a usuarios anglófonos es la fase 1
104
+ (README + landing), porque es lo que se ve en npm y en GitHub. La fase 3 recién rinde cuando
105
+ ya hay alguien de habla inglesa llegando.
106
+
49
107
  ## Consecuencias
50
108
 
51
109
  - ✅ Alcance internacional con el README/landing en inglés (fase 1) sin reescribir todo.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
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/",