@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 +5 -2
- package/CHANGELOG.md +110 -0
- package/README.md +33 -8
- package/VERSION +1 -1
- package/cli/dai.mjs +64 -28
- package/cli/lib/bootstrap.mjs +7 -3
- package/cli/lib/http.mjs +7 -1
- package/cli/lib/pm-adapter.mjs +4 -1
- package/cli/lib/pm-clickup.mjs +3 -1
- package/cli/lib/pm-jira.mjs +30 -11
- package/cli/lib/pr.mjs +36 -7
- package/cli/lib/tracker-url.mjs +36 -0
- package/docs/PROBAR.md +2 -1
- package/package.json +1 -1
- package/skills/dai-review/SKILL.md +16 -4
- package/templates/pull-request.md +5 -1
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 (
|
|
10
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
-
│ ├──
|
|
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.
|
|
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
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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}
|
|
447
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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 {
|
|
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)…");
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/
|
|
904
|
-
|
|
905
|
-
|
|
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 =
|
|
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
|
-
|
|
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":
|
|
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" +
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -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=\
|
|
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
|
|
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
|
"",
|
package/cli/lib/pm-adapter.mjs
CHANGED
|
@@ -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";
|
package/cli/lib/pm-clickup.mjs
CHANGED
|
@@ -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
|
-
|
|
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), {
|
package/cli/lib/pm-jira.mjs
CHANGED
|
@@ -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
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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)
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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. **
|
|
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
|
-
##
|
|
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. **
|
|
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
|
-
<!--
|
|
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
|
|