@dforce2055/dai 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/{.env.example → .env.dai.example} +3 -3
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +36 -25
  4. package/VERSION +1 -1
  5. package/cli/dai.mjs +97 -41
  6. package/cli/lib/bootstrap.mjs +39 -7
  7. package/cli/lib/env.mjs +12 -0
  8. package/cli/lib/pm-clickup.mjs +2 -2
  9. package/cli/lib/pm-jira.mjs +2 -2
  10. package/cli/lib/skills-source.mjs +8 -0
  11. package/docs/EJEMPLO-END-TO-END.md +52 -43
  12. package/docs/MANIFIESTO.md +2 -2
  13. package/docs/METODOLOGIA.md +20 -15
  14. package/docs/PROBAR.md +13 -14
  15. package/docs/SCRUM-CON-IA.md +10 -10
  16. package/docs/adr/0003-deteccion-y-estampado-son-comandos.md +1 -1
  17. package/docs/adr/0006-distribucion-y-licencia.md +1 -1
  18. package/docs/adr/0013-skills-externas-install-from.md +11 -4
  19. package/docs/adr/0015-jira-corporativo.md +1 -1
  20. package/docs/adr/0017-env-dai.md +64 -0
  21. package/docs/adr/README.md +1 -0
  22. package/docs/detalle/01-refinamiento.md +6 -5
  23. package/docs/detalle/03-ramas.md +2 -2
  24. package/docs/detalle/04-tdd.md +15 -9
  25. package/docs/detalle/06-code-review.md +8 -5
  26. package/docs/detalle/08-daily.md +1 -1
  27. package/docs/detalle/README.md +1 -1
  28. package/docs/glosario.md +2 -2
  29. package/docs/guias/dev.md +10 -7
  30. package/docs/guias/index.md +12 -0
  31. package/docs/guias/lead.md +1 -1
  32. package/docs/guias/po.md +13 -7
  33. package/docs/index.md +35 -0
  34. package/docs/public/favicon.svg +12 -0
  35. package/docs/public/logo-link.svg +12 -0
  36. package/docs/public/logo.svg +12 -0
  37. package/docs/public/tutoriales/clickup-1-settings.png +0 -0
  38. package/docs/public/tutoriales/clickup-2-api.png +0 -0
  39. package/docs/public/tutoriales/clickup-3-generate-copy.png +0 -0
  40. package/docs/public/tutoriales/jira-1-avatar.png +0 -0
  41. package/docs/public/tutoriales/jira-2-seguridad-tokens.png +0 -0
  42. package/docs/public/tutoriales/jira-3-crear-token.png +0 -0
  43. package/docs/public/tutoriales/jira-4-nombre-vencimiento.png +0 -0
  44. package/docs/public/tutoriales/jira-5-copiar.png +0 -0
  45. package/docs/tutoriales/claves-ssh.md +93 -0
  46. package/docs/tutoriales/configurar-git.md +53 -0
  47. package/docs/tutoriales/index.md +18 -0
  48. package/docs/tutoriales/instalar-glab.md +74 -0
  49. package/docs/tutoriales/token-clickup.md +73 -0
  50. package/docs/tutoriales/token-jira.md +74 -0
  51. package/package.json +9 -3
@@ -1,6 +1,6 @@
1
- # dai · configuración del adaptador de PM (copiar a .env y completar).
2
- # El .env está gitignoreado para no filtrar secretos. Este .example se versiona.
3
- # Las variables ya exportadas en la shell/CI ganan sobre el .env.
1
+ # dai · configuración del adaptador de PM (copiar a .env.dai y completar).
2
+ # El .env.dai NO se versiona (evita filtrar secretos) y el .env del equipo no se toca
3
+ # (ADR-0017). Este .example se versiona. Precedencia: shell/CI > .env.dai > .env.
4
4
 
5
5
  # ── Backend del tracker ──────────────────────────────────────────────────────
6
6
  # md | jira | clickup
package/CHANGELOG.md CHANGED
@@ -3,6 +3,46 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.10.0] — 2026-07-18
7
+
8
+ **La config de dai deja de vivir en el `.env` del equipo y pasa a un `.env.dai` propio (no
9
+ versionado). Resuelve el caso de las empresas que versionan el `.env` como política: dai no
10
+ toca ese archivo y guarda sus secretos donde git realmente los ignora. Y `dai init` estrena
11
+ una bienvenida con el Sol de Mayo en bloques.**
12
+
13
+ ### Agregado
14
+ - **Banner de bienvenida en `dai init`**: el Sol de Mayo de dai en bloques (cuerpo y rayos
15
+ rectos en oro, rayos ondulados en celeste) junto al título, más un preview de lo que se va
16
+ a configurar. Cero dependencias (solo ANSI); degrada a ASCII sin color en no-TTY o con
17
+ `NO_COLOR`.
18
+ - **`dai skills install --from npm:@scope/pkg`** — nueva fuente para skills externas
19
+ ([ADR-0013](docs/adr/0013-skills-externas-install-from.md)): además de git URL y path
20
+ local, ahora un **paquete npm**. dai hace `npm install` a un temp (respetando el `.npmrc`
21
+ del repo, así resuelve **registries privados con scope**; `npm pack` no sirve con los
22
+ registries de grupo de GitLab). Es común distribuir skills como paquete npm; antes había
23
+ que materializarlo a mano.
24
+ - **Descripciones en bloque YAML (`|` / `>`) en el frontmatter de las skills.** El parser
25
+ de frontmatter ahora lee bloques literales/plegados multilínea (antes tomaba solo la
26
+ primera línea `|` y el validador lo rechazaba). Es como se escriben las skills reales:
27
+ descripciones ricas con "USAR CUANDO / NO USAR CUANDO" que el agente usa para elegirlas.
28
+
29
+ ### Cambiado
30
+ - **`dai init` escribe en `.env.dai` + `.env.dai.example`, no en `.env`/`.env.example`**
31
+ ([ADR-0017](docs/adr/0017-env-dai.md)). El `.env` del equipo queda intacto (dai solo lo
32
+ lee). `.env.dai` (secretos) se gitignorea; `.env.dai.example` (plantilla) se versiona.
33
+ En un repo sin `.env`, dai ya no crea uno: es del equipo, no de dai.
34
+ - **El loader lee `.env.dai` y `.env`** con precedencia **shell/CI > `.env.dai` > `.env`**.
35
+ Seguir leyendo `.env` mantiene la compatibilidad: los repos que ya tenían los `DAI_*` ahí
36
+ no se rompen.
37
+ - **`.gitignore`**: dai ignora `.env.dai` (su archivo), no `.env`. Ya no fuerza un ignore
38
+ sobre un archivo que muchas orgs versionan a propósito.
39
+ - Se renombró el `.env.example` del paquete a `.env.dai.example`, y se actualizaron doctor,
40
+ mensajes de init/sync, tutoriales, constitución y README a la nueva convención.
41
+
42
+ ### Interno
43
+ - **228 tests** (+4 desde 0.9.0): precedencia de `loadDaiEnv` (shell > `.env.dai` > `.env`),
44
+ compat con `.env`, y sin-archivos. Smokes de `dai init` con y sin `.env` preexistente.
45
+
6
46
  ## [0.9.0] — 2026-07-17
7
47
 
8
48
  **El review de dai deja de ser un comentario al final del hilo y pasa a ser un review
@@ -405,6 +445,7 @@ ClickUp y Jira Cloud.
405
445
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
406
446
  `files` de npm sin tests ni secretos.
407
447
 
448
+ [0.10.0]: https://github.com/dforce2055/dai/releases/tag/v0.10.0
408
449
  [0.9.0]: https://github.com/dforce2055/dai/releases/tag/v0.9.0
409
450
  [0.8.2]: https://github.com/dforce2055/dai/releases/tag/v0.8.2
410
451
  [0.8.1]: https://github.com/dforce2055/dai/releases/tag/v0.8.1
package/README.md CHANGED
@@ -63,10 +63,10 @@ Y después, en el chat del asistente, según lo que tengas:
63
63
 
64
64
  > Las skills **te interrogan** hasta que la historia es testeable (nunca inventan
65
65
  > requerimientos: los sacan a preguntas), y la **publican en el tracker** que configuraste
66
- > (`DAI_PM` en el `.env`): por el **MCP** de Jira/ClickUp si está conectado, o con
66
+ > (`DAI_PM` en el `.env.dai`): por el **MCP** de Jira/ClickUp si está conectado, o con
67
67
  > **`dai publish <us.md>`** si no (crea el issue vía token). Tú respondes y decides.
68
68
 
69
- ¿Sin MCP, o querés publicar a mano? `dai publish` sube el `.md` al tracker y te devuelve el key:
69
+ ¿Sin MCP, o quieres publicar a mano? `dai publish` sube el `.md` al tracker y te devuelve el key:
70
70
 
71
71
  ```bash
72
72
  dai publish historia.md # crea la US → devuelve el key
@@ -87,7 +87,7 @@ Una vez, preparas el repo:
87
87
  ```bash
88
88
  npm i -g @dforce2055/dai
89
89
  cd mi-repo && dai init # bootstrap: skills + tracker + OpenSpec + PR template
90
- # completa el token del tracker en .env (o DAI_PM=md para probar sin credenciales)
90
+ # completa el token del tracker en .env.dai (o DAI_PM=md para probar sin credenciales)
91
91
  dai doctor # verifica que todo quedó en su lugar
92
92
  ```
93
93
 
@@ -112,12 +112,12 @@ dai pr --assignee <compañero> # crea la PR precargada y se la asigna a un com
112
112
  dai stamp # al mergear: estampa la cobertura en el tracker
113
113
  ```
114
114
 
115
- > **Para que `dai pr` cree la PR/MR** necesitás el CLI del forge instalado y autenticado, una
115
+ > **Para que `dai pr` cree la PR/MR** necesitas el CLI del forge instalado y autenticado, una
116
116
  > sola vez por máquina:
117
117
  > - **GitHub** → [`gh`](https://cli.github.com) · `gh auth login`
118
118
  > - **GitLab** → [`glab`](https://gitlab.com/gitlab-org/cli) · `glab auth login --hostname <tu-gitlab>`
119
119
  > — en un GitLab self-hosted/corporativo el `--hostname` es **obligatorio**, y el token va con
120
- > scope `api`. (En Windows, tras instalar `glab` con `winget`, abrí una terminal nueva para que
120
+ > scope `api`. (En Windows, tras instalar `glab` con `winget`, abre una terminal nueva para que
121
121
  > tome el PATH.)
122
122
  >
123
123
  > Sin el CLI del forge, `dai pr` **igual pushea tu branch** y te dice qué instalar — después
@@ -169,10 +169,10 @@ flowchart TD
169
169
  | 14 | **Cerrar la US** | `dai done` → vuelve a la base, actualiza y borra la branch local (si está mergeada) | dev |
170
170
 
171
171
  > **Paso 3b (publicar):** el MCP crea el issue interactivamente; `dai publish` necesita el
172
- > token del tracker en `.env` (Jira además `DAI_JIRA_PROJECT`, ClickUp `DAI_CLICKUP_LIST_ID`).
172
+ > token del tracker en `.env.dai` (Jira además `DAI_JIRA_PROJECT`, ClickUp `DAI_CLICKUP_LIST_ID`).
173
173
  >
174
174
  > **Paso 7 (OpenSpec):** `dai init` te ofrece instalarlo **e inicializarlo** solo. Si los
175
- > comandos `/opsx:*` no aparecen en el asistente, reinicia el IDE (se cargan al arrancar).
175
+ > comandos `/opsx:*` no aparecen en el asistente, reinicia el IDE (se cargan al iniciar).
176
176
  >
177
177
  > **Paso 11 (PR):** necesita un remoto git (`origin`) y `gh`/`glab` autenticado. Sin remoto,
178
178
  > dai no crea la PR (te deja el texto listo igual).
@@ -183,8 +183,8 @@ flowchart TD
183
183
  |---|---|
184
184
  | `dai init [<repo>]` | scaffolder interactivo del repo. Flags: `--for claude\|copilot\|both\|cursor\|all` (asistente, default `all`) · `--pm md\|jira\|clickup` (tracker) · `--openspec` |
185
185
  | `dai skills install [--global \| --local <repo>] [--force] [--dry-run] [--for claude\|copilot\|cursor\|all]` | instala/actualiza las skills de dai en Claude, Copilot y/o Cursor (`--for all` por defecto; Copilot global → `~/.copilot/skills`, local → `.github/skills/`). `--force` re-copia. Alias: **`dai install`**. Ej: `dai skills install --local . --for cursor --force` |
186
- | `dai skills install --from <git-url\|path>[#ref] [--for …]` | instala **skills externas** (por-stack: .NET, Java, …) desde un repo/dir, **convertidas para los 3 asistentes**. Self-service, one-off, sin registro; `dai sync` no las toca. Colisión con una skill de dai → salta ([ADR-0013](docs/adr/0013-skills-externas-install-from.md)). Ej: `dai skills install --from github.com/mi-org/net-skills` |
187
- | `dai sync [--dry-run] [--for <asistentes>]` | **refresca** skills, constitución, templates y PR template a la versión del CLI — **aditivo** (no pisa tu `CLAUDE.md`), no toca el `.env` ni OpenSpec. Detecta los asistentes del repo o pasás `--for`. `--dry-run` muestra qué cambiaría ([ADR-0010](docs/adr/0010-versionado-y-upgrade.md)) |
186
+ | `dai skills install --from <git-url\|npm:pkg\|path>[#ref] [--for …]` | instala **skills externas** (por-stack: .NET, Java, …) desde un repo/dir/**paquete npm**, **convertidas para los 3 asistentes**. Self-service, one-off, sin registro; `dai sync` no las toca. Colisión con una skill de dai → salta ([ADR-0013](docs/adr/0013-skills-externas-install-from.md)). Ej: `dai skills install --from github.com/mi-org/net-skills` · `dai skills install --from npm:@mi-org/ui-skills` (usa el `.npmrc` del repo → registries privados OK) |
187
+ | `dai sync [--dry-run] [--for <asistentes>]` | **refresca** skills, constitución, templates y PR template a la versión del CLI — **aditivo** (no pisa tu `CLAUDE.md`), no toca el `.env.dai` ni OpenSpec. Detecta los asistentes del repo o pasás `--for`. `--dry-run` muestra qué cambiaría ([ADR-0010](docs/adr/0010-versionado-y-upgrade.md)) |
188
188
  | `dai upgrade [--check] [--dry-run]` · alias `dai update` | **actualiza el CLI global** a la última publicada (`npm i -g …@latest`) — self-update. **No toca el repo**: reporta el drift del scaffold pero deja el `dai sync` al mantenedor. `--check` solo informa · `--dry-run` muestra el comando ([ADR-0012](docs/adr/0012-upgrade-self-update-del-cli.md)) |
189
189
  | `dai publish <us.md> [--parent KEY] [--issuetype T] [--field alias=valor]` | crea la US en el tracker (Jira/ClickUp/md) desde un `.md` y devuelve el key — el fallback del MCP para publicar sin el asistente. `--parent PROJ-42` la cuelga de su épica · `--issuetype Epic` publica una épica (fallback CLI de `grill-epic`) · `--field clasificacion=Mejora` (repetible) manda los campos propios que exige tu Jira, declarados en `.dai/jira-fields.json` ([ADR-0015](docs/adr/0015-jira-corporativo.md)) |
190
190
  | `dai link-us <ID> [--us <md>]` | crea branch + `implements.yaml`; sin `--us` trae la US del tracker |
@@ -203,20 +203,22 @@ flowchart TD
203
203
  > **🆕 Mantené tu repo al día — `dai sync`.** Las skills, la constitución y los templates son un
204
204
  > *caché derivable* del CLI. Cuando actualizás `dai` (`dai upgrade`), **`dai doctor` y
205
205
  > `dai version` te avisan solos** si tu scaffold quedó atrás — con color y un `⬆️` —, y **`dai sync`**
206
- > lo refresca: **aditivo** (conserva tu `CLAUDE.md` propio), sin tocar el `.env` ni OpenSpec. Probá sin
206
+ > lo refresca: **aditivo** (conserva tu `CLAUDE.md` propio), sin tocar el `.env.dai` ni OpenSpec. Prueba sin
207
207
  > riesgo con `dai sync --dry-run`. El versionado es semver: patch/minor no rompen nada; solo un major
208
208
  > pediría migración. ([ADR-0010](docs/adr/0010-versionado-y-upgrade.md))
209
209
 
210
210
  > **🧩 Skills de cualquier stack — `dai skills install --from`.** Además de las skills de
211
- > dai, cada equipo suma las suyas (por-stack: .NET, Java, Rust…) desde su propio repo:
212
- > `dai skills install --from github.com/tu-org/net-skills`. dai las **convierte para los 3
213
- > asistentes** (Claude/Cursor/Copilot) e instala. **dai es el distribuidor de skills de
214
- > cualquier stack, sin opinar sobre su contenido** self-service, sin registro; `dai sync`
215
- > sigue siendo solo de dai. ([ADR-0013](docs/adr/0013-skills-externas-install-from.md))
211
+ > dai, cada equipo suma las suyas (por-stack: .NET, Java, Rust…) desde su propio repo o
212
+ > **paquete npm**: `dai skills install --from github.com/tu-org/net-skills` · `dai skills
213
+ > install --from npm:@tu-org/ui-skills` (el paquete npm usa el `.npmrc` del repo, así
214
+ > resuelve registries privados con scope). dai las **convierte para los 3 asistentes**
215
+ > (Claude/Cursor/Copilot) e instala. **dai es el distribuidor de skills de cualquier stack,
216
+ > sin opinar sobre su contenido** — self-service, sin registro; `dai sync` sigue siendo solo
217
+ > de dai. ([ADR-0013](docs/adr/0013-skills-externas-install-from.md))
216
218
  >
217
219
  > La fuente puede ser **pública, privada (por SSH) o un path local**: dai no hace auth
218
- > propia, delega en git — **si podés `git clone` el repo, dai instala desde ahí**. Para
219
- > privados usá la forma SSH (`git@github.com:tu-org/net-skills.git`), consistente con el
220
+ > propia, delega en git — **si puedes `git clone` el repo, dai instala desde ahí**. Para
221
+ > privados usa la forma SSH (`git@github.com:tu-org/net-skills.git`), consistente con el
220
222
  > modelo de auth de dai ([ADR-0007](docs/adr/0007-modelo-de-autenticacion.md)). Público =
221
223
  > cero fricción entre equipos/máquinas.
222
224
  >
@@ -229,8 +231,9 @@ flowchart TD
229
231
  > en [`skills/`](skills/).
230
232
 
231
233
  Skills (se invocan en el asistente): `/doc-to-backlog` · `/grill-intent` · `/grill-epic` · `/grill-user-story` · `/link-us` ·
232
- `/tdd` · `/dai-review`. Config del tracker (`md`\|`jira`\|`clickup`) y tokens: en `.env`
233
- ver [`.env.example`](.env.example). Auth (SSH + tokens): [ADR-0007](docs/adr/0007-modelo-de-autenticacion.md).
234
+ `/tdd` · `/dai-review`. Config del tracker (`md`\|`jira`\|`clickup`) y tokens: en `.env.dai`
235
+ (no versionado; el `.env` del equipo no se toca — [ADR-0017](docs/adr/0017-env-dai.md)) —
236
+ ver [`.env.dai.example`](.env.dai.example). Auth (SSH + tokens): [ADR-0007](docs/adr/0007-modelo-de-autenticacion.md).
234
237
 
235
238
  ## El flag `--for` — ¿para qué asistente preparo el repo?
236
239
 
@@ -262,7 +265,7 @@ Después de `dai init` (con `--for all`):
262
265
  ```
263
266
  mi-repo/
264
267
  ├── CLAUDE.md · Constitución del proyecto (auto-cargada por Claude)
265
- ├── .env · configurado con tu tracker (gitignored, completa el token)
268
+ ├── .env.dai · tu tracker (NO versionado, completa el token) + .env.dai.example (plantilla, sí versionada)
266
269
  ├── .claude/skills/ · Las skills, locales al repo (el equipo las hereda)
267
270
  │ └── doc-to-backlog · grill-intent · grill-epic · grill-user-story · link-us · tdd · dai-review
268
271
  ├── .github/
@@ -334,7 +337,7 @@ dai/
334
337
  ├── skills/ 🤖 doc-to-backlog · grill-intent · grill-epic · grill-user-story · link-us · tdd · dai-review
335
338
  ├── governance/ 🛡️ branch-naming · ci-rules · commit-convention
336
339
  ├── index.html 📊 landing autocontenido (la historia); publicable por GitHub Pages
337
- └── manifest.yaml · VERSION · install.sh (shim) · .env.example
340
+ └── manifest.yaml · VERSION · install.sh (shim) · .env.dai.example
338
341
  ```
339
342
 
340
343
  ## Licencia
@@ -351,9 +354,17 @@ Detalle en [ADR-0006](docs/adr/0006-distribucion-y-licencia.md).
351
354
 
352
355
  ## Agradecimientos
353
356
 
354
- dai se apoya en ideas y aprendizajes de la comunidad. En particular, gracias a
355
- **[Matt Pocock](https://github.com/mattpocock)** por su aporte con las *skills* y por su
356
- [canal de YouTube](https://www.youtube.com/@mattpocockuk) explicándolas — su trabajo nos ayudó
357
- a construir esta herramienta.
357
+ dai se apoya en ideas y herramientas de la comunidad.
358
+
359
+ Un agradecimiento especial a **[OpenSpec](https://github.com/Fission-AI/OpenSpec)** (de
360
+ [Fission AI](https://github.com/Fission-AI), `@fission-ai/openspec`): es la herramienta
361
+ sobre la que se construye todo el **CÓMO** de dai — explorar la solución, proponer el diseño
362
+ y las tareas, e implementar el change (`opsx:explore → propose → apply → archive`). dai no
363
+ la bundlea ni la reemplaza; se apoya en ella para convertir el QUÉ en un plan técnico
364
+ trazable. Sin OpenSpec, la mitad del método no existiría.
365
+
366
+ Y gracias a **[Matt Pocock](https://github.com/mattpocock)** por su aporte con las *skills*
367
+ y por su [canal de YouTube](https://www.youtube.com/@mattpocockuk) explicándolas — su
368
+ trabajo nos ayudó a construir esta herramienta.
358
369
 
359
370
  Seguridad: [`SECURITY.md`](SECURITY.md) · Conducta: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.0
1
+ 0.10.0
package/cli/dai.mjs CHANGED
@@ -21,7 +21,7 @@ import { createInterface } from "node:readline/promises";
21
21
  import { acHash } from "./lib/ac-hash.mjs";
22
22
  import { discoverImplements, isPlaceholderId } from "./lib/implements.mjs";
23
23
  import { isValidKey, slugify, branchName, extractTitle, renderImplementsYaml } from "./lib/link-us.mjs";
24
- import { loadEnv } from "./lib/env.mjs";
24
+ import { loadDaiEnv } from "./lib/env.mjs";
25
25
  import { getAdapter, coverageStatus, statusLabel } from "./lib/pm-adapter.mjs";
26
26
  import { branchUrl, commitUrl, parseRemote, detectForge } from "./lib/forge-url.mjs";
27
27
  import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs";
@@ -48,8 +48,33 @@ const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
48
48
  // Color ANSI mínimo — solo si es TTY y no está NO_COLOR (así no ensucia pipes/CI).
49
49
  const _color = process.stdout.isTTY && !process.env.NO_COLOR;
50
50
  const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
51
- const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m) };
51
+ const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m), dim: (m) => paint("2", m) };
52
52
  const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
53
+
54
+ // Banner de bienvenida de `dai init`: el Sol de Mayo en bloques (cuerpo y rayos rectos en
55
+ // oro; rayos ondulados en celeste) al lado del título, más el preview de lo que se configura.
56
+ // Todo con caracteres — cero-dep. Degrada a ASCII sin color si no hay TTY o con NO_COLOR.
57
+ const initBanner = () => {
58
+ const sun = [
59
+ " █ ",
60
+ " ▒ ▄███▄ ▒ ",
61
+ "██ █████ ██",
62
+ " ▒ ▀███▀ ▒ ",
63
+ " █ ",
64
+ ];
65
+ const paintSun = (line) => [...line].map((ch) => (ch === "▒" ? C.cy(ch) : ch === " " ? " " : C.y(ch))).join("");
66
+ const aside = ["", paint("1;33", "dai") + " · Desarrollo Asistido por IA", C.dim("La IA asiste; la persona firma."), "", ""];
67
+ let out = "\n";
68
+ for (let i = 0; i < sun.length; i++) out += " " + paintSun(sun[i]) + (aside[i] ? " " + aside[i] : "") + "\n";
69
+ out += "\n " + C.b("Esto va a configurar el repo:") + "\n";
70
+ for (const b of [
71
+ "Skills del método (grill · link-us · tdd · dai-review) en tu asistente",
72
+ "La constitución del proyecto — las reglas del trabajo",
73
+ "OpenSpec para el CÓMO (design / tasks) — opcional",
74
+ "Plantilla de PR + .env.dai para el tracker",
75
+ ]) out += " " + C.cy("▸") + " " + b + "\n";
76
+ return out;
77
+ };
53
78
  const CLAUDE_SKILLS_DIR = process.env.CLAUDE_SKILLS_DIR || join(homedir(), ".claude", "skills");
54
79
  const CURSOR_SKILLS_DIR = process.env.CURSOR_SKILLS_DIR || join(homedir(), ".cursor", "skills");
55
80
  // Copilot lee las skills personales de ~/.copilot/skills (NO de ~/.claude/skills, que
@@ -119,10 +144,10 @@ async function cmdLinkUs(key, opts) {
119
144
  title = opts.title || extractTitle(md);
120
145
  } else {
121
146
  // Fuente tracker: traer la US del adaptador (mismo hash que usará `dai check`).
122
- loadEnv();
147
+ loadDaiEnv();
123
148
  const adapter = getAdapter(process.env);
124
149
  const us = await adapter.fetchUS(key);
125
- if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.`, 2);
150
+ if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.dai.`, 2);
126
151
  hash = us.ac_hash;
127
152
  if (hash == null) fail(`la US ${key} no tiene una sección 'Criterios de aceptación' con criterios testeables → sin ac_hash, no se puede linkear.\n Agregá la sección en el tracker, o corré /grill-user-story ${key} para pulir la US (te interroga y la re-publica).`, 2);
128
153
  title = opts.title || us.title;
@@ -180,7 +205,7 @@ function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return
180
205
 
181
206
  // ── check ──────────────────────────────────────────────────────────────────
182
207
  async function cmdCheck() {
183
- loadEnv();
208
+ loadDaiEnv();
184
209
  const adapter = getAdapter(process.env);
185
210
  const found = discoverImplements(process.cwd(), { includeArchived: false });
186
211
  let worst = 0, n = 0;
@@ -211,7 +236,7 @@ async function cmdCheck() {
211
236
 
212
237
  // ── stamp ──────────────────────────────────────────────────────────────────
213
238
  async function cmdStamp() {
214
- loadEnv();
239
+ loadDaiEnv();
215
240
  const adapter = getAdapter(process.env);
216
241
  const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
217
242
  const found = discoverImplements(process.cwd());
@@ -233,7 +258,7 @@ async function cmdStamp() {
233
258
 
234
259
  // ── forge (review) ───────────────────────────────────────────────────────────
235
260
  async function cmdForge(sub, ref, opts) {
236
- loadEnv();
261
+ loadDaiEnv();
237
262
  const pr = parsePrRef(ref, gitRemote());
238
263
  if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
239
264
  if (sub === "pr") {
@@ -332,7 +357,7 @@ function loadJiraFieldsSpec() {
332
357
 
333
358
  async function cmdPublish(file, opts = {}) {
334
359
  if (!file) fail("uso: dai publish <archivo-us.md> [--parent KEY] [--issuetype T] [--field alias=valor]", 1);
335
- loadEnv();
360
+ loadDaiEnv();
336
361
  const md = readFileSync(file, "utf8");
337
362
  const title = extractTitle(md);
338
363
  if (!title) fail("no pude extraer el título de la US (falta un '# Título').", 1);
@@ -409,7 +434,7 @@ function cmdDone(opts) {
409
434
  // ── pr: crea TU PROPIA PR/MR precargada desde el template + el link ────────────
410
435
  // (Distinto de dai-review, que revisa la PR de OTRO. Tu PR la creas y revisas tú.)
411
436
  async function cmdPr(opts) {
412
- loadEnv();
437
+ loadDaiEnv();
413
438
  const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
414
439
  if (!remote) fail("no hay remoto git 'origin'. Configúralo para crear la PR.", 1);
415
440
  if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
@@ -447,7 +472,7 @@ async function cmdPr(opts) {
447
472
  for (const f of found) for (const im of f.implements || []) {
448
473
  if (!isPlaceholderId(im.id)) { entry = { f, im }; break; }
449
474
  }
450
- if (!entry) fail("no encontré un implements.yaml con una US real. Ejecuta `dai link-us` primero.", 1);
475
+ if (!entry) fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
451
476
  const { id, version, ac_hash } = entry.im;
452
477
 
453
478
  // 2. Estado de trazabilidad (dai check) contra la US viva.
@@ -470,7 +495,7 @@ async function cmdPr(opts) {
470
495
  const usUrl = usUrlFor(id, live?.url);
471
496
  if (!usUrl) {
472
497
  warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
473
- warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env (p. ej. https://tu-tracker/browse/{id}).`);
498
+ warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
474
499
  }
475
500
  const body = composePrBody(readFileSync(tplPath, "utf8"), {
476
501
  id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
@@ -632,7 +657,7 @@ async function cmdInstall(opts) {
632
657
  // dai (colisión → warn + skip). `dai sync` NO las toca: es solo de dai.
633
658
  function cmdInstallFrom(opts) {
634
659
  if (typeof opts.from !== "string" || !opts.from.trim())
635
- fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]) o un path local", 2);
660
+ fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]), un paquete npm (npm:@scope/pkg) o un path local", 2);
636
661
  let want;
637
662
  try { want = parseAssistants(typeof opts.for === "string" ? opts.for : "all"); }
638
663
  catch (e) { fail(`--for ${e.message}`); }
@@ -651,6 +676,33 @@ function cmdInstallFrom(opts) {
651
676
  try { git(args); }
652
677
  catch (e) { rmSync(tmp, { recursive: true, force: true }); fail(`no pude clonar la fuente: ${String(e.message).split("\n")[0]}`, 1); }
653
678
  root = tmp;
679
+ } else if (src.type === "npm") {
680
+ tmp = mkdtempSync(join(tmpdir(), "dai-skills-"));
681
+ info(`bajando el paquete npm ${src.location} …`);
682
+ try {
683
+ // `npm install` (no `npm pack`) en el temp. Dos decisiones a propósito:
684
+ // 1) install en vez de pack: los registries de GRUPO de GitLab devuelven una
685
+ // `dist.tarball` malformada (el scope duplicado en el nombre) que `npm pack` sigue
686
+ // literal y da 404; `npm install` —como `npx`— reconstruye la URL y resuelve.
687
+ // 2) copiamos el `.npmrc` del repo al temp y corremos con cwd=temp: así npm ve el
688
+ // registry/scope privado (con `--prefix` npm NO lee el `.npmrc` del cwd y se va al
689
+ // registry público). `--ignore-scripts`: no corremos scripts de un paquete de 3ros.
690
+ const repoNpmrc = join(process.cwd(), ".npmrc");
691
+ if (existsSync(repoNpmrc)) cpSync(repoNpmrc, join(tmp, ".npmrc"));
692
+ runNpmTool("npm", ["install", src.location,
693
+ "--no-save", "--no-package-lock", "--ignore-scripts", "--no-audit", "--no-fund"],
694
+ { cwd: tmp, stdio: ["ignore", "ignore", "pipe"] });
695
+ // El paquete queda en <tmp>/node_modules/<name>. name = spec sin la versión
696
+ // (@scope/pkg@1.2.3 → @scope/pkg; el `@` inicial del scope no cuenta).
697
+ let name = src.location; const at = name.lastIndexOf("@");
698
+ if (at > 0) name = name.slice(0, at);
699
+ root = join(tmp, "node_modules", name);
700
+ if (!existsSync(root)) throw new Error(`npm install no dejó '${name}' en node_modules`);
701
+ } catch (e) {
702
+ rmSync(tmp, { recursive: true, force: true });
703
+ const detail = String(e.stderr || e.stdout || e.message || "").split("\n").map((s) => s.trim()).filter(Boolean).slice(0, 3).join("\n ");
704
+ fail(`no pude bajar el paquete npm '${src.location}':\n ${detail || "npm install falló — revisá el spec, el registry y el .npmrc del repo"}`, 1);
705
+ }
654
706
  } else {
655
707
  root = src.location;
656
708
  if (!existsSync(root)) fail(`no existe la fuente: ${root}`, 2);
@@ -738,7 +790,7 @@ async function cmdInit(repo, opts) {
738
790
  }
739
791
  const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
740
792
 
741
- process.stdout.write("\n dai · configurar este repo para desarrollo asistido por IA\n");
793
+ process.stdout.write(initBanner());
742
794
 
743
795
  // Preguntas primero (después cerramos readline para liberar stdin a los instaladores).
744
796
  let forOpt = typeof opts.for === "string" ? opts.for.toLowerCase() : null;
@@ -789,28 +841,32 @@ async function cmdInit(repo, opts) {
789
841
  writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
790
842
  ok(".dai/ moldes (templates) + reglas (governance) del método");
791
843
 
792
- // .env.example aditivo, reflejando el --pm elegido: mismas claves que el .env
793
- // (con valores VACÍOS, sin secretos) para que el token del tracker esté presente.
794
- const exSrc = envFor(pm);
795
- const exPath = join(repo, ".env.example");
844
+ // Config de dai en SUS PROPIOS archivos, sin tocar el `.env`/`.env.example` del equipo:
845
+ // muchas orgs versionan el `.env` como política, así que dai lo deja en paz (solo lo lee,
846
+ // por compat) y pone lo suyo en `.env.dai` (ver ADR-0017). `.env.dai.example` se versiona
847
+ // como plantilla; `.env.dai` (gitignored) es donde cada dev completa token y datos propios.
848
+ const envBlock = envFor(pm);
849
+
850
+ // .env.dai.example — plantilla VERSIONADA, mismas claves con valores VACÍOS (sin secretos).
851
+ const exPath = join(repo, ".env.dai.example");
796
852
  if (existsSync(exPath)) {
797
- const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur, exSrc);
798
- if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.example claves de dai agregadas (aditivo)"); }
799
- else ok(".env.example ya tenía la config de dai");
800
- } else { writeFileSync(exPath, exSrc); ok(".env.example creado"); }
853
+ const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur, envBlock);
854
+ if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.dai.example claves de dai agregadas (aditivo)"); }
855
+ else ok(".env.dai.example ya tenía la config de dai");
856
+ } else { writeFileSync(exPath, envBlock); ok(".env.dai.example creado (plantilla versionada)"); }
801
857
 
802
- // .env — aditivo: agrega las claves de dai que falten; si no existe, lo crea.
803
- const envPath = join(repo, ".env"), envBlock = envFor(pm);
858
+ // .env.daiel real de cada dev (gitignored): aditivo; si no existe, lo crea.
859
+ const envPath = join(repo, ".env.dai");
804
860
  if (existsSync(envPath)) {
805
861
  const cur = readFileSync(envPath, "utf8"), merged = mergeEnv(cur, envBlock);
806
- if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env claves de dai agregadas (aditivo, DAI_PM=${pm}${pm === "md" ? "" : " — completa el token"})`); }
807
- else ok(".env ya tenía la config de dai");
808
- } else { writeFileSync(envPath, envBlock); ok(`.env listo, DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
862
+ if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env.dai claves de dai agregadas (aditivo, DAI_PM=${pm}${pm === "md" ? "" : " — completa el token"})`); }
863
+ else ok(".env.dai ya tenía la config de dai");
864
+ } else { writeFileSync(envPath, envBlock); ok(`.env.dai creado (no versionado), DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
809
865
 
810
866
  // .gitignore — versiona los artefactos de dai (según --for), deja fuera solo lo personal.
811
867
  const giPath = join(repo, ".gitignore");
812
868
  const gi = reconcileGitignore(existsSync(giPath) ? readFileSync(giPath, "utf8") : "", want);
813
- if (gi.changed) { writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"); ok(".gitignore ajustado (skills/constitución versionadas; .env y settings.local.json fuera)"); }
869
+ if (gi.changed) { writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"); ok(".gitignore ajustado (skills/constitución versionadas; .env.dai y settings.local.json fuera)"); }
814
870
 
815
871
  mkdirSync(join(repo, ".github"), { recursive: true });
816
872
  cpSync(join(ROOT, "templates", "pull-request.md"), join(repo, ".github", "pull_request_template.md"));
@@ -894,12 +950,12 @@ async function cmdInit(repo, opts) {
894
950
  }
895
951
 
896
952
  // ── Próximos pasos ─────────────────────────────────────────────────────────
897
- process.stdout.write("\n ✔ Repo configurado. Próximos pasos:\n");
953
+ process.stdout.write("\n " + C.y("") + " " + C.b("Repo configurado.") + " Próximos pasos:\n");
898
954
  process.stdout.write(pm === "md"
899
- ? " 1. Crea tu primera US en .dai/us/<ID>.md (criterios bajo '## Criterios de aceptación')\n"
900
- : ` 1. Completa el token de ${pm} en .env, y verifica con: dai doctor\n`);
901
- process.stdout.write(" 2. dai link-us <ID> → crea la branch + el link a la US\n");
902
- process.stdout.write(" 3. Implementa con test primero, después: dai check\n");
955
+ ? ` 1. Crea tu primera US en ${C.cy(".dai/us/<ID>.md")} (criterios bajo '## Criterios de aceptación')\n`
956
+ : ` 1. Copia ${C.cy(".env.dai.example")} → ${C.cy(".env.dai")} y completa el token de ${pm}; verifica con ${C.y("dai doctor")}\n`);
957
+ process.stdout.write(` 2. ${C.y("dai link-us <ID>")} → crea la branch + el link a la US\n`);
958
+ process.stdout.write(` 3. Implementa con test primero, después: ${C.y("dai check")}\n`);
903
959
  process.stdout.write(" Guía paso a paso: https://github.com/dforce2055/dai/blob/main/docs/PROBAR.md\n\n");
904
960
  }
905
961
 
@@ -1031,7 +1087,7 @@ function cmdSync(repo, opts) {
1031
1087
  () => writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"));
1032
1088
 
1033
1089
  if (dry) info("dry-run: nada escrito. Quitá --dry-run para aplicar.");
1034
- else { process.stdout.write("\n"); ok(`sync completo — .dai/ ahora en v${cliV}`); process.stdout.write(" (El .env y OpenSpec no se tocan: OpenSpec se actualiza aparte con `openspec`.)\n"); }
1090
+ else { process.stdout.write("\n"); ok(`sync completo — .dai/ ahora en v${cliV}`); process.stdout.write(" (El .env.dai y OpenSpec no se tocan: OpenSpec se actualiza aparte con `openspec`.)\n"); }
1035
1091
  }
1036
1092
 
1037
1093
  // Imprime el estado de version-drift del scaffold (ADR-0010) con color + ícono.
@@ -1097,7 +1153,7 @@ function cmdUpgrade(opts) {
1097
1153
 
1098
1154
  // ── doctor: diagnóstico ───────────────────────────────────────────────────────
1099
1155
  function cmdDoctor() {
1100
- loadEnv();
1156
+ loadDaiEnv();
1101
1157
  info(`dai doctor — versión v${readFileSync(join(ROOT, "VERSION"), "utf8").trim()}`);
1102
1158
 
1103
1159
  // Una skill sirve si está en el repo actual (la puso `dai init`) o global (la puso
@@ -1151,11 +1207,11 @@ function cmdDoctor() {
1151
1207
  const pm = process.env.DAI_PM || "md";
1152
1208
  ok(`DAI_PM=${pm}`);
1153
1209
  if (pm === "jira") {
1154
- if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en el .env");
1155
- if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en el .env");
1210
+ if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en .env.dai");
1211
+ if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en .env.dai");
1156
1212
  // Ojo: solo miramos que el token ESTÉ, no que sirva — uno vencido pasa este chequeo
1157
1213
  // y recién falla al publicar. Verificarlo de verdad es pegarle a la red.
1158
- if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en el .env"); else ok("token de Jira presente (no verificado: eso lo dice `dai publish`)");
1214
+ if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en .env.dai"); else ok("token de Jira presente (no verificado: eso lo dice `dai publish`)");
1159
1215
  if (!process.env.DAI_JIRA_PROJECT) warn("DAI_JIRA_PROJECT vacío — solo hace falta para `dai publish` (crear issues)");
1160
1216
  else {
1161
1217
  try { ok(`proyecto=${assertProjectKey(process.env.DAI_JIRA_PROJECT)} (para dai publish)`); }
@@ -1173,7 +1229,7 @@ function cmdDoctor() {
1173
1229
  }
1174
1230
  }
1175
1231
  if (pm === "clickup") {
1176
- if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en el .env"); else ok("token de ClickUp presente");
1232
+ if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en .env.dai"); else ok("token de ClickUp presente");
1177
1233
  process.env.DAI_CLICKUP_LIST_ID ? ok(`lista=${process.env.DAI_CLICKUP_LIST_ID} (para dai publish)`)
1178
1234
  : warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
1179
1235
  }
@@ -1238,16 +1294,16 @@ switch (cmd) {
1238
1294
  " Sin --yes no postea nada: muestra el preview y valida que cada hallazgo apunte al diff.\n\n" +
1239
1295
  "Instalación:\n" +
1240
1296
  " skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
1241
- " skills install --from <git-url|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
1297
+ " skills install --from <git-url|npm:pkg|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
1242
1298
  " init [<repo>] scaffolder interactivo del repo (asistente, gestor, OpenSpec)\n" +
1243
1299
  " --for <asistentes> claude|copilot|cursor (combinables con coma) · o both|all (default all)\n" +
1244
1300
  " ej: --for claude,cursor · --for copilot · --for all\n" +
1245
1301
  " --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
1246
- " sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env ni OpenSpec)\n" +
1302
+ " sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env.dai ni OpenSpec)\n" +
1247
1303
  " upgrade [--check] [--dry-run] (alias: update) actualiza el CLI global a la última (npm i -g …@latest) y avisa si el repo quedó atrasado (ADR-0012)\n" +
1248
1304
  " docs <destino> documentación conceptual → <destino>\n" +
1249
1305
  " doctor diagnóstico del entorno\n\n" +
1250
- " (config: .env — ver .env.example)\n"
1306
+ " (config: .env.dai — ver .env.dai.example)\n"
1251
1307
  );
1252
1308
  process.exit(cmd && cmd !== "help" ? 1 : 0);
1253
1309
  }
@@ -47,13 +47,38 @@ function unquoteScalar(s) {
47
47
  // Parsea el frontmatter YAML de un SKILL.md → { name, description, body }.
48
48
  // Devuelve los valores YA sin comillas, para que quien los reserialice (skillToCursor)
49
49
  // no los cite dos veces.
50
+ // Lee un campo del frontmatter. Soporta escalar de una línea (con/sin comillas) y BLOQUE
51
+ // YAML (`|` literal, `>` plegado, con chomp `-`/`+`): junta las líneas indentadas siguientes
52
+ // hasta la próxima clave (columna 0). El parser de dai es regex, no un YAML completo — esto
53
+ // cubre lo común: descripciones multilínea, que es como se escriben las skills reales.
54
+ function readFmField(fm, key) {
55
+ const lines = fm.split(/\r?\n/);
56
+ const keyRe = new RegExp(`^${key}:\\s*(.*)$`);
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const m = lines[i].match(keyRe);
59
+ if (!m) continue;
60
+ const inline = m[1].trim();
61
+ const blk = inline.match(/^([|>])[-+]?\s*$/); // |, >, |-, |+, >-, >+
62
+ if (!blk) return unquoteScalar(inline) || null; // escalar de una línea
63
+ const buf = [];
64
+ for (let j = i + 1; j < lines.length; j++) {
65
+ if (lines[j].trim() === "") { buf.push(""); continue; }
66
+ if (!/^\s/.test(lines[j])) break; // sin indentar → empezó otra clave
67
+ buf.push(lines[j]);
68
+ }
69
+ const indent = ((buf.find((l) => l.trim() !== "") || "").match(/^\s*/) || [""])[0].length;
70
+ const text = buf.map((l) => l.slice(indent)).join("\n").replace(/\s+$/, "");
71
+ // `>` plegado: un salto simple → espacio; los dobles (párrafo) se conservan.
72
+ return (blk[1] === ">" ? text.replace(/([^\n])\n(?!\n)/g, "$1 ") : text) || null;
73
+ }
74
+ return null;
75
+ }
76
+
50
77
  export function parseFrontmatter(md) {
51
78
  const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
52
79
  if (!m) return { name: null, description: null, body: md.trim() };
53
80
  const fm = m[1];
54
- const name = unquoteScalar((fm.match(/^name:\s*(.+)$/m) || [])[1]) || null;
55
- const description = unquoteScalar((fm.match(/^description:\s*(.+)$/m) || [])[1]) || null;
56
- return { name, description, body: m[2].trim() };
81
+ return { name: readFmField(fm, "name"), description: readFmField(fm, "description"), body: m[2].trim() };
57
82
  }
58
83
 
59
84
  // Valida el contrato MÍNIMO de un SKILL.md para que dai lo ingiera y los asistentes lo
@@ -66,7 +91,11 @@ export function validateSkill(md) {
66
91
  if (!name) return "falta 'name' en el frontmatter";
67
92
  if (!description) return "falta 'description' en el frontmatter";
68
93
  for (const key of ["name", "description"]) {
69
- const issue = yamlScalarIssue(rawFrontmatterValue(md, key));
94
+ const raw = rawFrontmatterValue(md, key);
95
+ // Un bloque YAML (`|`, `>`, con chomp) es literal → siempre válido; su contenido va en las
96
+ // líneas indentadas, no en el valor de la clave. No lo pasamos por el scalar-check.
97
+ if (/^[|>][-+]?\s*$/.test(String(raw ?? "").trim())) continue;
98
+ const issue = yamlScalarIssue(raw);
70
99
  if (issue) return `'${key}' no es YAML válido: ${issue} — citá el valor con comillas dobles`;
71
100
  }
72
101
  return null;
@@ -98,7 +127,7 @@ export function skillToCursor(md) {
98
127
  // la URL canónica que devuelve el tracker, así que el `/t/{id}` que escribíamos acá
99
128
  // tapaba la de ClickUp con team_id. Queda como override manual para trackers raros.
100
129
  export function envFor(pm) {
101
- const head = "# Config de dai — completá lo que falte. NUNCA commitees tokens (.env está gitignored).\n";
130
+ const head = "# Config de dai — va en .env.dai (NO versionado), no en el .env del equipo.\n# Completá lo que falte. NUNCA commitees tokens.\n";
102
131
  if (pm === "clickup") {
103
132
  return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n";
104
133
  }
@@ -156,7 +185,10 @@ export function reconcileGitignore(text, want) {
156
185
  // `.dai/` NO va acá: ahí vive config que SÍ se versiona (jira-fields.json). Solo se
157
186
  // ignora `.dai/reviews/`, que son borradores de `dai forge review` — efímeros, con
158
187
  // hallazgos a medio editar, y no tienen por qué viajar en un commit.
159
- const ensure = [".env", ".dai/reviews/"];
188
+ // `.env.dai` (config y secretos de dai) SÍ se ignora; el `.env` del equipo NO lo tocamos:
189
+ // es suyo y muchas orgs lo versionan como política (ADR-0017). La plantilla
190
+ // `.env.dai.example` sí se versiona (no matchea `.env.dai` exacto, así que no se ignora).
191
+ const ensure = [".env.dai", ".dai/reviews/"];
160
192
  if (want.claude) { broad.add("CLAUDE.md"); broad.add(".claude"); ensure.push(".claude/settings.local.json"); }
161
193
  if (want.cursor) { broad.add(".cursor"); }
162
194
  let changed = false;
@@ -199,7 +231,7 @@ export function constitution(kind) {
199
231
  - **El link se autora una vez** (\`implements.yaml\`); la cobertura se **deriva** (nunca a mano).
200
232
  - **Verifica el comportamiento, no solo que compile:** que pase el chequeo estático o el build no prueba que funcione; ejercita el flujo real antes de darlo por hecho.
201
233
  - **La IA confirma antes de construir:** el asistente declara que entendió esta constitución y la va a obedecer antes de generar código.
202
- - **Secretos:** en \`.env\` (nunca commiteados). git por **SSH**, APIs por **token scopeado**.
234
+ - **Secretos:** en \`.env.dai\` (NO versionado; el \`.env\` del equipo no se toca). git por **SSH**, APIs por **token scopeado**.
203
235
  - **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.
204
236
  - **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.
205
237
  - **Docs vivas:** una constitución o arquitectura desactualizada es un defecto, no documentación.
package/cli/lib/env.mjs CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  import { readFileSync } from "node:fs";
6
6
 
7
+ // Config de dai: `.env.dai` (propio, nunca versionado) tiene prioridad sobre `.env`.
8
+ // Pensado para equipos que versionan el `.env` (política de empresa): dai deja ese
9
+ // archivo en paz y pone sus claves y secretos en `.env.dai` (gitignored). Se carga
10
+ // `.env.dai` PRIMERO porque el loader es "primero-gana" (ver más abajo), así la
11
+ // precedencia queda: entorno (shell/CI) > .env.dai > .env. Leer también `.env`
12
+ // mantiene compatibilidad con repos previos que tienen los DAI_* ahí.
13
+ export function loadDaiEnv(env = process.env) {
14
+ loadEnv(".env.dai", env);
15
+ loadEnv(".env", env);
16
+ return env;
17
+ }
18
+
7
19
  export function loadEnv(path = ".env", env = process.env) {
8
20
  let text;
9
21
  try { text = readFileSync(path, "utf8"); } catch { return env; } // sin .env: no pasa nada