@dforce2055/dai 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,74 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.8.1] — 2026-07-16
7
+
8
+ **Primera prueba real en Windows con analistas y devs de una empresa: el ciclo completo
9
+ contra un Jira corporativo anduvo, y de paso destapó lo que faltaba pulir para que un
10
+ equipo Windows + Copilot + GitLab no se topara con muros.**
11
+
12
+ ### Arreglado
13
+ - **`dai sync` crasheaba en repos Copilot** con `ReferenceError: skillToPrompt is not
14
+ defined`. El pase a Agent Skills nativas (0.8.0, [ADR-0014](docs/adr/0014-copilot-agent-skills.md))
15
+ borró `skillToPrompt` y migró `dai init` a `.github/skills/`, pero dejó dos llamadas
16
+ colgadas en el path viejo `.github/prompts/`: `dai sync` y `dai skills install --from`
17
+ (rama Copilot). Ahora ambos copian la skill nativa (`.github/skills/<name>`, con
18
+ `templates/`) igual que `init`, y `sync` **migra** el repo: limpia los `.prompt.md`
19
+ viejos de dai. `dai sync` también detecta Copilot por `.github/skills` (antes solo por
20
+ `.github/prompts`, así que ni veía los repos nuevos). Cubierto por un test de
21
+ integración de `dai sync --for copilot` — el hueco que dejó pasar la regresión.
22
+ - **`dai upgrade` (y `dai init --openspec`) fallaban en Windows** con un genérico *"no
23
+ pude consultar el registry (¿sin red?)"* aunque npm anduviera perfecto. En Windows `npm`
24
+ y `openspec` son shims `.cmd`, y desde Node 18.20 / 20.12 / 21.7 (fix de CVE-2024-27980)
25
+ `execFileSync` **se niega a lanzar un `.cmd` sin `shell:true`** — tira `EINVAL`, que dai
26
+ confundía con falta de red. Ahora esos binarios se corren con `shell` en Windows.
27
+ - **`dai pr` fallaba el push la primera vez contra un remoto HTTPS corporativo.** El push
28
+ corría con stdin ignorado, así que Git Credential Manager no podía pedir la credencial y
29
+ el push moría en seco — mientras el error real de git quedaba oculto tras un genérico
30
+ *"Command failed"*. Ahora el push hereda stdin (con `GIT_TERMINAL_PROMPT=1`) para que el
31
+ login se pueda completar, **muestra el stderr real de git**, y si aún falla sugiere
32
+ pushear a mano una vez para cachear la credencial.
33
+
34
+ ### Agregado
35
+ - **`dai mr` como alias de `dai pr`.** En un shop de GitLab uno tipea "mr" (merge request);
36
+ ahora funciona. Es el mismo comando (detecta el forge y usa `gh`/`glab`), solo más natural.
37
+
38
+ ### Cambiado
39
+ - **El diagnóstico de un 400 de `dai publish` ahora coincide con lo que Jira dijo.** Antes
40
+ mandaba SIEMPRE a declarar un campo propio (`customfield_NNNNN` en `.dai/jira-fields.json`),
41
+ aunque el 400 fuera *"Por favor indicar épica…"* — confuso, porque el fix real es
42
+ `--parent`. Ahora la ayuda se elige según el cuerpo del error: regla de épica padre →
43
+ `--parent`, campo obligatorio → `jira-fields.json`, genérico → nombra las dos causas sin
44
+ empujar una sola.
45
+ - **`dai pr` explica por qué no pudo crear la PR/MR** en vez de un *"¿instalado y
46
+ autenticado?"*. Si `gh`/`glab` no está instalado (`ENOENT`) lo dice y enlaza la
47
+ instalación + `… auth login` (con `--hostname` para GitLab self-hosted); si falla por
48
+ otra cosa, **imprime el stderr real del forge** (auth vencida, host sin configurar, flag
49
+ desconocido). Era el caso ciego del equipo con GitLab corporativo.
50
+ - **El diagnóstico de TLS ofrece primero el camino más simple**: `NODE_OPTIONS=--use-system-ca`
51
+ (Node ≥ 22.15), que usa el trust store del sistema donde el navegador ya confía en la CA
52
+ de la empresa — validado contra el proxy real. `NODE_EXTRA_CA_CERTS` queda como
53
+ alternativa para cualquier versión de Node. (Sigue prohibido `NODE_TLS_REJECT_UNAUTHORIZED=0`.)
54
+
55
+ ### Docs
56
+ - **El prerequisito de `dai pr`: `gh`/`glab` instalado y autenticado.** Un callout en la
57
+ sección del dev con el setup one-time del CLI del forge (`gh auth login` /
58
+ `glab auth login --hostname <tu-gitlab>`, con la nota del PATH en Windows tras instalar con
59
+ winget). Era el paso que faltaba documentar — sin el CLI, `dai pr` pushea igual y avisa.
60
+ - **README consistente con Copilot nativo.** La tabla de superficies ya decía que Copilot
61
+ carga skills en app / CLI / IDE / cloud (solo el chat de github.com queda afuera), pero el
62
+ resto del README seguía en el modelo viejo: la tabla de `--for`, el árbol del repo y el
63
+ snippet de `dai skills install` hablaban de `.github/prompts/*.prompt.md` y de que "Copilot
64
+ no tiene skills instalables". Corregido a `.github/skills/` nativo — validado con un dev
65
+ corriendo dai en la app de GitHub Copilot desktop.
66
+
67
+ ### Interno
68
+ - **161 tests** (+3): integración de `dai sync --for copilot` (regresión + migración del
69
+ layout viejo).
70
+ - **Conocido, sin arreglar:** en Windows, tras un `dai publish` con red, puede aparecer al
71
+ salir `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c` — un
72
+ crash de *teardown* de libuv, posterior a toda la salida útil (cosmético). Se investiga.
73
+
6
74
  ## [0.8.0] — 2026-07-15
7
75
 
8
76
  **El primer analista funcional real usó dai contra un Jira corporativo, y encontró el
package/README.md CHANGED
@@ -49,7 +49,7 @@ deja las skills disponibles:
49
49
 
50
50
  ```bash
51
51
  npm i -g @dforce2055/dai # el CLI
52
- dai skills install # skills de IA → Claude y Cursor (global por defecto)
52
+ dai skills install # skills de IA → Claude, Copilot y Cursor (global por defecto)
53
53
  ```
54
54
 
55
55
  Y después, en el chat del asistente, según lo que tengas:
@@ -66,6 +66,20 @@ Y después, en el chat del asistente, según lo que tengas:
66
66
  > (`DAI_PM` en el `.env`): 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:
70
+
71
+ ```bash
72
+ dai publish historia.md # crea la US → devuelve el key
73
+ dai publish historia.md --parent PROJ-42 # colgándola de su épica
74
+ dai publish historia.md --field clasificacion=Mejora # con un campo propio que tu Jira exige
75
+ dai publish epica.md --issuetype Epic # publicar una épica (no una US)
76
+ ```
77
+
78
+ > **Jira corporativo:** si tu proyecto exige campos propios (`customfield_…`), declaralos una
79
+ > vez en `.dai/jira-fields.json` (molde en `.dai/templates/`) y pasalos con `--field`. Si una
80
+ > historia tiene que colgar de una épica, `--parent PROJ-42`. dai valida antes de la red y, si
81
+ > Jira igual rechaza, el error te dice **qué** falta ([ADR-0015](docs/adr/0015-jira-corporativo.md)).
82
+
69
83
  ### 🔵 Como dev — tengo una US y voy a implementarla
70
84
 
71
85
  Una vez, preparas el repo:
@@ -98,6 +112,17 @@ dai pr --assignee <compañero> # crea la PR precargada y se la asigna a un com
98
112
  dai stamp # al mergear: estampa la cobertura en el tracker
99
113
  ```
100
114
 
115
+ > **Para que `dai pr` cree la PR/MR** necesitás el CLI del forge instalado y autenticado, una
116
+ > sola vez por máquina:
117
+ > - **GitHub** → [`gh`](https://cli.github.com) · `gh auth login`
118
+ > - **GitLab** → [`glab`](https://gitlab.com/gitlab-org/cli) · `glab auth login --hostname <tu-gitlab>`
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
121
+ > tome el PATH.)
122
+ >
123
+ > Sin el CLI del forge, `dai pr` **igual pushea tu branch** y te dice qué instalar — después
124
+ > creás la PR/MR a mano desde la web. dai nunca te deja a medias sin decir por qué.
125
+
101
126
  ## Cómo usarlo — el flujo, paso a paso
102
127
 
103
128
  Guía completa con salidas reales en [`docs/PROBAR.md`](docs/PROBAR.md); un caso narrado en
@@ -157,16 +182,16 @@ flowchart TD
157
182
  | Comando | Qué hace |
158
183
  |---|---|
159
184
  | `dai init [<repo>]` | scaffolder interactivo del repo. Flags: `--for claude\|copilot\|both\|cursor\|all` (asistente, default `all`) · `--pm md\|jira\|clickup` (tracker) · `--openspec` |
160
- | `dai skills install [--global \| --local <repo>] [--force] [--dry-run] [--for claude\|cursor\|all]` | instala/actualiza las skills de dai en Claude y/o Cursor (`--for all` por defecto). `--force` re-copia. Alias: **`dai install`**. Ej: `dai skills install --local . --for cursor --force` |
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` |
161
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` |
162
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)) |
163
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)) |
164
- | `dai publish <us.md>` | crea la US en el tracker (Jira/ClickUp/md) desde un `.md` y devuelve el key. Es el fallback del MCP para publicar sin el asistente |
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)) |
165
190
  | `dai link-us <ID> [--us <md>]` | crea branch + `implements.yaml`; sin `--us` trae la US del tracker |
166
191
  | `dai link-us <ID> --resync` | re-estampa el `ac_hash` contra la US viva (tras un ⚠️ de check) |
167
192
  | `dai check` | compara tu código vs la US viva → ✅ al día / ⚠️ atrasado (exit code = gate de PR) |
168
193
  | `dai ls [--json]` | lista las US que implementa el repo + su link al tracker |
169
- | `dai pr [--assignee u] [--base b] [--draft] [--yes]` | crea TU PR/MR precargada: pregunta la branch base (default `main`), muestra el texto y confirma antes de publicar |
194
+ | `dai pr [--assignee u] [--base b] [--draft] [--yes]` · 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 |
170
195
  | `dai stamp` | estampa la cobertura inversa en el tracker (branch + commit-ancla) |
171
196
  | `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 |
172
197
  | `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` |
@@ -214,14 +239,14 @@ ver [`.env.example`](.env.example). Auth (SSH + tokens): [ADR-0007](docs/adr/000
214
239
  | Valor | Genera | Elígelo si… |
215
240
  |---|---|---|
216
241
  | `--for claude` | `.claude/skills/` + `CLAUDE.md` | tu equipo usa **Claude** (Code / Desktop) |
217
- | `--for copilot` | `.github/prompts/*.prompt.md` + `.github/copilot-instructions.md` | tu equipo usa **GitHub Copilot** (en VS Code / JetBrains) |
242
+ | `--for copilot` | `.github/skills/` + `.github/copilot-instructions.md` | tu equipo usa **GitHub Copilot** (app, CLI, VS Code / JetBrains o cloud agent) |
218
243
  | `--for cursor` | `.cursor/skills/` + `.cursor/rules/dai-constitution.mdc` | tu equipo usa **Cursor Agent** |
219
244
  | `--for both` | Claude + Copilot | equipo mixto sin Cursor |
220
245
  | `--for all` *(default)* | Claude + Copilot + Cursor | quieres dejar el repo listo para cualquier asistente |
221
246
 
222
247
  - **Combinables:** pasá un subconjunto separado por coma — `--for claude,cursor` o
223
- `--for copilot`. `all` = los tres · `both` = Claude+Copilot. (Igual en `dai install`,
224
- aunque ahí Copilot no aplica: no tiene skills instalables.)
248
+ `--for copilot`. `all` = los tres · `both` = Claude+Copilot. (Igual en `dai install`:
249
+ Copilot lee `SKILL.md` nativo, así que sus skills se instalan como las de Claude/Cursor.)
225
250
  - Es **aditivo, no destructivo**: los conjuntos conviven sin pisarse (viven en
226
251
  carpetas distintas). La **misma** skill se transforma al formato de cada asistente.
227
252
  - Si ejecutas `dai init` sin flag, te lo pregunta de forma interactiva.
@@ -241,7 +266,7 @@ mi-repo/
241
266
  │ └── doc-to-backlog · grill-intent · grill-epic · grill-user-story · link-us · tdd · dai-review
242
267
  ├── .github/
243
268
  │ ├── copilot-instructions.md · La constitución, auto-inyectada en cada chat de Copilot
244
- │ ├── prompts/*.prompt.md · Las mismas skills, generadas en formato Copilot
269
+ │ ├── skills/ · Las mismas skills, en formato Copilot nativo (SKILL.md)
245
270
  │ └── pull_request_template.md · Molde de PR/MR atado al link
246
271
  ├── .cursor/
247
272
  │ ├── skills/ · Las mismas skills, en formato Cursor
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.0
1
+ 0.8.1
package/cli/dai.mjs CHANGED
@@ -58,9 +58,14 @@ function git(args, opts = {}) {
58
58
  // quedan en e.stderr para quien quiera inspeccionarlos.
59
59
  return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
60
60
  }
61
- // En Windows los binarios instalados por npm (npm, openspec) son shims `.cmd` que
62
- // execFileSync no resuelve solo. git/gh/glab son `.exe` y se resuelven normal.
63
- const npmBin = (name) => (process.platform === "win32" ? `${name}.cmd` : name);
61
+ // En Windows los binarios instalados por npm (npm, openspec) son shims `.cmd`. Desde
62
+ // Node 18.20 / 20.12 / 21.7 (fix de CVE-2024-27980) execFileSync se NIEGA a lanzar un
63
+ // `.cmd` salvo con shell:true si no, tira EINVAL, que dai confundía con "sin red".
64
+ // Por eso van con shell en Windows. git/gh/glab son `.exe`: se resuelven normal, sin shell.
65
+ function runNpmTool(name, args, opts = {}) {
66
+ const win = process.platform === "win32";
67
+ return execFileSync(win ? `${name}.cmd` : name, args, { shell: win, ...opts });
68
+ }
64
69
  function trackerUrl(id) {
65
70
  const tpl = process.env.DAI_TRACKER_URL_TEMPLATE;
66
71
  return tpl ? tpl.replace("{id}", id) : id;
@@ -423,8 +428,16 @@ async function cmdPr(opts) {
423
428
  writeFileSync(bodyFile, body);
424
429
  try {
425
430
  info(`Publicando la branch ${branch}…`);
426
- git(["push", "-u", "origin", branch]);
427
- } catch (e) { fail(`no pude pushear la branch: ${String(e.message).split("\n")[0]}`, 1); }
431
+ // stdin heredado + GIT_TERMINAL_PROMPT=1: la PRIMERA vez contra un remoto HTTPS
432
+ // corporativo, git/credential-manager necesita poder pedir la credencial. Con stdin
433
+ // ignorado (el default de git()) el login no completaba y el push fallaba en seco.
434
+ git(["push", "-u", "origin", branch], { stdio: ["inherit", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
435
+ } catch (e) {
436
+ const err = String(e.stderr || e.message || "").trim();
437
+ if (err) process.stderr.write(" " + err.split("\n").join("\n ") + "\n");
438
+ process.stdout.write(` Si es la primera vez contra este remoto, autenticá pusheando a mano una vez:\n git push -u origin ${branch}\n y volvé a correr: dai pr\n`);
439
+ fail(`no pude pushear la branch '${branch}'.`, 1);
440
+ }
428
441
 
429
442
  const cmd = tool === "gh"
430
443
  ? ["pr", "create", "--title", title, "--body-file", bodyFile, "--base", base,
@@ -439,12 +452,20 @@ async function cmdPr(opts) {
439
452
  try { rmSync(bodyFile); } catch { /* noop */ }
440
453
  } catch (e) {
441
454
  const msg = String(e.stderr || e.message || "");
442
- if (/no history in common|no commits between|not found.*base|base.*not found/i.test(msg)) {
455
+ const manual = ` Comando listo para correr a mano:\n ${tool} ${cmd.map((c) => /\s/.test(c) ? `'${c}'` : c).join(" ")}\n`;
456
+ if (e.code === "ENOENT") {
457
+ // El binario del forge no está instalado (el caso más común detrás de "no salió la MR").
458
+ const doc = tool === "glab" ? "https://gitlab.com/gitlab-org/cli/-/releases" : "https://cli.github.com";
459
+ warn(`'${tool}' no está instalado. El body quedó en ${bodyFile}.`);
460
+ process.stdout.write(` Instalá ${tool} (${doc}) y autenticá con \`${tool} auth login\`${tool === "glab" ? " --hostname " + (parseRemote(remote)?.host || "tu-gitlab") : ""}, o creá la PR a mano.\n${manual}`);
461
+ } else if (/no history in common|no commits between|not found.*base|base.*not found/i.test(msg)) {
443
462
  warn(`la branch no comparte historia con '${base}' en el remoto (o '${base}' no existe allá).`);
444
463
  process.stdout.write(` Suele pasar cuando el repo local y el remoto son distintos. Empuja la base primero:\n git push origin ${base}\n y vuelve a correr: dai pr\n`);
445
464
  } else {
446
- warn(`no pude crear la PR con ${tool} (¿instalado y autenticado?). El body quedó en ${bodyFile}.`);
447
- process.stdout.write(` Comando listo para correr a mano:\n ${tool} ${cmd.map((c) => /\s/.test(c) ? `'${c}'` : c).join(" ")}\n`);
465
+ warn(`no pude crear la PR con ${tool}. El body quedó en ${bodyFile}.`);
466
+ // El stderr real de gh/glab (auth vencida, host no configurado, flag desconocido…).
467
+ if (msg.trim()) process.stdout.write(` ${tool} dijo:\n ${msg.trim().split("\n").join("\n ")}\n`);
468
+ process.stdout.write(manual);
448
469
  }
449
470
  }
450
471
  }
@@ -595,8 +616,9 @@ function cmdInstallFrom(opts) {
595
616
  rmSync(t, { recursive: true, force: true }); cpSync(srcDir, t, { recursive: true }); writeFileSync(join(t, "SKILL.md"), skillToCursor(md));
596
617
  }
597
618
  if (want.copilot) {
598
- const pdir = join(repo, ".github", "prompts"); mkdirSync(pdir, { recursive: true });
599
- writeFileSync(join(pdir, `${name}.prompt.md`), skillToPrompt(md));
619
+ // Copilot lee SKILL.md nativo (Agent Skills, ADR-0014): copia cruda, con templates/.
620
+ const t = join(repo, ".github", "skills", name);
621
+ rmSync(t, { recursive: true, force: true }); mkdirSync(dirname(t), { recursive: true }); cpSync(srcDir, t, { recursive: true });
600
622
  }
601
623
  ok(name); installed++;
602
624
  }
@@ -763,7 +785,7 @@ async function cmdInit(repo, opts) {
763
785
  // así que sí lo inicializamos nosotros — mapeando --for a sus tools. (Antes se
764
786
  // dejaba a medias porque se intentaba correr su modo interactivo anidado.)
765
787
  process.stdout.write("\n");
766
- const openspecPresent = () => { try { execFileSync(npmBin("openspec"), ["--version"], { stdio: "ignore" }); return true; } catch { return false; } };
788
+ const openspecPresent = () => { try { runNpmTool("openspec", ["--version"], { stdio: "ignore" }); return true; } catch { return false; } };
767
789
  const osTools = [want.claude && "claude", want.copilot && "github-copilot", want.cursor && "cursor"]
768
790
  .filter(Boolean).join(",") || "claude,github-copilot,cursor";
769
791
  const osHint = "para sumarlo después: npm i -g @fission-ai/openspec@latest && openspec init --tools " + osTools;
@@ -774,13 +796,13 @@ async function cmdInit(repo, opts) {
774
796
  } else if (installOpenspec) {
775
797
  let cliOk = openspecPresent();
776
798
  if (!cliOk) {
777
- try { info("OpenSpec: instalando el CLI (npm i -g @fission-ai/openspec)…"); execFileSync(npmBin("npm"), ["install", "-g", "@fission-ai/openspec@latest"], { stdio: "inherit" }); cliOk = openspecPresent(); }
799
+ try { info("OpenSpec: instalando el CLI (npm i -g @fission-ai/openspec)…"); runNpmTool("npm", ["install", "-g", "@fission-ai/openspec@latest"], { stdio: "inherit" }); cliOk = openspecPresent(); }
778
800
  catch { cliOk = false; }
779
801
  }
780
802
  if (cliOk) {
781
803
  try {
782
804
  info(`OpenSpec: inicializando en el repo (--tools ${osTools})…`);
783
- execFileSync(npmBin("openspec"), ["init", "--tools", osTools, "--force"], { stdio: "inherit", cwd: repo === "." ? process.cwd() : repo });
805
+ runNpmTool("openspec", ["init", "--tools", osTools, "--force"], { stdio: "inherit", cwd: repo === "." ? process.cwd() : repo });
784
806
  ok("OpenSpec: instalado e inicializado — genera design/tasks con /opsx:*");
785
807
  } catch {
786
808
  warn("OpenSpec: el CLI está pero falló `openspec init`. Ejecuta a mano en el repo:");
@@ -836,7 +858,7 @@ function cmdArchive(changeArg, opts) {
836
858
  const args = ["archive", change, "--yes"];
837
859
  if (opts.skipSpecs) args.push("--skip-specs");
838
860
  try {
839
- execFileSync(npmBin("openspec"), args, { stdio: "inherit", cwd: repo });
861
+ runNpmTool("openspec", args, { stdio: "inherit", cwd: repo });
840
862
  } catch (e) {
841
863
  fail(`openspec archive falló (¿tasks incompletas? ¿change inexistente?): ${String(e.message).split("\n")[0]}`, 1);
842
864
  }
@@ -861,11 +883,13 @@ function cmdSync(repo, opts) {
861
883
  } else {
862
884
  want = {
863
885
  claude: existsSync(join(repo, ".claude", "skills")),
864
- copilot: existsSync(join(repo, ".github", "prompts")),
886
+ // .github/skills es el layout nativo (ADR-0014); .github/prompts es el viejo, que
887
+ // seguimos detectando para migrar repos que quedaron en el formato anterior.
888
+ copilot: existsSync(join(repo, ".github", "skills")) || existsSync(join(repo, ".github", "prompts")),
865
889
  cursor: existsSync(join(repo, ".cursor", "skills")),
866
890
  };
867
891
  if (!want.claude && !want.copilot && !want.cursor)
868
- fail("no detecté asistentes instalados (.claude/.cursor/.github/prompts). Pasá --for.", 2);
892
+ fail("no detecté asistentes instalados (.claude/skills · .github/skills · .cursor/skills). Pasá --for.", 2);
869
893
  }
870
894
 
871
895
  const cliV = readFileSync(join(ROOT, "VERSION"), "utf8").trim();
@@ -900,11 +924,16 @@ function cmdSync(repo, opts) {
900
924
  const cPath = join(repo, "CLAUDE.md"), cCur = existsSync(cPath) ? readFileSync(cPath, "utf8") : "";
901
925
  writeFileSync(cPath, upsertBlock(cCur, constitution("claude")));
902
926
  });
903
- if (want.copilot) step(`Copilot: .github/prompts/ (${skills.length}) + copilot-instructions.md`, () => {
904
- const pdir = join(repo, ".github", "prompts"); mkdirSync(pdir, { recursive: true });
905
- for (const name of skills) writeFileSync(join(pdir, `${name}.prompt.md`), skillToPrompt(readFileSync(join(skillsSrc, name, "SKILL.md"), "utf8")));
927
+ if (want.copilot) step(`Copilot: .github/skills/ (${skills.length}) + copilot-instructions.md`, () => {
928
+ // Copilot lee SKILL.md nativo (ADR-0014): copia cruda, con templates/ igual que `dai init`.
929
+ const dir = join(repo, ".github", "skills"); mkdirSync(dir, { recursive: true });
930
+ for (const name of skills) cpSync(join(skillsSrc, name), join(dir, name), { recursive: true });
906
931
  const ciPath = join(repo, ".github", "copilot-instructions.md"), ciCur = existsSync(ciPath) ? readFileSync(ciPath, "utf8") : "";
907
932
  writeFileSync(ciPath, upsertBlock(ciCur, constitution("copilot")));
933
+ // Limpia los .prompt.md viejos de dai (ahora son skills nativas) para no duplicar cada /comando.
934
+ const pdir = join(repo, ".github", "prompts");
935
+ const stale = existsSync(pdir) ? stalePromptFiles(skills).filter((f) => existsSync(join(pdir, f))) : [];
936
+ for (const f of stale) rmSync(join(pdir, f), { force: true });
908
937
  });
909
938
  if (want.cursor) step(`Cursor: .cursor/skills/ (${skills.length}) + .cursor/rules/dai-constitution.mdc`, () => {
910
939
  const dir = join(repo, ".cursor", "skills"); mkdirSync(dir, { recursive: true });
@@ -963,7 +992,7 @@ function cmdUpgrade(opts) {
963
992
 
964
993
  let latestV;
965
994
  try {
966
- latestV = execFileSync(npmBin("npm"), ["view", name, "version"], { encoding: "utf8" }).trim();
995
+ latestV = runNpmTool("npm", ["view", name, "version"], { encoding: "utf8" }).trim();
967
996
  } catch {
968
997
  fail(`no pude consultar el registry (¿sin red?). Actualizá a mano: ${manual}`, 1);
969
998
  }
@@ -979,7 +1008,7 @@ function cmdUpgrade(opts) {
979
1008
 
980
1009
  info(`actualizando v${plan.from} → v${plan.to} …`);
981
1010
  try {
982
- execFileSync(npmBin("npm"), ["install", "-g", `${name}@latest`], { stdio: "inherit" });
1011
+ runNpmTool("npm", ["install", "-g", `${name}@latest`], { stdio: "inherit" });
983
1012
  } catch {
984
1013
  fail(`el install falló. Probá a mano: ${manual}`, 1);
985
1014
  }
@@ -1093,7 +1122,8 @@ switch (cmd) {
1093
1122
  case "stamp": cmdStamp().catch((e) => fail(String(e.message))); break;
1094
1123
  case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
1095
1124
  case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
1096
- case "pr": cmdPr(opts).catch((e) => fail(String(e.message))); break;
1125
+ case "pr":
1126
+ case "mr": cmdPr(opts).catch((e) => fail(String(e.message))); break; // `mr` = alias para GitLab (merge request)
1097
1127
  case "done": cmdDone(opts); break;
1098
1128
  case "archive": cmdArchive(pos[0], opts); break;
1099
1129
  case "install": cmdInstall(opts).catch((e) => fail(String(e.message))); break; // alias de `dai skills install`
@@ -1123,7 +1153,7 @@ switch (cmd) {
1123
1153
  " stamp estampa la cobertura en el tracker (ADR-0005)\n" +
1124
1154
  " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
1125
1155
  " 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" +
1126
- " pr [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1156
+ " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1127
1157
  " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n\n" +
1128
1158
  "Instalación:\n" +
1129
1159
  " skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
package/cli/lib/http.mjs CHANGED
@@ -35,7 +35,13 @@ export function tlsHint(host, code) {
35
35
  " Casi siempre es un proxy corporativo que intercepta TLS con su propia CA. Node no",
36
36
  " usa el trust store de Windows/macOS, por eso el navegador anda y dai no.",
37
37
  "",
38
- " Solución: exportá la CA raíz de tu empresa a un .pem y declarásela a Node:",
38
+ " Solución más simple (Node 22.15): usá el trust store del sistema, donde tu navegador",
39
+ " ya confía en la CA de tu empresa:",
40
+ " Windows (PowerShell): $env:NODE_OPTIONS=\"--use-system-ca\"",
41
+ " macOS / Linux: export NODE_OPTIONS=--use-system-ca",
42
+ "",
43
+ " Alternativa (cualquier versión de Node): exportá la CA raíz de tu empresa a un .pem",
44
+ " y declarásela a Node:",
39
45
  " Windows (PowerShell): $env:NODE_EXTRA_CA_CERTS=\"C:\\ruta\\ca-empresa.pem\"",
40
46
  " macOS / Linux: export NODE_EXTRA_CA_CERTS=/ruta/ca-empresa.pem",
41
47
  "",
@@ -106,16 +106,32 @@ export function renderCoverageAdf(id, r) {
106
106
  return { type: "doc", version: 1, content };
107
107
  }
108
108
 
109
- // Un 400 al crear casi siempre es un campo obligatorio del proyecto que no mandamos.
110
- // El cuerpo de Jira nombra el customfield_NNNNN pero no dice qué hacer: lo decimos acá.
111
- function createHint(status) {
109
+ // Un 400 al crear puede ser (a) un campo propio obligatorio que no mandamos, (b) una regla
110
+ // del proyecto —p. ej. exigir una épica padre— o (c) otra cosa. Jira dice cuál en el cuerpo,
111
+ // pero no dice qué hacer: elegimos la ayuda según lo que dice el error, no una fija. Antes
112
+ // mandábamos SIEMPRE a declarar un campo, aunque el 400 fuera "falta la épica" (confuso).
113
+ export function createHint(status, body = "") {
112
114
  if (status !== 400) return "";
113
- return "\n\n Si el error nombra un 'customfield_NNNNN', tu proyecto exige un campo propio.\n" +
114
- " Declaralo en .dai/jira-fields.json y volvé a publicar no hace falta improvisar\n" +
115
- " una llamada a mano:\n" +
116
- ' { "Story": { "clasificacion": { "field": "customfield_NNNNN",\n' +
117
- ' "options": ["Mejora", "Corrección"] } } }\n' +
118
- " dai publish <us.md> --field clasificacion=Corrección";
115
+ const b = String(body);
116
+ // Regla de workflow: la historia tiene que colgar de una épica (no es un campo propio).
117
+ if (/[ée]pica|epic|parent/i.test(b)) {
118
+ return "\n\n Tu proyecto pide que esta historia cuelgue de una épica. Pasá la key de la\n" +
119
+ " épica padre con --parent (es la épica, no el proyecto):\n" +
120
+ " dai publish <us.md> --parent PROJ-123 --field clasificacion=<valor>";
121
+ }
122
+ // Campo propio obligatorio que no mandamos (el customfield_NNNNN del error).
123
+ if (/customfield_/i.test(b)) {
124
+ return "\n\n Tu proyecto exige un campo propio (el 'customfield_NNNNN' del error).\n" +
125
+ " Declaralo en .dai/jira-fields.json y volvé a publicar — no hace falta improvisar\n" +
126
+ " una llamada a mano:\n" +
127
+ ' { "Story": { "clasificacion": { "field": "customfield_NNNNN",\n' +
128
+ ' "options": ["Mejora", "Corrección"] } } }\n' +
129
+ " dai publish <us.md> --field clasificacion=Corrección";
130
+ }
131
+ // 400 genérico: no adivinamos. Nombramos las dos causas típicas sin empujar una.
132
+ return "\n\n Jira rechazó la creación (400) — el mensaje de arriba dice por qué. Las dos causas\n" +
133
+ " típicas: un campo propio obligatorio (declaralo en .dai/jira-fields.json) o una regla\n" +
134
+ " del proyecto como exigir una épica padre (--parent PROJ-123).";
119
135
  }
120
136
 
121
137
  export function jiraAdapter(env) {
@@ -155,7 +171,10 @@ export function jiraAdapter(env) {
155
171
  method: "POST", headers: jiraAuthHeaders(env),
156
172
  body: JSON.stringify({ fields: payload }),
157
173
  });
158
- if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}${createHint(res.status)}`);
174
+ if (!res.ok) {
175
+ const body = await res.text();
176
+ throw new Error(`jira ${res.status}: ${body}${createHint(res.status, body)}`);
177
+ }
159
178
  const j = await res.json();
160
179
  return { id: j.key, url: `${trim(base)}/browse/${j.key}` };
161
180
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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/",