@dforce2055/dai 0.10.0 → 0.11.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
@@ -35,6 +35,9 @@ import { parseSource } from "./lib/skills-source.mjs";
35
35
  import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles } from "./lib/bootstrap.mjs";
36
36
  import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/jira-fields.mjs";
37
37
  import { assertProjectKey } from "./lib/pm-jira.mjs";
38
+ import { flattenImplements, stampScope, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
39
+ import { describeForgeError, parseForgeError } from "./lib/forge-api.mjs";
40
+ import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion } from "./lib/us-format.mjs";
38
41
 
39
42
  const HERE = dirname(fileURLToPath(import.meta.url));
40
43
 
@@ -50,6 +53,9 @@ const _color = process.stdout.isTTY && !process.env.NO_COLOR;
50
53
  const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
51
54
  const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m), dim: (m) => paint("2", m) };
52
55
  const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
56
+ // Rutas relativas al cwd en la salida: una ruta absoluta de 120 caracteres no se lee ni
57
+ // se copia. Si el archivo está fuera del cwd (`../otro`), se muestra tal cual.
58
+ const rel = (p) => { const r = relative(process.cwd(), p); return !r || r.startsWith("..") ? p : r; };
53
59
 
54
60
  // Banner de bienvenida de `dai init`: el Sol de Mayo en bloques (cuerpo y rayos rectos en
55
61
  // oro; rayos ondulados en celeste) al lado del título, más el preview de lo que se configura.
@@ -203,6 +209,81 @@ function gitRemote() { try { return git(["remote", "get-url", "origin"]); } catc
203
209
  function gitBranch() { try { return git(["rev-parse", "--abbrev-ref", "HEAD"]); } catch { return null; } }
204
210
  function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return null; } }
205
211
 
212
+ // ── check --ci: el gate de governance/ci-rules.md, ejecutable ────────────────
213
+ //
214
+ // La brecha del issue #26: ci-rules.md prometía "sin implements.yaml el CI bloquea",
215
+ // pero no existía el comando que lo hiciera. Era una regla escrita que nadie aplicaba.
216
+ //
217
+ // Lo que NO hace: exigirle US a todo. Una `chore/` o una `fix/` sin ticket son trabajo
218
+ // legítimo (branch-naming.md), y un gate que las bloquea se desactiva a la semana.
219
+ // Quién decide es requiresLink(), leyendo el nombre de la branch.
220
+ //
221
+ // Salidas: 0 = pasa · 1 = falta el link · 2 = hay link pero el QUÉ cambió (atrasado)
222
+ async function cmdCheckCi(opts = {}) {
223
+ const branch = opts.branch || process.env.DAI_CI_BRANCH || ciBranch() || gitBranch();
224
+ const { required, reason } = requiresLink(branch);
225
+ const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
226
+
227
+ info(`branch '${branch || "(desconocida)"}' — ${reason}`);
228
+ if (!required) {
229
+ if (rows.length) info(`igual declara ${rows.length} US (${rows.map((r) => r.id).join(", ")}) — se chequea su cobertura.`);
230
+ else { ok("gate OK — esta branch no requiere US."); process.exit(0); }
231
+ }
232
+ if (required && rows.length === 0) {
233
+ const ids = trackerKeysIn(branch);
234
+ process.stderr.write(
235
+ "✗ gate: falta el link QUÉ↔CÓMO — esta branch no tiene implements.yaml.\n" +
236
+ ` Crealo: dai link-us ${ids[0] || "<ID-DE-LA-US>"}\n` +
237
+ " Si NO implementa una US (tooling, deps, docs), renombrá la branch con un\n" +
238
+ " prefijo exento — chore/, docs/, ci/ — según governance/branch-naming.md.\n");
239
+ process.exit(1);
240
+ }
241
+
242
+ // Hay link: que además esté al día contra la US viva. Sin token/backend eso no se
243
+ // puede saber, y un gate que bloquea por falta de red es un gate que se apaga:
244
+ // con --no-network (o sin adaptador utilizable) valida el link y no más.
245
+ if (opts.noNetwork) {
246
+ ok(`gate OK — ${rows.length} US linkeada(s): ${rows.map((r) => r.id).join(", ")} (--no-network: no se comparó contra la US viva).`);
247
+ process.exit(0);
248
+ }
249
+ loadDaiEnv();
250
+ let adapter;
251
+ try { adapter = getAdapter(process.env); }
252
+ catch (e) {
253
+ warn(`no puedo comparar contra la US viva: ${e.message}`);
254
+ ok(`gate OK igual — el link existe (${rows.map((r) => r.id).join(", ")}). Configurá el backend para chequear también el atraso.`);
255
+ process.exit(0);
256
+ }
257
+ let worst = 0;
258
+ for (const r of rows) {
259
+ let live = null, netErr = null;
260
+ try { live = await adapter.fetchUS(r.id); } catch (e) { netErr = String(e.message).split("\n")[0]; }
261
+ if (netErr) { warn(`${r.id}: no pude leer la US (${netErr}) — no bloqueo por un problema de red/credencial.`); continue; }
262
+ const status = coverageStatus(r.ac_hash, live?.ac_hash);
263
+ if (status === "al-dia") ok(`${r.id} al día (${r.version})`);
264
+ else if (status === "atrasado") {
265
+ process.stderr.write(`✗ gate: ${r.id} ATRASADO — implementaste ${r.ac_hash}, la US viva es ${live.ac_hash}.\n` +
266
+ ` El QUÉ cambió. Resincronizá y revisá que lo cubras: dai link-us ${r.id} --resync\n`);
267
+ worst = Math.max(worst, 2);
268
+ } else {
269
+ warn(`${r.id}: no encontré la US en ${adapter.kind} — el link apunta a un ID que el tracker no tiene.`);
270
+ worst = Math.max(worst, 2);
271
+ }
272
+ }
273
+ if (worst === 0) ok(`gate OK — ${rows.length} US linkeada(s) y al día.`);
274
+ process.exit(worst);
275
+ }
276
+
277
+ // La branch real en CI: en una PR, HEAD es un merge commit detached, así que
278
+ // `git rev-parse --abbrev-ref HEAD` devuelve "HEAD" y no el nombre. Cada forge la
279
+ // expone en su propia variable.
280
+ function ciBranch() {
281
+ const e = process.env;
282
+ return e.GITHUB_HEAD_REF || e.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || e.CI_COMMIT_REF_NAME ||
283
+ e.BITBUCKET_BRANCH || e.BUILD_SOURCEBRANCHNAME ||
284
+ (e.GITHUB_REF_NAME && !/^\d+\/merge$/.test(e.GITHUB_REF_NAME) ? e.GITHUB_REF_NAME : null) || null;
285
+ }
286
+
206
287
  // ── check ──────────────────────────────────────────────────────────────────
207
288
  async function cmdCheck() {
208
289
  loadDaiEnv();
@@ -235,38 +316,106 @@ async function cmdCheck() {
235
316
  }
236
317
 
237
318
  // ── stamp ──────────────────────────────────────────────────────────────────
238
- async function cmdStamp() {
319
+ // Estampa la cobertura de UNA US: la de esta branch. Antes recorría todo el repo
320
+ // —archivados incluidos— así que cerrar una US le dejaba un comentario a las cuatro
321
+ // del sprint (issue #22). Ahora decide con branch-scope.mjs y, si no puede saber cuál,
322
+ // PREGUNTA en vez de estampar de más: un comentario en el tracker no se deshace.
323
+ async function cmdStamp(ids = [], opts = {}) {
239
324
  loadDaiEnv();
240
325
  const adapter = getAdapter(process.env);
241
326
  const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
242
- const found = discoverImplements(process.cwd());
243
- let n = 0;
244
- for (const f of found) for (const im of f.implements || []) {
245
- if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
246
- n++;
247
- const live = await adapter.fetchUS(im.id);
248
- const status = coverageStatus(im.ac_hash, live?.ac_hash);
327
+ const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
328
+ const allRows = flattenImplements(discoverImplements(process.cwd()));
329
+ const scope = stampScope({ branch, rows, allRows, ids, all: !!opts.all });
330
+
331
+ if (scope.mode === "none") { process.stdout.write("No hay implements.yaml para estampar.\n"); return; }
332
+ if (scope.mode === "explicit" && scope.missing?.length) {
333
+ fail(`no encontré implements.yaml para: ${scope.missing.join(", ")}.\n` +
334
+ ` US en este repo: ${allRows.map((r) => r.id).join(", ") || "(ninguna)"}`, 1);
335
+ }
336
+
337
+ let targets = scope.targets;
338
+ if (scope.mode === "ambiguous") {
339
+ warn(`${scope.reason}.`);
340
+ const listed = scope.candidates.map((r, i) => ` ${i + 1}) ${r.id} ${C.dim(`(${r.change})`)}`).join("\n");
341
+ process.stdout.write(` US vivas en el repo:\n${listed}\n`);
342
+ if (opts.yes || !process.stdin.isTTY) {
343
+ fail("no sé cuál estampar. Decilo explícitamente: dai stamp <ID> (o `dai stamp --all` para todas).", 1);
344
+ }
345
+ const ans = await askOrCancel(" ¿Cuál estampo? (número, varios con coma, 'a'=todas, Enter=cancelar) ");
346
+ if (!ans) { info("Cancelado — no se estampó nada."); return; }
347
+ if (/^a(ll|)$/i.test(ans)) targets = scope.candidates;
348
+ else {
349
+ const picked = ans.split(/[,\s]+/).filter(Boolean).map((t) => Number(t));
350
+ if (picked.some((n) => !Number.isInteger(n) || n < 1 || n > scope.candidates.length)) {
351
+ fail(`respuesta inválida: '${ans}'. Se esperaba número(s) entre 1 y ${scope.candidates.length}, o 'a'.`, 1);
352
+ }
353
+ targets = picked.map((n) => scope.candidates[n - 1]);
354
+ }
355
+ } else if (scope.mode !== "all") {
356
+ info(`${scope.reason} → estampo ${targets.map((t) => t.id).join(", ")}.`);
357
+ }
358
+
359
+ for (const r of targets) {
360
+ const live = await adapter.fetchUS(r.id);
361
+ const status = coverageStatus(r.ac_hash, live?.ac_hash);
249
362
  const record = {
250
- repo: f.repo, change: f.change, version: im.version, ac_hash: im.ac_hash, status,
363
+ repo: r.repo, change: r.change, version: r.version, ac_hash: r.ac_hash, status,
251
364
  branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
252
365
  };
253
- const where = await adapter.stamp(im.id, record);
254
- process.stdout.write(`✓ ${im.id} → ${where} (${statusLabel(status)})\n`);
366
+ const where = await adapter.stamp(r.id, record);
367
+ process.stdout.write(`✓ ${r.id} → ${where} (${statusLabel(status)})\n`);
255
368
  }
256
- if (n === 0) process.stdout.write("No hay implements.yaml para estampar.\n");
369
+ if (targets.length === 0) process.stdout.write("No se estampó nada.\n");
257
370
  }
258
371
 
259
372
  // ── forge (review) ───────────────────────────────────────────────────────────
373
+ // El review.json lo escribe la SKILL, no el CLI — así que `dai init`/`dai sync` son los
374
+ // únicos que ponían `.dai/reviews/` en el .gitignore, y un repo inicializado con una dai
375
+ // vieja se comía borradores a medio editar en un commit (issue #25). Acá lo arreglamos
376
+ // donde duele: al consumir el archivo. Aditivo, y solo si el borrador está bajo
377
+ // `.dai/reviews/` — si el equipo decidió versionar sus reviews en otro lado, no opinamos.
378
+ function ensureReviewsIgnored(fromPath) {
379
+ const rel = relative(process.cwd(), fromPath).replace(/\\/g, "/");
380
+ if (!rel.startsWith(".dai/reviews/")) return;
381
+ const giPath = join(process.cwd(), ".gitignore");
382
+ if (!existsSync(giPath)) return; // sin .gitignore no inventamos uno
383
+ const cur = readFileSync(giPath, "utf8");
384
+ const gi = reconcileGitignore(cur, {}); // {} → solo el `ensure` base (.env.dai, .dai/reviews/)
385
+ if (!gi.changed) return;
386
+ writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n");
387
+ info(".gitignore: agregué `.dai/reviews/` — los borradores de review no viajan en un commit.");
388
+ info(" Si tu equipo los quiere versionar, sacá esa línea a mano.");
389
+ }
390
+
391
+ // Una pregunta puntual por TTY. Ctrl+D (EOF) devuelve "" — se trata como "cancelar", no
392
+ // como un crash: en un prompt que precede a una acción irreversible, abortar es la
393
+ // respuesta más segura, y "Aborted with Ctrl+D" no le dice eso a nadie.
394
+ async function askOrCancel(q) {
395
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
396
+ try { return (await rl.question(q)).trim(); }
397
+ catch { process.stdout.write("\n"); return ""; }
398
+ finally { rl.close(); }
399
+ }
400
+
401
+ // Corre una llamada al forge y, si falla, la traduce a una causa concreta (issue #24)
402
+ // en vez del genérico "¿token? ¿ref correcta?".
403
+ async function forgeCall(pr, fn) {
404
+ try { return await fn(); }
405
+ catch (e) { fail(describeForgeError(pr, { ...parseForgeError(e), env: process.env }), 1); }
406
+ }
407
+
260
408
  async function cmdForge(sub, ref, opts) {
261
409
  loadDaiEnv();
262
410
  const pr = parsePrRef(ref, gitRemote());
263
411
  if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
264
412
  if (sub === "pr") {
265
- process.stdout.write(JSON.stringify(await getPR(pr, process.env), null, 2) + "\n");
413
+ const j = await forgeCall(pr, () => getPR(pr, process.env));
414
+ process.stdout.write(JSON.stringify(j, null, 2) + "\n");
266
415
  } else if (sub === "comment") {
267
416
  const body = opts.bodyFile ? readFileSync(opts.bodyFile, "utf8") : opts.body;
268
417
  if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
269
- const res = await postComment(pr, body, process.env);
418
+ const res = await forgeCall(pr, () => postComment(pr, body, process.env));
270
419
  process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
271
420
  } else if (sub === "review") {
272
421
  await cmdForgeReview(pr, opts);
@@ -284,11 +433,11 @@ async function cmdForge(sub, ref, opts) {
284
433
  async function cmdForgeReview(pr, opts) {
285
434
  if (!opts.from) fail("falta --from <review.json>. Lo escribe la skill dai-review; revisalo antes de postear.", 1);
286
435
  const review = parseFindings(readFileSync(opts.from, "utf8"));
436
+ ensureReviewsIgnored(opts.from);
287
437
 
288
438
  // El diff sale de git (local, por SSH), no de la API: es la fuente de verdad de qué
289
439
  // línea es comentable, y no gasta rate limit.
290
- const remote = await Promise.resolve(getPR(pr, process.env)).catch(() => null);
291
- if (!remote) fail("no pude leer la PR/MR del forge (¿token? ¿ref correcta?).", 1);
440
+ const remote = await forgeCall(pr, () => getPR(pr, process.env));
292
441
  const base = opts.base || remote.baseRef;
293
442
  if (!base) fail("no pude saber la branch base de la PR. Pasala con --base <branch>.", 1);
294
443
  let diff = "";
@@ -335,7 +484,7 @@ async function cmdForgeReview(pr, opts) {
335
484
  }
336
485
  if (!kept.length && !review.summary) fail("no hay nada que postear (0 comentarios y resumen vacío).", 1);
337
486
 
338
- const res = await postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env);
487
+ const res = await forgeCall(pr, () => postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env));
339
488
  ok(`review posteado${res.url ? `: ${res.url}` : ""} — ${res.posted} comentario(s) en línea.`);
340
489
  if (res.failed.length) {
341
490
  warn(`${res.failed.length} comentario(s) NO entraron (gitlab no es atómico: el resumen y el resto SÍ están posteados):`);
@@ -387,6 +536,254 @@ async function cmdPublish(file, opts = {}) {
387
536
  info(`Próximo paso (el dev abre el CÓMO): dai link-us ${r.id}`);
388
537
  }
389
538
 
539
+ // ── edit-us / update-us: el QUÉ cambia y el tracker se entera ────────────────
540
+ //
541
+ // Dos puertas de entrada a UN camino. La diferencia es de dónde sale el markdown:
542
+ //
543
+ // dai edit-us <ID> trae la US del tracker → la abrís en tu editor → valida → empuja
544
+ // dai update-us <ID> ya tenés el .md escrito (lo refinaste implementando) → empuja
545
+ //
546
+ // El tramo compartido —validar, mostrar el diff, proponer el bump de spec_version,
547
+ // confirmar, escribir, re-estampar el ac_hash local— es `pushUS`. No es código duplicado
548
+ // con dos nombres: es un solo camino con dos entradas, y por eso las dos puertas dan
549
+ // exactamente el mismo preview y la misma confirmación.
550
+
551
+ // Los campos propios de Jira, si el proyecto los exige (compartido por las dos puertas).
552
+ function fieldsFor(adapter, opts) {
553
+ if (opts.field === undefined) return undefined;
554
+ if (adapter.kind !== "jira") fail(`--field es solo para jira (DAI_PM=${adapter.kind}).`, 2);
555
+ return resolveJiraFields({
556
+ spec: loadJiraFieldsSpec(),
557
+ issuetype: opts.issuetype || process.env.DAI_JIRA_ISSUETYPE || "Story",
558
+ overrides: parseFieldOverrides(asList(opts.field)),
559
+ });
560
+ }
561
+
562
+ // El backend md devuelve SINCRÓNICO (el contrato del adaptador lo permite), así que
563
+ // `adapter.fetchUS(id).catch(...)` explotaba con DAI_PM=md. Se normaliza a promesa.
564
+ const fetchLive = (adapter, id) => Promise.resolve().then(() => adapter.fetchUS(id)).catch(() => null);
565
+
566
+ // Valida el formato e imprime el veredicto. Devuelve el resultado; corta si hay errores.
567
+ function validateOrFail(md, source, { strict = false } = {}) {
568
+ const v = validateUS(md);
569
+ const lines = renderValidation(v);
570
+ process.stdout.write(`\n ── formato de la US (${source}) ────────────\n`);
571
+ for (const l of lines) process.stdout.write(l + "\n");
572
+ if (!v.ok) {
573
+ process.stdout.write("\n");
574
+ fail("la US no tiene el formato mínimo para viajar al tracker (ver arriba).\n" +
575
+ " El molde canónico está en .dai/templates/formato-us.md, y /grill-user-story te interroga hasta llegar a él.", 2);
576
+ }
577
+ if (strict && v.warnings.length) {
578
+ process.stdout.write("\n");
579
+ fail(`--strict: ${v.warnings.length} advertencia(s) y ninguna se puede ignorar en este modo.`, 2);
580
+ }
581
+ return v;
582
+ }
583
+
584
+ // El tramo compartido: preview → spec_version → confirmación → tracker → ac_hash local.
585
+ // `md` puede reescribirse acá (el bump de spec_version), por eso devuelve el markdown final.
586
+ async function pushUS(id, md, { adapter, file, opts, live }) {
587
+ const validation = validateOrFail(md, rel(file), { strict: !!opts.strict });
588
+ const title = opts.title || validation.title;
589
+ const newHash = acHash(md);
590
+
591
+ // ── spec_version: el número COMUNICA, el hash DETECTA (METODOLOGIA §4) ──────
592
+ // Si cambiaron los criterios se PROPONE subirlo, no se impone: dai mirando el hash no
593
+ // distingue un criterio nuevo de un typo corregido, y quien sabe la diferencia es el PO.
594
+ const curVer = parseSpecVersion(md);
595
+ const hashChanged = live?.ac_hash !== newHash;
596
+ let newVer = curVer;
597
+ if (hashChanged && !opts.noBump) {
598
+ const proposed = bumpSpecVersion(curVer);
599
+ if (opts.bump === true || opts.yes) newVer = proposed;
600
+ else if (typeof opts.bump === "string") newVer = opts.bump;
601
+ else if (process.stdin.isTTY) {
602
+ process.stdout.write(`\n Cambiaron los criterios (ac_hash ${C.dim(live?.ac_hash ?? "ninguno")} → ${C.b(newHash)}).\n`);
603
+ process.stdout.write(` s = cambio ${C.b("material")}: subo spec_version a ${C.b(proposed)} y los repos con ${curVer || "la versión vieja"} se marcan ATRASADOS\n`);
604
+ process.stdout.write(` n = cambio ${C.b("editorial")} (typo, redacción): se queda en ${curVer || "(sin versión)"}\n`);
605
+ const ans = await askOrCancel(` ¿Subo spec_version a ${proposed}? (S/n) `);
606
+ if (!/^n/i.test(ans)) newVer = proposed;
607
+ } else {
608
+ // Sin TTY y sin --bump/--no-bump no hay quién decida, y decidir por nuestra cuenta
609
+ // sería inventar la respuesta a la única pregunta que este comando NO puede
610
+ // responder solo. Se deja como está, pero se DICE — un no-op silencioso acá
611
+ // termina en un spec_version que dejó de comunicar nada.
612
+ info(`cambiaron los criterios y spec_version se queda en ${curVer || "(sin versión)"}: no hay TTY para preguntarlo.`);
613
+ info(` Si el cambio es material: --bump (${curVer || "v1"} → ${proposed}, marca atrasados a los repos)`);
614
+ info(" Si es editorial (typo): --no-bump");
615
+ }
616
+ }
617
+ if (newVer && newVer !== curVer) md = setSpecVersion(md, newVer);
618
+
619
+ // ── Preview: qué cambia ALLÁ ARRIBA. Se muestra siempre, antes de escribir nada ──
620
+ // "(ninguno)" también del lado del "sin cambios": imprimir el `null` crudo hace dudar
621
+ // de si el comando se rompió, justo en el preview que la persona lee antes de aprobar.
622
+ const show = (v) => v ?? "(ninguno)";
623
+ const chg = (from, to) => (from === to ? `${show(to)}${C.dim(" (sin cambios)")}` : `${C.dim(show(from))} → ${C.b(show(to))}`);
624
+ process.stdout.write(`\n ── ${id} · qué cambia en ${adapter.kind} ────────────\n`);
625
+ process.stdout.write(` título: ${chg(live?.title, title)}\n`);
626
+ process.stdout.write(` criterios: ${validation.criteria.length}\n`);
627
+ process.stdout.write(` version: ${chg(curVer, newVer)}\n`);
628
+ process.stdout.write(` ac_hash: ${chg(live?.ac_hash, newHash)}\n`);
629
+ process.stdout.write(` fuente: ${rel(file)}\n ──────────────────────────────────────\n`);
630
+
631
+ if (opts.dryRun) { info("[dry-run] no se tocó el tracker."); return md; }
632
+ if (!opts.yes && process.stdin.isTTY) {
633
+ const ans = await askOrCancel(` Esto PISA la US ${id} en ${adapter.kind}. ¿Guardo? (s/N) `);
634
+ if (!/^s|^y/i.test(ans)) { info("Cancelado — el tracker quedó como estaba."); return null; }
635
+ }
636
+ if (md !== readFileSync(file, "utf8")) writeFileSync(file, md); // el bump también queda local
637
+
638
+ const r = await adapter.updateUS(id, { title, descriptionMarkdown: md, fields: fieldsFor(adapter, opts) });
639
+ ok(`${id} actualizada en ${adapter.kind}${r.url ? ` → ${r.url}` : ""}`);
640
+
641
+ // Re-estampar el ac_hash local: si no, `dai check` marca atrasado por tu propia edición.
642
+ if (opts.noResync) { info("--no-resync: el implements.yaml quedó con el ac_hash viejo (dai check te lo va a marcar)."); return md; }
643
+ const target = discoverImplements(process.cwd()).find((f) => (f.implements || []).some((im) => im.id === id));
644
+ if (!target) { info(`sin implements.yaml para ${id} en este repo — no hay ac_hash local que resincronizar.`); return md; }
645
+ const prev = (target.implements.find((im) => im.id === id) || {}).ac_hash;
646
+ if (prev === newHash && !newVer) { ok(`ac_hash local ya estaba al día (${newHash}).`); return md; }
647
+ let txt = readFileSync(target.path, "utf8").replace(/^(\s*ac_hash:\s*).*$/m, `$1${newHash}`);
648
+ if (newVer) txt = txt.replace(/^(\s*version:\s*).*$/m, `$1${newVer}`);
649
+ writeFileSync(target.path, txt);
650
+ ok(`ac_hash re-estampado: ${prev} → ${newHash}${newVer && newVer !== curVer ? ` (${newVer})` : ""} en ${rel(target.path)}`);
651
+ if (prev !== newHash) warn("cambiaron los criterios: revisá que tu implementación los cubra, y corré tus tests.");
652
+ return md;
653
+ }
654
+
655
+ // El .md de trabajo de una US: explícito con --us, o el `us.md` del change que la implementa.
656
+ function usFileFor(id, opts, { quiet = false } = {}) {
657
+ if (typeof opts.us === "string") return opts.us;
658
+ const target = discoverImplements(process.cwd()).find((f) => (f.implements || []).some((im) => im.id === id));
659
+ const guess = target ? join(dirname(target.path), "us.md") : null;
660
+ if (guess && existsSync(guess)) {
661
+ if (!quiet) info(`sin --us: uso ${rel(guess)} (el change que implementa ${id}).`);
662
+ return guess;
663
+ }
664
+ return null;
665
+ }
666
+
667
+ // ── edit-us: traer la US del tracker, editarla, validarla y guardarla ─────────
668
+ //
669
+ // Para el PO: la US vive en el tracker, no en un .md que alguien tiene que acordarse de
670
+ // sincronizar. Este comando la BAJA, te la abre en tu editor, valida el formato cuando
671
+ // guardás, te muestra qué cambia y recién ahí la sube. Si el formato no da, te deja
672
+ // volver al editor en vez de tirarte el trabajo.
673
+ async function cmdEditUs(id, opts = {}) {
674
+ if (!id) fail("uso: dai edit-us <ID> [--us <archivo.md>] [--strict] [--dry-run] [--yes]", 1);
675
+ if (!isValidKey(id)) fail(`key inválido: '${id}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
676
+ loadDaiEnv();
677
+ const adapter = getAdapter(process.env);
678
+ if (typeof adapter.updateUS !== "function") fail(`el backend '${adapter.kind}' no soporta actualizar US.`, 1);
679
+
680
+ const live = await fetchLive(adapter, id);
681
+ if (!live) {
682
+ fail(`no encontré la US ${id} en ${adapter.kind}. ¿Es el key correcto?\n` +
683
+ ` Para CREARLA: dai publish <us.md> (edit-us edita una que ya existe)`, 2);
684
+ }
685
+
686
+ // Dónde se edita: el us.md del change si existe (así el dev y el PO tocan el MISMO
687
+ // archivo), si no `.dai/us/<ID>.md`, que es la convención del backend md.
688
+ const file = usFileFor(id, opts, { quiet: true }) ||
689
+ join(process.env.DAI_MD_US_DIR || join(".dai", "us"), `${id}.md`);
690
+
691
+ // El cuerpo que baja del tracker. Si ya hay un .md local con el MISMO ac_hash, se
692
+ // respeta el local: puede tener secciones del molde (contexto, fuera de scope) que el
693
+ // tracker no devuelve, y pisarlas con la versión de arriba sería perder trabajo.
694
+ let md = live.raw || null;
695
+ const localExists = existsSync(file);
696
+ const local = localExists ? readFileSync(file, "utf8") : null;
697
+ if (local && acHash(local) === live.ac_hash) {
698
+ md = local;
699
+ info(`${rel(file)} ya está al día con ${adapter.kind} (ac_hash ${live.ac_hash}) — edito el local, que tiene el molde completo.`);
700
+ } else if (md == null) {
701
+ fail(`el backend '${adapter.kind}' no devuelve el cuerpo de la US, así que no puedo traerla para editar.\n` +
702
+ " Editá el .md a mano y empujalo con: dai update-us " + id + " --us <archivo.md>", 1);
703
+ } else if (localExists && typeof opts.us === "string") {
704
+ // Pediste ESE archivo: es la fuente, punto. Bajarle la versión del tracker encima
705
+ // borraría justo lo que viniste a subir — es el camino que usa /grill-user-story,
706
+ // que escribe la US refinada al .md y después la empuja.
707
+ md = local;
708
+ info(`${rel(file)} es tu fuente (local ${acHash(local) || "sin criterios"} vs vivo ${live.ac_hash}) — no lo piso con el tracker.`);
709
+ } else if (localExists) {
710
+ warn(`${rel(file)} existe pero DIFIERE del tracker (local ${acHash(local) || "sin criterios"} vs vivo ${live.ac_hash}).`);
711
+ // Ante la duda gana lo LOCAL: es trabajo que alguien escribió y que el tracker no
712
+ // tiene. Pisarlo es la única de las dos opciones que destruye algo.
713
+ if (!opts.yes && process.stdin.isTTY) {
714
+ const ans = await askOrCancel(" ¿Lo piso con la versión del tracker? (s/N — 'n' edita el local tal cual) ");
715
+ if (!/^s|^y/i.test(ans)) { md = local; info("Edito el local, sin pisarlo."); }
716
+ } else {
717
+ md = local;
718
+ info(`edito el local sin pisarlo (${opts.yes ? "--yes" : "no interactivo"}). Para partir del tracker, borrá ${rel(file)} o pasá otro --us.`);
719
+ }
720
+ }
721
+
722
+ mkdirSync(dirname(file), { recursive: true });
723
+ if (md !== local) { writeFileSync(file, md); ok(`traje ${id} de ${adapter.kind} → ${rel(file)}`); }
724
+
725
+ // ── El ciclo editar → validar ──────────────────────────────────────────────
726
+ // Un formato inválido NO tira el trabajo: te devuelve al editor con los errores a la
727
+ // vista. Se sale con Ctrl+C, no perdiendo lo escrito.
728
+ for (;;) {
729
+ if (!opts.noEditor) await openEditor(file);
730
+ md = readFileSync(file, "utf8");
731
+ const v = validateUS(md);
732
+ if (v.ok || opts.noEditor || !process.stdin.isTTY) break;
733
+ process.stdout.write(`\n ── formato de la US (${rel(file)}) ────────────\n`);
734
+ for (const l of renderValidation(v)) process.stdout.write(l + "\n");
735
+ const ans = await askOrCancel("\n El formato no da. ¿Vuelvo a abrir el editor? (S/n — 'n' aborta sin tocar el tracker) ");
736
+ if (/^n/i.test(ans)) { info("Abortado — el tracker quedó como estaba; tu edición está en " + rel(file) + "."); return; }
737
+ }
738
+
739
+ await pushUS(id, md, { adapter, file, opts, live });
740
+ }
741
+
742
+ // Abre $VISUAL/$EDITOR sobre el archivo. Sin editor configurado no se impone `vi`: se
743
+ // pide editar el archivo y volver — funciona igual en una terminal, en un IDE, o con el
744
+ // archivo abierto en otra ventana.
745
+ async function openEditor(file) {
746
+ const ed = process.env.VISUAL || process.env.EDITOR;
747
+ if (ed && process.stdin.isTTY) {
748
+ info(`abriendo ${rel(file)} en ${ed}…`);
749
+ try {
750
+ execFileSync(ed, [file], { stdio: "inherit", shell: process.platform === "win32" });
751
+ return;
752
+ } catch (e) {
753
+ warn(`no pude abrir '${ed}': ${String(e.message).split("\n")[0]}`);
754
+ }
755
+ }
756
+ if (!process.stdin.isTTY) return;
757
+ if (!ed) info("no hay $EDITOR ni $VISUAL configurados.");
758
+ await askOrCancel(` Editá ${rel(file)} y presioná Enter cuando termines (Ctrl+C para abortar) `);
759
+ }
760
+
761
+ // ── update-us: empuja al tracker una US que ya escribiste ─────────────────────
762
+ //
763
+ // El inverso de `dai publish`: la US ya existe con su key, la refinaste implementando (un
764
+ // criterio que apareció escribiendo el test) y el tracker quedó viejo. Sin esto había que
765
+ // copiar y pegar a mano, que es justo lo que el método no quiere (Art. 10).
766
+ async function cmdUpdateUs(id, opts = {}) {
767
+ if (!id) fail("uso: dai update-us <ID> [--us <archivo.md>] [--strict] [--no-resync] [--dry-run] [--yes]", 1);
768
+ if (!isValidKey(id)) fail(`key inválido: '${id}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
769
+ loadDaiEnv();
770
+
771
+ const file = usFileFor(id, opts);
772
+ if (!file) {
773
+ fail(`falta --us <archivo.md> con la US (no encontré un us.md junto al implements.yaml de ${id}).\n` +
774
+ ` Si querés traerla del tracker y editarla ahí mismo: dai edit-us ${id}`, 1);
775
+ }
776
+ if (!existsSync(file)) fail(`no existe el archivo '${file}'.`, 1);
777
+
778
+ const adapter = getAdapter(process.env);
779
+ if (typeof adapter.updateUS !== "function") fail(`el backend '${adapter.kind}' no soporta actualizar US.`, 1);
780
+ const live = await fetchLive(adapter, id);
781
+ if (!live && adapter.kind !== "md") {
782
+ fail(`no encontré la US ${id} en ${adapter.kind}. ¿Es el key correcto? Para CREARLA: dai publish ${rel(file)}`, 2);
783
+ }
784
+ await pushUS(id, readFileSync(file, "utf8"), { adapter, file, opts, live });
785
+ }
786
+
390
787
  // ── done: cierra una US — vuelve a la base, actualiza y borra la branch local ──
391
788
  function cmdDone(opts) {
392
789
  const base = opts.base || "main";
@@ -840,6 +1237,7 @@ async function cmdInit(repo, opts) {
840
1237
  }
841
1238
  writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
842
1239
  ok(".dai/ moldes (templates) + reglas (governance) del método");
1240
+ info(" gate de CI opcional: cp .dai/templates/ci-dai-gate.yml .github/workflows/");
843
1241
 
844
1242
  // Config de dai en SUS PROPIOS archivos, sin tocar el `.env`/`.env.example` del equipo:
845
1243
  // muchas orgs versionan el `.env` como política, así que dai lo deja en paz (solo lo lee,
@@ -1252,8 +1650,10 @@ switch (cmd) {
1252
1650
  case "ac-hash": cmdAcHash(pos[0]); break;
1253
1651
  case "ls": cmdLs(opts); break;
1254
1652
  case "link-us": cmdLinkUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1255
- case "check": cmdCheck().catch((e) => fail(String(e.message))); break;
1256
- case "stamp": cmdStamp().catch((e) => fail(String(e.message))); break;
1653
+ case "check": (opts.ci ? cmdCheckCi(opts) : cmdCheck()).catch((e) => fail(String(e.message))); break;
1654
+ case "stamp": cmdStamp(pos, opts).catch((e) => fail(String(e.message))); break;
1655
+ case "update-us": cmdUpdateUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1656
+ case "edit-us": cmdEditUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1257
1657
  case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
1258
1658
  case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
1259
1659
  case "pr":
@@ -1283,8 +1683,19 @@ switch (cmd) {
1283
1683
  " [--field alias=valor] campos propios que exige tu Jira (.dai/jira-fields.json); repetible\n" +
1284
1684
  " link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
1285
1685
  " link-us <KEY> --resync re-estampa el ac_hash contra la US viva (tras un ⚠️ de check)\n" +
1686
+ " edit-us <KEY> trae la US del tracker, la abrís en tu editor, valida el formato,\n" +
1687
+ " muestra qué cambia y la guarda (para el PO)\n" +
1688
+ " [--no-editor] no abre $EDITOR (para skills/scripts que ya escribieron el .md)\n" +
1689
+ " [--bump | --no-bump] decide el spec_version sin preguntar (sin TTY no se toca y avisa)\n" +
1690
+ " update-us <KEY> [--us <md>] empuja al tracker un .md que ya escribiste + re-estampa el ac_hash\n" +
1691
+ " [--dry-run] [--yes] sin --yes muestra el diff y pide confirmación · [--no-resync]\n" +
1692
+ " [--strict] las advertencias de formato también frenan · [--no-bump] no toca spec_version\n" +
1286
1693
  " check compara vs la US viva → atrasado (ADR-0003)\n" +
1287
- " stamp estampa la cobertura en el tracker (ADR-0005)\n" +
1694
+ " check --ci gate de CI: exige el link según branch-naming (chore/ y docs/ exentas)\n" +
1695
+ " [--branch b] la branch a evaluar (en CI se detecta sola) · [--no-network]\n" +
1696
+ " salidas: 0 pasa · 1 falta el link · 2 el QUÉ cambió\n" +
1697
+ " stamp [<ID>…] [--all] estampa la cobertura en el tracker (ADR-0005)\n" +
1698
+ " sin ID: la US de esta branch; si hay varias, pregunta\n" +
1288
1699
  " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
1289
1700
  " 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" +
1290
1701
  " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
@@ -197,8 +197,11 @@ export function reconcileGitignore(text, want) {
197
197
  if (broad.has(norm(line))) { changed = true; return false; }
198
198
  return true;
199
199
  });
200
- const have = new Set(lines.map((l) => l.trim()));
201
- const add = ensure.filter((e) => !have.has(e));
200
+ // Se compara NORMALIZADO: `.dai/reviews`, `/.dai/reviews/` y `.dai/reviews/` son la
201
+ // misma regla para git. Comparando el texto crudo agregábamos un duplicado al repo
202
+ // de quien ya la había puesto a mano.
203
+ const have = new Set(lines.filter((l) => !l.trim().startsWith("#")).map((l) => norm(l)));
204
+ const add = ensure.filter((e) => !have.has(norm(e)));
202
205
  if (add.length) {
203
206
  changed = true;
204
207
  if (lines.length && lines[lines.length - 1].trim() !== "") lines.push("");