@dforce2055/dai 0.9.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.
Files changed (63) hide show
  1. package/{.env.example → .env.dai.example} +3 -3
  2. package/CHANGELOG.md +148 -0
  3. package/README.md +47 -26
  4. package/VERSION +1 -1
  5. package/cli/dai.mjs +528 -61
  6. package/cli/lib/bootstrap.mjs +44 -9
  7. package/cli/lib/branch-scope.mjs +144 -0
  8. package/cli/lib/env.mjs +12 -0
  9. package/cli/lib/forge-api.mjs +57 -0
  10. package/cli/lib/pm-adapter.mjs +16 -2
  11. package/cli/lib/pm-clickup.mjs +18 -3
  12. package/cli/lib/pm-jira.mjs +23 -3
  13. package/cli/lib/skills-source.mjs +8 -0
  14. package/cli/lib/us-format.mjs +147 -0
  15. package/docs/EJEMPLO-END-TO-END.md +78 -46
  16. package/docs/MANIFIESTO.md +2 -2
  17. package/docs/METODOLOGIA.md +25 -15
  18. package/docs/PROBAR.md +25 -15
  19. package/docs/SCRUM-CON-IA.md +11 -11
  20. package/docs/adr/0003-deteccion-y-estampado-son-comandos.md +1 -1
  21. package/docs/adr/0006-distribucion-y-licencia.md +1 -1
  22. package/docs/adr/0013-skills-externas-install-from.md +11 -4
  23. package/docs/adr/0015-jira-corporativo.md +1 -1
  24. package/docs/adr/0017-env-dai.md +64 -0
  25. package/docs/adr/0018-alcance-de-stamp-y-gate-de-ci.md +183 -0
  26. package/docs/adr/README.md +2 -0
  27. package/docs/detalle/01-refinamiento.md +20 -5
  28. package/docs/detalle/03-ramas.md +2 -2
  29. package/docs/detalle/04-tdd.md +15 -9
  30. package/docs/detalle/06-code-review.md +8 -5
  31. package/docs/detalle/07-merge-trazabilidad.md +13 -2
  32. package/docs/detalle/08-daily.md +1 -1
  33. package/docs/detalle/README.md +1 -1
  34. package/docs/glosario.md +3 -3
  35. package/docs/guias/dev.md +31 -7
  36. package/docs/guias/index.md +12 -0
  37. package/docs/guias/lead.md +1 -1
  38. package/docs/guias/po.md +53 -8
  39. package/docs/index.md +35 -0
  40. package/docs/public/favicon.svg +12 -0
  41. package/docs/public/logo-link.svg +12 -0
  42. package/docs/public/logo.svg +12 -0
  43. package/docs/public/tutoriales/clickup-1-settings.png +0 -0
  44. package/docs/public/tutoriales/clickup-2-api.png +0 -0
  45. package/docs/public/tutoriales/clickup-3-generate-copy.png +0 -0
  46. package/docs/public/tutoriales/jira-1-avatar.png +0 -0
  47. package/docs/public/tutoriales/jira-2-seguridad-tokens.png +0 -0
  48. package/docs/public/tutoriales/jira-3-crear-token.png +0 -0
  49. package/docs/public/tutoriales/jira-4-nombre-vencimiento.png +0 -0
  50. package/docs/public/tutoriales/jira-5-copiar.png +0 -0
  51. package/docs/tutoriales/claves-ssh.md +93 -0
  52. package/docs/tutoriales/configurar-git.md +53 -0
  53. package/docs/tutoriales/index.md +18 -0
  54. package/docs/tutoriales/instalar-glab.md +74 -0
  55. package/docs/tutoriales/token-clickup.md +73 -0
  56. package/docs/tutoriales/token-jira.md +74 -0
  57. package/governance/ci-rules.md +47 -8
  58. package/package.json +9 -3
  59. package/skills/dai-review/SKILL.md +1 -1
  60. package/skills/grill-epic/SKILL.md +2 -2
  61. package/skills/grill-intent/SKILL.md +1 -1
  62. package/skills/grill-user-story/SKILL.md +58 -10
  63. package/templates/ci-dai-gate.yml +50 -0
package/cli/dai.mjs CHANGED
@@ -21,7 +21,7 @@ import { createInterface } from "node:readline/promises";
21
21
  import { acHash } from "./lib/ac-hash.mjs";
22
22
  import { discoverImplements, isPlaceholderId } from "./lib/implements.mjs";
23
23
  import { isValidKey, slugify, branchName, extractTitle, renderImplementsYaml } from "./lib/link-us.mjs";
24
- import { loadEnv } from "./lib/env.mjs";
24
+ import { loadDaiEnv } from "./lib/env.mjs";
25
25
  import { getAdapter, coverageStatus, statusLabel } from "./lib/pm-adapter.mjs";
26
26
  import { branchUrl, commitUrl, parseRemote, detectForge } from "./lib/forge-url.mjs";
27
27
  import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs";
@@ -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
 
@@ -48,8 +51,36 @@ const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
48
51
  // Color ANSI mínimo — solo si es TTY y no está NO_COLOR (así no ensucia pipes/CI).
49
52
  const _color = process.stdout.isTTY && !process.env.NO_COLOR;
50
53
  const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
51
- const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m) };
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; };
59
+
60
+ // Banner de bienvenida de `dai init`: el Sol de Mayo en bloques (cuerpo y rayos rectos en
61
+ // oro; rayos ondulados en celeste) al lado del título, más el preview de lo que se configura.
62
+ // Todo con caracteres — cero-dep. Degrada a ASCII sin color si no hay TTY o con NO_COLOR.
63
+ const initBanner = () => {
64
+ const sun = [
65
+ " █ ",
66
+ " ▒ ▄███▄ ▒ ",
67
+ "██ █████ ██",
68
+ " ▒ ▀███▀ ▒ ",
69
+ " █ ",
70
+ ];
71
+ const paintSun = (line) => [...line].map((ch) => (ch === "▒" ? C.cy(ch) : ch === " " ? " " : C.y(ch))).join("");
72
+ const aside = ["", paint("1;33", "dai") + " · Desarrollo Asistido por IA", C.dim("La IA asiste; la persona firma."), "", ""];
73
+ let out = "\n";
74
+ for (let i = 0; i < sun.length; i++) out += " " + paintSun(sun[i]) + (aside[i] ? " " + aside[i] : "") + "\n";
75
+ out += "\n " + C.b("Esto va a configurar el repo:") + "\n";
76
+ for (const b of [
77
+ "Skills del método (grill · link-us · tdd · dai-review) en tu asistente",
78
+ "La constitución del proyecto — las reglas del trabajo",
79
+ "OpenSpec para el CÓMO (design / tasks) — opcional",
80
+ "Plantilla de PR + .env.dai para el tracker",
81
+ ]) out += " " + C.cy("▸") + " " + b + "\n";
82
+ return out;
83
+ };
53
84
  const CLAUDE_SKILLS_DIR = process.env.CLAUDE_SKILLS_DIR || join(homedir(), ".claude", "skills");
54
85
  const CURSOR_SKILLS_DIR = process.env.CURSOR_SKILLS_DIR || join(homedir(), ".cursor", "skills");
55
86
  // Copilot lee las skills personales de ~/.copilot/skills (NO de ~/.claude/skills, que
@@ -119,10 +150,10 @@ async function cmdLinkUs(key, opts) {
119
150
  title = opts.title || extractTitle(md);
120
151
  } else {
121
152
  // Fuente tracker: traer la US del adaptador (mismo hash que usará `dai check`).
122
- loadEnv();
153
+ loadDaiEnv();
123
154
  const adapter = getAdapter(process.env);
124
155
  const us = await adapter.fetchUS(key);
125
- if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.`, 2);
156
+ if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.dai.`, 2);
126
157
  hash = us.ac_hash;
127
158
  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);
128
159
  title = opts.title || us.title;
@@ -178,9 +209,84 @@ function gitRemote() { try { return git(["remote", "get-url", "origin"]); } catc
178
209
  function gitBranch() { try { return git(["rev-parse", "--abbrev-ref", "HEAD"]); } catch { return null; } }
179
210
  function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return null; } }
180
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
+
181
287
  // ── check ──────────────────────────────────────────────────────────────────
182
288
  async function cmdCheck() {
183
- loadEnv();
289
+ loadDaiEnv();
184
290
  const adapter = getAdapter(process.env);
185
291
  const found = discoverImplements(process.cwd(), { includeArchived: false });
186
292
  let worst = 0, n = 0;
@@ -210,38 +316,106 @@ async function cmdCheck() {
210
316
  }
211
317
 
212
318
  // ── stamp ──────────────────────────────────────────────────────────────────
213
- async function cmdStamp() {
214
- loadEnv();
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 = {}) {
324
+ loadDaiEnv();
215
325
  const adapter = getAdapter(process.env);
216
326
  const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
217
- const found = discoverImplements(process.cwd());
218
- let n = 0;
219
- for (const f of found) for (const im of f.implements || []) {
220
- if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
221
- n++;
222
- const live = await adapter.fetchUS(im.id);
223
- 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);
224
362
  const record = {
225
- 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,
226
364
  branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
227
365
  };
228
- const where = await adapter.stamp(im.id, record);
229
- 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`);
230
368
  }
231
- 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");
232
370
  }
233
371
 
234
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
+
235
408
  async function cmdForge(sub, ref, opts) {
236
- loadEnv();
409
+ loadDaiEnv();
237
410
  const pr = parsePrRef(ref, gitRemote());
238
411
  if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
239
412
  if (sub === "pr") {
240
- 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");
241
415
  } else if (sub === "comment") {
242
416
  const body = opts.bodyFile ? readFileSync(opts.bodyFile, "utf8") : opts.body;
243
417
  if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
244
- const res = await postComment(pr, body, process.env);
418
+ const res = await forgeCall(pr, () => postComment(pr, body, process.env));
245
419
  process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
246
420
  } else if (sub === "review") {
247
421
  await cmdForgeReview(pr, opts);
@@ -259,11 +433,11 @@ async function cmdForge(sub, ref, opts) {
259
433
  async function cmdForgeReview(pr, opts) {
260
434
  if (!opts.from) fail("falta --from <review.json>. Lo escribe la skill dai-review; revisalo antes de postear.", 1);
261
435
  const review = parseFindings(readFileSync(opts.from, "utf8"));
436
+ ensureReviewsIgnored(opts.from);
262
437
 
263
438
  // El diff sale de git (local, por SSH), no de la API: es la fuente de verdad de qué
264
439
  // línea es comentable, y no gasta rate limit.
265
- const remote = await Promise.resolve(getPR(pr, process.env)).catch(() => null);
266
- if (!remote) fail("no pude leer la PR/MR del forge (¿token? ¿ref correcta?).", 1);
440
+ const remote = await forgeCall(pr, () => getPR(pr, process.env));
267
441
  const base = opts.base || remote.baseRef;
268
442
  if (!base) fail("no pude saber la branch base de la PR. Pasala con --base <branch>.", 1);
269
443
  let diff = "";
@@ -310,7 +484,7 @@ async function cmdForgeReview(pr, opts) {
310
484
  }
311
485
  if (!kept.length && !review.summary) fail("no hay nada que postear (0 comentarios y resumen vacío).", 1);
312
486
 
313
- 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));
314
488
  ok(`review posteado${res.url ? `: ${res.url}` : ""} — ${res.posted} comentario(s) en línea.`);
315
489
  if (res.failed.length) {
316
490
  warn(`${res.failed.length} comentario(s) NO entraron (gitlab no es atómico: el resumen y el resto SÍ están posteados):`);
@@ -332,7 +506,7 @@ function loadJiraFieldsSpec() {
332
506
 
333
507
  async function cmdPublish(file, opts = {}) {
334
508
  if (!file) fail("uso: dai publish <archivo-us.md> [--parent KEY] [--issuetype T] [--field alias=valor]", 1);
335
- loadEnv();
509
+ loadDaiEnv();
336
510
  const md = readFileSync(file, "utf8");
337
511
  const title = extractTitle(md);
338
512
  if (!title) fail("no pude extraer el título de la US (falta un '# Título').", 1);
@@ -362,6 +536,254 @@ async function cmdPublish(file, opts = {}) {
362
536
  info(`Próximo paso (el dev abre el CÓMO): dai link-us ${r.id}`);
363
537
  }
364
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
+
365
787
  // ── done: cierra una US — vuelve a la base, actualiza y borra la branch local ──
366
788
  function cmdDone(opts) {
367
789
  const base = opts.base || "main";
@@ -409,7 +831,7 @@ function cmdDone(opts) {
409
831
  // ── pr: crea TU PROPIA PR/MR precargada desde el template + el link ────────────
410
832
  // (Distinto de dai-review, que revisa la PR de OTRO. Tu PR la creas y revisas tú.)
411
833
  async function cmdPr(opts) {
412
- loadEnv();
834
+ loadDaiEnv();
413
835
  const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
414
836
  if (!remote) fail("no hay remoto git 'origin'. Configúralo para crear la PR.", 1);
415
837
  if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
@@ -447,7 +869,7 @@ async function cmdPr(opts) {
447
869
  for (const f of found) for (const im of f.implements || []) {
448
870
  if (!isPlaceholderId(im.id)) { entry = { f, im }; break; }
449
871
  }
450
- if (!entry) fail("no encontré un implements.yaml con una US real. Ejecuta `dai link-us` primero.", 1);
872
+ if (!entry) fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
451
873
  const { id, version, ac_hash } = entry.im;
452
874
 
453
875
  // 2. Estado de trazabilidad (dai check) contra la US viva.
@@ -470,7 +892,7 @@ async function cmdPr(opts) {
470
892
  const usUrl = usUrlFor(id, live?.url);
471
893
  if (!usUrl) {
472
894
  warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
473
- warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env (p. ej. https://tu-tracker/browse/{id}).`);
895
+ warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
474
896
  }
475
897
  const body = composePrBody(readFileSync(tplPath, "utf8"), {
476
898
  id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
@@ -632,7 +1054,7 @@ async function cmdInstall(opts) {
632
1054
  // dai (colisión → warn + skip). `dai sync` NO las toca: es solo de dai.
633
1055
  function cmdInstallFrom(opts) {
634
1056
  if (typeof opts.from !== "string" || !opts.from.trim())
635
- fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]) o un path local", 2);
1057
+ fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]), un paquete npm (npm:@scope/pkg) o un path local", 2);
636
1058
  let want;
637
1059
  try { want = parseAssistants(typeof opts.for === "string" ? opts.for : "all"); }
638
1060
  catch (e) { fail(`--for ${e.message}`); }
@@ -651,6 +1073,33 @@ function cmdInstallFrom(opts) {
651
1073
  try { git(args); }
652
1074
  catch (e) { rmSync(tmp, { recursive: true, force: true }); fail(`no pude clonar la fuente: ${String(e.message).split("\n")[0]}`, 1); }
653
1075
  root = tmp;
1076
+ } else if (src.type === "npm") {
1077
+ tmp = mkdtempSync(join(tmpdir(), "dai-skills-"));
1078
+ info(`bajando el paquete npm ${src.location} …`);
1079
+ try {
1080
+ // `npm install` (no `npm pack`) en el temp. Dos decisiones a propósito:
1081
+ // 1) install en vez de pack: los registries de GRUPO de GitLab devuelven una
1082
+ // `dist.tarball` malformada (el scope duplicado en el nombre) que `npm pack` sigue
1083
+ // literal y da 404; `npm install` —como `npx`— reconstruye la URL y resuelve.
1084
+ // 2) copiamos el `.npmrc` del repo al temp y corremos con cwd=temp: así npm ve el
1085
+ // registry/scope privado (con `--prefix` npm NO lee el `.npmrc` del cwd y se va al
1086
+ // registry público). `--ignore-scripts`: no corremos scripts de un paquete de 3ros.
1087
+ const repoNpmrc = join(process.cwd(), ".npmrc");
1088
+ if (existsSync(repoNpmrc)) cpSync(repoNpmrc, join(tmp, ".npmrc"));
1089
+ runNpmTool("npm", ["install", src.location,
1090
+ "--no-save", "--no-package-lock", "--ignore-scripts", "--no-audit", "--no-fund"],
1091
+ { cwd: tmp, stdio: ["ignore", "ignore", "pipe"] });
1092
+ // El paquete queda en <tmp>/node_modules/<name>. name = spec sin la versión
1093
+ // (@scope/pkg@1.2.3 → @scope/pkg; el `@` inicial del scope no cuenta).
1094
+ let name = src.location; const at = name.lastIndexOf("@");
1095
+ if (at > 0) name = name.slice(0, at);
1096
+ root = join(tmp, "node_modules", name);
1097
+ if (!existsSync(root)) throw new Error(`npm install no dejó '${name}' en node_modules`);
1098
+ } catch (e) {
1099
+ rmSync(tmp, { recursive: true, force: true });
1100
+ const detail = String(e.stderr || e.stdout || e.message || "").split("\n").map((s) => s.trim()).filter(Boolean).slice(0, 3).join("\n ");
1101
+ fail(`no pude bajar el paquete npm '${src.location}':\n ${detail || "npm install falló — revisá el spec, el registry y el .npmrc del repo"}`, 1);
1102
+ }
654
1103
  } else {
655
1104
  root = src.location;
656
1105
  if (!existsSync(root)) fail(`no existe la fuente: ${root}`, 2);
@@ -738,7 +1187,7 @@ async function cmdInit(repo, opts) {
738
1187
  }
739
1188
  const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
740
1189
 
741
- process.stdout.write("\n dai · configurar este repo para desarrollo asistido por IA\n");
1190
+ process.stdout.write(initBanner());
742
1191
 
743
1192
  // Preguntas primero (después cerramos readline para liberar stdin a los instaladores).
744
1193
  let forOpt = typeof opts.for === "string" ? opts.for.toLowerCase() : null;
@@ -788,29 +1237,34 @@ async function cmdInit(repo, opts) {
788
1237
  }
789
1238
  writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
790
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/");
1241
+
1242
+ // Config de dai en SUS PROPIOS archivos, sin tocar el `.env`/`.env.example` del equipo:
1243
+ // muchas orgs versionan el `.env` como política, así que dai lo deja en paz (solo lo lee,
1244
+ // por compat) y pone lo suyo en `.env.dai` (ver ADR-0017). `.env.dai.example` se versiona
1245
+ // como plantilla; `.env.dai` (gitignored) es donde cada dev completa token y datos propios.
1246
+ const envBlock = envFor(pm);
791
1247
 
792
- // .env.example — aditivo, reflejando el --pm elegido: mismas claves que el .env
793
- // (con valores VACÍOS, sin secretos) para que el token del tracker esté presente.
794
- const exSrc = envFor(pm);
795
- const exPath = join(repo, ".env.example");
1248
+ // .env.dai.example — plantilla VERSIONADA, mismas claves con valores VACÍOS (sin secretos).
1249
+ const exPath = join(repo, ".env.dai.example");
796
1250
  if (existsSync(exPath)) {
797
- const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur, exSrc);
798
- if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.example claves de dai agregadas (aditivo)"); }
799
- else ok(".env.example ya tenía la config de dai");
800
- } else { writeFileSync(exPath, exSrc); ok(".env.example creado"); }
1251
+ const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur, envBlock);
1252
+ if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.dai.example claves de dai agregadas (aditivo)"); }
1253
+ else ok(".env.dai.example ya tenía la config de dai");
1254
+ } else { writeFileSync(exPath, envBlock); ok(".env.dai.example creado (plantilla versionada)"); }
801
1255
 
802
- // .env — aditivo: agrega las claves de dai que falten; si no existe, lo crea.
803
- const envPath = join(repo, ".env"), envBlock = envFor(pm);
1256
+ // .env.daiel real de cada dev (gitignored): aditivo; si no existe, lo crea.
1257
+ const envPath = join(repo, ".env.dai");
804
1258
  if (existsSync(envPath)) {
805
1259
  const cur = readFileSync(envPath, "utf8"), merged = mergeEnv(cur, envBlock);
806
- if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env claves de dai agregadas (aditivo, DAI_PM=${pm}${pm === "md" ? "" : " — completa el token"})`); }
807
- else ok(".env ya tenía la config de dai");
808
- } else { writeFileSync(envPath, envBlock); ok(`.env listo, DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
1260
+ if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env.dai claves de dai agregadas (aditivo, DAI_PM=${pm}${pm === "md" ? "" : " — completa el token"})`); }
1261
+ else ok(".env.dai ya tenía la config de dai");
1262
+ } else { writeFileSync(envPath, envBlock); ok(`.env.dai creado (no versionado), DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
809
1263
 
810
1264
  // .gitignore — versiona los artefactos de dai (según --for), deja fuera solo lo personal.
811
1265
  const giPath = join(repo, ".gitignore");
812
1266
  const gi = reconcileGitignore(existsSync(giPath) ? readFileSync(giPath, "utf8") : "", want);
813
- if (gi.changed) { writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"); ok(".gitignore ajustado (skills/constitución versionadas; .env y settings.local.json fuera)"); }
1267
+ if (gi.changed) { writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"); ok(".gitignore ajustado (skills/constitución versionadas; .env.dai y settings.local.json fuera)"); }
814
1268
 
815
1269
  mkdirSync(join(repo, ".github"), { recursive: true });
816
1270
  cpSync(join(ROOT, "templates", "pull-request.md"), join(repo, ".github", "pull_request_template.md"));
@@ -894,12 +1348,12 @@ async function cmdInit(repo, opts) {
894
1348
  }
895
1349
 
896
1350
  // ── Próximos pasos ─────────────────────────────────────────────────────────
897
- process.stdout.write("\n ✔ Repo configurado. Próximos pasos:\n");
1351
+ process.stdout.write("\n " + C.y("") + " " + C.b("Repo configurado.") + " Próximos pasos:\n");
898
1352
  process.stdout.write(pm === "md"
899
- ? " 1. Crea tu primera US en .dai/us/<ID>.md (criterios bajo '## Criterios de aceptación')\n"
900
- : ` 1. Completa el token de ${pm} en .env, y verifica con: dai doctor\n`);
901
- process.stdout.write(" 2. dai link-us <ID> → crea la branch + el link a la US\n");
902
- process.stdout.write(" 3. Implementa con test primero, después: dai check\n");
1353
+ ? ` 1. Crea tu primera US en ${C.cy(".dai/us/<ID>.md")} (criterios bajo '## Criterios de aceptación')\n`
1354
+ : ` 1. Copia ${C.cy(".env.dai.example")} → ${C.cy(".env.dai")} y completa el token de ${pm}; verifica con ${C.y("dai doctor")}\n`);
1355
+ process.stdout.write(` 2. ${C.y("dai link-us <ID>")} → crea la branch + el link a la US\n`);
1356
+ process.stdout.write(` 3. Implementa con test primero, después: ${C.y("dai check")}\n`);
903
1357
  process.stdout.write(" Guía paso a paso: https://github.com/dforce2055/dai/blob/main/docs/PROBAR.md\n\n");
904
1358
  }
905
1359
 
@@ -1031,7 +1485,7 @@ function cmdSync(repo, opts) {
1031
1485
  () => writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"));
1032
1486
 
1033
1487
  if (dry) info("dry-run: nada escrito. Quitá --dry-run para aplicar.");
1034
- else { process.stdout.write("\n"); ok(`sync completo — .dai/ ahora en v${cliV}`); process.stdout.write(" (El .env y OpenSpec no se tocan: OpenSpec se actualiza aparte con `openspec`.)\n"); }
1488
+ else { process.stdout.write("\n"); ok(`sync completo — .dai/ ahora en v${cliV}`); process.stdout.write(" (El .env.dai y OpenSpec no se tocan: OpenSpec se actualiza aparte con `openspec`.)\n"); }
1035
1489
  }
1036
1490
 
1037
1491
  // Imprime el estado de version-drift del scaffold (ADR-0010) con color + ícono.
@@ -1097,7 +1551,7 @@ function cmdUpgrade(opts) {
1097
1551
 
1098
1552
  // ── doctor: diagnóstico ───────────────────────────────────────────────────────
1099
1553
  function cmdDoctor() {
1100
- loadEnv();
1554
+ loadDaiEnv();
1101
1555
  info(`dai doctor — versión v${readFileSync(join(ROOT, "VERSION"), "utf8").trim()}`);
1102
1556
 
1103
1557
  // Una skill sirve si está en el repo actual (la puso `dai init`) o global (la puso
@@ -1151,11 +1605,11 @@ function cmdDoctor() {
1151
1605
  const pm = process.env.DAI_PM || "md";
1152
1606
  ok(`DAI_PM=${pm}`);
1153
1607
  if (pm === "jira") {
1154
- if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en el .env");
1155
- if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en el .env");
1608
+ if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en .env.dai");
1609
+ if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en .env.dai");
1156
1610
  // Ojo: solo miramos que el token ESTÉ, no que sirva — uno vencido pasa este chequeo
1157
1611
  // y recién falla al publicar. Verificarlo de verdad es pegarle a la red.
1158
- if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en el .env"); else ok("token de Jira presente (no verificado: eso lo dice `dai publish`)");
1612
+ if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en .env.dai"); else ok("token de Jira presente (no verificado: eso lo dice `dai publish`)");
1159
1613
  if (!process.env.DAI_JIRA_PROJECT) warn("DAI_JIRA_PROJECT vacío — solo hace falta para `dai publish` (crear issues)");
1160
1614
  else {
1161
1615
  try { ok(`proyecto=${assertProjectKey(process.env.DAI_JIRA_PROJECT)} (para dai publish)`); }
@@ -1173,7 +1627,7 @@ function cmdDoctor() {
1173
1627
  }
1174
1628
  }
1175
1629
  if (pm === "clickup") {
1176
- if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en el .env"); else ok("token de ClickUp presente");
1630
+ if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en .env.dai"); else ok("token de ClickUp presente");
1177
1631
  process.env.DAI_CLICKUP_LIST_ID ? ok(`lista=${process.env.DAI_CLICKUP_LIST_ID} (para dai publish)`)
1178
1632
  : warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
1179
1633
  }
@@ -1196,8 +1650,10 @@ switch (cmd) {
1196
1650
  case "ac-hash": cmdAcHash(pos[0]); break;
1197
1651
  case "ls": cmdLs(opts); break;
1198
1652
  case "link-us": cmdLinkUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1199
- case "check": cmdCheck().catch((e) => fail(String(e.message))); break;
1200
- 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;
1201
1657
  case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
1202
1658
  case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
1203
1659
  case "pr":
@@ -1227,8 +1683,19 @@ switch (cmd) {
1227
1683
  " [--field alias=valor] campos propios que exige tu Jira (.dai/jira-fields.json); repetible\n" +
1228
1684
  " link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
1229
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" +
1230
1693
  " check compara vs la US viva → atrasado (ADR-0003)\n" +
1231
- " 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" +
1232
1699
  " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
1233
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" +
1234
1701
  " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
@@ -1238,16 +1705,16 @@ switch (cmd) {
1238
1705
  " Sin --yes no postea nada: muestra el preview y valida que cada hallazgo apunte al diff.\n\n" +
1239
1706
  "Instalación:\n" +
1240
1707
  " skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
1241
- " skills install --from <git-url|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
1708
+ " skills install --from <git-url|npm:pkg|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
1242
1709
  " init [<repo>] scaffolder interactivo del repo (asistente, gestor, OpenSpec)\n" +
1243
1710
  " --for <asistentes> claude|copilot|cursor (combinables con coma) · o both|all (default all)\n" +
1244
1711
  " ej: --for claude,cursor · --for copilot · --for all\n" +
1245
1712
  " --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
1246
- " sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env ni OpenSpec)\n" +
1713
+ " 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" +
1247
1714
  " 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" +
1248
1715
  " docs <destino> documentación conceptual → <destino>\n" +
1249
1716
  " doctor diagnóstico del entorno\n\n" +
1250
- " (config: .env — ver .env.example)\n"
1717
+ " (config: .env.dai — ver .env.dai.example)\n"
1251
1718
  );
1252
1719
  process.exit(cmd && cmd !== "help" ? 1 : 0);
1253
1720
  }