@dforce2055/dai 0.13.0 → 0.13.2

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,118 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.13.2] — 2026-09-02
7
+
8
+ **Una PR se publicaba con la descripción vacía y la lista de cambios diciendo "Cambio 1,
9
+ Cambio 2". No siempre: a veces salía perfecta. Lo raro es que "Enlaces relacionados", que
10
+ vive en el mismo template, nunca falló — y ahí estaba la pista. Y de yapa, el paquete de npm
11
+ adelgaza de 3.7 MB a 703 kB: cargaba las capturas del sitio.**
12
+
13
+ `dai pr` rellenaba cada sección **solo si tenía el dato** y, si no lo tenía, devolvía el
14
+ molde del template intacto, en silencio. La "Descripción" quedaba en su comentario HTML, que
15
+ no se renderiza: en GitHub y en GitLab la sección se ve **vacía**. El bloque de enlaces nunca
16
+ falló porque su código siempre escribe — si no encuentra la sección, la agrega. Esa
17
+ disciplina ahora vale para todo el cuerpo de la PR.
18
+
19
+ Detrás del bug había algo más de fondo: **no existía forma de escribir la descripción**.
20
+ `dai pr` no aceptaba ningún texto, así que el propósito de la PR solo podía salir del título
21
+ de la US y de los subjects de los commits. Ni el dev ni su agente podían hacerlo bien aunque
22
+ quisieran.
23
+
24
+ ### Arreglado
25
+ - **La PR salía con el molde del template sin llenar.** Tres caminos llevaban al mismo
26
+ resultado, los tres silenciosos: (1) el tracker no contestaba —sin token, sin red, `DAI_PM`
27
+ mal seteado— y sin el título de la US no se llenaba "Descripción"; (2) la branch base no
28
+ existía **en local** —clones `--single-branch`, repos donde se trabaja sobre `develop` y la
29
+ base es `main`— y `git log base..HEAD` fallaba dentro de un `catch` vacío, dejando "Cambios
30
+ realizados" con `Cambio 1 / Cambio 2`; (3) una branch exenta (`chore/`, `docs/`) nunca
31
+ llenaba "Descripción", ni siquiera pudiendo. Ese mismo `catch` vacío también se comía el
32
+ chequeo de *"sin commits sobre la base no hay PR"*.
33
+ - **La branch base ahora se resuelve** a `main` o, si no está en local, a `origin/main`.
34
+ - **Las secciones se reconocen aunque el repo tenga su propio molde** (`## 📝 Descripción del
35
+ cambio`, `### CAMBIOS REALIZADOS`): antes el match era exacto, no encontraba la sección y
36
+ devolvía el body sin tocar — otra vez, sin decir nada. Ignora los `##` que estén dentro de
37
+ un bloque de código y, si la sección no existe, la agrega.
38
+
39
+ ### Agregado
40
+ - **`dai pr --description "…"` y `--description-file <archivo.md>`** — el propósito de un
41
+ cambio no se deriva de git ni del tracker. dai llena la US, el estado del check, los commits
42
+ y los links; el porqué lo escribe quien crea la PR. **dai no lo inventa: lo pide.**
43
+ - **`--changes` y `--changes-file`** — reemplazan el detalle de "Cambios realizados" cuando los
44
+ commits no cuentan bien la historia. Sin ellos, siguen saliendo de los commits de la branch.
45
+ - **La constitución que escriben `dai init` y `dai sync` lo dice**, para que el agente que
46
+ corre `dai pr` sepa que la descripción es suya. Los repos ya inicializados la reciben con
47
+ `dai sync`.
48
+
49
+ ### Cambiado
50
+ - ⚠️ **`dai pr` no publica una PR que saldría con el molde sin llenar.** Con `--yes` o sin TTY
51
+ —el camino de un agente o de CI— **aborta** y dice qué flag pasar; en terminal avisa y decide
52
+ la persona. **Si tenés automatización con `dai pr --yes` sin `--description`, se va a frenar**:
53
+ es justamente lo que publicaba las PRs vacías. El molde también se detecta en la cabecera
54
+ (`ABC-###`), la misma familia de los issues #31/#33.
55
+
56
+ ### Arreglado — el paquete de npm
57
+ - **`npm i -g @dforce2055/dai` bajaba las capturas del sitio.** `files[]` lista `docs`
58
+ entero, y ahí adentro viven las de los tutoriales (`docs/public/tutoriales/*.png`): **2.9 MB
59
+ de los 3.7 MB del paquete**, que el CLI no abre nunca. El test de higiene que ya cubría el
60
+ sitio (`index.html`, `onboarding.html`) no las veía porque entraban por otra puerta.
61
+ **3.7 MB → 703 kB**, 130 → 117 archivos, cero PNG en el tarball ([#37](https://github.com/dforce2055/dai/issues/37)).
62
+ - **`dai docs` copiaba imágenes que no se veían.** Los `.md` referencian las capturas con la
63
+ ruta absoluta del sitio (`![…](/tutoriales/x.png)`, que VitePress resuelve contra
64
+ `docs/public/`); fuera del sitio esa ruta apunta a la raíz del filesystem, así que en la
65
+ copia **ya estaban rotas**, con los 2.9 MB adentro y todo. Ahora `dai docs` no copia
66
+ `public/` —son assets del sitio, no documentación para leer desde un repo— y **absolutiza**
67
+ esos links contra el sitio publicado: la doc copiada por fin muestra las capturas.
68
+
69
+ ### Interno
70
+ - **358 tests** (+19). Uno por cada camino que dejaba pasar el molde del template —tracker
71
+ caído, sin commits, branch sin US, template propio del repo, headings dentro de un fence, y
72
+ el caso peor (sin tracker y sin commits a la vez)— más los de la reescritura de links y dos
73
+ de higiene que fijan la regla del paquete: que `files[]` declare la exclusión, y que no
74
+ aparezcan imágenes versionadas bajo `docs/` fuera de esa carpeta. Si mañana una captura
75
+ aterriza en otro lado, el test obliga a decidir ahí, no midiendo el tarball.
76
+
77
+ ## [0.13.1] — 2026-08-24
78
+
79
+ **Un dev en Windows no podía pushear contra el GitLab de su empresa. `ssh -T` le autenticaba
80
+ perfecto, `git push` moría con `Permission denied (publickey…)`. Dos días buscando el problema
81
+ en la clave, en el token y en los permisos del server: no estaba en ninguno de los tres, y dai
82
+ tapaba la única línea que lo decía.**
83
+
84
+ Su clave tenía passphrase. En Windows conviven dos `ssh.exe` — el de OpenSSH for Windows, que
85
+ habla con el servicio `ssh-agent`, y el que trae Git for Windows, que no lo ve. `ssh -T` usaba
86
+ el primero (la firma la hacía el agente, passphrase nunca), `git push` usaba el segundo y pedía
87
+ la passphrase por stderr. Ese prompt caía en un pipe de dai: el dev veía un cuelgue sin
88
+ explicación, ssh se rendía, caía a autenticación por password y lo único legible al final era
89
+ un error que acusa a la clave. Todo lo que hacía falta para resolverlo estaba en pantalla, y no
90
+ llegaba.
91
+
92
+ ### Arreglado
93
+ - **`dai pr` se tragaba lo que git y ssh preguntan.** El push corría con `stderr` en `pipe`
94
+ para no ensuciar la salida, pero git y ssh **preguntan por stderr**: la passphrase de una
95
+ clave, la confirmación de un host nuevo, el aviso del credential manager. Con el prompt
96
+ invisible el comando no se cuelga por un bug, se cuelga esperando una respuesta que nadie
97
+ sabe que tiene que dar. Ahora `stderr` va heredado y el push pregunta a la vista. El error de
98
+ git se lee en vivo, cuando todavía sirve, en vez de aparecer resumido después del fracaso.
99
+ - **La pista al fallar el push asumía que el remoto era HTTPS.** Decía siempre *"si es la
100
+ primera vez contra este remoto, autenticá pusheando a mano una vez"*. Eso arregla HTTPS,
101
+ donde el credential manager pide la credencial la primera vez. Contra un remoto **SSH** el
102
+ push a mano falla exactamente igual — así que la pista mandaba a repetir un comando condenado
103
+ y a seguir buscando en el lugar equivocado. Ahora el consejo depende del transporte: en SSH
104
+ aclara que el token de `gh`/`glab` no interviene en el push, que lo que hay que mirar es la
105
+ clave, y deriva a `dai doctor`.
106
+
107
+ ### Agregado
108
+ - **`dai doctor` — sección `forge`.** Reporta el remoto `origin` con su forge detectado y, en
109
+ Windows con remoto SSH, **qué `ssh.exe` va a usar git**: si es el suyo (el de Git for
110
+ Windows, que no llega al `ssh-agent`), lo advierte, explica el modo de falla y da el fix
111
+ (`git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"`). Respeta la
112
+ precedencia real de git —`GIT_SSH_COMMAND` > `GIT_SSH` > `core.sshCommand`— y avisa cuando
113
+ una variable de entorno está pisando un `core.sshCommand` que ya estaba bien: ese caso es
114
+ particularmente cruel, porque el dev arregla el config, no funciona, y mirando el
115
+ `.gitconfig` no hay nada que ver. Fuera de Windows, o con un remoto HTTPS, no opina.
116
+ Núcleo puro y testeado en `cli/lib/git-ssh.mjs` (14 tests); en `dai.mjs` queda solo el I/O.
117
+
6
118
  ## [0.13.0] — 2026-08-21
7
119
 
8
120
  **Un dev de backend en Windows siguió el tutorial al pie de la letra y el agente se puso a
@@ -696,6 +808,8 @@ ClickUp y Jira Cloud.
696
808
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
697
809
  `files` de npm sin tests ni secretos.
698
810
 
811
+ [0.13.2]: https://github.com/dforce2055/dai/releases/tag/v0.13.2
812
+ [0.13.1]: https://github.com/dforce2055/dai/releases/tag/v0.13.1
699
813
  [0.13.0]: https://github.com/dforce2055/dai/releases/tag/v0.13.0
700
814
  [0.12.0]: https://github.com/dforce2055/dai/releases/tag/v0.12.0
701
815
  [0.11.0]: https://github.com/dforce2055/dai/releases/tag/v0.11.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.0
1
+ 0.13.2
package/cli/dai.mjs CHANGED
@@ -27,7 +27,9 @@ import { branchUrl, commitUrl, parseRemote, detectForge } from "./lib/forge-url.
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";
32
+ import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
31
33
  import { dirsEqual } from "./lib/fsutil.mjs";
32
34
  import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
33
35
  import { versionDrift, planUpgrade, compareVersions } from "./lib/semver.mjs";
@@ -215,6 +217,27 @@ function gitUser() {
215
217
  function gitRemote() { try { return git(["remote", "get-url", "origin"]); } catch { return null; } }
216
218
  function gitBranch() { try { return git(["rev-parse", "--abbrev-ref", "HEAD"]); } catch { return null; } }
217
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
+ }
218
241
 
219
242
  // ── check --ci: el gate de governance/ci-rules.md, ejecutable ────────────────
220
243
  //
@@ -878,9 +901,16 @@ async function cmdPr(opts) {
878
901
  let base = opts.base;
879
902
  if (!base) { const ans = await ask(" ¿Contra qué branch va la PR? (main) "); base = ans || "main"; }
880
903
 
904
+ // La base puede no existir LOCAL (clones con --single-branch, repos donde el dev
905
+ // trabaja sobre develop y la base es main, corporativos con la base solo en origin).
906
+ // Antes el catch vacío se comía el error y seguía de largo: ni se contaban los commits
907
+ // (→ "Cambios realizados" salía con el molde) ni frenaba el chequeo de abajo.
908
+ const baseRev = resolveRev(base) || resolveRev(`origin/${base}`);
909
+ if (!baseRev) warn(`no encuentro la branch base '${base}' (ni local ni en origin/${base}). Trae la base primero: git fetch origin ${base}`);
910
+
881
911
  // Sin commits sobre la base no hay PR.
882
912
  let ahead = null;
883
- try { ahead = Number(git(["rev-list", "--count", `${base}..HEAD`])); } catch { /* base no existe local */ }
913
+ try { ahead = Number(git(["rev-list", "--count", `${baseRev}..HEAD`])); } catch { /* base ausente */ }
884
914
  if (ahead === 0) {
885
915
  fail(`no hay commits en '${branch}' por encima de '${base}'. Una PR necesita cambios: haz commit primero (git commit).`, 1);
886
916
  }
@@ -943,7 +973,13 @@ async function cmdPr(opts) {
943
973
  : join(ROOT, "templates", "pull-request.md");
944
974
  // Commits de la branch (para precargar "Cambios realizados").
945
975
  let commits = [];
946
- try { commits = git(["log", `${base}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* base local ausente */ }
976
+ if (baseRev) {
977
+ try { commits = git(["log", `${baseRev}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* rango inválido */ }
978
+ }
979
+ // El texto que escribe quien crea la PR (el dev o su agente). Es la ÚNICA fuente de
980
+ // una descripción de verdad: dai lee git y el tracker, no el porqué del cambio.
981
+ const description = textOpt(opts, "description");
982
+ const changes = textOpt(opts, "changes");
947
983
  // La canónica del tracker (live.url) gana sobre la derivada; el template gana sobre todo.
948
984
  const usUrl = id ? usUrlFor(id, live?.url) : null;
949
985
  if (id && !usUrl) {
@@ -951,7 +987,8 @@ async function cmdPr(opts) {
951
987
  warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
952
988
  }
953
989
  const body = composePrBody(readFileSync(tplPath, "utf8"), {
954
- id, version, ac_hash, status, usUrl, usTitle: live?.title, commits, noUsReason: id ? null : scope.reason,
990
+ id, version, ac_hash, status, usUrl, usTitle: live?.title, commits, description, changes,
991
+ noUsReason: id ? null : scope.reason,
955
992
  branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
956
993
  });
957
994
  // Sin US el título sale del último commit: describe lo que hay adentro, en vez de
@@ -968,6 +1005,27 @@ async function cmdPr(opts) {
968
1005
  process.stdout.write(` ─────────────────────────────────────────────────────\n\n${body}\n`);
969
1006
  process.stdout.write(` ─────────────────────────────────────────────────────\n`);
970
1007
 
1008
+ // 4b. Gate: una PR con el molde del template sin llenar no se puede revisar.
1009
+ // Pasaba en repos reales — "Descripción" con el comentario HTML (que no se renderiza:
1010
+ // la sección se ve VACÍA) y "Cambios realizados" con `Cambio 1/Cambio 2` — cada vez
1011
+ // que el tracker no respondía o la base no estaba local. dai NO inventa la descripción:
1012
+ // la pide. Sin TTY (el camino del agente) frena; en terminal avisa y decidís vos.
1013
+ const gaps = bodyGaps(body);
1014
+ if (gaps.length) {
1015
+ const how =
1016
+ ` Escribí el texto y pasáselo a dai:\n` +
1017
+ ` dai pr --description "Qué resuelve este cambio y por qué (2–4 líneas)."\n` +
1018
+ ` dai pr --description-file notas.md --changes-file cambios.md (markdown multilínea)\n` +
1019
+ ` El detalle de "Cambios realizados" sale de los commits si no pasás --changes.\n`;
1020
+ if (opts.yes || !process.stdin.isTTY) {
1021
+ closeRl();
1022
+ fail(`la PR saldría con el molde del template sin llenar: ${gaps.join(", ")}.\n` +
1023
+ ` Una PR sin descripción no se puede revisar, así que no la publico.\n${how}`, 1);
1024
+ }
1025
+ warn(`la PR va a salir con el molde sin llenar: ${gaps.join(", ")}.`);
1026
+ process.stdout.write(how);
1027
+ }
1028
+
971
1029
  // Archivo de paso para gh/glab: en el temp del sistema, NO en el repo (no lo ensucia).
972
1030
  const bodyFile = join(mkdtempSync(join(tmpdir(), "dai-pr-")), "body.md");
973
1031
  if (!opts.yes) {
@@ -990,11 +1048,21 @@ async function cmdPr(opts) {
990
1048
  // stdin heredado + GIT_TERMINAL_PROMPT=1: la PRIMERA vez contra un remoto HTTPS
991
1049
  // corporativo, git/credential-manager necesita poder pedir la credencial. Con stdin
992
1050
  // ignorado (el default de git()) el login no completaba y el push fallaba en seco.
993
- git(["push", "-u", "origin", branch], { stdio: ["inherit", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
1051
+ //
1052
+ // stderr heredado, y esto no es cosmético: git y ssh PREGUNTAN por stderr. Con
1053
+ // stderr en 'pipe' el prompt de una clave con passphrase ("Enter passphrase for
1054
+ // key …") caía en el pipe, invisible: el dev veía un cuelgue sin explicación, ssh
1055
+ // se rendía y terminaba cayendo a autenticación por password, y lo único que se
1056
+ // llegaba a leer era un "Permission denied (publickey…)" que acusa a la clave
1057
+ // cuando la clave estaba bien. Que pregunte a la vista.
1058
+ git(["push", "-u", "origin", branch], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
994
1059
  } catch (e) {
995
- const err = String(e.stderr || e.message || "").trim();
1060
+ // git ya imprimió su error en vivo (stderr heredado); acá solo va la pista, y esa
1061
+ // pista depende del transporte: "pushea a mano una vez" arregla HTTPS y no arregla
1062
+ // nada en SSH, donde el push a mano falla igual (lib/git-ssh.mjs).
1063
+ const err = String(e.stderr || "").trim();
996
1064
  if (err) process.stderr.write(" " + err.split("\n").join("\n ") + "\n");
997
- process.stdout.write(` Si es la primera vez contra este remoto, autenticá pusheando a mano una vez:\n git push -u origin ${branch}\n y volvé a correr: dai pr\n`);
1065
+ for (const l of pushFailureHint(remote, branch)) process.stdout.write(` ${l}\n`);
998
1066
  fail(`no pude pushear la branch '${branch}'.`, 1);
999
1067
  }
1000
1068
 
@@ -1420,8 +1488,28 @@ async function cmdInit(repo, opts) {
1420
1488
  function cmdDocs(dest) {
1421
1489
  if (!dest) fail("uso: dai docs <destino>");
1422
1490
  mkdirSync(dest, { recursive: true });
1423
- cpSync(join(ROOT, "docs"), dest, { recursive: true });
1491
+ // `public/` son los assets del sitio (VitePress), no documentación para leer desde el
1492
+ // repo de nadie: las capturas de los tutoriales ni siquiera viajan en el paquete npm
1493
+ // (issue #37). Se saltea, y los links que las nombran se absolutizan contra el sitio.
1494
+ cpSync(join(ROOT, "docs"), dest, { recursive: true, filter: (src) => !/[/\\]public([/\\]|$)/.test(src) });
1495
+ let reescritos = 0;
1496
+ for (const f of walkMd(dest)) {
1497
+ const md = readFileSync(f, "utf8");
1498
+ const out = absolutizeSiteLinks(md);
1499
+ if (out !== md) { writeFileSync(f, out); reescritos++; }
1500
+ }
1424
1501
  ok(`documentación copiada a ${dest}`);
1502
+ if (reescritos) info(`${reescritos} documento(s) con capturas: los links apuntan al sitio publicado.`);
1503
+ }
1504
+
1505
+ // Los .md de un árbol, para la reescritura de links de cmdDocs.
1506
+ function walkMd(dir, out = []) {
1507
+ for (const name of readdirSync(dir)) {
1508
+ const p = join(dir, name);
1509
+ if (statSync(p).isDirectory()) walkMd(p, out);
1510
+ else if (name.endsWith(".md")) out.push(p);
1511
+ }
1512
+ return out;
1425
1513
  }
1426
1514
 
1427
1515
  // ── archive: funde los delta specs del change en los specs canónicos y lo archiva ─
@@ -1728,6 +1816,44 @@ function cmdDoctor() {
1728
1816
  : warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
1729
1817
  }
1730
1818
 
1819
+ // ── forge: el CÓMO sale del repo por acá ─────────────────────────────────────
1820
+ // Si el push no sale, no hay PR; sin PR no hay link QUÉ↔CÓMO. El chequeo del cliente
1821
+ // ssh existe porque su modo de fallar miente: ver lib/git-ssh.mjs.
1822
+ const remote = gitRemote();
1823
+ info("forge:");
1824
+ if (!remote) warn("no hay remoto 'origin' — `dai pr` no tiene dónde publicar la branch");
1825
+ else {
1826
+ const p = parseRemote(remote);
1827
+ ok(`remoto origin: ${remote}${p ? ` (${detectForge(p.host)})` : ""}`);
1828
+ let sshConfig = null;
1829
+ try { sshConfig = git(["config", "--get", "core.sshCommand"]) || null; } catch { /* sin valor: git sale 1 */ }
1830
+ const d = diagnoseGitSsh({
1831
+ platform: process.platform,
1832
+ remote,
1833
+ config: sshConfig,
1834
+ env: { GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND, GIT_SSH: process.env.GIT_SSH },
1835
+ hasWindowsOpenSsh: existsSync(WINDOWS_OPENSSH),
1836
+ });
1837
+ if (d.status === "ok") ok(`git usa el OpenSSH de Windows (ve al ssh-agent)`);
1838
+ if (d.status === "ok-por-env") {
1839
+ ok(`git usa el OpenSSH de Windows (ve al ssh-agent)`);
1840
+ warn(`pero viene de ${d.source}, no del config: se pierde al cerrar la terminal.`);
1841
+ process.stdout.write(` Para que quede: git config --global core.sshCommand "${WINDOWS_OPENSSH}"\n`);
1842
+ }
1843
+ if (d.status === "bundled" || d.status === "otro-ssh") {
1844
+ warn(d.status === "bundled"
1845
+ ? "git usa su propio ssh (Git for Windows), que NO ve al ssh-agent de Windows."
1846
+ : `git usa ${d.bin} (por ${d.source}), que probablemente no vea al ssh-agent de Windows.`);
1847
+ process.stdout.write(` Si tu clave tiene passphrase, cada push te la va a pedir — y si el prompt no llega,\n`);
1848
+ process.stdout.write(` ssh cae a password y falla con "Permission denied (publickey…)", que acusa a la clave.\n`);
1849
+ if (d.fix) process.stdout.write(` → git config --global core.sshCommand "${d.fix}"\n`);
1850
+ if (d.shadowed) process.stdout.write(` Ojo: ${d.source} está seteada y pisa tu core.sshCommand. Límpiala primero.\n`);
1851
+ }
1852
+ if (d.status === "bundled-sin-openssh") {
1853
+ warn("git usa su propio ssh y no encontré el OpenSSH de Windows: si el push pide passphrase, instálalo (Configuración → Características opcionales → Cliente OpenSSH).");
1854
+ }
1855
+ }
1856
+
1731
1857
  // ── version-drift del scaffold vs el CLI (ADR-0010) ──────────────────────────
1732
1858
  if (existsSync(join(process.cwd(), ".dai", "VERSION"))) { info("versión del scaffold:"); reportDrift(); }
1733
1859
  }
@@ -1796,6 +1922,10 @@ switch (cmd) {
1796
1922
  " 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" +
1797
1923
  " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1798
1924
  " [--us <ID>] [--title t] la US la resuelve la branch; si hay varias, pregunta (sin TTY, falla)\n" +
1925
+ " --description <texto> QUÉ resuelve la PR y por qué → sección 'Descripción' (o --description-file <f>)\n" +
1926
+ " --changes <texto> detalle de 'Cambios realizados' (default: los commits) (o --changes-file <f>)\n" +
1927
+ " sin descripción y sin commits, con --yes o sin TTY, dai NO publica: la PR\n" +
1928
+ " saldría con el molde del template y no se podría revisar\n" +
1799
1929
  " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
1800
1930
  " forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
1801
1931
  " --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
+ }
@@ -0,0 +1,107 @@
1
+ // dai · qué cliente SSH usa git, y si ese cliente llega al agente (Windows).
2
+ //
3
+ // En Windows conviven dos ssh.exe: el de OpenSSH for Windows (C:\Windows\System32\
4
+ // OpenSSH), que habla con el servicio `ssh-agent`, y el que trae Git for Windows
5
+ // (MSYS), que NO lo ve. Con una clave con passphrase el síntoma es desconcertante:
6
+ // `ssh -T` autentica (la firma la hace el agente) y `git push` pide la passphrase o,
7
+ // si nadie la contesta, cae a autenticación por password y muere con
8
+ // "Permission denied (publickey…)" — un error que apunta al lugar equivocado y se
9
+ // come una mañana buscando el problema en la clave, en el token o en el server.
10
+ //
11
+ // Núcleo puro: el I/O (git config, existsSync) vive en dai.mjs.
12
+
13
+ export const WINDOWS_OPENSSH = "C:/Windows/System32/OpenSSH/ssh.exe";
14
+
15
+ // ¿Por dónde habla el remoto? Decide qué consejo tiene sentido cuando el push falla.
16
+ export function remoteTransport(remote) {
17
+ const r = String(remote ?? "").trim();
18
+ if (!r) return null;
19
+ if (/^ssh:\/\//i.test(r)) return "ssh";
20
+ if (/^https?:\/\//i.test(r)) return "https";
21
+ if (/^[\w.-]+@[^:]+:/.test(r)) return "ssh"; // scp-like: git@host:org/repo.git
22
+ return null; // git://, file://, ruta local…
23
+ }
24
+
25
+ // El binario de un core.sshCommand / GIT_SSH_COMMAND, que puede traer argumentos y
26
+ // comillas: `"C:/Program Files/Git/usr/bin/ssh.exe" -v` → la ruta sola.
27
+ export function sshBinaryOf(cmd) {
28
+ const s = String(cmd ?? "").trim();
29
+ if (!s) return null;
30
+ if (s[0] === '"' || s[0] === "'") {
31
+ const end = s.indexOf(s[0], 1);
32
+ return end === -1 ? s.slice(1) : s.slice(1, end);
33
+ }
34
+ const sp = s.search(/\s/);
35
+ return sp === -1 ? s : s.slice(0, sp);
36
+ }
37
+
38
+ // ¿Esa ruta es el OpenSSH de Windows? Normalizado: mayúsculas y `\` vs `/` varían
39
+ // según quién haya escrito el config (git acepta las dos formas).
40
+ export function isWindowsOpenSsh(bin) {
41
+ const p = String(bin ?? "").replace(/\\/g, "/").toLowerCase();
42
+ return /(^|\/)system32\/openssh\/ssh(\.exe)?$/.test(p);
43
+ }
44
+
45
+ // Diagnóstico del cliente ssh que va a usar git. Entradas ya resueltas por el
46
+ // llamador; acá no se toca el disco ni se lanza un proceso.
47
+ // platform — process.platform
48
+ // remote — url de origin (o null)
49
+ // config — `git config --get core.sshCommand` (o null si no está)
50
+ // env — { GIT_SSH_COMMAND, GIT_SSH }
51
+ // hasWindowsOpenSsh — existsSync(WINDOWS_OPENSSH)
52
+ //
53
+ // status:
54
+ // n/a → no aplica (no es Windows, o el remoto no habla SSH)
55
+ // ok → apunta al OpenSSH de Windows por core.sshCommand
56
+ // ok-por-env → apunta bien, pero desde una env var: se pierde al cerrar la terminal
57
+ // otro-ssh → apunta a otro ssh (el de MSYS, plink…)
58
+ // bundled → nadie lo configuró: git usa el suyo, que no ve el agente
59
+ // bundled-sin-openssh → igual, pero no hay OpenSSH de Windows que recomendar
60
+ export function diagnoseGitSsh({ platform, remote, config, env = {}, hasWindowsOpenSsh = false } = {}) {
61
+ if (platform !== "win32") return { status: "n/a", reason: "no-windows" };
62
+ const transport = remoteTransport(remote);
63
+ if (transport !== "ssh") return { status: "n/a", reason: transport ? "remoto-https" : "sin-remoto" };
64
+
65
+ // Precedencia real de git: GIT_SSH_COMMAND > GIT_SSH > core.sshCommand.
66
+ const [source, raw] =
67
+ env.GIT_SSH_COMMAND ? ["GIT_SSH_COMMAND", env.GIT_SSH_COMMAND]
68
+ : env.GIT_SSH ? ["GIT_SSH", env.GIT_SSH]
69
+ : config ? ["core.sshCommand", config]
70
+ : ["default", null];
71
+
72
+ // Una env var pisando un core.sshCommand que ya estaba bien: el dev "arregló" el
73
+ // config, no funciona, y no hay forma de verlo mirando el .gitconfig.
74
+ const shadowed = source !== "default" && source !== "core.sshCommand" && Boolean(config);
75
+
76
+ if (source === "default") {
77
+ return hasWindowsOpenSsh
78
+ ? { status: "bundled", source, bin: null, fix: WINDOWS_OPENSSH, shadowed }
79
+ : { status: "bundled-sin-openssh", source, bin: null, fix: null, shadowed };
80
+ }
81
+
82
+ const bin = sshBinaryOf(raw);
83
+ if (isWindowsOpenSsh(bin)) {
84
+ return { status: source === "core.sshCommand" ? "ok" : "ok-por-env", source, bin, fix: null, shadowed };
85
+ }
86
+ return { status: "otro-ssh", source, bin, fix: hasWindowsOpenSsh ? WINDOWS_OPENSSH : null, shadowed };
87
+ }
88
+
89
+ // Qué decirle al dev cuando `git push` falla. El consejo de "pusheá a mano una vez"
90
+ // solo aplica a HTTPS, donde el credential manager pide la credencial la primera vez;
91
+ // contra un remoto SSH el push a mano falla EXACTAMENTE igual, así que mandarlo por ahí
92
+ // es hacerle perder el tiempo mientras el problema real (la clave, el agente) sigue ahí.
93
+ export function pushFailureHint(remote, branch) {
94
+ if (remoteTransport(remote) === "ssh") {
95
+ return [
96
+ `El remoto habla SSH: en el push no interviene el token de gh/glab, solo tu clave.`,
97
+ `Prueba a mano — así ves lo que ssh pregunta (p. ej. la passphrase de tu clave):`,
98
+ ` git push -u origin ${branch}`,
99
+ `Si te la pide en cada push, tu clave está en un agente que git no ve: dai doctor`,
100
+ ];
101
+ }
102
+ return [
103
+ `Si es la primera vez contra este remoto, autentica pusheando a mano una vez:`,
104
+ ` git push -u origin ${branch}`,
105
+ `y vuelve a ejecutar: dai pr`,
106
+ ];
107
+ }
package/cli/lib/pr.mjs CHANGED
@@ -4,48 +4,134 @@
4
4
 
5
5
  const EMOJI = { "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US" };
6
6
 
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.
7
+ // Las secciones que dai se compromete a entregar llenas. Si alguna sale con el molde
8
+ // del template, la PR se publica vacía y el review no tiene qué mirar (era el bug:
9
+ // "Descripción" con el comentario HTML y "Cambios realizados" con `Cambio 1/Cambio 2`).
10
+ // `dai pr` las verifica con bodyGaps() antes de publicar.
11
+ export const OWNED_SECTIONS = ["Descripción", "Cambios realizados"];
12
+
13
+ // ── Secciones ────────────────────────────────────────────────────────────────
14
+ // Normaliza un heading para compararlo: sin acentos, sin emoji ni puntuación, minúscula.
15
+ // Un repo puede traer su propio template ("## 📝 Descripción del cambio") y dai igual
16
+ // tiene que reconocer la sección: con el match exacto de antes no la encontraba y
17
+ // devolvía el body intacto EN SILENCIO, que es la forma más cara de fallar.
18
+ const norm = (s) => String(s).normalize("NFD").replace(/[\u0300-\u036f]/gu, "")
19
+ .replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
20
+
21
+ // Ubica una sección markdown (## Heading … hasta el próximo heading de igual o menor
22
+ // nivel). Ignora lo que esté dentro de un bloque de código: un `## ` en un fence es
23
+ // texto, no estructura. Devuelve null si no está.
24
+ function findSection(lines, heading) {
25
+ const want = norm(heading);
26
+ let start = -1, level = 0, fence = false;
27
+ for (let i = 0; i < lines.length; i++) {
28
+ if (/^\s*(```|~~~)/.test(lines[i])) { fence = !fence; continue; }
29
+ if (fence) continue;
30
+ const m = /^(#{2,6})[ \t]+(.+?)[ \t]*$/.exec(lines[i]);
31
+ if (!m) continue;
32
+ if (start === -1) { if (norm(m[2]).startsWith(want)) { start = i; level = m[1].length; } }
33
+ else if (m[1].length <= level) return { start, end: i, level };
34
+ }
35
+ return start === -1 ? null : { start, end: lines.length, level };
36
+ }
37
+
38
+ // El cuerpo crudo de una sección, o null si la sección no existe.
39
+ export function sectionBody(body, heading) {
40
+ const lines = body.split("\n");
41
+ const at = findSection(lines, heading);
42
+ return at ? lines.slice(at.start + 1, at.end).join("\n") : null;
43
+ }
44
+
45
+ // Reemplaza el cuerpo de una sección por content. Tolerante: si no encuentra la
46
+ // sección, devuelve el body igual (para eso está upsertSection).
10
47
  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}`);
48
+ const lines = body.split("\n");
49
+ const at = findSection(lines, heading);
50
+ if (!at) return body;
51
+ return [...lines.slice(0, at.start + 1), "", content, "", ...lines.slice(at.end)].join("\n");
52
+ }
53
+
54
+ // Como replaceSection, pero si la sección no está la agrega al final con su heading.
55
+ // Misma disciplina que el bloque de enlaces: dai siempre entrega el dato, nunca lo
56
+ // pierde porque el template del repo no tenía dónde ponerlo. content null = no sé
57
+ // nada → no toco (y bodyGaps lo va a reportar; dai no inventa contenido).
58
+ export function upsertSection(body, heading, content) {
59
+ if (content == null || !String(content).trim()) return body;
60
+ if (findSection(body.split("\n"), heading)) return replaceSection(body, heading, content);
61
+ return `${body.replace(/\s*$/, "")}\n\n## ${heading}\n\n${content}\n`;
62
+ }
63
+
64
+ // ── Detección del molde sin llenar ───────────────────────────────────────────
65
+ // Moldes conocidos del template: líneas que no dicen nada sobre ESTE cambio.
66
+ const MOCK_LINES = [/^-?\s*\[[ x]\]\s*cambio\s*\d+\s*$/i, /^cambio\s*\d+\s*$/i, /^-?\s*\[[ x]\]\s*$/];
67
+
68
+ // ¿El cuerpo de una sección es puro molde? Los comentarios HTML no cuentan como
69
+ // contenido: no se renderizan, así que en la PR publicada la sección se ve VACÍA
70
+ // (por eso el bug pasaba desapercibido hasta que alguien abría la PR).
71
+ export function isPlaceholder(text) {
72
+ const lines = String(text ?? "").replace(/<!--[\s\S]*?-->/g, "").split("\n").map((l) => l.trim()).filter(Boolean);
73
+ if (!lines.length) return true;
74
+ return lines.every((l) => MOCK_LINES.some((re) => re.test(l)));
75
+ }
76
+
77
+ // Qué le falta al body para ser revisable. Vacío = la PR se puede publicar.
78
+ // `dai pr` lo usa como gate: sin TTY (el camino del agente) falla en vez de publicar
79
+ // una PR que nadie puede revisar.
80
+ export function bodyGaps(body) {
81
+ const gaps = OWNED_SECTIONS.filter((h) => {
82
+ const s = sectionBody(body, h);
83
+ return s === null || isPlaceholder(s);
84
+ });
85
+ // Placeholders de la cabecera: la PR saldría diciendo `ABC-###` (issues #31/#33).
86
+ if (/ABC-###|`<hash>`|@ `vX`/.test(body)) gaps.push("🔗 Implementa");
87
+ return gaps;
88
+ }
89
+
90
+ // ── Composición ──────────────────────────────────────────────────────────────
91
+ // Descripción: lo que escribió quien crea la PR (agente o dev) gana; si no, se deriva
92
+ // de la US. Si dai no sabe nada, devuelve null: no inventa el propósito de un cambio.
93
+ export function descriptionFor(d) {
94
+ const explicit = String(d.description ?? "").trim();
95
+ if (explicit) return explicit;
96
+ if (d.usTitle) return `Implementa la US **${d.usTitle}** (\`${d.id}\`). Ver los criterios de aceptación en el tracker.`;
97
+ return null;
15
98
  }
16
99
 
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.
100
+ // Cambios realizados: el detalle explícito gana; si no, los commits de la branch
101
+ // (como `gh pr create --fill`). Sin ninguno de los dos, null.
102
+ export function changesFor(d) {
103
+ const explicit = String(d.changes ?? "").trim();
104
+ if (explicit) return explicit;
105
+ if (d.commits && d.commits.length) return d.commits.map((c) => `- [x] ${c}`).join("\n");
106
+ return null;
107
+ }
108
+
109
+ // Rellena el template del PR con los datos precargados. Tolerante con el resto del
110
+ // template (checklists, secciones propias del repo): dai suma, no borra.
19
111
  export function composePrBody(template, d) {
112
+ let b = d.id ? fillUsHeader(template, d) : fillNoUsHeader(template, d);
113
+ b = upsertSection(b, "Descripción", descriptionFor(d));
114
+ b = upsertSection(b, "Cambios realizados", changesFor(d));
115
+ return upsertLinksBlock(b, d);
116
+ }
117
+
118
+ function fillUsHeader(template, d) {
20
119
  let b = template;
21
- if (!d.id) return composePrBodyNoUs(b, d);
22
120
  b = b.replace(/`ABC-###`/g, `\`${d.id}\``);
23
121
  b = b.replace(/@ `vX`/g, `@ \`${d.version}\``);
24
122
  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
- }
33
- return upsertLinksBlock(b, d);
123
+ return b.replace(/verificado con `dai check` ✅/g, `verificado con \`dai check\`: ${EMOJI[d.status] || d.status}`);
34
124
  }
35
125
 
36
126
  // PR de una branch exenta (chore/, docs/, release/…): no implementa una US y no se le
37
127
  // exige link. Lo que NO puede pasar es que salga con la US de otro ni con el placeholder
38
128
  // `ABC-###` del template — las dos cosas pasaron en repos reales (issues #31, #33).
39
129
  // Se dice explícitamente que no hay US, y por qué.
40
- function composePrBodyNoUs(template, d) {
41
- let b = replaceSection(template, "🔗 Implementa",
130
+ function fillNoUsHeader(template, d) {
131
+ return replaceSection(template, "🔗 Implementa",
42
132
  `- **Sin US:** esta PR no implementa una User Story.\n` +
43
133
  (d.noUsReason ? `- **Motivo:** ${d.noUsReason}.\n` : "") +
44
134
  `- 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
135
  }
50
136
 
51
137
  // ── Bloque de enlaces ────────────────────────────────────────────────────────
@@ -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.0",
3
+ "version": "0.13.2",
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",
@@ -21,10 +21,19 @@
21
21
 
22
22
  ## Descripción
23
23
 
24
- <!-- Breve propósito de este PR, en términos de negocio (2–4 líneas). -->
24
+ <!--
25
+ Breve propósito de este PR, en términos de negocio (2–4 líneas).
26
+ Lo precarga `dai pr --description "…"` (o `--description-file <archivo.md>`).
27
+ dai NO lo inventa: si esta sección queda sin llenar, `dai pr` no publica la PR.
28
+ -->
25
29
 
26
30
  ## Cambios realizados
27
31
 
32
+ <!--
33
+ Lo precarga `dai pr` con los commits de la branch; `--changes` / `--changes-file`
34
+ lo reemplazan por el detalle que quieras contar.
35
+ -->
36
+
28
37
  - [ ] Cambio 1
29
38
  - [ ] Cambio 2
30
39