@dforce2055/dai 0.13.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/dai.mjs CHANGED
@@ -28,6 +28,13 @@ import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs"
28
28
  import { trackerUrl } from "./lib/tracker-url.mjs";
29
29
  import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
30
30
  import { composePrBody, prTitle, forgeTool, bodyGaps } from "./lib/pr.mjs";
31
+ import { resolveBase, isProdBranch, branchFlow, baseHint, parseOriginHead } from "./lib/branch-flow.mjs";
32
+ import { listPrCmd, parsePrList, updatePrCmd, updatePrApiCmd, isAlreadyExistsError, describeUpdate } from "./lib/pr-remote.mjs";
33
+ import { parseCommitLog, proposeBump, nextVersion, buildManifest, renderManifest, BUMP_CAVEAT } from "./lib/release-plan.mjs";
34
+ import { bumpPackageJson, bumpVersionFile, changelogEntry, insertChangelogEntry, changelogSection, changelogGaps, releaseBranch, tagName, normalizeVersion } from "./lib/release-files.mjs";
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";
37
+ import { parseImplements } from "./lib/implements.mjs";
31
38
  import { absolutizeSiteLinks } from "./lib/docs-links.mjs";
32
39
  import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
33
40
  import { dirsEqual } from "./lib/fsutil.mjs";
@@ -39,7 +46,8 @@ import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/j
39
46
  import { assertProjectKey } from "./lib/pm-jira.mjs";
40
47
  import { flattenImplements, stampScope, prScope, matchBranchToImplements, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
41
48
  import { describeForgeError, parseForgeError } from "./lib/forge-api.mjs";
42
- import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion } from "./lib/us-format.mjs";
49
+ import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion, PENDING_VERSION } from "./lib/us-format.mjs";
50
+ import { isHelpToken, wantsHelp, helpTopic, helpFor, globalUsage } from "./lib/help.mjs";
43
51
 
44
52
  const HERE = dirname(fileURLToPath(import.meta.url));
45
53
 
@@ -150,13 +158,14 @@ function cmdLs(opts) {
150
158
  async function cmdLinkUs(key, opts) {
151
159
  if (!isValidKey(key)) fail(`key inválido: '${key}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
152
160
 
153
- let title, hash, version = "v1";
161
+ let title, hash, version = null;
154
162
  if (opts.us) {
155
163
  // Fuente local: un .md con la US.
156
164
  const md = readFileSync(opts.us, "utf8");
157
165
  hash = acHash(md);
158
166
  if (hash == null) fail(`la US en ${opts.us} no tiene una sección 'Criterios de aceptación' con criterios testeables → sin ac_hash.\n Agregá los criterios bajo '## Criterios de aceptación', o corré /grill-user-story para pulir la US.`, 2);
159
167
  title = opts.title || extractTitle(md);
168
+ version = parseSpecVersion(md);
160
169
  } else {
161
170
  // Fuente tracker: traer la US del adaptador (mismo hash que usará `dai check`).
162
171
  loadDaiEnv();
@@ -166,7 +175,16 @@ async function cmdLinkUs(key, opts) {
166
175
  hash = us.ac_hash;
167
176
  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);
168
177
  title = opts.title || us.title;
169
- version = us.spec_version || "v1";
178
+ version = us.spec_version;
179
+ }
180
+
181
+ // Sin spec_version NO se inventa un `v1`: ese número se publica en la PR y se estampa en
182
+ // el tracker como si fuera un dato, y `dai check` lo reporta con un ✅ que da por buena
183
+ // una versión que no existe (issue #46). Un placeholder visible dice la verdad.
184
+ if (!version) {
185
+ version = PENDING_VERSION;
186
+ warn(`la US ${key} no declara spec_version — el link queda con 'version: ${PENDING_VERSION}'.`);
187
+ process.stdout.write(` Agregá la fila 'spec_version | v1' a la metadata de la US y re-estampá: dai link-us ${key} --resync\n`);
170
188
  }
171
189
 
172
190
  // ── modo resync: re-estampar el ac_hash en el implements.yaml existente ──────
@@ -222,6 +240,14 @@ function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return
222
240
  function resolveRev(ref) {
223
241
  try { git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); return ref; } catch { return null; }
224
242
  }
243
+ // La rama default del remoto (`origin/HEAD`). Es mejor fallback que un `main` fijo, pero
244
+ // sigue siendo un fallback: en un repo con ramas de ambiente, la default del remoto suele
245
+ // ser justo la de producción. Por eso el preview siempre dice de dónde salió la base.
246
+ function originHeadBranch() {
247
+ try { return parseOriginHead(git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])); }
248
+ catch { return null; }
249
+ }
250
+
225
251
  // Un texto que se puede pasar inline (`--description "…"`) o por archivo
226
252
  // (`--description-file notas.md`). Un agente casi siempre quiere el archivo: markdown
227
253
  // multilínea no sobrevive entero a la línea de comandos.
@@ -297,7 +323,7 @@ async function cmdCheckCi(opts = {}) {
297
323
  try { live = await adapter.fetchUS(r.id); } catch (e) { netErr = String(e.message).split("\n")[0]; }
298
324
  if (netErr) { warn(`${r.id}: no pude leer la US (${netErr}) — no bloqueo por un problema de red/credencial.`); continue; }
299
325
  const status = coverageStatus(r.ac_hash, live?.ac_hash);
300
- if (status === "al-dia") ok(`${r.id} al día (${r.version})`);
326
+ if (status === "al-dia") { ok(`${r.id} al día (${r.version})`); versionDriftHint(r, live); }
301
327
  else if (status === "atrasado") {
302
328
  process.stderr.write(`✗ gate: ${r.id} ATRASADO — implementaste ${r.ac_hash}, la US viva es ${live.ac_hash}.\n` +
303
329
  ` El QUÉ cambió. Resincronizá y revisá que lo cubras: dai link-us ${r.id} --resync\n`);
@@ -321,6 +347,18 @@ function ciBranch() {
321
347
  (e.GITHUB_REF_NAME && !/^\d+\/merge$/.test(e.GITHUB_REF_NAME) ? e.GITHUB_REF_NAME : null) || null;
322
348
  }
323
349
 
350
+ // El ac_hash dice si el QUÉ cambió; el `version` del link es el número que se PUBLICA
351
+ // (en el body de la PR, en el stamp del tracker). Que coincidan no es cosmético: un link
352
+ // al día con `version: v1` contra una US en v4 estampa una versión que no existe, y el ✅
353
+ // lo hace pasar por verificado (issue #46). No cambia el exit code — el hash manda.
354
+ function versionDriftHint(im, live) {
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}'.`);
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
+ }
361
+
324
362
  // ── check ──────────────────────────────────────────────────────────────────
325
363
  // `process.exitCode` en vez de `process.exit()`: ver la nota en cmdCheckCi.
326
364
  async function cmdCheck() {
@@ -334,7 +372,10 @@ async function cmdCheck() {
334
372
  n++;
335
373
  const { us: live, unreachable, reason } = await fetchLiveUS(adapter, im.id);
336
374
  const status = coverageStatus(im.ac_hash, live?.ac_hash, { unreachable });
337
- if (status === "al-dia") process.stdout.write(`✅ ${im.id} al día (${im.version})\n`);
375
+ if (status === "al-dia") {
376
+ process.stdout.write(`✅ ${im.id} al día (${im.version})\n`);
377
+ versionDriftHint(im, live);
378
+ }
338
379
  else if (status === "atrasado") {
339
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`);
340
381
  atrasadas.push(im.id);
@@ -833,8 +874,13 @@ async function cmdUpdateUs(id, opts = {}) {
833
874
 
834
875
  // ── done: cierra una US — vuelve a la base, actualiza y borra la branch local ──
835
876
  function cmdDone(opts) {
836
- const base = opts.base || "main";
877
+ loadDaiEnv();
878
+ // Misma resolución que `dai pr`: el `main` fijo mandaba a checkout+pull a la rama
879
+ // equivocada en cualquier repo que integre contra develop/testing (issue #46).
880
+ if (opts.base === true) fail("--base necesita el nombre de una branch (ej: --base develop).", 1);
881
+ const baseFlag = Array.isArray(opts.base) ? opts.base[opts.base.length - 1] : (typeof opts.base === "string" ? opts.base : null);
837
882
  const branch = gitBranch();
883
+ const { base, source: baseSource } = resolveBase({ flag: baseFlag, branch, env: process.env, originHead: originHeadBranch() });
838
884
  if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
839
885
  if (branch === base) fail(`ya estás en '${base}' — nada que cerrar.`, 1);
840
886
 
@@ -857,7 +903,7 @@ function cmdDone(opts) {
857
903
  ).map((r) => r.id);
858
904
 
859
905
  // Ir a la base y actualizar.
860
- info(`Cambiando a '${base}' y actualizando…`);
906
+ info(`Cambiando a '${base}' y actualizando… ${C.dim(`(base: ${baseSource})`)}`);
861
907
  try { git(["checkout", base]); } catch (e) { fail(`no pude cambiar a '${base}': ${String(e.message).split("\n")[0]}`, 1); }
862
908
  try { git(["fetch", "--prune"]); } catch { /* sin remoto */ }
863
909
  try { git(["pull", "--ff-only"]); } catch { warn(`no pude hacer 'pull --ff-only' en '${base}' (¿divergió?). Revisa a mano.`); }
@@ -879,6 +925,23 @@ function cmdDone(opts) {
879
925
  info("La branch remota (si existe) la maneja el forge (auto-delete on merge) o bórrala tú.");
880
926
  }
881
927
 
928
+ // El comando del forge, listo para copiar y pegar. Cuando dai no llega, deja al dev
929
+ // parado exactamente donde estaba, no un paso atrás.
930
+ function shellHint(tool, cmd) {
931
+ return ` Comando listo para correr a mano:\n ${tool} ${cmd.map((c) => /\s/.test(c) ? `'${c}'` : c).join(" ")}\n`;
932
+ }
933
+
934
+ // La PR/MR abierta de esta branch, si la hay.
935
+ // { number, url, title, base } → existe · null → no hay · undefined → no se pudo saber
936
+ // El `undefined` importa: sin binario, sin auth o con un glab viejo, dai no puede afirmar
937
+ // que NO existe, así que sigue por el camino de crear (que también sabe reconocerla).
938
+ function findExistingPr(tool, branch) {
939
+ try {
940
+ const out = execFileSync(tool, listPrCmd(tool, branch), { encoding: "utf8", cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
941
+ return parsePrList(tool, out);
942
+ } catch { return undefined; }
943
+ }
944
+
882
945
  // ── pr: crea TU PROPIA PR/MR precargada desde el template + el link ────────────
883
946
  // (Distinto de dai-review, que revisa la PR de OTRO. Tu PR la creas y revisas tú.)
884
947
  async function cmdPr(opts) {
@@ -906,9 +969,18 @@ async function cmdPr(opts) {
906
969
  };
907
970
  const closeRl = () => { if (_rl) { _rl.close(); _rl = null; } };
908
971
 
909
- // Elegir la branch base (default main). Se pregunta justo después del aviso de cambios.
910
- let base = opts.base;
911
- if (!base) { const ans = await ask(" ¿Contra qué branch va la PR? (main) "); base = ans || "main"; }
972
+ // Elegir la branch base. El default NO es `main` fijo: sale de la config del repo
973
+ // (DAI_BRANCH_DEV / DAI_BRANCH_PROD) o de la rama default del remoto, y el preview lo dice.
974
+ // Con `main` hardcodeado, en un repo donde main DESPLIEGA A PRODUCCIÓN la MR quedaba
975
+ // proponiendo un merge a PRO y nada lo destacaba (issue #46).
976
+ if (opts.base === true) { closeRl(); fail("--base necesita el nombre de una branch (ej: --base develop).", 1); }
977
+ const baseFlag = Array.isArray(opts.base) ? opts.base[opts.base.length - 1] : (typeof opts.base === "string" ? opts.base : null);
978
+ let { base, source: baseSource, reason: baseWhy } = resolveBase({ flag: baseFlag, branch, env: process.env, originHead: originHeadBranch() });
979
+ if (!baseFlag) {
980
+ const ans = await ask(` ¿Contra qué branch va la PR? (${base}) `);
981
+ if (ans) { base = ans; baseSource = "lo respondiste vos"; baseWhy = null; }
982
+ }
983
+ const toProd = isProdBranch(base, process.env);
912
984
 
913
985
  // La base puede no existir LOCAL (clones con --single-branch, repos donde el dev
914
986
  // trabaja sobre develop y la base es main, corporativos con la base solo en origin).
@@ -1012,13 +1084,40 @@ async function cmdPr(opts) {
1012
1084
  const forge = detectForge(parseRemote(remote)?.host);
1013
1085
  const tool = forgeTool(forge);
1014
1086
 
1087
+ // ¿Ya hay una PR/MR abierta para esta branch? Si la hay, esto es una ACTUALIZACIÓN, y
1088
+ // hay que decirlo ANTES de confirmar: el bug del issue #46 era pushear (el diff quedaba
1089
+ // al día), fallar al crear, y dejar la MR con la descripción vieja sin avisar.
1090
+ // null → no hay ninguna abierta · undefined → no se pudo saber (sin binario, sin auth)
1091
+ const existing = findExistingPr(tool, branch);
1092
+
1015
1093
  // 4. Mostrar y pedir confirmación (acción hacia afuera).
1016
- process.stdout.write(`\n ── Pull Request a crear ──────────────────────────────\n`);
1017
- process.stdout.write(` título: ${title}\n de: ${branch}\n a: ${base}\n`);
1094
+ const accion = existing ? `a ACTUALIZAR (#${existing.number})` : "a crear";
1095
+ process.stdout.write(`\n ── Pull Request ${accion} ──────────────────────────────\n`);
1096
+ process.stdout.write(` título: ${title}\n de: ${branch}\n`);
1097
+ process.stdout.write(` a: ${base} ${C.dim(`(${baseSource}${baseWhy ? ` — ${baseWhy}` : ""})`)}${toProd ? ` ${C.b("⚠️ DESPLIEGA A PRODUCCIÓN")}` : ""}\n`);
1018
1098
  process.stdout.write(` US: ${id || C.dim("(sin US)")} ${C.dim(`— ${usWhy}`)}\n`);
1019
1099
  process.stdout.write(` forge: ${forge} (${tool})${opts.assignee ? `\n asignar: ${opts.assignee}` : ""}${opts.draft ? "\n draft: sí" : ""}\n`);
1020
1100
  process.stdout.write(` ─────────────────────────────────────────────────────\n\n${body}\n`);
1021
1101
  process.stdout.write(` ─────────────────────────────────────────────────────\n`);
1102
+ const hint = baseHint(baseSource, base);
1103
+ if (hint) info(hint);
1104
+ if (existing) for (const l of describeUpdate(existing, { base, tool })) warn(l);
1105
+
1106
+ // 4a. Gate de producción: proponer un merge a la rama que despliega a PRO no puede salir
1107
+ // de un default ni colarse con un `--yes` puesto por costumbre. Que lo diga alguien.
1108
+ if (toProd) {
1109
+ warn(`'${base}' está declarada como rama de PRODUCCIÓN (DAI_BRANCH_PROD).`);
1110
+ if (!opts.toProd) {
1111
+ if (opts.yes || !process.stdin.isTTY) {
1112
+ closeRl();
1113
+ fail(`no publico una PR contra producción sin que lo digas explícitamente.\n` +
1114
+ ` Si es a propósito: dai pr --base ${base} --to-prod --yes\n` +
1115
+ ` Si era otra la base: dai pr --base <rama-de-integracion>`, 1);
1116
+ }
1117
+ const a = await ask(` Escribí '${base}' para confirmar que esta PR va a PRODUCCIÓN (Enter = cancelar): `);
1118
+ if (String(a ?? "").trim() !== base) { closeRl(); info("Cancelado — no se creó la PR."); return; }
1119
+ }
1120
+ }
1022
1121
 
1023
1122
  // 4b. Gate: una PR con el molde del template sin llenar no se puede revisar.
1024
1123
  // Pasaba en repos reales — "Descripción" con el comentario HTML (que no se renderiza:
@@ -1044,8 +1143,8 @@ async function cmdPr(opts) {
1044
1143
  // Archivo de paso para gh/glab: en el temp del sistema, NO en el repo (no lo ensucia).
1045
1144
  const bodyFile = join(mkdtempSync(join(tmpdir(), "dai-pr-")), "body.md");
1046
1145
  if (!opts.yes) {
1047
- if (!process.stdin.isTTY) { closeRl(); writeFileSync(bodyFile, body); info(`Body guardado en ${bodyFile}. Revisa y re-ejecuta con --yes para crear.`); return; }
1048
- const a = (await ask(` ¿Publico la branch y creo el PR con ${tool}? (s/N) `) || "").toLowerCase();
1146
+ if (!process.stdin.isTTY) { closeRl(); writeFileSync(bodyFile, body); info(`Body guardado en ${bodyFile}. Revisa y re-ejecuta con --yes para ${existing ? "actualizar" : "crear"}.`); return; }
1147
+ const a = (await ask(` ¿Publico la branch y ${existing ? `actualizo la PR/MR #${existing.number}` : `creo el PR`} con ${tool}? (s/N) `) || "").toLowerCase();
1049
1148
  closeRl();
1050
1149
  if (!["s", "si", "sí", "y", "yes"].includes(a)) {
1051
1150
  writeFileSync(bodyFile, body);
@@ -1081,6 +1180,42 @@ async function cmdPr(opts) {
1081
1180
  fail(`no pude pushear la branch '${branch}'.`, 1);
1082
1181
  }
1083
1182
 
1183
+ // Actualizar la PR/MR que ya está abierta: mismo body, misma disciplina. La alternativa
1184
+ // —fallar y dejarla con la descripción vieja— es la que rompía el issue #46.
1185
+ const doUpdate = (number) => {
1186
+ const ucmd = updatePrCmd(tool, { number, title, body, bodyFile });
1187
+ try {
1188
+ info(`Actualizando la PR/MR #${number} con ${tool}…`);
1189
+ const out = execFileSync(tool, ucmd, { encoding: "utf8", cwd: process.cwd() });
1190
+ process.stdout.write(out);
1191
+ ok(`PR/MR #${number} actualizada: título + descripción${existing?.url ? ` — ${existing.url}` : ""}.`);
1192
+ try { rmSync(bodyFile); } catch { /* noop */ }
1193
+ return true;
1194
+ } catch (e) {
1195
+ const msg = String(e.stderr || e.message || "");
1196
+ // Plan B por REST: el comando de alto nivel puede fallar por algo ajeno a la edición
1197
+ // (gh consulta GraphQL y arrastra campos deprecados del servidor). Se intenta callado
1198
+ // y solo se reporta si TAMBIÉN falla — avisar de un error que se resolvió solo es ruido.
1199
+ const api = updatePrApiCmd(tool, { number, title, bodyFile, projectPath: parseRemote(remote)?.path });
1200
+ if (api) {
1201
+ try {
1202
+ execFileSync(tool, api, { encoding: "utf8", cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
1203
+ ok(`PR/MR #${number} actualizada: título + descripción${existing?.url ? ` — ${existing.url}` : ""}.`);
1204
+ info(`(\`${tool} pr edit\` falló por algo ajeno a la edición; se actualizó por la API REST)`);
1205
+ try { rmSync(bodyFile); } catch { /* noop */ }
1206
+ return true;
1207
+ } catch (e2) { process.stdout.write(` ${tool} api también falló: ${String(e2.stderr || e2.message).split("\n")[0]}\n`); }
1208
+ }
1209
+ warn(`no pude actualizar la PR/MR #${number} con ${tool}. El body quedó en ${bodyFile}.`);
1210
+ if (msg.trim()) process.stdout.write(` ${tool} dijo:\n ${msg.trim().split("\n").join("\n ")}\n`);
1211
+ process.stdout.write(shellHint(tool, ucmd));
1212
+ process.stdout.write(` Si preferís no editarla: cerrá la PR/MR y volvé a correr \`dai pr\`.\n`);
1213
+ process.exitCode = 1;
1214
+ return false;
1215
+ }
1216
+ };
1217
+ if (existing) { doUpdate(existing.number); return; }
1218
+
1084
1219
  const cmd = tool === "gh"
1085
1220
  ? ["pr", "create", "--title", title, "--body-file", bodyFile, "--base", base,
1086
1221
  ...(opts.assignee ? ["--assignee", opts.assignee] : []), ...(opts.draft ? ["--draft"] : [])]
@@ -1094,7 +1229,18 @@ async function cmdPr(opts) {
1094
1229
  try { rmSync(bodyFile); } catch { /* noop */ }
1095
1230
  } catch (e) {
1096
1231
  const msg = String(e.stderr || e.message || "");
1097
- const manual = ` Comando listo para correr a mano:\n ${tool} ${cmd.map((c) => /\s/.test(c) ? `'${c}'` : c).join(" ")}\n`;
1232
+ const manual = shellHint(tool, cmd);
1233
+ if (isAlreadyExistsError(msg) || isAlreadyExistsError(String(e.stdout || ""))) {
1234
+ // La detección de arriba no la vio (glab viejo, `--output json` no soportado, sin
1235
+ // permiso de lectura). El forge sí sabe que existe: se busca de nuevo y se actualiza.
1236
+ warn("el forge dice que YA hay una PR/MR abierta para esta branch, así que no se creó otra.");
1237
+ const found = findExistingPr(tool, branch);
1238
+ if (found) { doUpdate(found.number); return; }
1239
+ warn(`tampoco pude averiguar su número con ${tool}, así que NO toqué su descripción: quedó la vieja.`);
1240
+ process.stdout.write(` Abrila y pegá el body de ${bodyFile}, o cerrala y volvé a correr \`dai pr\`.\n`);
1241
+ process.exitCode = 1;
1242
+ return;
1243
+ }
1098
1244
  if (e.code === "ENOENT") {
1099
1245
  // El binario del forge no está instalado (el caso más común detrás de "no salió la MR").
1100
1246
  const doc = tool === "glab" ? "https://gitlab.com/gitlab-org/cli/-/releases" : "https://cli.github.com";
@@ -1112,7 +1258,523 @@ async function cmdPr(opts) {
1112
1258
  }
1113
1259
  }
1114
1260
 
1115
- // ── install: skills ~/.claude/skills o <repo>/.claude/skills ────────────────
1261
+ // ── release plan: el manifiesto de la versión ─────────────────────────────────
1262
+ //
1263
+ // Contesta la pregunta que a un equipo sin versiones no le contesta nadie: qué User
1264
+ // Stories tiene esta release. El tag, el CHANGELOG, el comentario en cada ticket y el
1265
+ // aviso al canal son formas distintas de publicar ESTE dato, así que primero el dato.
1266
+
1267
+ // El último tag alcanzable, ordenado por semver (no por fecha: un hotfix tagueado tarde
1268
+ // no es "el último release"). null si el repo todavía no tiene ninguno.
1269
+ function lastTag() {
1270
+ try {
1271
+ const out = git(["tag", "--sort=-v:refname", "--merged", "HEAD"]);
1272
+ return out.split("\n").map((t) => t.trim()).filter(Boolean)[0] || null;
1273
+ } catch { return null; }
1274
+ }
1275
+
1276
+ // Las US que entraron en el rango, leyendo los implements.yaml TAL COMO ESTABAN en cada
1277
+ // commit que los tocó. El link viaja con el código, así que esto no depende de que la
1278
+ // branch siga existiendo ni de que el change no se haya archivado — que es exactamente
1279
+ // lo que pasa cuando llegás a cortar la release, días después del merge.
1280
+ function linkedInRange(range) {
1281
+ const rows = [];
1282
+ let raw;
1283
+ try {
1284
+ raw = git(["log", "--format=\x1e%H", "--name-only", "--diff-filter=AMR", range, "--", "*implements.yaml"]);
1285
+ } catch { return rows; }
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)) {
1292
+ if (!path.endsWith("implements.yaml")) continue;
1293
+ let texto;
1294
+ try { texto = git(["show", `${sha}:${path}`]); } catch { continue; } // borrado después
1295
+ let parsed;
1296
+ try { parsed = parseImplements(texto); } catch { continue; }
1297
+ for (const im of parsed.implements || []) {
1298
+ if (isPlaceholderId(im.id)) continue;
1299
+ rows.push({ ...im, change: parsed.change, repo: parsed.repo, path, sha, order: order++ });
1300
+ }
1301
+ }
1302
+ }
1303
+ return rows;
1304
+ }
1305
+
1306
+ // El manifiesto, sin render: lo comparten plan, cut y done. Un solo lugar que sabe
1307
+ // contestar "qué entra en esta versión" — si cada comando lo calculara a su manera,
1308
+ // tarde o temprano dirían cosas distintas y le creerías al que tenés más a mano.
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;
1315
+
1316
+ let commits = [];
1317
+ try { commits = parseCommitLog(git(["log", "--format=%H\x1f%s", range])); }
1318
+ catch (e) { fail(`no pude leer el historial de '${range}': ${String(e.message).split("\n")[0]}`, 1); }
1319
+
1320
+ const linked = linkedInRange(range);
1321
+ const live = {};
1322
+ let unreachable = false;
1323
+ if (!opts.noNetwork && linked.length) {
1324
+ try {
1325
+ const adapter = getAdapter(process.env);
1326
+ for (const id of new Set(linked.map((r) => r.id))) {
1327
+ const { us, unreachable: nore } = await fetchLiveUS(adapter, id);
1328
+ if (nore) { unreachable = true; break; }
1329
+ if (us) live[id] = us;
1330
+ }
1331
+ } catch (e) { unreachable = true; warn(`no puedo verificar contra el tracker: ${String(e.message).split("\n")[0]}`); }
1332
+ }
1333
+ // ¿Este repo trabaja con User Stories? Si nunca declaró ninguna (archivadas incluidas),
1334
+ // marcar cada branch como "entró sin link" es ruido: en un repo de tooling ninguna va a
1335
+ // declararla nunca. Se mira el repo entero, no el rango.
1336
+ const repoUsesStories = flattenImplements(discoverImplements(process.cwd())).length > 0;
1337
+ const m = buildManifest({ commits, linked, live, unreachable: unreachable || Boolean(opts.noNetwork), repoUsesStories });
1338
+ return { manifest: m, commits, from: desde, to: hasta, range };
1339
+ }
1340
+
1341
+ // La versión que este repo declara hoy. `null` si no espeja el número en ningún archivo
1342
+ // que dai reconozca — que es legítimo: el tag es la fuente de verdad.
1343
+ function currentVersion() {
1344
+ for (const [file, leer] of [
1345
+ ["VERSION", (t) => t.trim().split("\n")[0]],
1346
+ ["package.json", (t) => { try { return JSON.parse(t).version || null; } catch { return null; } }],
1347
+ ]) {
1348
+ const p = join(process.cwd(), file);
1349
+ if (existsSync(p)) { const v = leer(readFileSync(p, "utf8")); if (v) return { version: v, file }; }
1350
+ }
1351
+ return { version: null, file: null };
1352
+ }
1353
+
1354
+ async function cmdReleasePlan(opts = {}) {
1355
+ loadDaiEnv();
1356
+ if (!gitBranch()) fail("esto no parece un repo git.", 1);
1357
+ const { manifest: m, commits, from, to } = await releaseManifest(opts);
1358
+ const { version: current } = currentVersion();
1359
+ const { bump, reason } = proposeBump(commits);
1360
+ const proposed = current ? nextVersion(current, bump) : null;
1361
+
1362
+ if (opts.json) {
1363
+ process.stdout.write(JSON.stringify({ from, to, current, proposed, bump, reason, caveat: BUMP_CAVEAT, ...m }, null, 2) + "\n");
1364
+ return;
1365
+ }
1366
+ process.stdout.write("\n" + renderManifest(m, { from, to, current: current || "(sin archivo de versión)", proposed, bump, reason }) + "\n");
1367
+ process.stdout.write(` ${C.dim(BUMP_CAVEAT.split("\n").join("\n "))}\n\n`);
1368
+ if (m.counts.commits === 0) { info("no hay nada nuevo para promover: no hace falta cortar una versión."); return; }
1369
+ info(`Cuando lo confirmes: dai release cut ${proposed || "<X.Y.Z>"}`);
1370
+ }
1371
+
1372
+ // ── release cut: preparar la versión ─────────────────────────────────────────
1373
+ // Branch + bump + entrada del CHANGELOG + commit. Todo lo que pasa ANTES de la firma
1374
+ // humana; nada que hable hacia afuera (ni push, ni tag, ni PR).
1375
+ async function cmdReleaseCut(versionArg, opts = {}) {
1376
+ loadDaiEnv();
1377
+ const branch = gitBranch();
1378
+ if (!branch) fail("esto no parece un repo git.", 1);
1379
+ let version;
1380
+ try { version = normalizeVersion(versionArg); }
1381
+ catch (e) { fail(`${e.message}\n Uso: dai release cut 1.2.0`, 1); }
1382
+
1383
+ const dirty = (() => { try { return git(["status", "--porcelain"]).length > 0; } catch { return false; } })();
1384
+ if (dirty && !opts.dryRun) fail("tenés cambios sin commitear. Una versión se corta sobre un árbol limpio.", 1);
1385
+
1386
+ const { manifest: m, commits, from, to } = await releaseManifest(opts);
1387
+ const { version: current, file: versionFile } = currentVersion();
1388
+ const { bump, reason } = proposeBump(commits);
1389
+ const sugerida = current ? nextVersion(current, bump) : null;
1390
+
1391
+ // Qué archivos espejan el número en ESTE repo. Puede no haber ninguno (un repo .NET, un
1392
+ // frontend corporativo): el tag sigue siendo la versión.
1393
+ const espejos = [];
1394
+ for (const [file, fn] of [["VERSION", bumpVersionFile], ["package.json", bumpPackageJson]]) {
1395
+ const p = join(process.cwd(), file);
1396
+ if (!existsSync(p)) continue;
1397
+ const r = fn(readFileSync(p, "utf8"), version);
1398
+ espejos.push({ file, path: p, ...r });
1399
+ }
1400
+
1401
+ const crearBranch = opts.branch !== false && !opts.noBranch;
1402
+ const nombreBranch = releaseBranch(version);
1403
+
1404
+ process.stdout.write("\n" + renderManifest(m, { from, to, current: current || "(sin archivo)", proposed: version, bump: `pediste ${version}`, reason }) + "\n");
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`);
1410
+ process.stdout.write(` CHANGELOG entrada nueva para ${version} (con el manifiesto para repartir)\n`);
1411
+ process.stdout.write(` commit: chore(release): v${version}\n`);
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.`);
1414
+ if (m.orphans.length) warn(`hay ${m.orphans.length} branch(es) sin US ni prefijo exento en el rango.`);
1415
+
1416
+ if (opts.dryRun) { info("[dry-run] no se tocó nada."); return; }
1417
+ if (!opts.yes) {
1418
+ if (!process.stdin.isTTY) { info("Sin TTY y sin --yes: no preparo la versión. Revisá el plan y re-ejecutá con --yes."); return; }
1419
+ const a = (await askOrCancel(` ¿Preparo la versión ${version}? (s/N) `) || "").toLowerCase();
1420
+ if (!["s", "si", "sí", "y", "yes"].includes(a)) { info("Cancelado — no se tocó nada."); return; }
1421
+ }
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); }
1426
+ }
1427
+ const tocados = [];
1428
+ for (const e of espejos) {
1429
+ if (!e.changed) continue;
1430
+ writeFileSync(e.path, e.text);
1431
+ tocados.push(e.file);
1432
+ ok(`${e.file}: ${e.from} → ${version}`);
1433
+ }
1434
+
1435
+ // CHANGELOG: el andamio y el material. La prosa la escribe una persona (o la skill).
1436
+ const chPath = join(process.cwd(), "CHANGELOG.md");
1437
+ const entry = changelogEntry({ version, date: new Date().toISOString().slice(0, 10), manifest: m });
1438
+ const previo = existsSync(chPath) ? readFileSync(chPath, "utf8") : "# Changelog\n";
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}`); }
1442
+ else warn(`CHANGELOG.md sin tocar — ${res.reason}.`);
1443
+
1444
+ if (tocados.length === 0) { warn("no hubo nada que cambiar: no hago un commit vacío."); return; }
1445
+ git(["add", ...tocados]);
1446
+ git(["commit", "-m", `chore(release): v${version}`, "-m", `Manifiesto: ${m.counts.stories} US · ${m.counts.commits} commits desde ${from || "el principio"}.`]);
1447
+ ok(`commit chore(release): v${version}`);
1448
+
1449
+ process.stdout.write("\n");
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}`);
1452
+ }
1453
+
1454
+ // ── release done: cerrar la versión ────────────────────────────────────────
1455
+ // DESPUÉS del merge. Tag + release note + back-merge + aviso. Es la mitad que se olvida
1456
+ // cuando la ceremonia se hace a mano, y la que habla hacia afuera: cada paso se reporta
1457
+ // por separado, porque si el release note falla el tag YA existe y hay que decirlo.
1458
+ async function cmdReleaseDone(versionArg, opts = {}) {
1459
+ loadDaiEnv();
1460
+ let version;
1461
+ try { version = normalizeVersion(versionArg); }
1462
+ catch (e) { fail(`${e.message}\n Uso: dai release done 1.2.0`, 1); }
1463
+ const tag = tagName(version);
1464
+ const flow = branchFlow(process.env);
1465
+ const prod = textOpt(opts, "base") || flow.prod || parseOriginHead(safeGit(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])) || "main";
1466
+ const dev = flow.dev;
1467
+
1468
+ if (safeGit(["tag", "-l", tag])?.trim() === tag && !opts.force) {
1469
+ fail(`el tag ${tag} ya existe. Una versión no se re-taguea: si querés rehacerla, borrá el tag a mano (local y remoto) sabiendo lo que eso implica.`, 1);
1470
+ }
1471
+ try { git(["checkout", prod]); } catch (e) { fail(`no pude cambiar a '${prod}': ${String(e.message).split("\n")[0]}`, 1); }
1472
+ try { git(["pull", "--ff-only"]); } catch { warn(`no pude actualizar '${prod}' con pull --ff-only. Revisá a mano antes de taguear.`); }
1473
+
1474
+ const head = gitCommit();
1475
+ const { version: enArchivo } = currentVersion();
1476
+ if (enArchivo && enArchivo !== version) {
1477
+ fail(`'${prod}' declara la versión ${enArchivo}, no ${version}.\n ¿Se mergeó la PR de release? El tag tiene que apuntar al commit final.`, 1);
1478
+ }
1479
+
1480
+ const { manifest: m } = await releaseManifest(opts, { to: prod });
1481
+ const notes = existsSync(join(process.cwd(), "CHANGELOG.md"))
1482
+ ? changelogSection(readFileSync(join(process.cwd(), "CHANGELOG.md"), "utf8"), version) : null;
1483
+ const cfg = (() => { try { return notifyConfig(process.env); } catch (e) { warn(String(e.message)); return null; } })();
1484
+ const avisar = cfg && !opts.noNotify;
1485
+
1486
+ process.stdout.write(`\n ── Cerrar la versión ${version} ──────────────────────────\n`);
1487
+ process.stdout.write(` tag: ${tag} → ${prod} @ ${String(head).slice(0, 8)}\n`);
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
+ process.stdout.write(` back-merge: ${dev ? `${prod} → ${dev}` : C.dim("no (DAI_BRANCH_DEV no declarada)")}\n`);
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`);
1498
+ process.stdout.write(` ─────────────────────────────────────────────────────\n`);
1499
+ if (opts.dryRun) { info("[dry-run] no se tocó nada."); return; }
1500
+ if (!opts.yes) {
1501
+ if (!process.stdin.isTTY) { info("Sin TTY y sin --yes: no cierro la versión."); return; }
1502
+ const a = (await askOrCancel(` ¿Cierro la versión ${version}? (s/N) `) || "").toLowerCase();
1503
+ if (!["s", "si", "sí", "y", "yes"].includes(a)) { info("Cancelado — no se tocó nada."); return; }
1504
+ }
1505
+
1506
+ // 1. El tag. Es LA versión: si esto falla, no sigue nada.
1507
+ try {
1508
+ git(["tag", "-a", tag, "-m", `${tag}`, ...(opts.force ? ["-f"] : [])]);
1509
+ git(["push", "origin", tag, ...(opts.force ? ["--force"] : [])], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
1510
+ ok(`tag ${tag} creado y publicado`);
1511
+ } catch (e) { fail(`no pude crear o publicar el tag ${tag}: ${String(e.message).split("\n")[0]}`, 1); }
1512
+
1513
+ // 2. Release note en el forge. De acá en adelante, cada paso reporta y sigue: el tag ya
1514
+ // existe, y abortar dejaría la versión a medio cerrar sin decir en qué mitad quedó.
1515
+ let releaseUrl = null;
1516
+ if (!opts.noRelease) {
1517
+ const forge = detectForge(parseRemote(gitRemote())?.host);
1518
+ const tool = forgeTool(forge);
1519
+ const notesFile = join(mkdtempSync(join(tmpdir(), "dai-rel-")), "notes.md");
1520
+ if (notes) writeFileSync(notesFile, notes + "\n");
1521
+ const cmd = tool === "gh"
1522
+ ? ["release", "create", tag, "--target", prod, "--title", `${tag}`, ...(notes ? ["--notes-file", notesFile] : ["--generate-notes"]), "--latest"]
1523
+ : ["release", "create", tag, "--name", `${tag}`, ...(notes ? ["--notes-file", notesFile] : [])];
1524
+ try {
1525
+ const out = execFileSync(tool, cmd, { encoding: "utf8", cwd: process.cwd() });
1526
+ releaseUrl = (String(out).match(/https?:\/\/\S+/) || [null])[0];
1527
+ process.stdout.write(out);
1528
+ ok(`release note publicada${releaseUrl ? ` — ${releaseUrl}` : ""}`);
1529
+ } catch (e) {
1530
+ warn(`no pude publicar la release note con ${tool}: ${String(e.stderr || e.message).split("\n")[0]}`);
1531
+ process.stdout.write(shellHint(tool, cmd));
1532
+ process.stdout.write(` El tag ${tag} SÍ está publicado: la versión existe, le falta la nota.\n`);
1533
+ }
1534
+ }
1535
+
1536
+ // 3. Back-merge: el otro paso que se olvida. Sin esto, integración queda una versión atrás.
1537
+ if (dev) {
1538
+ try {
1539
+ git(["checkout", dev]); git(["pull", "--ff-only"]);
1540
+ git(["merge", "--no-edit", prod]);
1541
+ git(["push", "origin", dev], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
1542
+ ok(`back-merge ${prod} → ${dev}`);
1543
+ } catch (e) {
1544
+ warn(`no pude hacer el back-merge ${prod} → ${dev}: ${String(e.message).split("\n")[0]}`);
1545
+ process.stdout.write(` Hacelo a mano: git checkout ${dev} && git merge ${prod} && git push origin ${dev}\n`);
1546
+ process.stdout.write(` Si no, '${dev}' queda una versión atrás y el próximo corte arranca torcido.\n`);
1547
+ }
1548
+ }
1549
+
1550
+ // 4. Borrar la rama de release. Es el ÚNICO punto del ciclo donde dai puede afirmar que
1551
+ // es seguro: ya está mergeada en producción, tagueada y con el back-merge hecho. Si esto
1552
+ // no pasa acá, se acumulan — y una rama de release que sobrevive a su release es un fork.
1553
+ // Misma disciplina que `dai done`: `git branch -d` (minúscula) se niega si no está
1554
+ // mergeada, así que la red de seguridad la pone git, no una suposición nuestra.
1555
+ if (limpiar) {
1556
+ if (hayLocal) {
1557
+ try { git(["branch", "-d", relBranch]); ok(`borrada la branch local ${relBranch}`); }
1558
+ catch (e) {
1559
+ warn(`no borré '${relBranch}' local: ${String(e.message).split("\n")[0]}`);
1560
+ process.stdout.write(` git se niega a borrar una branch sin mergear. Revisala: quizá tiene commits que no llegaron a '${prod}'.\n`);
1561
+ }
1562
+ }
1563
+ if (hayRemota) {
1564
+ try {
1565
+ git(["push", "origin", "--delete", relBranch], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
1566
+ ok(`borrada la branch remota origin/${relBranch}`);
1567
+ } catch (e) {
1568
+ 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`);
1570
+ }
1571
+ }
1572
+ }
1573
+
1574
+ // 5. El aviso. Lo último a propósito: anunciar algo que después falla es peor que no anunciar.
1575
+ if (avisar) {
1576
+ const ev = {
1577
+ event: "released", app: releaseApp(opts), version, environment: null,
1578
+ author: gitUser(), date: formatFecha(), url: releaseUrl,
1579
+ stories: m.stories.map((s) => ({ id: s.id, title: s.title })),
1580
+ };
1581
+ await confirmarYAvisar(cfg, ev, opts);
1582
+ }
1583
+ process.stdout.write("\n");
1584
+ ok(`versión ${version} cerrada.`);
1585
+ info(`Cuando la despliegues: dai release stamp ${version} --env <ambiente> (opcional: la release ya está hecha)`);
1586
+ }
1587
+
1588
+ // ── release stamp: avisarle a cada US en qué versión y ambiente salió ────────
1589
+ //
1590
+ // Es el comando que más cuidado necesita del lote: escribe N veces hacia afuera, en
1591
+ // tickets de gente distinta, y no se deshace. Por eso muestra el ALCANCE REAL antes de
1592
+ // escribir —cuántos comentarios, en qué tickets, cuáles se saltean— y pide confirmación.
1593
+ //
1594
+ // Y es OPCIONAL: decir que no acá no rompe nada. Para cuando esto corre, el tag existe y
1595
+ // el release note existe; la versión ya está. Que alguien elija no hacer ruido en veinte
1596
+ // tickets es una decisión legítima, no un error a corregir — así que sale con 0.
1597
+ async function cmdReleaseStamp(versionArg, opts = {}) {
1598
+ loadDaiEnv();
1599
+ let version;
1600
+ try { version = normalizeVersion(versionArg); }
1601
+ catch (e) { fail(`${e.message}\n Uso: dai release stamp 1.2.0 --env prod`, 1); }
1602
+ const environment = textOpt(opts, "env");
1603
+ if (!environment) {
1604
+ fail("falta --env: a qué ambiente se desplegó esta versión (prod, pre, test, el nombre que uses).\n" +
1605
+ " dai no tiene catálogo de ambientes: el que pases es el que se estampa.", 1);
1606
+ }
1607
+ const app = releaseApp(opts);
1608
+ const tag = tagName(version);
1609
+
1610
+ // El manifiesto de ESA versión: lo que entró entre el tag anterior y este. Se pide por
1611
+ // 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;
1613
+ if (!safeGit(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`])) {
1614
+ 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
+ }
1616
+ const { manifest: m } = await releaseManifest(opts, { from: previo, to: tag });
1617
+
1618
+ // ¿Cuáles ya están estampadas? Sin poder leer los comentarios dai NO afirma que no las
1619
+ // estampó: lo dice, y quien decide es la persona. Afirmar de más acá se paga en duplicados.
1620
+ const marker = releaseMarker({ app, version, environment });
1621
+ const adapter = getAdapter(process.env);
1622
+ const stamped = new Set();
1623
+ let unknown = false;
1624
+ if (typeof adapter.listComments === "function") {
1625
+ for (const s of m.stories) {
1626
+ try { if (alreadyStamped(await adapter.listComments(s.id), marker)) stamped.add(s.id); }
1627
+ catch { unknown = true; }
1628
+ }
1629
+ } else unknown = true;
1630
+
1631
+ const plan = stampPlan({ stories: m.stories, stamped, unknown });
1632
+ process.stdout.write("\n" + renderStampPlan(plan, { app, version, environment, tracker: adapter.kind }) + "\n");
1633
+ warn(stampWarning(plan));
1634
+ if (plan.pendientes.length === 0) { info("Nada que hacer."); return; }
1635
+
1636
+ if (opts.dryRun) { info("[dry-run] no se escribió nada."); return; }
1637
+ if (!opts.yes) {
1638
+ 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.`);
1640
+ return;
1641
+ }
1642
+ const a = (await askOrCancel(` ¿Estampo ${plan.pendientes.length} comentario(s)? (s/N) `) || "").toLowerCase();
1643
+ if (!["s", "si", "sí", "y", "yes"].includes(a)) {
1644
+ info("No se estampó nada — la release está hecha igual.");
1645
+ info(SIN_ESTAMPAR);
1646
+ return; // salida 0: decir que no es una respuesta válida, no un error
1647
+ }
1648
+ }
1649
+
1650
+ const cuerpo = renderReleaseStamp({
1651
+ app, version, environment, date: formatFecha(), commit: safeGit(["rev-list", "-n", "1", tag])?.trim(),
1652
+ url: textOpt(opts, "url") || null,
1653
+ });
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.`);
1662
+ process.exitCode = 1;
1663
+ } else ok(`${hechos} US estampada(s) con ${app} ${tag} → ${environment.toUpperCase()}.`);
1664
+
1665
+ // El aviso al canal es del EVENTO de despliegue, no del estampado: sale aunque no se
1666
+ // haya estampado nada (para eso está `--only-notify`), y no sale si el repo no lo declaró.
1667
+ const cfg = (() => { try { return notifyConfig(process.env); } catch (e) { warn(String(e.message)); return null; } })();
1668
+ if (cfg && !opts.noNotify) {
1669
+ await confirmarYAvisar(cfg, {
1670
+ event: "deployed", app, version, environment, author: gitUser(), date: formatFecha(),
1671
+ url: textOpt(opts, "url") || null, stories: m.stories.map((x) => ({ id: x.id, title: x.title })),
1672
+ }, opts);
1673
+ }
1674
+ }
1675
+
1676
+ // ── release status: ¿dónde estoy en el ciclo? ────────────────────────────────
1677
+ // Todo local + el forge: rápido y sin tocar el tracker. Lo que NO contesta —qué versión
1678
+ // hay en cada ambiente— vive en los stamps de las US, porque un despliegue es un evento
1679
+ // y no un archivo del repo: cambia sin que cambie el código.
1680
+ async function cmdReleaseStatus(opts = {}) {
1681
+ loadDaiEnv();
1682
+ if (!gitBranch()) fail("esto no parece un repo git.", 1);
1683
+ const flow = branchFlow(process.env);
1684
+ const { version: declarada, file } = currentVersion();
1685
+ const tag = lastTag();
1686
+ const dev = flow.dev || gitBranch();
1687
+ const prod = flow.prod;
1688
+
1689
+ info(`versión declarada: ${declarada ? `${declarada} ${C.dim(`(${file})`)}` : C.dim("ningún archivo la espeja — la versión es el tag")}`);
1690
+ info(`último tag: ${tag || C.dim("(ninguno)")}`);
1691
+ if (tag && declarada && tagName(declarada) !== tag) {
1692
+ warn(`el archivo dice ${declarada} y el último tag es ${tag}: hay una versión preparada sin cerrar, o un tag sin bump.`);
1693
+ }
1694
+
1695
+ // ¿Hay algo sin promover? Es la pregunta que dispara el ciclo.
1696
+ const { manifest: m, commits } = await releaseManifest({ ...opts, noNetwork: true }, { from: tag, to: dev });
1697
+ if (m.counts.commits === 0) info(`'${dev}' no tiene nada nuevo sobre ${tag || "el principio"}: no hay versión que cortar.`);
1698
+ else {
1699
+ const { bump } = proposeBump(commits);
1700
+ warn(`'${dev}' tiene ${m.counts.commits} commit(s) sin promover · ${m.counts.stories} US · bump propuesto: ${bump}.`);
1701
+ process.stdout.write(` Ver el detalle: dai release plan\n`);
1702
+ }
1703
+
1704
+ // Back-merge pendiente: producción adelante de integración es el olvido clásico.
1705
+ 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.`);
1709
+ process.stdout.write(` git checkout ${flow.dev} && git merge ${prod} && git push origin ${flow.dev}\n`);
1710
+ }
1711
+ }
1712
+
1713
+ // 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)"]) || "")
1715
+ .split("\n").map((x) => x.trim()).filter(Boolean);
1716
+ if (abiertas.length) {
1717
+ warn(`${abiertas.length} branch(es) de release sin borrar: ${abiertas.join(", ")}`);
1718
+ process.stdout.write(` Una rama de release que sobrevive a su release es un fork. Desde 0.15.0 las borra\n`);
1719
+ 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`);
1721
+ }
1722
+
1723
+ const cfg = (() => { try { return notifyConfig(process.env); } catch { return null; } })();
1724
+ info(`aviso al canal: ${cfg ? `${describeTarget(cfg)} ${C.dim("(no verificado: eso lo dice `dai release notify --test`)")}` : C.dim("sin declarar (DAI_NOTIFY)")}`);
1725
+
1726
+ // Los últimos releases publicados, según el forge. Es lo más cerca de "qué hay afuera"
1727
+ // que dai puede saber sin preguntarle al tracker ni al pipeline.
1728
+ if (!opts.noNetwork) {
1729
+ const tool = forgeTool(detectForge(parseRemote(gitRemote())?.host));
1730
+ try {
1731
+ const out = execFileSync(tool, ["release", "list", ...(tool === "gh" ? ["--limit", "3"] : ["--per-page", "3"])],
1732
+ { 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`); }
1735
+ } catch { /* sin binario, sin auth o sin releases: no es un problema que resolver acá */ }
1736
+ }
1737
+ }
1738
+
1739
+ // ── release notify --test: probar el canal sin esperar a una release ─────────
1740
+ // Un webhook no se puede validar sin postear. Fingir que sí sería justo lo que dai no hace,
1741
+ // así que esto POSTEA de verdad — y lo dice antes.
1742
+ async function cmdReleaseNotify(opts = {}) {
1743
+ loadDaiEnv();
1744
+ let cfg;
1745
+ try { cfg = notifyConfig(process.env); } catch (e) { fail(String(e.message), 1); }
1746
+ if (!cfg) fail("no hay canal declarado: poné DAI_NOTIFY (y DAI_NOTIFY_WEBHOOK) en el .env.dai.", 1);
1747
+ 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);
1749
+ }
1750
+
1751
+ // El nombre de la app en los avisos y en el stamp. Sale del repo, no de una variable
1752
+ // nueva: `--app` lo pisa cuando el nombre lindo no es el del directorio.
1753
+ function releaseApp(opts) {
1754
+ return textOpt(opts, "app") || gitRepoName() || basename(process.cwd()) || null;
1755
+ }
1756
+
1757
+ // Mostrar el mensaje EXACTO que va a salir y pedir confirmación. Un mensaje a un canal de
1758
+ // equipo no se desmanda: se muestra antes, siempre, aunque el aviso sea de una sola línea.
1759
+ async function confirmarYAvisar(cfg, ev, opts = {}) {
1760
+ const msg = renderNotice(ev);
1761
+ process.stdout.write(`\n ── Aviso a ${describeTarget(cfg)} ──\n`);
1762
+ process.stdout.write(msg.split("\n").map((l) => ` │ ${l}`).join("\n") + "\n");
1763
+ process.stdout.write(` ──────────────────────────────────────\n`);
1764
+ if (opts.dryRun) { info("[dry-run] no se envió."); return; }
1765
+ if (!opts.yes) {
1766
+ if (!process.stdin.isTTY) { info("Sin TTY y sin --yes: no aviso al canal."); return; }
1767
+ const a = (await askOrCancel(` ¿Aviso al canal? (s/N) `) || "").toLowerCase();
1768
+ if (!["s", "si", "sí", "y", "yes"].includes(a)) { info("No se avisó — la release está hecha igual."); return; }
1769
+ }
1770
+ const r = await sendNotice(cfg, ev);
1771
+ if (r.ok) ok(`avisado a ${describeTarget(cfg)}`);
1772
+ else { warn(r.error); process.stdout.write(` El aviso no salió, pero la release SÍ está hecha.\n`); }
1773
+ }
1774
+
1775
+ function safeGit(args) { try { return git(args); } catch { return null; } }
1776
+
1777
+ // ── install: skills → ~/.claude/skills o <repo>/.claude/skills ────────────────// ── install: skills → ~/.claude/skills o <repo>/.claude/skills ────────────────
1116
1778
  async function cmdInstall(opts) {
1117
1779
  if (opts.from !== undefined) return cmdInstallFrom(opts); // skills externas (ADR-0013)
1118
1780
  const skillsSrc = join(ROOT, "skills");
@@ -1869,6 +2531,20 @@ function cmdDoctor() {
1869
2531
  }
1870
2532
  }
1871
2533
 
2534
+ // ── flujo de branches: contra qué integra este repo, y qué rama es producción ──
2535
+ // Sin esto declarado, `dai pr` adivina la base — y en un repo con ramas de ambiente
2536
+ // adivinar significa proponer un merge a producción sin que nada lo destaque (issue #46).
2537
+ info("flujo de branches (dai pr · dai done):");
2538
+ const flow = branchFlow(process.env);
2539
+ if (flow.dev) ok(`integración: ${flow.dev} (DAI_BRANCH_DEV) — ahí van las PR de feature/ y fix/`);
2540
+ else {
2541
+ const { base: adivinada, source: src } = resolveBase({ env: process.env, originHead: originHeadBranch() });
2542
+ warn(`sin DAI_BRANCH_DEV: las PR van a '${adivinada}', que sale de ${src}, no de tu config.`);
2543
+ process.stdout.write(" Declaralo una vez en el .env.dai: DAI_BRANCH_DEV=<rama-que-integra>\n");
2544
+ }
2545
+ if (flow.prod) ok(`producción: ${flow.prod} (DAI_BRANCH_PROD) — ahí van release/ y hotfix/, con confirmación explícita`);
2546
+ else info("sin DAI_BRANCH_PROD: dai no marca ninguna rama como producción (no adivina cuál es)");
2547
+
1872
2548
  // ── version-drift del scaffold vs el CLI (ADR-0010) ──────────────────────────
1873
2549
  if (existsSync(join(process.cwd(), ".dai", "VERSION"))) { info("versión del scaffold:"); reportDrift(); }
1874
2550
  }
@@ -1881,8 +2557,21 @@ function cmdVersion() {
1881
2557
 
1882
2558
  let [cmd, ...rest] = process.argv.slice(2);
1883
2559
  if (cmd === "--version" || cmd === "-v") cmd = "version";
1884
- if (cmd === "--help" || cmd === "-h") cmd = "help";
1885
2560
  const { opts, pos } = parseFlags(rest);
2561
+
2562
+ // ── Convención de ayuda (vale para TODOS los comandos) ────────────────────────
2563
+ // Pedir ayuda nunca ejecuta nada: sale por stdout y termina con 0. Antes el `--help`
2564
+ // caía en `opts` y el comando corría igual — `dai stamp --help` dejaba un comentario en
2565
+ // el tracker y `dai pr --help` publicaba una branch. Los agentes lo pisan seguido, porque
2566
+ // probar `<cmd> --help` antes de usar un comando es exactamente lo que hay que hacer.
2567
+ if (isHelpToken(cmd) || cmd === undefined || wantsHelp({ opts, pos })) {
2568
+ const { text, known } = helpFor(helpTopic(isHelpToken(cmd) ? null : cmd, pos));
2569
+ if (known) { process.stdout.write(text); process.exit(0); }
2570
+ process.stderr.write(`dai: no conozco el comando '${helpTopic(isHelpToken(cmd) ? null : cmd, pos)}'.\n\n`);
2571
+ process.stderr.write(text);
2572
+ process.exit(1);
2573
+ }
2574
+
1886
2575
  switch (cmd) {
1887
2576
  case "ac-hash": cmdAcHash(pos[0]); break;
1888
2577
  case "ls": cmdLs(opts); break;
@@ -1896,6 +2585,15 @@ switch (cmd) {
1896
2585
  case "pr":
1897
2586
  case "mr": cmdPr(opts).catch((e) => failSoft(String(e.message))); break; // `mr` = alias para GitLab (merge request)
1898
2587
  case "done": cmdDone(opts); break;
2588
+ case "release":
2589
+ if (pos[0] === "plan") cmdReleasePlan(opts).catch((e) => failSoft(String(e.message)));
2590
+ else if (pos[0] === "cut") cmdReleaseCut(pos[1], opts).catch((e) => failSoft(String(e.message)));
2591
+ else if (pos[0] === "done") cmdReleaseDone(pos[1], opts).catch((e) => failSoft(String(e.message)));
2592
+ else if (pos[0] === "stamp") cmdReleaseStamp(pos[1], opts).catch((e) => failSoft(String(e.message)));
2593
+ else if (pos[0] === "status") cmdReleaseStatus(opts).catch((e) => failSoft(String(e.message)));
2594
+ else if (pos[0] === "notify") cmdReleaseNotify(opts).catch((e) => failSoft(String(e.message)));
2595
+ else fail(`subcomando de release desconocido: '${pos[0] ?? "(ninguno)"}' (plan | cut | done | stamp | status | notify)`, 2);
2596
+ break;
1899
2597
  case "archive": cmdArchive(pos[0], opts); break;
1900
2598
  case "install": cmdInstall(opts).catch((e) => failSoft(String(e.message))); break; // alias de `dai skills install`
1901
2599
  case "skills":
@@ -1910,53 +2608,10 @@ switch (cmd) {
1910
2608
  case "doctor": cmdDoctor(); break;
1911
2609
  case "version": cmdVersion(); break;
1912
2610
  default:
1913
- process.stderr.write(
1914
- "Uso: dai <comando> [args]\n\n" +
1915
- "Trazabilidad:\n" +
1916
- " ac-hash <us.md> calcula el ac_hash (ADR-0001)\n" +
1917
- " ls [--json] lista lo que implementa el repo (ADR-0005)\n" +
1918
- " publish <us.md> crea la US en el tracker (Jira/ClickUp/md) y devuelve el key\n" +
1919
- " [--parent KEY] la cuelga de su épica · [--issuetype T] p. ej. Epic\n" +
1920
- " [--field alias=valor] campos propios que exige tu Jira (.dai/jira-fields.json); repetible\n" +
1921
- " link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
1922
- " link-us <KEY> --resync re-estampa el ac_hash contra la US viva (tras un ⚠️ de check)\n" +
1923
- " edit-us <KEY> trae la US del tracker, la abrís en tu editor, valida el formato,\n" +
1924
- " muestra qué cambia y la guarda (para el PO)\n" +
1925
- " [--no-editor] no abre $EDITOR (para skills/scripts que ya escribieron el .md)\n" +
1926
- " [--bump | --no-bump] decide el spec_version sin preguntar (sin TTY no se toca y avisa)\n" +
1927
- " update-us <KEY> [--us <md>] empuja al tracker un .md que ya escribiste + re-estampa el ac_hash\n" +
1928
- " [--dry-run] [--yes] sin --yes muestra el diff y pide confirmación · [--no-resync]\n" +
1929
- " [--strict] las advertencias de formato también frenan · [--no-bump] no toca spec_version\n" +
1930
- " check compara vs la US viva → atrasado (ADR-0003)\n" +
1931
- " check --ci gate de CI: exige el link según branch-naming (chore/ y docs/ exentas)\n" +
1932
- " [--branch b] la branch a evaluar (en CI se detecta sola) · [--no-network]\n" +
1933
- " salidas: 0 pasa · 1 falta el link · 2 el QUÉ cambió\n" +
1934
- " stamp [<ID>…] [--all] estampa la cobertura en el tracker (ADR-0005)\n" +
1935
- " sin ID: la US de esta branch; si hay varias, pregunta\n" +
1936
- " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
1937
- " 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" +
1938
- " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1939
- " [--us <ID>] [--title t] la US la resuelve la branch; si hay varias, pregunta (sin TTY, falla)\n" +
1940
- " --description <texto> QUÉ resuelve la PR y por qué → sección 'Descripción' (o --description-file <f>)\n" +
1941
- " --changes <texto> detalle de 'Cambios realizados' (default: los commits) (o --changes-file <f>)\n" +
1942
- " sin descripción y sin commits, con --yes o sin TTY, dai NO publica: la PR\n" +
1943
- " saldría con el molde del template y no se podría revisar\n" +
1944
- " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
1945
- " forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
1946
- " --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
1947
- " Sin --yes no postea nada: muestra el preview y valida que cada hallazgo apunte al diff.\n\n" +
1948
- "Instalación:\n" +
1949
- " skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
1950
- " skills install --from <git-url|npm:pkg|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
1951
- " init [<repo>] scaffolder interactivo del repo (asistente, gestor, OpenSpec)\n" +
1952
- " --for <asistentes> claude|copilot|cursor (combinables con coma) · o both|all (default all)\n" +
1953
- " ej: --for claude,cursor · --for copilot · --for all\n" +
1954
- " --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
1955
- " 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" +
1956
- " 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" +
1957
- " docs <destino> documentación conceptual → <destino>\n" +
1958
- " doctor diagnóstico del entorno\n\n" +
1959
- " (config: .env.dai — ver .env.dai.example)\n"
1960
- );
1961
- process.exit(cmd && cmd !== "help" ? 1 : 0);
2611
+ // Comando desconocido: la ayuda global por stderr y salida ≠ 0 (la ayuda PEDIDA sale
2612
+ // por stdout y con 0, arriba). Distinguirlos es lo que deja `dai foo --help` usable
2613
+ // en un script sin tener que adivinar de dónde leer.
2614
+ process.stderr.write(`dai: no conozco el comando '${cmd}'.\n\n`);
2615
+ process.stderr.write(globalUsage());
2616
+ process.exit(1);
1962
2617
  }