@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 +114 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/cli/dai.mjs +138 -8
- package/cli/lib/bootstrap.mjs +2 -1
- package/cli/lib/docs-links.mjs +23 -0
- package/cli/lib/git-ssh.mjs +107 -0
- package/cli/lib/pr.mjs +111 -25
- package/docs/EJEMPLO-END-TO-END.md +3 -1
- package/docs/guias/dev.md +5 -2
- package/package.json +2 -1
- package/templates/pull-request.md +10 -1
- package/docs/public/tutoriales/clickup-1-settings.png +0 -0
- package/docs/public/tutoriales/clickup-2-api.png +0 -0
- package/docs/public/tutoriales/clickup-3-generate-copy.png +0 -0
- package/docs/public/tutoriales/funcional-1-skills-usuario.png +0 -0
- package/docs/public/tutoriales/funcional-2-copilot-signin.png +0 -0
- package/docs/public/tutoriales/funcional-3-carpeta-configurada.png +0 -0
- package/docs/public/tutoriales/funcional-4-doctor.png +0 -0
- package/docs/public/tutoriales/funcional-5-publish-parent.png +0 -0
- package/docs/public/tutoriales/jira-1-avatar.png +0 -0
- package/docs/public/tutoriales/jira-2-seguridad-tokens.png +0 -0
- package/docs/public/tutoriales/jira-3-crear-token.png +0 -0
- package/docs/public/tutoriales/jira-4-nombre-vencimiento.png +0 -0
- package/docs/public/tutoriales/jira-5-copiar.png +0 -0
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 (``, 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>
|
|
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
|
+
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", `${
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
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" +
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -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: ``, 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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
14
|
-
return
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
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**:
|
|
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.
|
|
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
|
-
<!--
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|