@dforce2055/dai 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,126 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.13.3] — 2026-09-02
7
+
8
+ **Una PR de dai se abría diciendo, en el mismo párrafo, dos cosas que no encajaban: que el
9
+ spec estaba "verificado con dai check: ✅ al día", y a continuación una explicación general
10
+ del método. Tirando de ese hilo aparecieron dos bugs distintos, y los dos eran dai afirmando
11
+ cosas que no le constaban.**
12
+
13
+ ### Arreglado
14
+ - **El relleno del estado reescribía la prosa del template.** `dai pr` hacía un replace
15
+ **global** de `verificado con `dai check` ✅`, y esa frase estaba dos veces en el molde: en
16
+ el dato (`## 🔗 Implementa`) y en la prosa que explica el método. Así que a una oración
17
+ general —igual en todas las PRs— dai le insertaba el estado de *esta* PR, y quedaba
18
+ publicado: *"verificado con `dai check`: ✅ al día. Sin esto, el código no sabe a qué QUÉ
19
+ responde…"*. Ni doctrina ni dato. Ahora el relleno se acota a la sección; sin la sección
20
+ (un template ajeno con otra forma) cae al body entero, porque rellenar de más es
21
+ recuperable y publicar `ABC-###` no.
22
+ - **`dai pr` decía "❓ sin US" cuando el tracker no contestaba** — dos líneas debajo del id de
23
+ la US que sí existe. `coverageStatus` colapsaba en un mismo `sin-us` dos cosas que no
24
+ significan lo mismo: *el tracker contestó y la US no está* y *no hubo respuesta* (sin red,
25
+ sin token, 5xx, certificado corporativo). Los adaptadores ya distinguían los dos casos
26
+ (`404 → null`, cualquier otro error → `throw`), y el gate de CI también con su try/catch
27
+ propio; lo que rompía la distinción era un `.catch(() => null)` en `dai pr`. Hay un estado
28
+ nuevo, **`sin-respuesta`** (`⚠️ no verificado`), y la PR dice *"no verificado (el tracker no
29
+ respondió)"*, que es lo único cierto ([#43](https://github.com/dforce2055/dai/issues/43)).
30
+ - **`dai: fetch failed` era todo lo que se llegaba a leer.** Es el mensaje pelado de undici
31
+ cuando no hay red, el host no resuelve o el certificado no valida: no dice qué se estaba
32
+ consultando, ni contra qué, ni qué mirar — indistinguible de un bug de dai, el mismo modo de
33
+ falla que el push por SSH de la 0.13.1. Ahora el error nombra la US, el backend y el host, y
34
+ distingue red / credencial / error del tracker, con el próximo paso en cada caso.
35
+ - **`dai check` ya no se detenía en la primera US** que no pudiera consultar: lo reporta, sigue
36
+ con las demás y sale ≠ 0. **`dai stamp`** explicita que no estampa un estado que no pudo
37
+ verificar — escribir en el tracker de todo el equipo no se deshace.
38
+
39
+ ### Cambiado
40
+ - **El molde de PR adelgaza: la doctrina pasa a comentario HTML.** El encabezado tenía cuatro
41
+ bloques y un solo dato — la doctrina de los dos activos, una línea que repetía la de arriba
42
+ (*"este PR está atado a la US vía implements.yaml"*) y la instrucción de borrar la sección si
43
+ no hay US, que `dai pr` resuelve solo desde la 0.13.2. Nada de eso decía algo sobre *esa* PR,
44
+ y repetirlo a la vista en cada una entrena a saltear el principio del cuerpo, que es
45
+ justamente donde va la descripción. Sigue estando para quien edite el molde, invisible al
46
+ renderizar; y la doctrina vive donde se lee una vez y no quinientas: `docs/glosario.md`,
47
+ `docs/guias/dev.md`, `governance/ci-rules.md`. Los repos ya inicializados lo reciben con
48
+ `dai sync`.
49
+
50
+ ### Interno
51
+ - **367 tests** (+9): los dos caminos de la consulta (la US que no está y la que no se pudo
52
+ consultar), el mensaje de error por tipo de falla, y que el relleno del estado no toque la
53
+ prosa que lo rodea.
54
+
55
+ ## [0.13.2] — 2026-09-02
56
+
57
+ **Una PR se publicaba con la descripción vacía y la lista de cambios diciendo "Cambio 1,
58
+ Cambio 2". No siempre: a veces salía perfecta. Lo raro es que "Enlaces relacionados", que
59
+ vive en el mismo template, nunca falló — y ahí estaba la pista. Y de yapa, el paquete de npm
60
+ adelgaza de 3.7 MB a 703 kB: cargaba las capturas del sitio.**
61
+
62
+ `dai pr` rellenaba cada sección **solo si tenía el dato** y, si no lo tenía, devolvía el
63
+ molde del template intacto, en silencio. La "Descripción" quedaba en su comentario HTML, que
64
+ no se renderiza: en GitHub y en GitLab la sección se ve **vacía**. El bloque de enlaces nunca
65
+ falló porque su código siempre escribe — si no encuentra la sección, la agrega. Esa
66
+ disciplina ahora vale para todo el cuerpo de la PR.
67
+
68
+ Detrás del bug había algo más de fondo: **no existía forma de escribir la descripción**.
69
+ `dai pr` no aceptaba ningún texto, así que el propósito de la PR solo podía salir del título
70
+ de la US y de los subjects de los commits. Ni el dev ni su agente podían hacerlo bien aunque
71
+ quisieran.
72
+
73
+ ### Arreglado
74
+ - **La PR salía con el molde del template sin llenar.** Tres caminos llevaban al mismo
75
+ resultado, los tres silenciosos: (1) el tracker no contestaba —sin token, sin red, `DAI_PM`
76
+ mal seteado— y sin el título de la US no se llenaba "Descripción"; (2) la branch base no
77
+ existía **en local** —clones `--single-branch`, repos donde se trabaja sobre `develop` y la
78
+ base es `main`— y `git log base..HEAD` fallaba dentro de un `catch` vacío, dejando "Cambios
79
+ realizados" con `Cambio 1 / Cambio 2`; (3) una branch exenta (`chore/`, `docs/`) nunca
80
+ llenaba "Descripción", ni siquiera pudiendo. Ese mismo `catch` vacío también se comía el
81
+ chequeo de *"sin commits sobre la base no hay PR"*.
82
+ - **La branch base ahora se resuelve** a `main` o, si no está en local, a `origin/main`.
83
+ - **Las secciones se reconocen aunque el repo tenga su propio molde** (`## 📝 Descripción del
84
+ cambio`, `### CAMBIOS REALIZADOS`): antes el match era exacto, no encontraba la sección y
85
+ devolvía el body sin tocar — otra vez, sin decir nada. Ignora los `##` que estén dentro de
86
+ un bloque de código y, si la sección no existe, la agrega.
87
+
88
+ ### Agregado
89
+ - **`dai pr --description "…"` y `--description-file <archivo.md>`** — el propósito de un
90
+ cambio no se deriva de git ni del tracker. dai llena la US, el estado del check, los commits
91
+ y los links; el porqué lo escribe quien crea la PR. **dai no lo inventa: lo pide.**
92
+ - **`--changes` y `--changes-file`** — reemplazan el detalle de "Cambios realizados" cuando los
93
+ commits no cuentan bien la historia. Sin ellos, siguen saliendo de los commits de la branch.
94
+ - **La constitución que escriben `dai init` y `dai sync` lo dice**, para que el agente que
95
+ corre `dai pr` sepa que la descripción es suya. Los repos ya inicializados la reciben con
96
+ `dai sync`.
97
+
98
+ ### Cambiado
99
+ - ⚠️ **`dai pr` no publica una PR que saldría con el molde sin llenar.** Con `--yes` o sin TTY
100
+ —el camino de un agente o de CI— **aborta** y dice qué flag pasar; en terminal avisa y decide
101
+ la persona. **Si tenés automatización con `dai pr --yes` sin `--description`, se va a frenar**:
102
+ es justamente lo que publicaba las PRs vacías. El molde también se detecta en la cabecera
103
+ (`ABC-###`), la misma familia de los issues #31/#33.
104
+
105
+ ### Arreglado — el paquete de npm
106
+ - **`npm i -g @dforce2055/dai` bajaba las capturas del sitio.** `files[]` lista `docs`
107
+ entero, y ahí adentro viven las de los tutoriales (`docs/public/tutoriales/*.png`): **2.9 MB
108
+ de los 3.7 MB del paquete**, que el CLI no abre nunca. El test de higiene que ya cubría el
109
+ sitio (`index.html`, `onboarding.html`) no las veía porque entraban por otra puerta.
110
+ **3.7 MB → 703 kB**, 130 → 117 archivos, cero PNG en el tarball ([#37](https://github.com/dforce2055/dai/issues/37)).
111
+ - **`dai docs` copiaba imágenes que no se veían.** Los `.md` referencian las capturas con la
112
+ ruta absoluta del sitio (`![…](/tutoriales/x.png)`, que VitePress resuelve contra
113
+ `docs/public/`); fuera del sitio esa ruta apunta a la raíz del filesystem, así que en la
114
+ copia **ya estaban rotas**, con los 2.9 MB adentro y todo. Ahora `dai docs` no copia
115
+ `public/` —son assets del sitio, no documentación para leer desde un repo— y **absolutiza**
116
+ esos links contra el sitio publicado: la doc copiada por fin muestra las capturas.
117
+
118
+ ### Interno
119
+ - **358 tests** (+19). Uno por cada camino que dejaba pasar el molde del template —tracker
120
+ caído, sin commits, branch sin US, template propio del repo, headings dentro de un fence, y
121
+ el caso peor (sin tracker y sin commits a la vez)— más los de la reescritura de links y dos
122
+ de higiene que fijan la regla del paquete: que `files[]` declare la exclusión, y que no
123
+ aparezcan imágenes versionadas bajo `docs/` fuera de esa carpeta. Si mañana una captura
124
+ aterriza en otro lado, el test obliga a decidir ahí, no midiendo el tarball.
125
+
6
126
  ## [0.13.1] — 2026-08-24
7
127
 
8
128
  **Un dev en Windows no podía pushear contra el GitLab de su empresa. `ssh -T` le autenticaba
@@ -737,6 +857,8 @@ ClickUp y Jira Cloud.
737
857
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
738
858
  `files` de npm sin tests ni secretos.
739
859
 
860
+ [0.13.3]: https://github.com/dforce2055/dai/releases/tag/v0.13.3
861
+ [0.13.2]: https://github.com/dforce2055/dai/releases/tag/v0.13.2
740
862
  [0.13.1]: https://github.com/dforce2055/dai/releases/tag/v0.13.1
741
863
  [0.13.0]: https://github.com/dforce2055/dai/releases/tag/v0.13.0
742
864
  [0.12.0]: https://github.com/dforce2055/dai/releases/tag/v0.12.0
package/README.md CHANGED
@@ -103,7 +103,8 @@ dai link-us <ID> # trae la US del tracker → branch + implements
103
103
  ```bash
104
104
  dai check # ¿tu código sigue al día con la US? ✅ / ⚠️ atrasado
105
105
  # revisas tu propio código + smoke test local antes de la PR
106
- dai pr --assignee <compañero> # crea la PR precargada y se la asigna a un compañero
106
+ dai pr --assignee <compañero> \
107
+ --description "Qué resuelve y por qué" # crea la PR precargada y se la asigna a un compañero
107
108
  ```
108
109
  ```text
109
110
  /dai-review <PR> # tu compañero deja un review inline (comentario por línea); un humano aprueba
@@ -201,14 +202,14 @@ flowchart TD
201
202
  | `dai link-us <ID> --resync` | re-estampa el `ac_hash` contra la US viva (tras un ⚠️ de check) |
202
203
  | `dai check` | compara tu código vs la US viva → ✅ al día / ⚠️ atrasado (exit code = gate de PR) |
203
204
  | `dai ls [--json]` | lista las US que implementa el repo + su link al tracker |
204
- | `dai pr [--assignee u] [--base b] [--draft] [--yes] [--us ID] [--title t]` · alias **`dai mr`** | crea TU PR/MR precargada: pregunta la branch base (default `main`), muestra el texto y confirma antes de publicar. Detecta el forge (GitHub→PR con `gh` · GitLab→MR con `glab`); `mr` es el mismo comando, más natural en GitLab. **La US la resuelve la branch** (la nombra `dai link-us`): si hay varias vivas y ninguna coincide, **pregunta** en vez de elegir por vos (sin TTY falla pidiendo `--us <ID>`), y una branch `chore/`/`docs/` sale **sin US** en lugar de heredar la de otro |
205
+ | `dai pr [--assignee u] [--base b] [--draft] [--yes] [--us ID] [--title t] [--description t\|--description-file f] [--changes t\|--changes-file f]` · alias **`dai mr`** | crea TU PR/MR precargada: pregunta la branch base (default `main`), muestra el texto y confirma antes de publicar. Detecta el forge (GitHub→PR con `gh` · GitLab→MR con `glab`); `mr` es el mismo comando, más natural en GitLab. **La US la resuelve la branch** (la nombra `dai link-us`): si hay varias vivas y ninguna coincide, **pregunta** en vez de elegir por vos (sin TTY falla pidiendo `--us <ID>`), y una branch `chore/`/`docs/` sale **sin US** en lugar de heredar la de otro. **La descripción la escribís vos (o tu agente) con `--description`**: dai llena la US, los commits y los links, pero no inventa el propósito de un cambio — si "Descripción" o "Cambios realizados" quedarían con el molde del template, con `--yes` o sin TTY **no publica** y te dice qué falta |
205
206
  | `dai stamp` | estampa la cobertura inversa en el tracker (branch + commit-ancla) |
206
207
  | `dai done [--base main] [--force]` | cierra la US: vuelve a la base, `fetch --prune` + `pull`, y borra la branch local **si está mergeada** (chequeo estricto; `--force` la borra igual). Redes: no estar en la base, sin cambios sueltos, sin commits sin pushear |
207
208
  | `dai archive [<change>] [--skip-specs]` | **funde los delta specs del change en las specs canónicas** (`openspec/specs/`) y lo archiva. Lo corre el **aprobador** de la PR (gate de aprobación, [ADR-0011](docs/adr/0011-archive-gate-de-aprobacion.md)); detecta el change activo o le pasás el nombre. Envuelve `openspec archive` |
208
209
  | `dai forge review <ref> --from <review.json>` `[--dry-run\|--yes]` | **review inline**: un resumen + un comentario anclado a cada `archivo:línea`, clasificado low/medium/high. **Valida cada posición contra el diff** (descarta lo que el modelo inventó) antes de postear; sin `--yes` muestra el preview y no postea nada. Modo desatendido: `--min-severity`/`--min-confidence`/`--max-comments`. El review sale con `event: COMMENT`, nunca `APPROVE` ([ADR-0016](docs/adr/0016-review-inline.md)) |
209
210
  | `dai forge comment <ref> --body-file <f>` · `dai forge pr <ref>` | comentar / leer una PR/MR (GitHub/GitLab) — el fallback simple, sin anclar |
210
211
  | `dai ac-hash <us.md>` | calcula el hash de los criterios de aceptación de una US |
211
- | `dai doctor` · `dai docs <dest>` · `dai version` | diagnóstico del entorno (incluye **version-drift** del scaffold) · copiar la doc · versión (`dai version` avisa si tu repo quedó atrás) |
212
+ | `dai doctor` · `dai docs <dest>` · `dai version` | diagnóstico del entorno (incluye **version-drift** del scaffold) · copiar la doc (sin los assets del sitio; los links a las capturas apuntan al sitio publicado) · versión (`dai version` avisa si tu repo quedó atrás) |
212
213
 
213
214
  > **🆕 Mantené tu repo al día — `dai sync`.** Las skills, la constitución y los templates son un
214
215
  > *caché derivable* del CLI. Cuando actualizás `dai` (`dai upgrade`), **`dai doctor` y
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.13.1
1
+ 0.13.3
package/cli/dai.mjs CHANGED
@@ -22,12 +22,13 @@ 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
24
  import { loadDaiEnv } from "./lib/env.mjs";
25
- import { getAdapter, coverageStatus, statusLabel } from "./lib/pm-adapter.mjs";
25
+ import { getAdapter, coverageStatus, statusLabel, fetchLiveUS } 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";
28
28
  import { trackerUrl } from "./lib/tracker-url.mjs";
29
29
  import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
30
- import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
30
+ import { composePrBody, prTitle, forgeTool, bodyGaps } from "./lib/pr.mjs";
31
+ import { absolutizeSiteLinks } from "./lib/docs-links.mjs";
31
32
  import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
32
33
  import { dirsEqual } from "./lib/fsutil.mjs";
33
34
  import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
@@ -216,6 +217,27 @@ function gitUser() {
216
217
  function gitRemote() { try { return git(["remote", "get-url", "origin"]); } catch { return null; } }
217
218
  function gitBranch() { try { return git(["rev-parse", "--abbrev-ref", "HEAD"]); } catch { return null; } }
218
219
  function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return null; } }
220
+ // ¿Existe esta ref como commit? Devuelve la ref si sí, null si no. Sirve para caer de
221
+ // `main` a `origin/main` sin adivinar: en muchos repos la base solo existe en el remoto.
222
+ function resolveRev(ref) {
223
+ try { git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); return ref; } catch { return null; }
224
+ }
225
+ // Un texto que se puede pasar inline (`--description "…"`) o por archivo
226
+ // (`--description-file notas.md`). Un agente casi siempre quiere el archivo: markdown
227
+ // multilínea no sobrevive entero a la línea de comandos.
228
+ function textOpt(opts, name) {
229
+ const file = opts[`${name}File`];
230
+ if (file !== undefined) {
231
+ if (file === true) fail(`--${name}-file necesita la ruta de un archivo.`, 1);
232
+ const path = Array.isArray(file) ? file[file.length - 1] : file;
233
+ try { return readFileSync(path, "utf8"); }
234
+ catch { fail(`no pude leer --${name}-file '${path}'.`, 1); }
235
+ }
236
+ const v = opts[name];
237
+ if (v === true) fail(`--${name} necesita un texto (o usá --${name}-file <archivo>).`, 1);
238
+ if (Array.isArray(v)) return v.join("\n");
239
+ return typeof v === "string" ? v : null;
240
+ }
219
241
 
220
242
  // ── check --ci: el gate de governance/ci-rules.md, ejecutable ────────────────
221
243
  //
@@ -310,13 +332,18 @@ async function cmdCheck() {
310
332
  for (const f of found) for (const im of f.implements || []) {
311
333
  if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
312
334
  n++;
313
- const live = await adapter.fetchUS(im.id);
314
- const status = coverageStatus(im.ac_hash, live?.ac_hash);
335
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, im.id);
336
+ const status = coverageStatus(im.ac_hash, live?.ac_hash, { unreachable });
315
337
  if (status === "al-dia") process.stdout.write(`✅ ${im.id} al día (${im.version})\n`);
316
338
  else if (status === "atrasado") {
317
339
  process.stdout.write(`⚠️ ${im.id} ATRASADO: implementaste ${im.ac_hash}, la US viva es ${live.ac_hash}${live.spec_version ? ` (${live.spec_version})` : ""}\n`);
318
340
  atrasadas.push(im.id);
319
341
  worst = Math.max(worst, 1);
342
+ } else if (status === "sin-respuesta") {
343
+ // No es "no hay US": es "no pude preguntar". Antes moría acá con `fetch failed` y sin
344
+ // chequear las demás; ahora lo dice, sigue, y sale ≠ 0 porque no pudo verificar nada.
345
+ process.stdout.write(`⚠️ ${reason}\n`);
346
+ worst = Math.max(worst, 2);
320
347
  } else {
321
348
  process.stdout.write(`❓ ${im.id}: no encontré la US (backend ${adapter.kind}). ¿Falta el .md o el token?\n`);
322
349
  worst = Math.max(worst, 2);
@@ -373,7 +400,11 @@ async function cmdStamp(ids = [], opts = {}) {
373
400
  }
374
401
 
375
402
  for (const r of targets) {
376
- const live = await adapter.fetchUS(r.id);
403
+ // Estampar es ESCRIBIR en el tracker de todo el equipo, y no se deshace: si no se pudo
404
+ // verificar el estado, no se estampa. (Hoy ya frenaba, por la excepción sin atrapar; acá
405
+ // queda explícito, con el motivo, y cubierto por un test.)
406
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, r.id);
407
+ if (unreachable) fail(`${reason}\n No estampo un estado que no pude verificar.`, 2);
377
408
  const status = coverageStatus(r.ac_hash, live?.ac_hash);
378
409
  const record = {
379
410
  repo: r.repo, change: r.change, version: r.version, ac_hash: r.ac_hash, status,
@@ -879,9 +910,16 @@ async function cmdPr(opts) {
879
910
  let base = opts.base;
880
911
  if (!base) { const ans = await ask(" ¿Contra qué branch va la PR? (main) "); base = ans || "main"; }
881
912
 
913
+ // La base puede no existir LOCAL (clones con --single-branch, repos donde el dev
914
+ // trabaja sobre develop y la base es main, corporativos con la base solo en origin).
915
+ // Antes el catch vacío se comía el error y seguía de largo: ni se contaban los commits
916
+ // (→ "Cambios realizados" salía con el molde) ni frenaba el chequeo de abajo.
917
+ const baseRev = resolveRev(base) || resolveRev(`origin/${base}`);
918
+ if (!baseRev) warn(`no encuentro la branch base '${base}' (ni local ni en origin/${base}). Trae la base primero: git fetch origin ${base}`);
919
+
882
920
  // Sin commits sobre la base no hay PR.
883
921
  let ahead = null;
884
- try { ahead = Number(git(["rev-list", "--count", `${base}..HEAD`])); } catch { /* base no existe local */ }
922
+ try { ahead = Number(git(["rev-list", "--count", `${baseRev}..HEAD`])); } catch { /* base ausente */ }
885
923
  if (ahead === 0) {
886
924
  fail(`no hay commits en '${branch}' por encima de '${base}'. Una PR necesita cambios: haz commit primero (git commit).`, 1);
887
925
  }
@@ -931,8 +969,14 @@ async function cmdPr(opts) {
931
969
 
932
970
  // 2. Estado de trazabilidad (dai check) contra la US viva.
933
971
  const adapter = getAdapter(process.env);
934
- const live = id ? await Promise.resolve(adapter.fetchUS(id)).catch(() => null) : null;
935
- const status = id ? coverageStatus(ac_hash, live?.ac_hash) : null;
972
+ // El `.catch(() => null)` que había acá convertía "el tracker no contestó" en "no hay US",
973
+ // y esa afirmación se PUBLICABA en el cuerpo de la PR, al lado del id de la US que sí está.
974
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, id);
975
+ if (unreachable) {
976
+ warn(reason);
977
+ warn("la PR va a decir que no se pudo verificar contra el tracker — no que no hay US.");
978
+ }
979
+ const status = id ? coverageStatus(ac_hash, live?.ac_hash, { unreachable }) : null;
936
980
  if (status === "atrasado") {
937
981
  warn(`la US ${id} está ATRASADA respecto de tu implementación (${ac_hash} ≠ ${live?.ac_hash}).`);
938
982
  warn(`resincroniza antes de abrir la PR: dai link-us ${id} --resync`);
@@ -944,7 +988,13 @@ async function cmdPr(opts) {
944
988
  : join(ROOT, "templates", "pull-request.md");
945
989
  // Commits de la branch (para precargar "Cambios realizados").
946
990
  let commits = [];
947
- try { commits = git(["log", `${base}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* base local ausente */ }
991
+ if (baseRev) {
992
+ try { commits = git(["log", `${baseRev}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* rango inválido */ }
993
+ }
994
+ // El texto que escribe quien crea la PR (el dev o su agente). Es la ÚNICA fuente de
995
+ // una descripción de verdad: dai lee git y el tracker, no el porqué del cambio.
996
+ const description = textOpt(opts, "description");
997
+ const changes = textOpt(opts, "changes");
948
998
  // La canónica del tracker (live.url) gana sobre la derivada; el template gana sobre todo.
949
999
  const usUrl = id ? usUrlFor(id, live?.url) : null;
950
1000
  if (id && !usUrl) {
@@ -952,7 +1002,8 @@ async function cmdPr(opts) {
952
1002
  warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
953
1003
  }
954
1004
  const body = composePrBody(readFileSync(tplPath, "utf8"), {
955
- id, version, ac_hash, status, usUrl, usTitle: live?.title, commits, noUsReason: id ? null : scope.reason,
1005
+ id, version, ac_hash, status, usUrl, usTitle: live?.title, commits, description, changes,
1006
+ noUsReason: id ? null : scope.reason,
956
1007
  branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
957
1008
  });
958
1009
  // Sin US el título sale del último commit: describe lo que hay adentro, en vez de
@@ -969,6 +1020,27 @@ async function cmdPr(opts) {
969
1020
  process.stdout.write(` ─────────────────────────────────────────────────────\n\n${body}\n`);
970
1021
  process.stdout.write(` ─────────────────────────────────────────────────────\n`);
971
1022
 
1023
+ // 4b. Gate: una PR con el molde del template sin llenar no se puede revisar.
1024
+ // Pasaba en repos reales — "Descripción" con el comentario HTML (que no se renderiza:
1025
+ // la sección se ve VACÍA) y "Cambios realizados" con `Cambio 1/Cambio 2` — cada vez
1026
+ // que el tracker no respondía o la base no estaba local. dai NO inventa la descripción:
1027
+ // la pide. Sin TTY (el camino del agente) frena; en terminal avisa y decidís vos.
1028
+ const gaps = bodyGaps(body);
1029
+ if (gaps.length) {
1030
+ const how =
1031
+ ` Escribí el texto y pasáselo a dai:\n` +
1032
+ ` dai pr --description "Qué resuelve este cambio y por qué (2–4 líneas)."\n` +
1033
+ ` dai pr --description-file notas.md --changes-file cambios.md (markdown multilínea)\n` +
1034
+ ` El detalle de "Cambios realizados" sale de los commits si no pasás --changes.\n`;
1035
+ if (opts.yes || !process.stdin.isTTY) {
1036
+ closeRl();
1037
+ fail(`la PR saldría con el molde del template sin llenar: ${gaps.join(", ")}.\n` +
1038
+ ` Una PR sin descripción no se puede revisar, así que no la publico.\n${how}`, 1);
1039
+ }
1040
+ warn(`la PR va a salir con el molde sin llenar: ${gaps.join(", ")}.`);
1041
+ process.stdout.write(how);
1042
+ }
1043
+
972
1044
  // Archivo de paso para gh/glab: en el temp del sistema, NO en el repo (no lo ensucia).
973
1045
  const bodyFile = join(mkdtempSync(join(tmpdir(), "dai-pr-")), "body.md");
974
1046
  if (!opts.yes) {
@@ -1431,8 +1503,28 @@ async function cmdInit(repo, opts) {
1431
1503
  function cmdDocs(dest) {
1432
1504
  if (!dest) fail("uso: dai docs <destino>");
1433
1505
  mkdirSync(dest, { recursive: true });
1434
- cpSync(join(ROOT, "docs"), dest, { recursive: true });
1506
+ // `public/` son los assets del sitio (VitePress), no documentación para leer desde el
1507
+ // repo de nadie: las capturas de los tutoriales ni siquiera viajan en el paquete npm
1508
+ // (issue #37). Se saltea, y los links que las nombran se absolutizan contra el sitio.
1509
+ cpSync(join(ROOT, "docs"), dest, { recursive: true, filter: (src) => !/[/\\]public([/\\]|$)/.test(src) });
1510
+ let reescritos = 0;
1511
+ for (const f of walkMd(dest)) {
1512
+ const md = readFileSync(f, "utf8");
1513
+ const out = absolutizeSiteLinks(md);
1514
+ if (out !== md) { writeFileSync(f, out); reescritos++; }
1515
+ }
1435
1516
  ok(`documentación copiada a ${dest}`);
1517
+ if (reescritos) info(`${reescritos} documento(s) con capturas: los links apuntan al sitio publicado.`);
1518
+ }
1519
+
1520
+ // Los .md de un árbol, para la reescritura de links de cmdDocs.
1521
+ function walkMd(dir, out = []) {
1522
+ for (const name of readdirSync(dir)) {
1523
+ const p = join(dir, name);
1524
+ if (statSync(p).isDirectory()) walkMd(p, out);
1525
+ else if (name.endsWith(".md")) out.push(p);
1526
+ }
1527
+ return out;
1436
1528
  }
1437
1529
 
1438
1530
  // ── archive: funde los delta specs del change en los specs canónicos y lo archiva ─
@@ -1845,6 +1937,10 @@ switch (cmd) {
1845
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" +
1846
1938
  " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1847
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" +
1848
1944
  " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
1849
1945
  " forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
1850
1946
  " --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
@@ -277,6 +277,7 @@ export function constitution(kind) {
277
277
  - **La IA confirma antes de construir:** el asistente declara que entendió esta constitución y la va a obedecer antes de generar código.
278
278
  - **Secretos:** en \`.env.dai\` (NO versionado; el \`.env\` del equipo no se toca). git por **SSH**, APIs por **token scopeado**.
279
279
  - **No bajes la seguridad para avanzar:** si una llamada falla por el certificado, declara la CA (\`NODE_EXTRA_CA_CERTS\`). **Nunca** \`NODE_TLS_REJECT_UNAUTHORIZED=0\`, \`verify=False\`, \`-k\` ni equivalentes: apagan la verificación de toda la conexión, y por ahí viajan los tokens.
280
+ - **La PR la escribe quien la crea:** \`dai pr\` precarga la US, el estado del check, los commits y los links, pero **la Descripción la escribes tú**: \`dai pr --description "qué resuelve y por qué"\` (o \`--description-file <archivo.md>\` para markdown multilínea, y \`--changes\` si los commits no cuentan bien la historia). dai **no inventa** el propósito de un cambio: si la Descripción o los Cambios realizados quedarían con el molde del template, no publica la PR. Una PR sin descripción no se puede revisar.
280
281
  - **Si el CLI no llega, para y dilo:** cuando \`dai\` no cubre un caso, repórtalo — no improvises una llamada a la API por fuera. El atajo publica igual, pero rompe el link QUÉ↔CÓMO en silencio y nadie se entera hasta que la trazabilidad ya está mal.
281
282
  - **Docs vivas:** una constitución o arquitectura desactualizada es un defecto, no documentación.
282
283
  - Separa el QUÉ (funcional) del CÓMO (técnico); no mezcles.
@@ -293,7 +294,7 @@ export function constitution(kind) {
293
294
 
294
295
  - **Skills (el QUÉ):** \`doc-to-backlog\` (un doc → backlog) · \`grill-intent\` (Gate 0) · \`grill-epic\` (épicas) · \`grill-user-story\` (la US)
295
296
  - **Skills (el CÓMO):** \`link-us\`, \`tdd\`, \`dai-review\`
296
- - **CLI:** \`dai link-us <ID>\` · \`dai check\` · \`dai stamp\` · \`dai pr\` · \`dai ls\`
297
+ - **CLI:** \`dai link-us <ID>\` · \`dai check\` · \`dai stamp\` · \`dai pr --description "…"\` · \`dai ls\`
297
298
  - **Formatos:** \`.dai/templates/\` · **Governance:** \`.dai/governance/\`
298
299
 
299
300
  Detalle completo de la metodología: ${REPO_URL}
@@ -0,0 +1,23 @@
1
+ // dai · reescribir los links absolutos del sitio cuando la doc se copia afuera.
2
+ //
3
+ // Los .md de docs/ son la fuente del sitio (VitePress, `base: "/dai/docs/"`), así que las
4
+ // capturas se referencian con la ruta absoluta del sitio: `![…](/tutoriales/x.png)`, que
5
+ // VitePress resuelve contra docs/public/. Fuera del sitio esa ruta no resuelve a nada: en
6
+ // una copia hecha con `dai docs`, `/tutoriales/x.png` apunta a la raíz del filesystem (o
7
+ // del repo, si lo renderiza GitHub). O sea que la imagen ya estaba rota en la copia,
8
+ // tuviera o no el paquete los 2.9 MB de PNG adentro (issue #37).
9
+ //
10
+ // Al copiar, esos links se vuelven absolutos contra el sitio publicado: la doc copiada
11
+ // muestra las capturas, y el paquete de npm no las carga.
12
+
13
+ export const SITE_DOCS = "https://dforce2055.github.io/dai/docs";
14
+
15
+ // Rutas absolutas del sitio que aparecen en los .md. Se listan a propósito en vez de
16
+ // reescribir toda `](/…)`: un link a `/etc/hosts` en un ejemplo no es un link del sitio.
17
+ const SITE_PATHS = /\]\(\/(tutoriales\/[^)\s]+)\)/g;
18
+
19
+ // Devuelve el markdown con los links del sitio apuntando al sitio publicado.
20
+ // Idempotente: una URL ya absoluta no matchea (el patrón exige `](/`).
21
+ export function absolutizeSiteLinks(md) {
22
+ return String(md).replace(SITE_PATHS, (_, path) => `](${SITE_DOCS}/${path})`);
23
+ }
@@ -21,19 +21,36 @@
21
21
 
22
22
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
23
23
  import { join, dirname } from "node:path";
24
- import { parseUS, renderCoverage } from "./us.mjs";
24
+ import { parseUS, renderCoverage, explainFetchError } from "./us.mjs";
25
25
  import { slugify } from "./link-us.mjs";
26
26
  import { jiraAdapter } from "./pm-jira.mjs";
27
27
  import { clickupAdapter } from "./pm-clickup.mjs";
28
28
 
29
29
  // Re-export para compatibilidad (tests y CLI importan estos desde acá).
30
- export { parseUS, coverageStatus, statusLabel, renderCoverage } from "./us.mjs";
30
+ export { parseUS, coverageStatus, statusLabel, renderCoverage, explainFetchError } from "./us.mjs";
31
+
32
+ // Consulta la US preservando la diferencia entre "no existe" y "no pude preguntar". Los
33
+ // adaptadores ya la hacen —404 → null, cualquier otro error → throw— pero se perdía en cada
34
+ // caller: `dai pr` la borraba con un `.catch(() => null)` y publicaba "sin US" en la PR, y
35
+ // los demás morían con el `fetch failed` pelado de undici. El gate de CI era el único que la
36
+ // respetaba, con su try/catch propio; esto es ese criterio, compartido.
37
+ //
38
+ // → { us, unreachable, reason } · unreachable: no hubo respuesta, no sabemos nada
39
+ export async function fetchLiveUS(adapter, id) {
40
+ if (!id) return { us: null, unreachable: false, reason: null };
41
+ try {
42
+ return { us: await adapter.fetchUS(id), unreachable: false, reason: null };
43
+ } catch (e) {
44
+ return { us: null, unreachable: true, reason: explainFetchError(e, { kind: adapter.kind, endpoint: adapter.endpoint, id }) };
45
+ }
46
+ }
31
47
 
32
48
  // ── backend md (local, offline) ───────────────────────────────────────────────
33
49
  function mdAdapter(env) {
34
50
  const dir = env.DAI_MD_US_DIR || ".dai/us";
35
51
  return {
36
52
  kind: "md",
53
+ endpoint: dir,
37
54
  fetchUS(id) {
38
55
  const p = join(dir, `${id}.md`);
39
56
  if (!existsSync(p)) return null;
@@ -25,6 +25,7 @@ export function clickupAdapter(env) {
25
25
  if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env.dai (backend clickup).");
26
26
  return {
27
27
  kind: "clickup",
28
+ endpoint: "api.clickup.com",
28
29
  async fetchUS(id) {
29
30
  const res = await fetch(clickupTaskUrl(id), { headers: clickupAuthHeaders(env) });
30
31
  if (res.status === 404) return null;
@@ -139,6 +139,7 @@ export function jiraAdapter(env) {
139
139
  if (!base) throw new Error("falta DAI_JIRA_BASE_URL en el .env.dai (backend jira).");
140
140
  return {
141
141
  kind: "jira",
142
+ endpoint: trim(base),
142
143
  async fetchUS(id) {
143
144
  const res = await daiFetch(jiraIssueUrl(base, id), { headers: jiraAuthHeaders(env) });
144
145
  if (res.status === 404) return null;
package/cli/lib/pr.mjs CHANGED
@@ -2,50 +2,150 @@
2
2
  // Parte pura y testeable: rellena el template con los datos del link + git + check.
3
3
  // Los efectos (git push, gh/glab create) viven en dai.mjs.
4
4
 
5
- const EMOJI = { "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US" };
5
+ // Más explícito que el label del CLI a propósito: esto queda PUBLICADO en la PR, donde
6
+ // quien lee no tiene el contexto de la corrida que la creó.
7
+ const EMOJI = {
8
+ "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US",
9
+ "sin-respuesta": "⚠️ no verificado (el tracker no respondió)",
10
+ };
6
11
 
7
- // Reemplaza el cuerpo de una sección (## Heading hasta el próximo ## o el final)
8
- // por content. Tolerante: si no encuentra la sección, devuelve el body igual.
9
- // Sin flag `m`: `$` = fin de string (no fin de línea), así no corta antes de tiempo.
12
+ // Las secciones que dai se compromete a entregar llenas. Si alguna sale con el molde
13
+ // del template, la PR se publica vacía y el review no tiene qué mirar (era el bug:
14
+ // "Descripción" con el comentario HTML y "Cambios realizados" con `Cambio 1/Cambio 2`).
15
+ // `dai pr` las verifica con bodyGaps() antes de publicar.
16
+ export const OWNED_SECTIONS = ["Descripción", "Cambios realizados"];
17
+
18
+ // ── Secciones ────────────────────────────────────────────────────────────────
19
+ // Normaliza un heading para compararlo: sin acentos, sin emoji ni puntuación, minúscula.
20
+ // Un repo puede traer su propio template ("## 📝 Descripción del cambio") y dai igual
21
+ // tiene que reconocer la sección: con el match exacto de antes no la encontraba y
22
+ // devolvía el body intacto EN SILENCIO, que es la forma más cara de fallar.
23
+ const norm = (s) => String(s).normalize("NFD").replace(/[\u0300-\u036f]/gu, "")
24
+ .replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
25
+
26
+ // Ubica una sección markdown (## Heading … hasta el próximo heading de igual o menor
27
+ // nivel). Ignora lo que esté dentro de un bloque de código: un `## ` en un fence es
28
+ // texto, no estructura. Devuelve null si no está.
29
+ function findSection(lines, heading) {
30
+ const want = norm(heading);
31
+ let start = -1, level = 0, fence = false;
32
+ for (let i = 0; i < lines.length; i++) {
33
+ if (/^\s*(```|~~~)/.test(lines[i])) { fence = !fence; continue; }
34
+ if (fence) continue;
35
+ const m = /^(#{2,6})[ \t]+(.+?)[ \t]*$/.exec(lines[i]);
36
+ if (!m) continue;
37
+ if (start === -1) { if (norm(m[2]).startsWith(want)) { start = i; level = m[1].length; } }
38
+ else if (m[1].length <= level) return { start, end: i, level };
39
+ }
40
+ return start === -1 ? null : { start, end: lines.length, level };
41
+ }
42
+
43
+ // El cuerpo crudo de una sección, o null si la sección no existe.
44
+ export function sectionBody(body, heading) {
45
+ const lines = body.split("\n");
46
+ const at = findSection(lines, heading);
47
+ return at ? lines.slice(at.start + 1, at.end).join("\n") : null;
48
+ }
49
+
50
+ // Reemplaza el cuerpo de una sección por content. Tolerante: si no encuentra la
51
+ // sección, devuelve el body igual (para eso está upsertSection).
10
52
  export function replaceSection(body, heading, content) {
11
- const esc = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
- const re = new RegExp(`(^|\\n)(##[ \\t]*${esc}[ \\t]*\\n)([\\s\\S]*?)(\\n##\\s|$)`, "i");
13
- if (!re.test(body)) return body;
14
- return body.replace(re, (m, pre, h, _b, tail) => `${pre}${h}\n${content}\n${tail}`);
53
+ const lines = body.split("\n");
54
+ const at = findSection(lines, heading);
55
+ if (!at) return body;
56
+ return [...lines.slice(0, at.start + 1), "", content, "", ...lines.slice(at.end)].join("\n");
57
+ }
58
+
59
+ // Como replaceSection, pero si la sección no está la agrega al final con su heading.
60
+ // Misma disciplina que el bloque de enlaces: dai siempre entrega el dato, nunca lo
61
+ // pierde porque el template del repo no tenía dónde ponerlo. content null = no sé
62
+ // nada → no toco (y bodyGaps lo va a reportar; dai no inventa contenido).
63
+ export function upsertSection(body, heading, content) {
64
+ if (content == null || !String(content).trim()) return body;
65
+ if (findSection(body.split("\n"), heading)) return replaceSection(body, heading, content);
66
+ return `${body.replace(/\s*$/, "")}\n\n## ${heading}\n\n${content}\n`;
67
+ }
68
+
69
+ // ── Detección del molde sin llenar ───────────────────────────────────────────
70
+ // Moldes conocidos del template: líneas que no dicen nada sobre ESTE cambio.
71
+ const MOCK_LINES = [/^-?\s*\[[ x]\]\s*cambio\s*\d+\s*$/i, /^cambio\s*\d+\s*$/i, /^-?\s*\[[ x]\]\s*$/];
72
+
73
+ // ¿El cuerpo de una sección es puro molde? Los comentarios HTML no cuentan como
74
+ // contenido: no se renderizan, así que en la PR publicada la sección se ve VACÍA
75
+ // (por eso el bug pasaba desapercibido hasta que alguien abría la PR).
76
+ export function isPlaceholder(text) {
77
+ const lines = String(text ?? "").replace(/<!--[\s\S]*?-->/g, "").split("\n").map((l) => l.trim()).filter(Boolean);
78
+ if (!lines.length) return true;
79
+ return lines.every((l) => MOCK_LINES.some((re) => re.test(l)));
15
80
  }
16
81
 
17
- // Rellena el template del PR con los datos precargados. Tolerante: reemplaza los
18
- // placeholders que encuentra y deja el resto para que el humano lo edite.
82
+ // Qué le falta al body para ser revisable. Vacío = la PR se puede publicar.
83
+ // `dai pr` lo usa como gate: sin TTY (el camino del agente) falla en vez de publicar
84
+ // una PR que nadie puede revisar.
85
+ export function bodyGaps(body) {
86
+ const gaps = OWNED_SECTIONS.filter((h) => {
87
+ const s = sectionBody(body, h);
88
+ return s === null || isPlaceholder(s);
89
+ });
90
+ // Placeholders de la cabecera: la PR saldría diciendo `ABC-###` (issues #31/#33).
91
+ if (/ABC-###|`<hash>`|@ `vX`/.test(body)) gaps.push("🔗 Implementa");
92
+ return gaps;
93
+ }
94
+
95
+ // ── Composición ──────────────────────────────────────────────────────────────
96
+ // Descripción: lo que escribió quien crea la PR (agente o dev) gana; si no, se deriva
97
+ // de la US. Si dai no sabe nada, devuelve null: no inventa el propósito de un cambio.
98
+ export function descriptionFor(d) {
99
+ const explicit = String(d.description ?? "").trim();
100
+ if (explicit) return explicit;
101
+ if (d.usTitle) return `Implementa la US **${d.usTitle}** (\`${d.id}\`). Ver los criterios de aceptación en el tracker.`;
102
+ return null;
103
+ }
104
+
105
+ // Cambios realizados: el detalle explícito gana; si no, los commits de la branch
106
+ // (como `gh pr create --fill`). Sin ninguno de los dos, null.
107
+ export function changesFor(d) {
108
+ const explicit = String(d.changes ?? "").trim();
109
+ if (explicit) return explicit;
110
+ if (d.commits && d.commits.length) return d.commits.map((c) => `- [x] ${c}`).join("\n");
111
+ return null;
112
+ }
113
+
114
+ // Rellena el template del PR con los datos precargados. Tolerante con el resto del
115
+ // template (checklists, secciones propias del repo): dai suma, no borra.
19
116
  export function composePrBody(template, d) {
20
- let b = template;
21
- if (!d.id) return composePrBodyNoUs(b, d);
22
- b = b.replace(/`ABC-###`/g, `\`${d.id}\``);
23
- b = b.replace(/@ `vX`/g, `@ \`${d.version}\``);
24
- b = b.replace(/`<hash>`/g, `\`${d.ac_hash}\``);
25
- b = b.replace(/verificado con `dai check` ✅/g, `verificado con \`dai check\`: ${EMOJI[d.status] || d.status}`);
26
-
27
- // Descripción: default desde la US (el humano lo pule).
28
- if (d.usTitle) b = replaceSection(b, "Descripción", `Implementa la US **${d.usTitle}** (\`${d.id}\`). Ver los criterios de aceptación en el tracker.`);
29
- // Cambios realizados: de los commits de la branch (como gh pr create --fill).
30
- if (d.commits && d.commits.length) {
31
- b = replaceSection(b, "Cambios realizados", d.commits.map((c) => `- [x] ${c}`).join("\n"));
32
- }
117
+ let b = d.id ? fillUsHeader(template, d) : fillNoUsHeader(template, d);
118
+ b = upsertSection(b, "Descripción", descriptionFor(d));
119
+ b = upsertSection(b, "Cambios realizados", changesFor(d));
33
120
  return upsertLinksBlock(b, d);
34
121
  }
35
122
 
123
+ // Los placeholders se rellenan DENTRO de la sección, no en todo el body: la misma frase
124
+ // ("verificado con `dai check` ✅") aparecía en la prosa que explica el método, así que un
125
+ // replace global le metía el estado de ESTA PR a una oración general y quedaba
126
+ // "verificado con dai check: ✅ al día. Sin esto, el código no sabe…". dai reescribiendo
127
+ // la prosa de otro es justo lo que el bloque de links ya evitaba con sus marcadores.
128
+ // Sin la sección (un template ajeno con otra forma) se cae al body entero: rellenar de más
129
+ // es recuperable, no rellenar deja la PR con `ABC-###` publicado.
130
+ function fillUsHeader(template, d) {
131
+ const fill = (t) => t
132
+ .replace(/`ABC-###`/g, `\`${d.id}\``)
133
+ .replace(/@ `vX`/g, `@ \`${d.version}\``)
134
+ .replace(/`<hash>`/g, `\`${d.ac_hash}\``)
135
+ .replace(/verificado con `dai check` ✅/g, `verificado con \`dai check\`: ${EMOJI[d.status] || d.status}`);
136
+ const sec = sectionBody(template, "🔗 Implementa");
137
+ return sec == null ? fill(template) : replaceSection(template, "🔗 Implementa", fill(sec).trim());
138
+ }
139
+
36
140
  // PR de una branch exenta (chore/, docs/, release/…): no implementa una US y no se le
37
141
  // exige link. Lo que NO puede pasar es que salga con la US de otro ni con el placeholder
38
142
  // `ABC-###` del template — las dos cosas pasaron en repos reales (issues #31, #33).
39
143
  // Se dice explícitamente que no hay US, y por qué.
40
- function composePrBodyNoUs(template, d) {
41
- let b = replaceSection(template, "🔗 Implementa",
144
+ function fillNoUsHeader(template, d) {
145
+ return replaceSection(template, "🔗 Implementa",
42
146
  `- **Sin US:** esta PR no implementa una User Story.\n` +
43
147
  (d.noUsReason ? `- **Motivo:** ${d.noUsReason}.\n` : "") +
44
148
  `- No se le exige link (\`governance/branch-naming.md\`).`);
45
- if (d.commits && d.commits.length) {
46
- b = replaceSection(b, "Cambios realizados", d.commits.map((c) => `- [x] ${c}`).join("\n"));
47
- }
48
- return upsertLinksBlock(b, d);
49
149
  }
50
150
 
51
151
  // ── Bloque de enlaces ────────────────────────────────────────────────────────
package/cli/lib/us.mjs CHANGED
@@ -12,12 +12,22 @@ export function parseUS(raw) {
12
12
  }
13
13
 
14
14
  // Compara el hash estampado (implements.yaml) con el hash vivo de la US.
15
- export function coverageStatus(stampedHash, liveHash) {
15
+ //
16
+ // `sin-us` y `sin-respuesta` NO son lo mismo, y confundirlos es caro: el primero es una
17
+ // RESPUESTA del tracker (preguntamos y la US no está), el segundo es la AUSENCIA de
18
+ // respuesta (sin red, sin token, 5xx, certificado corporativo). Colapsados en un mismo
19
+ // `null`, dai terminaba AFIRMANDO que no había US cuando lo único cierto era que no había
20
+ // podido preguntar — y eso se publicaba en el cuerpo de una PR, al lado del id de la US.
21
+ export function coverageStatus(stampedHash, liveHash, { unreachable = false } = {}) {
22
+ if (unreachable) return "sin-respuesta";
16
23
  if (liveHash == null) return "sin-us";
17
24
  return stampedHash === liveHash ? "al-dia" : "atrasado";
18
25
  }
19
26
 
20
- const STATUS_LABEL = { "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US" };
27
+ const STATUS_LABEL = {
28
+ "al-dia": "✅ al día", atrasado: "⚠️ atrasado",
29
+ "sin-us": "❓ sin US", "sin-respuesta": "⚠️ no verificado",
30
+ };
21
31
  export const statusLabel = (s) => STATUS_LABEL[s] || s;
22
32
 
23
33
  // Render de la cobertura como markdown (lo que un backend "estampa").
@@ -34,3 +44,25 @@ export function renderCoverage(id, r) {
34
44
  if (r.commitUrl) lines.push(`- commit: ${r.commit} → ${r.commitUrl} (ancla durable)`);
35
45
  return lines.join("\n") + "\n";
36
46
  }
47
+
48
+ // `fetch failed` es TODO lo que dice undici cuando no hay red, el host no resuelve, el DNS
49
+ // se cayó o el certificado no valida. Sin contexto es indistinguible de un bug de dai: el
50
+ // dev lee "dai: fetch failed" y no sabe ni qué se estaba consultando. Es el mismo modo de
51
+ // falla que el push por SSH de la 0.13.1 — el dato que resuelve el problema existe, y no
52
+ // llega. Acá se le pone alrededor qué, contra qué, y qué mirar.
53
+ export function explainFetchError(err, { kind, endpoint, id } = {}) {
54
+ const raw = String(err?.message ?? err ?? "").split("\n")[0] || "error desconocido";
55
+ const donde = [kind, endpoint].filter(Boolean).join(" · ");
56
+ const cabeza = `no pude consultar ${id ? `la US ${id}` : "el tracker"}${donde ? ` en ${donde}` : ""}: ${raw}`;
57
+ if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|certificate|self.signed/i.test(raw)) {
58
+ return `${cabeza}\n No llegó a haber respuesta. Revisá la red y el host del backend; si estás detrás de un\n` +
59
+ ` proxy corporativo, declará la CA con NODE_EXTRA_CA_CERTS (nunca NODE_TLS_REJECT_UNAUTHORIZED=0).\n` +
60
+ ` Diagnóstico: dai doctor`;
61
+ }
62
+ if (/\b40[13]\b|unauthorized|forbidden/i.test(raw)) {
63
+ return `${cabeza}\n El tracker rechazó las credenciales: revisá el token del .env.dai (¿venció?) y sus permisos.\n` +
64
+ ` Diagnóstico: dai doctor`;
65
+ }
66
+ if (/\b5\d\d\b/.test(raw)) return `${cabeza}\n El error es del tracker, no tuyo: probá de nuevo en un rato.`;
67
+ return cabeza;
68
+ }
@@ -239,7 +239,9 @@ código + spec trazable) — y la asigna a un partner:
239
239
  ```
240
240
  $ dai check
241
241
  ✅ ABC-482 al día (v1)
242
- $ dai pr --assignee mgomez
242
+ $ dai pr --assignee mgomez \
243
+ --description "Permite comprar sin crear cuenta: el checkout acepta un email de
244
+ contacto y genera la orden como invitado. Baja el abandono del paso 2."
243
245
  ✓ PR #123 creada → …/pull/123 (base: main · US: ABC-482 @ v1 · dai check ✅)
244
246
  ```
245
247
 
package/docs/guias/dev.md CHANGED
@@ -35,8 +35,11 @@
35
35
  (anti vibe-coding). Ajustas y **commiteas** lo que haga falta.
36
36
  6. **Creas la PR** → con el smoke verde y **todo commiteado** (lo que quede sin commitear
37
37
  **no entra** en la PR), corres `dai check` (gate: ¿al día con la US?) y en verde `dai pr`
38
- arma la PR **precargada** (US + estado del check + links; los **dos activos**: código +
39
- spec trazable) y la asignas a un partner.
38
+ arma la PR **precargada** (US + estado del check + commits + links; los **dos activos**:
39
+ código + spec trazable) y la asignas a un partner. **La descripción la escribes tú**
40
+ (`--description "…"`, o `--description-file notas.md`): dai llena lo que puede derivar,
41
+ pero no inventa el propósito de un cambio — y si esa sección quedaría con el molde del
42
+ template, **no publica la PR**. Una PR sin descripción no se puede revisar.
40
43
  7. **Review de un partner** → un compañero revisa tu PR y **firma** aprobación/rechazo
41
44
  (Art. 5). Se apoya en la skill `/dai-review` para un primer pase: un **review inline**
42
45
  (resumen + un comentario por línea), que le muestra el preview y espera su OK antes de postear.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Metodología de desarrollo asistido por IA — CLI de acciones deterministas (trazabilidad QUÉ↔CÓMO).",
5
5
  "repository": { "type": "git", "url": "git+https://github.com/dforce2055/dai.git" },
6
6
  "homepage": "https://dforce2055.github.io/dai/",
@@ -27,6 +27,7 @@
27
27
  "cli/dai.mjs",
28
28
  "cli/lib",
29
29
  "docs",
30
+ "!docs/public/tutoriales",
30
31
  "templates",
31
32
  "governance",
32
33
  "skills",
@@ -5,26 +5,39 @@
5
5
  Lo puede pre-llenar `dai`/una skill a partir del implements.yaml y el diff.
6
6
  -->
7
7
 
8
- > **Un PR en dai entrega dos activos, y el review cubre los dos:**
9
- > 1. **La implementación** — el código que resuelve la US.
10
- > 2. **El spec trazable** — el `implements.yaml` con el link a la US y el `@version`
11
- > (`ac_hash`) verificado con `dai check` ✅. Sin esto, el código no sabe *a qué QUÉ*
12
- > responde, y el CI bloquea el PR (ver `governance/ci-rules.md`).
13
-
14
8
  ## 🔗 Implementa
15
9
 
16
10
  - **US:** `ABC-###` @ `vX` · ac_hash: `<hash>` · verificado con `dai check` ✅
17
- - **Link:** este PR está atado a la US vía `implements.yaml`.
18
11
 
19
- > Si este PR no implementa una US (chore/fix sin ticket), borra esta sección y
20
- > aclara el motivo no se le exige link.
12
+ <!--
13
+ Un PR en dai entrega DOS activos, y el review cubre los dos: la implementación (el código
14
+ que resuelve la US) y el spec trazable (el `implements.yaml` con el link a la US y el
15
+ `@version`/`ac_hash` verificado). Sin el link, el código no sabe a qué QUÉ responde y el
16
+ CI bloquea el PR — ver `governance/ci-rules.md`.
17
+
18
+ ¿Chore o fix sin ticket? No hay nada que borrar: `dai pr` lo detecta por el nombre de la
19
+ branch y escribe "Sin US" con el motivo. No se le exige link.
20
+
21
+ Esto es un comentario a propósito: es doctrina del método, igual en las 500 PRs del repo.
22
+ Repetirla a la vista en cada una entrena a saltear el principio del cuerpo, que es
23
+ justamente donde va la descripción.
24
+ -->
21
25
 
22
26
  ## Descripción
23
27
 
24
- <!-- Breve propósito de este PR, en términos de negocio (2–4 líneas). -->
28
+ <!--
29
+ Breve propósito de este PR, en términos de negocio (2–4 líneas).
30
+ Lo precarga `dai pr --description "…"` (o `--description-file <archivo.md>`).
31
+ dai NO lo inventa: si esta sección queda sin llenar, `dai pr` no publica la PR.
32
+ -->
25
33
 
26
34
  ## Cambios realizados
27
35
 
36
+ <!--
37
+ Lo precarga `dai pr` con los commits de la branch; `--changes` / `--changes-file`
38
+ lo reemplazan por el detalle que quieras contar.
39
+ -->
40
+
28
41
  - [ ] Cambio 1
29
42
  - [ ] Cambio 2
30
43