@dforce2055/dai 0.8.0 → 0.8.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/.env.example CHANGED
@@ -6,8 +6,11 @@
6
6
  # md | jira | clickup
7
7
  DAI_PM=md
8
8
 
9
- # Plantilla del link al ticket (para `dai ls` / stamp). {id} se reemplaza.
10
- DAI_TRACKER_URL_TEMPLATE=https://jira.miempresa.com/browse/{id}
9
+ # OPCIONAL. Plantilla del link al ticket (`dai ls`, `dai pr`, stamp). {id} se reemplaza.
10
+ # Con DAI_PM=jira o =clickup, dai ya deduce el link solo: esto es un OVERRIDE, y solo
11
+ # hace falta si tu tracker vive en otra URL (p. ej. un Jira Server con path propio).
12
+ # Si dai no puede saber el link, avisa y deja la PR sin él — nunca escribe el id pelado.
13
+ # DAI_TRACKER_URL_TEMPLATE=https://jira.miempresa.com/browse/{id}
11
14
 
12
15
  # ── Backend md (local, offline) ──────────────────────────────────────────────
13
16
  # Carpeta donde viven las US como <ID>.md (p. ej. ABC-482.md).
package/CHANGELOG.md CHANGED
@@ -3,6 +3,114 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.8.2] — 2026-07-17
7
+
8
+ **Dos agujeros que destapó el uso real, y que tienen la misma forma: dai hacía algo
9
+ hacia afuera sin que un humano lo viera, o dejaba que otro le pisara lo que había
10
+ escrito. El [Art. 5](docs/MANIFIESTO.md#art-5) no se cumple solo con no clickear
11
+ Approve.**
12
+
13
+ ### Arreglado
14
+ - **`dai-review` posteaba el comentario sin mostrártelo.** La skill componía el review y
15
+ lo publicaba de una: el paso 6 decía *"Postear"* y no había gate. Y el comentario sale
16
+ con **tu token y tu nombre** (`GITHUB_TOKEN`/`GITLAB_TOKEN` son tuyos), así que en la
17
+ PR de un compañero figura como si lo hubieras escrito vos. El corte estaba puesto en el
18
+ lugar equivocado: no aprobar sin humano estaba bien, pero publicar un juicio sobre el
19
+ código de otro, firmado por alguien que no lo leyó, es el mismo problema con otro
20
+ disfraz. Ahora la skill **muestra el comentario entero y espera un OK explícito** en
21
+ ese turno; sin "sí", no se postea. Es el tercer corte duro de la skill.
22
+ - **`dai pr` escribía el id de la US disfrazado de link.** Sin `DAI_TRACKER_URL_TEMPLATE`,
23
+ `trackerUrl(id)` devolvía el **id pelado**; como un string es truthy, `composePrBody` lo
24
+ escribía igual y la PR quedaba con `- US: 86abc123` en vez de un enlace, sin un solo
25
+ aviso. Ahora la URL se resuelve por una cadena explícita —template > URL canónica del
26
+ tracker > derivada del backend > `null`— y **si dai no la sabe, avisa y omite la línea
27
+ en vez de mentir** (`lib/tracker-url.mjs`).
28
+ - **El bloque de enlaces de `dai pr` no sobrevivía a un edit.** Iba marcado con un
29
+ comentario suelto, así que cualquier agente que reescribiera *"Enlaces relacionados"*
30
+ se lo llevaba puesto sin dejar rastro — pasó en PRs reales. Y el propio template lo
31
+ invitaba: su hint pedía *"US en el tracker, commit ancla, docs, issues"*, o sea justo la
32
+ sección que `dai pr` acababa de llenar. dai se peleaba consigo mismo y ganaba el que
33
+ corría último. Ahora el bloque va **delimitado** (`<!-- dai:links:start … end -->`),
34
+ se **regenera de forma idempotente**, preserva lo que el humano sumó abajo, y el hint
35
+ del template pide solo lo que dai **no** sabe (docs, issues, PRs relacionadas).
36
+
37
+ ### Cambiado
38
+ - **dai deduce el link al tracker solo.** Con `DAI_PM=jira` o `=clickup` ya no hace falta
39
+ `DAI_TRACKER_URL_TEMPLATE`: se deriva de la config (`/browse/<KEY>` y
40
+ `/t/<id>`), y `fetchUS` ahora devuelve la **URL canónica** del tracker — en ClickUp, la
41
+ que trae el `team_id`, que no se puede deducir del id. La variable queda como
42
+ **override** para trackers con URL propia. `dai init` dejó de scaffoldearla: era
43
+ contraproducente, porque el template gana sobre la canónica y le tapaba el `team_id`.
44
+ Los `.env` que ya la tienen siguen andando igual (el override sigue ganando).
45
+
46
+ ## [0.8.1] — 2026-07-16
47
+
48
+ **Primera prueba real en Windows con analistas y devs de una empresa: el ciclo completo
49
+ contra un Jira corporativo anduvo, y de paso destapó lo que faltaba pulir para que un
50
+ equipo Windows + Copilot + GitLab no se topara con muros.**
51
+
52
+ ### Arreglado
53
+ - **`dai sync` crasheaba en repos Copilot** con `ReferenceError: skillToPrompt is not
54
+ defined`. El pase a Agent Skills nativas (0.8.0, [ADR-0014](docs/adr/0014-copilot-agent-skills.md))
55
+ borró `skillToPrompt` y migró `dai init` a `.github/skills/`, pero dejó dos llamadas
56
+ colgadas en el path viejo `.github/prompts/`: `dai sync` y `dai skills install --from`
57
+ (rama Copilot). Ahora ambos copian la skill nativa (`.github/skills/<name>`, con
58
+ `templates/`) igual que `init`, y `sync` **migra** el repo: limpia los `.prompt.md`
59
+ viejos de dai. `dai sync` también detecta Copilot por `.github/skills` (antes solo por
60
+ `.github/prompts`, así que ni veía los repos nuevos). Cubierto por un test de
61
+ integración de `dai sync --for copilot` — el hueco que dejó pasar la regresión.
62
+ - **`dai upgrade` (y `dai init --openspec`) fallaban en Windows** con un genérico *"no
63
+ pude consultar el registry (¿sin red?)"* aunque npm anduviera perfecto. En Windows `npm`
64
+ y `openspec` son shims `.cmd`, y desde Node 18.20 / 20.12 / 21.7 (fix de CVE-2024-27980)
65
+ `execFileSync` **se niega a lanzar un `.cmd` sin `shell:true`** — tira `EINVAL`, que dai
66
+ confundía con falta de red. Ahora esos binarios se corren con `shell` en Windows.
67
+ - **`dai pr` fallaba el push la primera vez contra un remoto HTTPS corporativo.** El push
68
+ corría con stdin ignorado, así que Git Credential Manager no podía pedir la credencial y
69
+ el push moría en seco — mientras el error real de git quedaba oculto tras un genérico
70
+ *"Command failed"*. Ahora el push hereda stdin (con `GIT_TERMINAL_PROMPT=1`) para que el
71
+ login se pueda completar, **muestra el stderr real de git**, y si aún falla sugiere
72
+ pushear a mano una vez para cachear la credencial.
73
+
74
+ ### Agregado
75
+ - **`dai mr` como alias de `dai pr`.** En un shop de GitLab uno tipea "mr" (merge request);
76
+ ahora funciona. Es el mismo comando (detecta el forge y usa `gh`/`glab`), solo más natural.
77
+
78
+ ### Cambiado
79
+ - **El diagnóstico de un 400 de `dai publish` ahora coincide con lo que Jira dijo.** Antes
80
+ mandaba SIEMPRE a declarar un campo propio (`customfield_NNNNN` en `.dai/jira-fields.json`),
81
+ aunque el 400 fuera *"Por favor indicar épica…"* — confuso, porque el fix real es
82
+ `--parent`. Ahora la ayuda se elige según el cuerpo del error: regla de épica padre →
83
+ `--parent`, campo obligatorio → `jira-fields.json`, genérico → nombra las dos causas sin
84
+ empujar una sola.
85
+ - **`dai pr` explica por qué no pudo crear la PR/MR** en vez de un *"¿instalado y
86
+ autenticado?"*. Si `gh`/`glab` no está instalado (`ENOENT`) lo dice y enlaza la
87
+ instalación + `… auth login` (con `--hostname` para GitLab self-hosted); si falla por
88
+ otra cosa, **imprime el stderr real del forge** (auth vencida, host sin configurar, flag
89
+ desconocido). Era el caso ciego del equipo con GitLab corporativo.
90
+ - **El diagnóstico de TLS ofrece primero el camino más simple**: `NODE_OPTIONS=--use-system-ca`
91
+ (Node ≥ 22.15), que usa el trust store del sistema donde el navegador ya confía en la CA
92
+ de la empresa — validado contra el proxy real. `NODE_EXTRA_CA_CERTS` queda como
93
+ alternativa para cualquier versión de Node. (Sigue prohibido `NODE_TLS_REJECT_UNAUTHORIZED=0`.)
94
+
95
+ ### Docs
96
+ - **El prerequisito de `dai pr`: `gh`/`glab` instalado y autenticado.** Un callout en la
97
+ sección del dev con el setup one-time del CLI del forge (`gh auth login` /
98
+ `glab auth login --hostname <tu-gitlab>`, con la nota del PATH en Windows tras instalar con
99
+ winget). Era el paso que faltaba documentar — sin el CLI, `dai pr` pushea igual y avisa.
100
+ - **README consistente con Copilot nativo.** La tabla de superficies ya decía que Copilot
101
+ carga skills en app / CLI / IDE / cloud (solo el chat de github.com queda afuera), pero el
102
+ resto del README seguía en el modelo viejo: la tabla de `--for`, el árbol del repo y el
103
+ snippet de `dai skills install` hablaban de `.github/prompts/*.prompt.md` y de que "Copilot
104
+ no tiene skills instalables". Corregido a `.github/skills/` nativo — validado con un dev
105
+ corriendo dai en la app de GitHub Copilot desktop.
106
+
107
+ ### Interno
108
+ - **161 tests** (+3): integración de `dai sync --for copilot` (regresión + migración del
109
+ layout viejo).
110
+ - **Conocido, sin arreglar:** en Windows, tras un `dai publish` con red, puede aparecer al
111
+ salir `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c` — un
112
+ crash de *teardown* de libuv, posterior a toda la salida útil (cosmético). Se investiga.
113
+
6
114
  ## [0.8.0] — 2026-07-15
7
115
 
8
116
  **El primer analista funcional real usó dai contra un Jira corporativo, y encontró el
@@ -258,6 +366,8 @@ ClickUp y Jira Cloud.
258
366
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
259
367
  `files` de npm sin tests ni secretos.
260
368
 
369
+ [0.8.2]: https://github.com/dforce2055/dai/releases/tag/v0.8.2
370
+ [0.8.1]: https://github.com/dforce2055/dai/releases/tag/v0.8.1
261
371
  [0.8.0]: https://github.com/dforce2055/dai/releases/tag/v0.8.0
262
372
  [0.7.0]: https://github.com/dforce2055/dai/releases/tag/v0.7.0
263
373
  [0.6.0]: https://github.com/dforce2055/dai/releases/tag/v0.6.0
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.2
package/cli/dai.mjs CHANGED
@@ -25,6 +25,7 @@ import { loadEnv } 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 } from "./lib/forge-api.mjs";
28
+ import { trackerUrl } from "./lib/tracker-url.mjs";
28
29
  import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
29
30
  import { dirsEqual } from "./lib/fsutil.mjs";
30
31
  import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
@@ -58,13 +59,17 @@ function git(args, opts = {}) {
58
59
  // quedan en e.stderr para quien quiera inspeccionarlos.
59
60
  return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
60
61
  }
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);
64
- function trackerUrl(id) {
65
- const tpl = process.env.DAI_TRACKER_URL_TEMPLATE;
66
- return tpl ? tpl.replace("{id}", id) : id;
62
+ // En Windows los binarios instalados por npm (npm, openspec) son shims `.cmd`. Desde
63
+ // Node 18.20 / 20.12 / 21.7 (fix de CVE-2024-27980) execFileSync se NIEGA a lanzar un
64
+ // `.cmd` salvo con shell:true si no, tira EINVAL, que dai confundía con "sin red".
65
+ // Por eso van con shell en Windows. git/gh/glab son `.exe`: se resuelven normal, sin shell.
66
+ function runNpmTool(name, args, opts = {}) {
67
+ const win = process.platform === "win32";
68
+ return execFileSync(win ? `${name}.cmd` : name, args, { shell: win, ...opts });
67
69
  }
70
+ // La URL de la US: template > canónica del tracker > derivada del backend > null.
71
+ // Nunca el id pelado: ver el porqué en lib/tracker-url.mjs.
72
+ const usUrlFor = (id, liveUrl = null) => trackerUrl(id, { env: process.env, liveUrl });
68
73
 
69
74
  // ── ac-hash ─────────────────────────────────────────────────────────────────
70
75
  function cmdAcHash(arg) {
@@ -83,7 +88,7 @@ function cmdLs(opts) {
83
88
  for (const im of f.implements || []) {
84
89
  if (isPlaceholderId(im.id)) continue; // saltea plantillas sin completar
85
90
  rows.push({ change: f.change, repo: f.repo, id: im.id, version: im.version,
86
- ac_hash: im.ac_hash, link: trackerUrl(im.id) });
91
+ ac_hash: im.ac_hash, link: usUrlFor(im.id) });
87
92
  }
88
93
  }
89
94
  if (opts.json) { process.stdout.write(JSON.stringify(rows, null, 2) + "\n"); return; }
@@ -389,8 +394,14 @@ async function cmdPr(opts) {
389
394
  // Commits de la branch (para precargar "Cambios realizados").
390
395
  let commits = [];
391
396
  try { commits = git(["log", `${base}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* base local ausente */ }
397
+ // La canónica del tracker (live.url) gana sobre la derivada; el template gana sobre todo.
398
+ const usUrl = usUrlFor(id, live?.url);
399
+ if (!usUrl) {
400
+ warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
401
+ warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env (p. ej. https://tu-tracker/browse/{id}).`);
402
+ }
392
403
  const body = composePrBody(readFileSync(tplPath, "utf8"), {
393
- id, version, ac_hash, status, usUrl: trackerUrl(id), usTitle: live?.title, commits,
404
+ id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
394
405
  branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
395
406
  });
396
407
  const title = prTitle(opts, id, live?.title);
@@ -423,8 +434,16 @@ async function cmdPr(opts) {
423
434
  writeFileSync(bodyFile, body);
424
435
  try {
425
436
  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); }
437
+ // stdin heredado + GIT_TERMINAL_PROMPT=1: la PRIMERA vez contra un remoto HTTPS
438
+ // corporativo, git/credential-manager necesita poder pedir la credencial. Con stdin
439
+ // ignorado (el default de git()) el login no completaba y el push fallaba en seco.
440
+ git(["push", "-u", "origin", branch], { stdio: ["inherit", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
441
+ } catch (e) {
442
+ const err = String(e.stderr || e.message || "").trim();
443
+ if (err) process.stderr.write(" " + err.split("\n").join("\n ") + "\n");
444
+ 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`);
445
+ fail(`no pude pushear la branch '${branch}'.`, 1);
446
+ }
428
447
 
429
448
  const cmd = tool === "gh"
430
449
  ? ["pr", "create", "--title", title, "--body-file", bodyFile, "--base", base,
@@ -439,12 +458,20 @@ async function cmdPr(opts) {
439
458
  try { rmSync(bodyFile); } catch { /* noop */ }
440
459
  } catch (e) {
441
460
  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)) {
461
+ const manual = ` Comando listo para correr a mano:\n ${tool} ${cmd.map((c) => /\s/.test(c) ? `'${c}'` : c).join(" ")}\n`;
462
+ if (e.code === "ENOENT") {
463
+ // El binario del forge no está instalado (el caso más común detrás de "no salió la MR").
464
+ const doc = tool === "glab" ? "https://gitlab.com/gitlab-org/cli/-/releases" : "https://cli.github.com";
465
+ warn(`'${tool}' no está instalado. El body quedó en ${bodyFile}.`);
466
+ 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}`);
467
+ } else if (/no history in common|no commits between|not found.*base|base.*not found/i.test(msg)) {
443
468
  warn(`la branch no comparte historia con '${base}' en el remoto (o '${base}' no existe allá).`);
444
469
  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
470
  } 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`);
471
+ warn(`no pude crear la PR con ${tool}. El body quedó en ${bodyFile}.`);
472
+ // El stderr real de gh/glab (auth vencida, host no configurado, flag desconocido…).
473
+ if (msg.trim()) process.stdout.write(` ${tool} dijo:\n ${msg.trim().split("\n").join("\n ")}\n`);
474
+ process.stdout.write(manual);
448
475
  }
449
476
  }
450
477
  }
@@ -595,8 +622,9 @@ function cmdInstallFrom(opts) {
595
622
  rmSync(t, { recursive: true, force: true }); cpSync(srcDir, t, { recursive: true }); writeFileSync(join(t, "SKILL.md"), skillToCursor(md));
596
623
  }
597
624
  if (want.copilot) {
598
- const pdir = join(repo, ".github", "prompts"); mkdirSync(pdir, { recursive: true });
599
- writeFileSync(join(pdir, `${name}.prompt.md`), skillToPrompt(md));
625
+ // Copilot lee SKILL.md nativo (Agent Skills, ADR-0014): copia cruda, con templates/.
626
+ const t = join(repo, ".github", "skills", name);
627
+ rmSync(t, { recursive: true, force: true }); mkdirSync(dirname(t), { recursive: true }); cpSync(srcDir, t, { recursive: true });
600
628
  }
601
629
  ok(name); installed++;
602
630
  }
@@ -763,7 +791,7 @@ async function cmdInit(repo, opts) {
763
791
  // así que sí lo inicializamos nosotros — mapeando --for a sus tools. (Antes se
764
792
  // dejaba a medias porque se intentaba correr su modo interactivo anidado.)
765
793
  process.stdout.write("\n");
766
- const openspecPresent = () => { try { execFileSync(npmBin("openspec"), ["--version"], { stdio: "ignore" }); return true; } catch { return false; } };
794
+ const openspecPresent = () => { try { runNpmTool("openspec", ["--version"], { stdio: "ignore" }); return true; } catch { return false; } };
767
795
  const osTools = [want.claude && "claude", want.copilot && "github-copilot", want.cursor && "cursor"]
768
796
  .filter(Boolean).join(",") || "claude,github-copilot,cursor";
769
797
  const osHint = "para sumarlo después: npm i -g @fission-ai/openspec@latest && openspec init --tools " + osTools;
@@ -774,13 +802,13 @@ async function cmdInit(repo, opts) {
774
802
  } else if (installOpenspec) {
775
803
  let cliOk = openspecPresent();
776
804
  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(); }
805
+ 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
806
  catch { cliOk = false; }
779
807
  }
780
808
  if (cliOk) {
781
809
  try {
782
810
  info(`OpenSpec: inicializando en el repo (--tools ${osTools})…`);
783
- execFileSync(npmBin("openspec"), ["init", "--tools", osTools, "--force"], { stdio: "inherit", cwd: repo === "." ? process.cwd() : repo });
811
+ runNpmTool("openspec", ["init", "--tools", osTools, "--force"], { stdio: "inherit", cwd: repo === "." ? process.cwd() : repo });
784
812
  ok("OpenSpec: instalado e inicializado — genera design/tasks con /opsx:*");
785
813
  } catch {
786
814
  warn("OpenSpec: el CLI está pero falló `openspec init`. Ejecuta a mano en el repo:");
@@ -836,7 +864,7 @@ function cmdArchive(changeArg, opts) {
836
864
  const args = ["archive", change, "--yes"];
837
865
  if (opts.skipSpecs) args.push("--skip-specs");
838
866
  try {
839
- execFileSync(npmBin("openspec"), args, { stdio: "inherit", cwd: repo });
867
+ runNpmTool("openspec", args, { stdio: "inherit", cwd: repo });
840
868
  } catch (e) {
841
869
  fail(`openspec archive falló (¿tasks incompletas? ¿change inexistente?): ${String(e.message).split("\n")[0]}`, 1);
842
870
  }
@@ -861,11 +889,13 @@ function cmdSync(repo, opts) {
861
889
  } else {
862
890
  want = {
863
891
  claude: existsSync(join(repo, ".claude", "skills")),
864
- copilot: existsSync(join(repo, ".github", "prompts")),
892
+ // .github/skills es el layout nativo (ADR-0014); .github/prompts es el viejo, que
893
+ // seguimos detectando para migrar repos que quedaron en el formato anterior.
894
+ copilot: existsSync(join(repo, ".github", "skills")) || existsSync(join(repo, ".github", "prompts")),
865
895
  cursor: existsSync(join(repo, ".cursor", "skills")),
866
896
  };
867
897
  if (!want.claude && !want.copilot && !want.cursor)
868
- fail("no detecté asistentes instalados (.claude/.cursor/.github/prompts). Pasá --for.", 2);
898
+ fail("no detecté asistentes instalados (.claude/skills · .github/skills · .cursor/skills). Pasá --for.", 2);
869
899
  }
870
900
 
871
901
  const cliV = readFileSync(join(ROOT, "VERSION"), "utf8").trim();
@@ -900,11 +930,16 @@ function cmdSync(repo, opts) {
900
930
  const cPath = join(repo, "CLAUDE.md"), cCur = existsSync(cPath) ? readFileSync(cPath, "utf8") : "";
901
931
  writeFileSync(cPath, upsertBlock(cCur, constitution("claude")));
902
932
  });
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")));
933
+ if (want.copilot) step(`Copilot: .github/skills/ (${skills.length}) + copilot-instructions.md`, () => {
934
+ // Copilot lee SKILL.md nativo (ADR-0014): copia cruda, con templates/ igual que `dai init`.
935
+ const dir = join(repo, ".github", "skills"); mkdirSync(dir, { recursive: true });
936
+ for (const name of skills) cpSync(join(skillsSrc, name), join(dir, name), { recursive: true });
906
937
  const ciPath = join(repo, ".github", "copilot-instructions.md"), ciCur = existsSync(ciPath) ? readFileSync(ciPath, "utf8") : "";
907
938
  writeFileSync(ciPath, upsertBlock(ciCur, constitution("copilot")));
939
+ // Limpia los .prompt.md viejos de dai (ahora son skills nativas) para no duplicar cada /comando.
940
+ const pdir = join(repo, ".github", "prompts");
941
+ const stale = existsSync(pdir) ? stalePromptFiles(skills).filter((f) => existsSync(join(pdir, f))) : [];
942
+ for (const f of stale) rmSync(join(pdir, f), { force: true });
908
943
  });
909
944
  if (want.cursor) step(`Cursor: .cursor/skills/ (${skills.length}) + .cursor/rules/dai-constitution.mdc`, () => {
910
945
  const dir = join(repo, ".cursor", "skills"); mkdirSync(dir, { recursive: true });
@@ -963,7 +998,7 @@ function cmdUpgrade(opts) {
963
998
 
964
999
  let latestV;
965
1000
  try {
966
- latestV = execFileSync(npmBin("npm"), ["view", name, "version"], { encoding: "utf8" }).trim();
1001
+ latestV = runNpmTool("npm", ["view", name, "version"], { encoding: "utf8" }).trim();
967
1002
  } catch {
968
1003
  fail(`no pude consultar el registry (¿sin red?). Actualizá a mano: ${manual}`, 1);
969
1004
  }
@@ -979,7 +1014,7 @@ function cmdUpgrade(opts) {
979
1014
 
980
1015
  info(`actualizando v${plan.from} → v${plan.to} …`);
981
1016
  try {
982
- execFileSync(npmBin("npm"), ["install", "-g", `${name}@latest`], { stdio: "inherit" });
1017
+ runNpmTool("npm", ["install", "-g", `${name}@latest`], { stdio: "inherit" });
983
1018
  } catch {
984
1019
  fail(`el install falló. Probá a mano: ${manual}`, 1);
985
1020
  }
@@ -1093,7 +1128,8 @@ switch (cmd) {
1093
1128
  case "stamp": cmdStamp().catch((e) => fail(String(e.message))); break;
1094
1129
  case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
1095
1130
  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;
1131
+ case "pr":
1132
+ case "mr": cmdPr(opts).catch((e) => fail(String(e.message))); break; // `mr` = alias para GitLab (merge request)
1097
1133
  case "done": cmdDone(opts); break;
1098
1134
  case "archive": cmdArchive(pos[0], opts); break;
1099
1135
  case "install": cmdInstall(opts).catch((e) => fail(String(e.message))); break; // alias de `dai skills install`
@@ -1123,7 +1159,7 @@ switch (cmd) {
1123
1159
  " stamp estampa la cobertura en el tracker (ADR-0005)\n" +
1124
1160
  " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
1125
1161
  " 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" +
1162
+ " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1127
1163
  " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n\n" +
1128
1164
  "Instalación:\n" +
1129
1165
  " skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
@@ -92,10 +92,15 @@ export function skillToCursor(md) {
92
92
  }
93
93
 
94
94
  // Contenido del .env según el backend de PM elegido (tokens vacíos, a completar).
95
+ //
96
+ // Sin DAI_TRACKER_URL_TEMPLATE a propósito: con jira/clickup, dai deduce el link solo
97
+ // (lib/tracker-url.mjs). Scaffoldearlo era peor que no ponerlo — el template GANA sobre
98
+ // la URL canónica que devuelve el tracker, así que el `/t/{id}` que escribíamos acá
99
+ // tapaba la de ClickUp con team_id. Queda como override manual para trackers raros.
95
100
  export function envFor(pm) {
96
101
  const head = "# Config de dai — completá lo que falte. NUNCA commitees tokens (.env está gitignored).\n";
97
102
  if (pm === "clickup") {
98
- return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\nDAI_TRACKER_URL_TEMPLATE=https://app.clickup.com/t/{id}\n";
103
+ return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n";
99
104
  }
100
105
  if (pm === "jira") {
101
106
  return head +
@@ -107,8 +112,7 @@ export function envFor(pm) {
107
112
  "DAI_JIRA_PROJECT=\n" +
108
113
  "DAI_JIRA_ISSUETYPE=Story\n" +
109
114
  "# Campos propios que tu Jira exige al crear. Si el archivo no existe, se ignora.\n" +
110
- "DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n" +
111
- "DAI_TRACKER_URL_TEMPLATE=\n";
115
+ "DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n";
112
116
  }
113
117
  return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n";
114
118
  }
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
  "",
@@ -7,9 +7,12 @@
7
7
  // clickup — REST v2 (pm-clickup.mjs)
8
8
  //
9
9
  // Interfaz (fetchUS/stamp pueden ser sync o async — el CLI siempre await-ea):
10
- // fetchUS(id) → { id, title, spec_version, ac_hash } | null
10
+ // fetchUS(id) → { id, title, spec_version, ac_hash, url } | null
11
11
  // stamp(id, record) → destino donde quedó la cobertura
12
12
  // kind → nombre del backend
13
+ //
14
+ // `url` es la URL web canónica de la US según el tracker (opcional: null si el backend
15
+ // no la sabe, como md). El CLI la prefiere sobre la derivada — ver tracker-url.mjs.
13
16
 
14
17
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
15
18
  import { join, dirname } from "node:path";
@@ -29,7 +29,9 @@ export function clickupAdapter(env) {
29
29
  const res = await fetch(clickupTaskUrl(id), { headers: clickupAuthHeaders(env) });
30
30
  if (res.status === 404) return null;
31
31
  if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
32
- return { id, ...parseUS(clickupTaskToText(await res.json())) };
32
+ const j = await res.json();
33
+ // `url` es la canónica (/t/<team_id>/<id>): la sabe ClickUp, no la deducimos.
34
+ return { id, ...parseUS(clickupTaskToText(j)), url: j.url || null };
33
35
  },
34
36
  async stamp(id, record) {
35
37
  const res = await fetch(clickupCommentUrl(id), {
@@ -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) {
@@ -127,7 +143,7 @@ export function jiraAdapter(env) {
127
143
  const res = await daiFetch(jiraIssueUrl(base, id), { headers: jiraAuthHeaders(env) });
128
144
  if (res.status === 404) return null;
129
145
  if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
130
- return { id, ...parseUS(jiraIssueToText(await res.json())) };
146
+ return { id, ...parseUS(jiraIssueToText(await res.json())), url: `${trim(base)}/browse/${id}` };
131
147
  },
132
148
  async stamp(id, record) {
133
149
  const res = await daiFetch(jiraCommentUrl(base, id), {
@@ -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/cli/lib/pr.mjs CHANGED
@@ -29,15 +29,44 @@ export function composePrBody(template, d) {
29
29
  if (d.commits && d.commits.length) {
30
30
  b = replaceSection(b, "Cambios realizados", d.commits.map((c) => `- [x] ${c}`).join("\n"));
31
31
  }
32
- // Bloque de enlaces (lo agrega dai; el humano completa el resto del template).
33
- const links = ["", "<!-- Enlaces precargados por `dai pr` -->"];
34
- if (d.usUrl) links.push(`- US: ${d.usUrl}`);
32
+ return upsertLinksBlock(b, d);
33
+ }
34
+
35
+ // ── Bloque de enlaces ────────────────────────────────────────────────────────
36
+ // Delimitado y regenerable a propósito. Con el comentario suelto de antes, cualquier
37
+ // agente que reescribiera "Enlaces relacionados" se lo llevaba puesto sin dejar rastro
38
+ // (pasó en PRs reales). Con marcadores, el bloque se detecta, se preserva y se
39
+ // regenera — y quien edite el body ve que es de dai y que se pisa solo.
40
+ export const LINKS_START = "<!-- dai:links:start · generado por `dai pr` — no editar a mano -->";
41
+ export const LINKS_END = "<!-- dai:links:end -->";
42
+
43
+ // Los links que dai sabe. Sin URL no inventa la línea: prefiere no decir nada.
44
+ export function renderLinks(d) {
45
+ const links = [LINKS_START];
46
+ if (d.usUrl) links.push(`- US \`${d.id}\`: ${d.usUrl}`);
35
47
  if (d.branchUrl) links.push(`- branch \`${d.branch}\`: ${d.branchUrl}`);
36
48
  if (d.commitUrl) links.push(`- commit \`${(d.commit || "").slice(0, 8)}\`: ${d.commitUrl}`);
37
- return b.replace(/(##\s*Enlaces relacionados\s*\n)(<!--[\s\S]*?-->)?/i,
38
- (m, h) => `${h}${links.join("\n")}\n`) === b
39
- ? b + "\n" + links.join("\n") + "\n" // si no había sección Enlaces, apéndela
40
- : b.replace(/(##\s*Enlaces relacionados\s*\n)(<!--[\s\S]*?-->)?/i, (m, h) => `${h}${links.join("\n")}\n`);
49
+ links.push(LINKS_END);
50
+ return links.join("\n");
51
+ }
52
+
53
+ // Inserta o reemplaza el bloque de dai. Idempotente: correrlo N veces da lo mismo.
54
+ export function upsertLinksBlock(body, d) {
55
+ const block = renderLinks(d);
56
+ // 1. ¿Ya está el bloque delimitado? Se reemplaza entero (regenerar, no duplicar).
57
+ const delimited = /<!--\s*dai:links:start[\s\S]*?dai:links:end\s*-->/i;
58
+ if (delimited.test(body)) return body.replace(delimited, block);
59
+ // 2. ¿Está la sección del template? El bloque va debajo del heading, PRESERVANDO el
60
+ // hint HTML si lo hay: es la guía para quien edite (y es invisible al renderizar).
61
+ // dai suma, no borra — borrar el texto de otro es justo lo que estamos arreglando.
62
+ // (el `\s*` tolera la línea en blanco entre el heading y el hint; como solo matchea
63
+ // espacios, no puede saltar a la sección siguiente para buscarse un comentario)
64
+ const heading = /(^|\n)(##[^\n]*Enlaces relacionados[^\n]*\n)(\s*<!--[\s\S]*?-->[ \t]*\n)?/i;
65
+ if (heading.test(body)) {
66
+ return body.replace(heading, (m, pre, h, hint) => `${pre}${h}${hint || ""}\n${block}\n`);
67
+ }
68
+ // 3. Ni bloque ni sección: se apéndea con su propio heading.
69
+ return `${body.replace(/\s*$/, "")}\n\n## Enlaces relacionados\n\n${block}\n`;
41
70
  }
42
71
 
43
72
  // Título del PR: el pasado a mano, o "<ID>: <título de la US>", o solo el ID.
@@ -0,0 +1,36 @@
1
+ // dai · la URL de la US en el tracker. Puro, sync, sin red (ADR-0007: config por .env).
2
+ //
3
+ // Cadena de resolución, del más específico al más general:
4
+ // 1. DAI_TRACKER_URL_TEMPLATE — override explícito del usuario: siempre gana.
5
+ // 2. La URL que devolvió el tracker en `fetchUS` — la canónica (ClickUp la trae con
6
+ // el team_id; la derivada no puede saberlo). Solo existe si hubo red.
7
+ // 3. Derivada del backend + su config — determinística y offline.
8
+ // 4. `null` — dai NO sabe la URL.
9
+ //
10
+ // El paso 4 es el que importa. Antes devolvíamos el `id` pelado, y como un string es
11
+ // truthy, quien consumía esto lo escribía igual — un id disfrazado de enlace, sin un
12
+ // solo aviso. Preferimos no decir nada antes que mentir: quien llama decide si omite
13
+ // la línea o avisa.
14
+
15
+ // URL web de la US deducida del backend, sin salir a la red.
16
+ export function deriveTrackerUrl(id, env = {}) {
17
+ if (!id) return null;
18
+ const kind = String(env.DAI_PM || "md").toLowerCase();
19
+ if (kind === "clickup") {
20
+ // /t/<id> redirige a la canónica /t/<team_id>/<id>. Sin token no sabemos el team.
21
+ return `https://app.clickup.com/t/${encodeURIComponent(id)}`;
22
+ }
23
+ if (kind === "jira") {
24
+ const base = String(env.DAI_JIRA_BASE_URL || "").replace(/\/+$/, "");
25
+ return base ? `${base}/browse/${encodeURIComponent(id)}` : null;
26
+ }
27
+ return null; // md: la US es un archivo local, no tiene URL web
28
+ }
29
+
30
+ // La URL final, o null si no hay forma de saberla. `liveUrl` es la que trajo fetchUS.
31
+ export function trackerUrl(id, { env = {}, liveUrl = null } = {}) {
32
+ if (!id) return null;
33
+ const tpl = env.DAI_TRACKER_URL_TEMPLATE;
34
+ if (tpl) return String(tpl).replace(/\{id\}/g, id);
35
+ return liveUrl || deriveTrackerUrl(id, env);
36
+ }
package/docs/PROBAR.md CHANGED
@@ -65,8 +65,9 @@ Si esto anda, el flujo está bien. Pasa al tracker real.
65
65
  cat > .env <<'EOF'
66
66
  DAI_PM=clickup
67
67
  DAI_CLICKUP_TOKEN=pk_XXXXXXXX
68
- DAI_TRACKER_URL_TEMPLATE=https://app.clickup.com/t/{id}
69
68
  EOF
69
+ # El link a la tarea lo deduce dai solo. Solo si tu tracker vive en otra URL:
70
+ # DAI_TRACKER_URL_TEMPLATE=https://mi-tracker/t/{id}
70
71
  dai doctor # confirma DAI_PM=clickup y el token
71
72
 
72
73
  dai link-us 86cxyz # trae la US de ClickUp → branch + implements.yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.8.0",
3
+ "version": "0.8.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/",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dai-review
3
- description: "Revisa una Pull/Merge Request de un repo remoto (GitHub o GitLab) de forma consciente de la metodología dai, y deja un comentario estándar en español con errores y mejoras. Corre `dai check` (¿la US está atrasada?), valida el Definition of Done, hace el review de código (correctitud + calidad), compone el comentario estándar y lo postea — vía el MCP del forge si está disponible, o vía `dai forge comment` (token) si no. Invocar como /dai-review <URL-de-la-PR o número>. Usar en el paso 6 de SCRUM-CON-IA (code review), antes de que un partner humano firme."
3
+ description: "Revisa una Pull/Merge Request de un repo remoto (GitHub o GitLab) de forma consciente de la metodología dai, y deja un comentario estándar en español con errores y mejoras. Corre `dai check` (¿la US está atrasada?), valida el Definition of Done, hace el review de código (correctitud + calidad), compone el comentario estándar, TE LO MUESTRA Y ESPERA TU OK, y recién entonces lo postea — vía el MCP del forge si está disponible, o vía `dai forge comment` (token) si no. Nunca postea sin aprobación explícita: sale con tu nombre y tu token. Invocar como /dai-review <URL-de-la-PR o número>. Usar en el paso 6 de SCRUM-CON-IA (code review), antes de que un partner humano firme."
4
4
  ---
5
5
 
6
6
  # dai-review — review de PR consciente de la metodología
@@ -39,7 +39,11 @@ Mismo comentario estándar, dos formas de dejarlo (elige la disponible, en este
39
39
  - 🟡 **Mejoras** de calidad (reuso, simplicidad, eficiencia).
40
40
  - ✅ Lo que está **bien** (refuerza lo bueno).
41
41
  5. **Componer el comentario estándar** (ver formato abajo).
42
- 6. **Postear** por MCP o `dai forge comment`. Confirmar el link al comentario.
42
+ 6. **Mostrarlo y esperar el OK.** Imprimí el comentario **entero**, tal cual va a salir,
43
+ y preguntá: _"¿lo posteo así, lo edito, o lo descarto?"_ **Frená ahí.** Si te piden
44
+ cambios, aplicalos y volvé a mostrarlo. No hay atajo: sin un "sí" explícito en este
45
+ turno, no se postea. Un "sí" de una PR anterior no cuenta para esta.
46
+ 7. **Postear** por MCP o `dai forge comment`. Confirmar el link al comentario.
43
47
 
44
48
  ## El comentario estándar
45
49
 
@@ -64,12 +68,20 @@ Es el mismo que emite `renderReviewComment` del CLI — respeta esta forma:
64
68
  _Revisión asistida por dai. La aprobación la firma un humano (Art. 5 del manifiesto)._
65
69
  ```
66
70
 
67
- ## Dos cortes duros
71
+ ## Tres cortes duros
68
72
 
69
73
  1. **No aprobar.** La skill **comenta**, no firma la aprobación. Eso es de un humano.
70
- 2. **Hallazgos concretos.** Nada de "mejorar la calidad" en abstracto: archivo, línea,
74
+ 2. **No postear sin OK.** El comentario sale **con el token del humano y con su nombre**
75
+ (`GITHUB_TOKEN`/`GITLAB_TOKEN` son suyos): en la PR de un compañero figura como si lo
76
+ hubiera escrito él. Publicar un juicio sobre el código de otro, firmado por alguien
77
+ que no lo leyó, es tan grave como aprobar sin mirar. El paso 6 no es opcional.
78
+ 3. **Hallazgos concretos.** Nada de "mejorar la calidad" en abstracto: archivo, línea,
71
79
  y el porqué. Si no es accionable, no va.
72
80
 
81
+ > **Por qué el corte 2 existe:** esta skill posteaba directo. El Art. 5 estaba bien
82
+ > leído en la letra —no clickeaba Approve— y mal puesto en la práctica: te dejaba
83
+ > firmar en público un review que nunca viste. Que salga bueno era suerte, no diseño.
84
+
73
85
  ## Relación con el modelo
74
86
 
75
87
  - Es el paso 6 de [`SCRUM-CON-IA.md`](../../docs/SCRUM-CON-IA.md).
@@ -42,7 +42,11 @@
42
42
 
43
43
  ## Enlaces relacionados
44
44
 
45
- <!-- US en el tracker, commit ancla, docs, issues. -->
45
+ <!--
46
+ La US, la branch y el commit ancla los precarga `dai pr` en el bloque `dai:links`
47
+ de abajo: NO los escribas a mano ni reescribas ese bloque (se regenera y te lo pisa).
48
+ Acá abajo sumá solo lo que dai no sabe: docs, issues, PRs relacionadas, dependencias.
49
+ -->
46
50
 
47
51
  ---
48
52