@dforce2055/dai 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/cli/dai.mjs +136 -39
- package/cli/lib/bootstrap.mjs +43 -2
- package/cli/lib/branch-scope.mjs +61 -5
- package/cli/lib/link-us.mjs +5 -2
- package/cli/lib/pr.mjs +21 -1
- package/docs/EJEMPLO-END-TO-END.md +1 -1
- package/docs/glosario.md +1 -1
- package/docs/guias/dev.md +11 -1
- package/docs/public/tutoriales/funcional-1-skills-usuario.png +0 -0
- package/docs/public/tutoriales/funcional-2-copilot-signin.png +0 -0
- package/docs/public/tutoriales/funcional-3-carpeta-configurada.png +0 -0
- package/docs/public/tutoriales/funcional-4-doctor.png +0 -0
- package/docs/public/tutoriales/funcional-5-publish-parent.png +0 -0
- package/docs/tutoriales/index.md +9 -0
- package/docs/tutoriales/setup-dev.md +616 -0
- package/docs/tutoriales/setup-funcional.md +480 -0
- package/package.json +1 -1
- package/skills/link-us/SKILL.md +1 -1
- package/skills/link-us/templates/implements.yaml +4 -2
- package/templates/definition-of-done.md +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,151 @@
|
|
|
3
3
|
Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
|
|
4
4
|
(ver `VERSION`).
|
|
5
5
|
|
|
6
|
+
## [0.13.0] — 2026-08-21
|
|
7
|
+
|
|
8
|
+
**Un dev de backend en Windows siguió el tutorial al pie de la letra y el agente se puso a
|
|
9
|
+
programar sin escribir la propuesta. No era Windows ni era su setup: le estábamos diciendo mal
|
|
10
|
+
el nombre del comando.**
|
|
11
|
+
|
|
12
|
+
### Arreglado
|
|
13
|
+
- **Los comandos de OpenSpec se documentaban solo en la forma de Claude Code.** OpenSpec
|
|
14
|
+
genera un archivo distinto por asistente, y el nombre del comando sale del archivo:
|
|
15
|
+
`.claude/commands/opsx/<id>.md` → `/opsx:propose`, pero
|
|
16
|
+
`.github/prompts/opsx-<id>.prompt.md` → `/opsx-propose` (Copilot) y
|
|
17
|
+
`.cursor/commands/opsx-<id>.md` → `/opsx-propose` (Cursor). O sea: **solo Claude usa los
|
|
18
|
+
dos puntos**, y el tutorial de setup del dev —que es el de Windows + Copilot— mostraba los
|
|
19
|
+
dos puntos en los tres pasos.
|
|
20
|
+
El síntoma no se parece en nada a la causa, y ahí está el daño: tipear `/opsx:propose` en
|
|
21
|
+
Copilot **no da error**. No matchea ningún comando, el workflow nunca se carga, y el agente
|
|
22
|
+
toma el texto suelto como una charla — se saltea el gate propose → aprobación → apply y
|
|
23
|
+
arranca a implementar. Se lee como "el bot hace lo que quiere" y se le echa la culpa al
|
|
24
|
+
asistente, al sistema operativo o al método. Es vibe coding servido por la documentación.
|
|
25
|
+
Ahora `dai init` imprime la forma que le toca a **tu** asistente (`opsxHint`), el tutorial
|
|
26
|
+
de Windows lo dice explícito con su propia entrada en *Cuando algo falla*, y la guía del
|
|
27
|
+
dev aclara que la forma con dos puntos es la de Claude.
|
|
28
|
+
- **`dai check` terminaba con código de error en Windows aunque el chequeo pasara**
|
|
29
|
+
(`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94`).
|
|
30
|
+
Salía con `process.exit()` inmediatamente después del `fetch` al tracker, y en Windows eso
|
|
31
|
+
aborta el proceso mientras undici todavía está desarmando sus handles. Imprimía
|
|
32
|
+
`✅ al día` y devolvía distinto de cero igual: un gate verde reportado como rojo en CI o en
|
|
33
|
+
un hook de git — justo el modo de falla que apaga un gate.
|
|
34
|
+
Ahora el código de salida se fija con `process.exitCode` y el event loop drena solo. Cuesta
|
|
35
|
+
~40 ms (los sockets keep-alive de undici están *unref'd*) y no cambia nada en macOS/Linux.
|
|
36
|
+
El mismo tratamiento va para los `.catch()` del dispatcher de **todos** los comandos que
|
|
37
|
+
salen a la red o spawnean npm (`link-us`, `stamp`, `update-us`, `edit-us`, `forge`,
|
|
38
|
+
`publish`, `pr`/`mr`, `install`, `init`), con `failSoft()`: ahí el comando ya terminó, así
|
|
39
|
+
que cortar de una no aportaba nada y podía tapar el mensaje de error con un stack de C.
|
|
40
|
+
Adentro de un comando `fail()` sigue saliendo de una — ahí sí hay que no volver.
|
|
41
|
+
|
|
42
|
+
### Agregado
|
|
43
|
+
- **Regla nueva en la constitución: *el diseño se aprueba antes de implementar*.** Al terminar
|
|
44
|
+
la propuesta el agente para y pide aprobación explícita; si no tiene una herramienta para
|
|
45
|
+
preguntar, pregunta en texto plano y espera. Vale para los tres asistentes. Es el borde
|
|
46
|
+
QUÉ↔CÓMO, que sí es dominio de dai: sin esa firma, la implementación no tiene contra qué
|
|
47
|
+
revisarse — y el gate no puede depender de que el asistente de turno tenga la herramienta
|
|
48
|
+
correcta. Los repos ya inicializados la reciben con `dai sync`.
|
|
49
|
+
- **`dai doctor` avisa si OpenSpec está por debajo de 1.10.0.** Hasta esa versión, los prompts
|
|
50
|
+
que OpenSpec generaba para Copilot y Cursor nombraban los comandos en la forma de Claude
|
|
51
|
+
(`/opsx:apply`, que ahí no existe) e invocaban `AskUserQuestion`/`TodoWrite`, que solo tiene
|
|
52
|
+
Claude Code. El agente terminaba nombrando comandos inexistentes y salteándose el gate de
|
|
53
|
+
aprobación porque su única forma de preguntar no existía. Está arreglado upstream
|
|
54
|
+
([#727](https://github.com/Fission-AI/OpenSpec/issues/727),
|
|
55
|
+
[#1307](https://github.com/Fission-AI/OpenSpec/issues/1307),
|
|
56
|
+
[#1103](https://github.com/Fission-AI/OpenSpec/issues/1103)), pero un equipo que instaló
|
|
57
|
+
antes se queda con la versión vieja y el síntoma no se parece a la causa.
|
|
58
|
+
- **`dai doctor` reporta los comandos de OpenSpec**, con la forma que le toca a **este** repo:
|
|
59
|
+
`✓ Copilot: /opsx-explore · /opsx-propose · /opsx-apply · /opsx-archive`. Si no están
|
|
60
|
+
generados, dice el comando exacto para generarlos —con el tool id que espera OpenSpec, que
|
|
61
|
+
para Copilot es `github-copilot` y no `copilot`—. Solo mira los asistentes configurados en
|
|
62
|
+
el repo: tener las skills instaladas globalmente no dice nada de los comandos, que son
|
|
63
|
+
archivos versionados.
|
|
64
|
+
|
|
65
|
+
### Cambiado
|
|
66
|
+
- **`introduces` se cierra al TERMINAR de implementar, y lo normal es que lo escriba el
|
|
67
|
+
agente.** El tutorial del dev pedía completarlo justo después de `dai link-us` — cuando
|
|
68
|
+
todavía no se puede saber qué capacidades técnicas va a introducir el change; al empezar
|
|
69
|
+
sería adivinar. Y la skill `link-us` decía explícito "dejar `introduces` para que el dev lo
|
|
70
|
+
liste", cargándole a mano un dato que sabe mejor quien acaba de implementar.
|
|
71
|
+
La frase "el único archivo que se autora **a mano**" empujaba el malentendido: lo que dice
|
|
72
|
+
el ADR-0004 es que es el único registro **autorado** —se escribe, no se deriva—, y `link-us`
|
|
73
|
+
ya resuelve `id`, `version`, `ac_hash`, `change`, `repo` y `autor` por construcción.
|
|
74
|
+
Ahora lo dicen igual el archivo generado, la skill, su template, el tutorial (con el paso de
|
|
75
|
+
cierre después de `/opsx-apply`), la guía del dev, el glosario y el ejemplo end-to-end. El
|
|
76
|
+
**DoD suma el ítem**: `introduces` cerrado, sin el placeholder `<capacidad-tecnica>`.
|
|
77
|
+
- **El DoD nombra los comandos de OpenSpec sin atarlos a un asistente.** Decía
|
|
78
|
+
`opsx:apply` → `opsx:archive`, la forma de Claude, en un template que viaja a los tres.
|
|
79
|
+
- **`DAI_JIRA_FIELDS_FILE` sale comentada en el `.env.dai` que genera `dai init`.** Apuntaba
|
|
80
|
+
al mismo valor que ya usa el CLI por defecto, así que no aportaba nada — pero hacía creer
|
|
81
|
+
que faltaba un archivo obligatorio, y un dev terminó pidiendo los `customfield_*` de la
|
|
82
|
+
empresa para un archivo que **solo hace falta para crear** US, no para leerlas.
|
|
83
|
+
|
|
84
|
+
### Interno
|
|
85
|
+
- **325 tests** (+6 desde 0.12.0): `opsxCommand` y `opsxHint` —que solo Claude lleva los dos
|
|
86
|
+
puntos, y la línea combinada de `dai init` cuando el repo configura varios asistentes—, el
|
|
87
|
+
archivo y el tool id de OpenSpec por asistente (`github-copilot`, que no es `copilot`),
|
|
88
|
+
`OPENSPEC_MIN`, que `DAI_JIRA_FIELDS_FILE` salga comentada, y que la regla nueva de la
|
|
89
|
+
constitución llegue a los tres asistentes.
|
|
90
|
+
|
|
91
|
+
## [0.12.0] — 2026-08-13
|
|
92
|
+
|
|
93
|
+
**La PR deja de robarle la US a otro. `dai pr` resolvía el link recorriendo todo el repo y
|
|
94
|
+
quedándose con el último `implements.yaml`; ahora lo resuelve la rama, que es la que sabe la
|
|
95
|
+
respuesta — y cuando no puede saberlo, pregunta en vez de elegir en silencio. Más el tutorial
|
|
96
|
+
de setup del dev, la contraparte del que ya tenía el funcional.**
|
|
97
|
+
|
|
98
|
+
### Arreglado
|
|
99
|
+
- **`dai pr` armaba la PR con la US equivocada** (issues [#31](https://github.com/dforce2055/dai/issues/31),
|
|
100
|
+
[#32](https://github.com/dforce2055/dai/issues/32), [#33](https://github.com/dforce2055/dai/issues/33)).
|
|
101
|
+
Recorría **todos** los `implements.yaml` del repo —archivados incluidos, porque era el
|
|
102
|
+
único comando que no pasaba `{ includeArchived: false }`— y el `break` cortaba solo el
|
|
103
|
+
bucle interno, así que ganaba el **último** en orden de lectura. La rama, que la nombra el
|
|
104
|
+
propio `dai link-us`, no entraba en la decisión.
|
|
105
|
+
El síntoma es silencioso y por eso duele: la PR sale con el título, el link y el
|
|
106
|
+
`dai check ✅` de **otra** US. En repos reales convivieron dos PRs con el mismo título y
|
|
107
|
+
contenidos que no tenían nada que ver, y una PR de archivado apareció rotulada con la
|
|
108
|
+
historia de un compañero. Es exactamente el modo de falla que la constitución quiere
|
|
109
|
+
evitar — el link QUÉ↔CÓMO queda mal y nadie se entera, porque **nadie lee el
|
|
110
|
+
`implements.yaml` en la lista de PRs: leen el título**.
|
|
111
|
+
Ahora decide `prScope` (`cli/lib/branch-scope.mjs`), hermana de `stampScope`: la rama
|
|
112
|
+
nombra una US viva → esa; una sola US viva → esa; varias candidatas → **pregunta** con TTY
|
|
113
|
+
y **falla** sin TTY, listándolas.
|
|
114
|
+
- **Una rama exenta ya no hereda la US del repo.** Un `chore/`/`docs/`/`release/` que no
|
|
115
|
+
nombra ninguna US genera la PR **sin** US —título del último commit y la sección
|
|
116
|
+
*Implementa* diciendo que no hay historia— en lugar de colgarle la de otro o dejar el
|
|
117
|
+
placeholder `ABC-###` del template.
|
|
118
|
+
- **`dai done` anunciaba `US cerrada:` con todas las US del repo**, archivadas incluidas.
|
|
119
|
+
Cerrar una rama informaba el cierre de medio sprint. Mismo defecto de clase, en un mensaje.
|
|
120
|
+
- **Ctrl+D en las preguntas de `dai pr`** cancela en vez de cortar con `Aborted with Ctrl+D`.
|
|
121
|
+
Todas ellas preceden a una acción hacia afuera (push + PR): ahí abortar es lo seguro.
|
|
122
|
+
|
|
123
|
+
### Agregado
|
|
124
|
+
- **`dai pr --us <ID>`** — el escape hatch explícito, y la única salida cuando hay ambigüedad
|
|
125
|
+
y no hay TTY (un pipeline). Acepta también un change ya archivado.
|
|
126
|
+
- **El preview dice de dónde salió la US**: `US: ABC-482 — la branch '…' nombra ABC-482`.
|
|
127
|
+
Cuando el título está mal, es lo único que lo delata.
|
|
128
|
+
- **[Tutorial de setup para desarrolladores (Windows)](docs/tutoriales/setup-dev.md)** — el
|
|
129
|
+
otro lado del que ya existía para el funcional: Node, dai, git + SSH + `glab`, las skills
|
|
130
|
+
en Copilot, OpenSpec, el `.env.dai` contra Jira, y el ciclo completo sobre una US real
|
|
131
|
+
(`link-us` → `check` → `mr` → `stamp` → `done`). El troubleshooting sale de lo que pasó de
|
|
132
|
+
verdad en Windows corporativo: el push HTTPS que necesita completar el credential manager,
|
|
133
|
+
el proxy con su propio certificado, el gate de CI.
|
|
134
|
+
|
|
135
|
+
### Versionado
|
|
136
|
+
|
|
137
|
+
**Minor → 0.12.0.** `dai pr` **cambia de comportamiento**: donde antes elegía una US en
|
|
138
|
+
silencio, ahora pregunta (o falla), y una rama exenta genera la PR sin US. En el papel es
|
|
139
|
+
incompatible; en la práctica el comportamiento viejo era el bug de los issues #31/#32/#33.
|
|
140
|
+
Suma la flag `--us`, aditiva. El contrato del modelo (`ac_hash`, schema de `implements.yaml`)
|
|
141
|
+
queda intacto.
|
|
142
|
+
|
|
143
|
+
### Interno
|
|
144
|
+
- **319 tests** (+12 desde 0.11.0): `prScope` ×10 —la rama manda sobre el orden de
|
|
145
|
+
directorio, los archivados no compiten, una `chore/` no hereda, la ambigüedad no se
|
|
146
|
+
resuelve sola— y el cuerpo/título de una PR sin US ×2.
|
|
147
|
+
- `requiresLink()` devuelve además `kind` (`always` / `exempt` / `untyped`): es lo que separa
|
|
148
|
+
"exenta por tipo" de "la rama no dice nada", y lo que `dai pr` necesitaba para no heredar
|
|
149
|
+
la US de otro sin romper el gate de CI.
|
|
150
|
+
|
|
6
151
|
## [0.11.0] — 2026-07-22
|
|
7
152
|
|
|
8
153
|
**Ronda de fixes reportados usándola, más el eslabón que faltaba: editar el QUÉ. `dai stamp`
|
|
@@ -551,6 +696,8 @@ ClickUp y Jira Cloud.
|
|
|
551
696
|
- Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
|
|
552
697
|
`files` de npm sin tests ni secretos.
|
|
553
698
|
|
|
699
|
+
[0.13.0]: https://github.com/dforce2055/dai/releases/tag/v0.13.0
|
|
700
|
+
[0.12.0]: https://github.com/dforce2055/dai/releases/tag/v0.12.0
|
|
554
701
|
[0.11.0]: https://github.com/dforce2055/dai/releases/tag/v0.11.0
|
|
555
702
|
[0.10.0]: https://github.com/dforce2055/dai/releases/tag/v0.10.0
|
|
556
703
|
[0.9.0]: https://github.com/dforce2055/dai/releases/tag/v0.9.0
|
package/README.md
CHANGED
|
@@ -201,7 +201,7 @@ flowchart TD
|
|
|
201
201
|
| `dai link-us <ID> --resync` | re-estampa el `ac_hash` contra la US viva (tras un ⚠️ de check) |
|
|
202
202
|
| `dai check` | compara tu código vs la US viva → ✅ al día / ⚠️ atrasado (exit code = gate de PR) |
|
|
203
203
|
| `dai ls [--json]` | lista las US que implementa el repo + su link al tracker |
|
|
204
|
-
| `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 |
|
|
204
|
+
| `dai pr [--assignee u] [--base b] [--draft] [--yes] [--us ID] [--title t]` · alias **`dai mr`** | crea TU PR/MR precargada: pregunta la branch base (default `main`), muestra el texto y confirma antes de publicar. Detecta el forge (GitHub→PR con `gh` · GitLab→MR con `glab`); `mr` es el mismo comando, más natural en GitLab. **La US la resuelve la branch** (la nombra `dai link-us`): si hay varias vivas y ninguna coincide, **pregunta** en vez de elegir por vos (sin TTY falla pidiendo `--us <ID>`), y una branch `chore/`/`docs/` sale **sin US** en lugar de heredar la de otro |
|
|
205
205
|
| `dai stamp` | estampa la cobertura inversa en el tracker (branch + commit-ancla) |
|
|
206
206
|
| `dai done [--base main] [--force]` | cierra la US: vuelve a la base, `fetch --prune` + `pull`, y borra la branch local **si está mergeada** (chequeo estricto; `--force` la borra igual). Redes: no estar en la base, sin cambios sueltos, sin commits sin pushear |
|
|
207
207
|
| `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` |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.13.0
|
package/cli/dai.mjs
CHANGED
|
@@ -30,12 +30,12 @@ import { parseFindings, diffPositions, validateFindings, filterFindings, renderF
|
|
|
30
30
|
import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
|
|
31
31
|
import { dirsEqual } from "./lib/fsutil.mjs";
|
|
32
32
|
import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
|
|
33
|
-
import { versionDrift, planUpgrade } from "./lib/semver.mjs";
|
|
33
|
+
import { versionDrift, planUpgrade, compareVersions } from "./lib/semver.mjs";
|
|
34
34
|
import { parseSource } from "./lib/skills-source.mjs";
|
|
35
|
-
import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles } from "./lib/bootstrap.mjs";
|
|
35
|
+
import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles, opsxHint, opsxCommand, OPSX_COMMAND_FILE, OPENSPEC_TOOL, OPENSPEC_MIN } from "./lib/bootstrap.mjs";
|
|
36
36
|
import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/jira-fields.mjs";
|
|
37
37
|
import { assertProjectKey } from "./lib/pm-jira.mjs";
|
|
38
|
-
import { flattenImplements, stampScope, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
|
|
38
|
+
import { flattenImplements, stampScope, prScope, matchBranchToImplements, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
|
|
39
39
|
import { describeForgeError, parseForgeError } from "./lib/forge-api.mjs";
|
|
40
40
|
import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion } from "./lib/us-format.mjs";
|
|
41
41
|
|
|
@@ -45,6 +45,13 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
45
45
|
process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); throw e; });
|
|
46
46
|
|
|
47
47
|
function fail(msg, code = 1) { process.stderr.write("dai: " + msg + "\n"); process.exit(code); }
|
|
48
|
+
|
|
49
|
+
// Igual que fail(), pero sin process.exit(). Lo usan los `.catch()` del dispatcher: ahí el
|
|
50
|
+
// comando YA terminó, así que cortar de una no aporta nada y en Windows cuesta caro — salir
|
|
51
|
+
// de golpe después de un fetch (o de un spawn de npm) aborta el proceso con un assert de
|
|
52
|
+
// libuv y tapa el mensaje de error con un stack de C. Ver la nota en cmdCheckCi.
|
|
53
|
+
// Adentro de un comando `fail()` sigue siendo lo correcto: ahí sí hay que no volver.
|
|
54
|
+
function failSoft(msg, code = 1) { process.stderr.write("dai: " + msg + "\n"); process.exitCode = code; }
|
|
48
55
|
const ok = (m) => process.stdout.write(`✓ ${m}\n`);
|
|
49
56
|
const info = (m) => process.stdout.write(`› ${m}\n`);
|
|
50
57
|
const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
|
|
@@ -219,6 +226,13 @@ function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return
|
|
|
219
226
|
// Quién decide es requiresLink(), leyendo el nombre de la branch.
|
|
220
227
|
//
|
|
221
228
|
// Salidas: 0 = pasa · 1 = falta el link · 2 = hay link pero el QUÉ cambió (atrasado)
|
|
229
|
+
//
|
|
230
|
+
// El código de salida se fija con `process.exitCode`, NO con `process.exit()`. En Windows,
|
|
231
|
+
// salir de golpe justo después de un fetch aborta el proceso con un assert de libuv
|
|
232
|
+
// (`!(handle->flags & UV_HANDLE_CLOSING)`, src\win\async.c) mientras undici todavía está
|
|
233
|
+
// desarmando sus handles. El gate imprimía "al día" y devolvía distinto de cero igual: un
|
|
234
|
+
// verde reportado como rojo en CI o en un hook. Dejar que el loop drene solo cuesta ~40ms
|
|
235
|
+
// (los sockets keep-alive de undici están unref'd) y no cambia nada en macOS/Linux.
|
|
222
236
|
async function cmdCheckCi(opts = {}) {
|
|
223
237
|
const branch = opts.branch || process.env.DAI_CI_BRANCH || ciBranch() || gitBranch();
|
|
224
238
|
const { required, reason } = requiresLink(branch);
|
|
@@ -227,7 +241,7 @@ async function cmdCheckCi(opts = {}) {
|
|
|
227
241
|
info(`branch '${branch || "(desconocida)"}' — ${reason}`);
|
|
228
242
|
if (!required) {
|
|
229
243
|
if (rows.length) info(`igual declara ${rows.length} US (${rows.map((r) => r.id).join(", ")}) — se chequea su cobertura.`);
|
|
230
|
-
else { ok("gate OK — esta branch no requiere US.");
|
|
244
|
+
else { ok("gate OK — esta branch no requiere US."); return; }
|
|
231
245
|
}
|
|
232
246
|
if (required && rows.length === 0) {
|
|
233
247
|
const ids = trackerKeysIn(branch);
|
|
@@ -236,7 +250,7 @@ async function cmdCheckCi(opts = {}) {
|
|
|
236
250
|
` Crealo: dai link-us ${ids[0] || "<ID-DE-LA-US>"}\n` +
|
|
237
251
|
" Si NO implementa una US (tooling, deps, docs), renombrá la branch con un\n" +
|
|
238
252
|
" prefijo exento — chore/, docs/, ci/ — según governance/branch-naming.md.\n");
|
|
239
|
-
process.
|
|
253
|
+
process.exitCode = 1; return;
|
|
240
254
|
}
|
|
241
255
|
|
|
242
256
|
// Hay link: que además esté al día contra la US viva. Sin token/backend eso no se
|
|
@@ -244,7 +258,7 @@ async function cmdCheckCi(opts = {}) {
|
|
|
244
258
|
// con --no-network (o sin adaptador utilizable) valida el link y no más.
|
|
245
259
|
if (opts.noNetwork) {
|
|
246
260
|
ok(`gate OK — ${rows.length} US linkeada(s): ${rows.map((r) => r.id).join(", ")} (--no-network: no se comparó contra la US viva).`);
|
|
247
|
-
process.
|
|
261
|
+
process.exitCode = 0; return;
|
|
248
262
|
}
|
|
249
263
|
loadDaiEnv();
|
|
250
264
|
let adapter;
|
|
@@ -252,7 +266,7 @@ async function cmdCheckCi(opts = {}) {
|
|
|
252
266
|
catch (e) {
|
|
253
267
|
warn(`no puedo comparar contra la US viva: ${e.message}`);
|
|
254
268
|
ok(`gate OK igual — el link existe (${rows.map((r) => r.id).join(", ")}). Configurá el backend para chequear también el atraso.`);
|
|
255
|
-
process.
|
|
269
|
+
process.exitCode = 0; return;
|
|
256
270
|
}
|
|
257
271
|
let worst = 0;
|
|
258
272
|
for (const r of rows) {
|
|
@@ -271,7 +285,7 @@ async function cmdCheckCi(opts = {}) {
|
|
|
271
285
|
}
|
|
272
286
|
}
|
|
273
287
|
if (worst === 0) ok(`gate OK — ${rows.length} US linkeada(s) y al día.`);
|
|
274
|
-
process.
|
|
288
|
+
process.exitCode = worst;
|
|
275
289
|
}
|
|
276
290
|
|
|
277
291
|
// La branch real en CI: en una PR, HEAD es un merge commit detached, así que
|
|
@@ -285,6 +299,7 @@ function ciBranch() {
|
|
|
285
299
|
}
|
|
286
300
|
|
|
287
301
|
// ── check ──────────────────────────────────────────────────────────────────
|
|
302
|
+
// `process.exitCode` en vez de `process.exit()`: ver la nota en cmdCheckCi.
|
|
288
303
|
async function cmdCheck() {
|
|
289
304
|
loadDaiEnv();
|
|
290
305
|
const adapter = getAdapter(process.env);
|
|
@@ -312,7 +327,7 @@ async function cmdCheck() {
|
|
|
312
327
|
for (const id of atrasadas) process.stdout.write(` dai link-us ${id} --resync # re-estampa el ac_hash contra la US viva\n`);
|
|
313
328
|
process.stdout.write(" Después, revisa si tu implementación cubre el criterio nuevo.\n");
|
|
314
329
|
}
|
|
315
|
-
process.
|
|
330
|
+
process.exitCode = worst;
|
|
316
331
|
}
|
|
317
332
|
|
|
318
333
|
// ── stamp ──────────────────────────────────────────────────────────────────
|
|
@@ -802,8 +817,12 @@ function cmdDone(opts) {
|
|
|
802
817
|
}
|
|
803
818
|
|
|
804
819
|
// La US que se cierra (informativo) — leerla ANTES de cambiar de branch.
|
|
805
|
-
|
|
806
|
-
|
|
820
|
+
// Solo la de ESTA branch: antes listaba todas las del repo (archivadas incluidas), así
|
|
821
|
+
// que cerrar una branch anunciaba el cierre de medio sprint — el mismo defecto que
|
|
822
|
+
// `dai pr` en los issues #31/#32/#33, acá en un mensaje.
|
|
823
|
+
const usIds = matchBranchToImplements(
|
|
824
|
+
branch, flattenImplements(discoverImplements(process.cwd(), { includeArchived: false })),
|
|
825
|
+
).map((r) => r.id);
|
|
807
826
|
|
|
808
827
|
// Ir a la base y actualizar.
|
|
809
828
|
info(`Cambiando a '${base}' y actualizando…`);
|
|
@@ -848,7 +867,10 @@ async function cmdPr(opts) {
|
|
|
848
867
|
const ask = async (q) => {
|
|
849
868
|
if (!process.stdin.isTTY) return null; // no interactivo → sin preguntas
|
|
850
869
|
if (!_rl) _rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
851
|
-
|
|
870
|
+
// Ctrl+D (EOF) → "" = cancelar, no un crash: todas estas preguntas preceden a una
|
|
871
|
+
// acción hacia afuera (push + PR), y ahí abortar es la respuesta segura.
|
|
872
|
+
try { return (await _rl.question(q)).trim(); }
|
|
873
|
+
catch { process.stdout.write("\n"); return ""; }
|
|
852
874
|
};
|
|
853
875
|
const closeRl = () => { if (_rl) { _rl.close(); _rl = null; } };
|
|
854
876
|
|
|
@@ -863,19 +885,53 @@ async function cmdPr(opts) {
|
|
|
863
885
|
fail(`no hay commits en '${branch}' por encima de '${base}'. Una PR necesita cambios: haz commit primero (git commit).`, 1);
|
|
864
886
|
}
|
|
865
887
|
|
|
866
|
-
// 1. Resolver el link (US) de
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
888
|
+
// 1. Resolver el link (US) de ESTA branch (issues #31, #32, #33).
|
|
889
|
+
// Antes se recorrían todos los implements.yaml del repo —archivados incluidos— y
|
|
890
|
+
// ganaba el último: la branch, que es la que sabe la respuesta, no entraba en la
|
|
891
|
+
// decisión. La PR salía titulada con la historia de otro y nadie se enteraba,
|
|
892
|
+
// porque el `dai check ✅` del body era el de esa otra US.
|
|
893
|
+
const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
|
|
894
|
+
const allRows = flattenImplements(discoverImplements(process.cwd()));
|
|
895
|
+
const scope = prScope({ branch, rows, allRows, ids: opts.us ? [String(opts.us)] : [] });
|
|
896
|
+
let entry = scope.target;
|
|
897
|
+
|
|
898
|
+
if (scope.mode === "explicit" && !entry) {
|
|
899
|
+
closeRl();
|
|
900
|
+
fail(`no encontré implements.yaml para '${opts.us}'.\n` +
|
|
901
|
+
` US en este repo: ${allRows.map((r) => r.id).join(", ") || "(ninguna)"}`, 1);
|
|
902
|
+
}
|
|
903
|
+
if (scope.mode === "none") {
|
|
904
|
+
closeRl();
|
|
905
|
+
fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), renombrá la branch a `chore/…` o creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
|
|
906
|
+
}
|
|
907
|
+
if (scope.mode === "ambiguous") {
|
|
908
|
+
// Elegir en silencio es EL bug: la PR sale publicada con el link QUÉ↔CÓMO de otra US.
|
|
909
|
+
warn(`${scope.reason}.`);
|
|
910
|
+
const listed = scope.candidates.map((r, i) => ` ${i + 1}) ${r.id} ${C.dim(`(${r.change})`)}`).join("\n");
|
|
911
|
+
process.stdout.write(` US vivas en el repo:\n${listed}\n`);
|
|
912
|
+
if (opts.yes || !process.stdin.isTTY) {
|
|
913
|
+
closeRl();
|
|
914
|
+
fail("no sé con qué US titular la PR. Decilo explícitamente: dai pr --us <ID>", 1);
|
|
915
|
+
}
|
|
916
|
+
const ans = await ask(` ¿Con qué US titulo la PR? (número, Enter=cancelar) `);
|
|
917
|
+
if (!ans) { closeRl(); info("Cancelado — no se creó la PR."); return; }
|
|
918
|
+
const n = Number(ans);
|
|
919
|
+
if (!Number.isInteger(n) || n < 1 || n > scope.candidates.length) {
|
|
920
|
+
closeRl();
|
|
921
|
+
fail(`respuesta inválida: '${ans}'. Se esperaba un número entre 1 y ${scope.candidates.length}.`, 1);
|
|
922
|
+
}
|
|
923
|
+
entry = scope.candidates[n - 1];
|
|
871
924
|
}
|
|
872
|
-
|
|
873
|
-
|
|
925
|
+
|
|
926
|
+
// De dónde salió la US: en el preview, el título sin justificación no delata nada
|
|
927
|
+
// cuando está mal (sugerencia del issue #32).
|
|
928
|
+
const usWhy = entry ? (scope.mode === "ambiguous" ? "la elegiste vos" : scope.reason) : scope.reason;
|
|
929
|
+
const { id, version, ac_hash } = entry || {};
|
|
874
930
|
|
|
875
931
|
// 2. Estado de trazabilidad (dai check) contra la US viva.
|
|
876
932
|
const adapter = getAdapter(process.env);
|
|
877
|
-
const live = await Promise.resolve(adapter.fetchUS(id)).catch(() => null);
|
|
878
|
-
const status = coverageStatus(ac_hash, live?.ac_hash);
|
|
933
|
+
const live = id ? await Promise.resolve(adapter.fetchUS(id)).catch(() => null) : null;
|
|
934
|
+
const status = id ? coverageStatus(ac_hash, live?.ac_hash) : null;
|
|
879
935
|
if (status === "atrasado") {
|
|
880
936
|
warn(`la US ${id} está ATRASADA respecto de tu implementación (${ac_hash} ≠ ${live?.ac_hash}).`);
|
|
881
937
|
warn(`resincroniza antes de abrir la PR: dai link-us ${id} --resync`);
|
|
@@ -889,22 +945,25 @@ async function cmdPr(opts) {
|
|
|
889
945
|
let commits = [];
|
|
890
946
|
try { commits = git(["log", `${base}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* base local ausente */ }
|
|
891
947
|
// La canónica del tracker (live.url) gana sobre la derivada; el template gana sobre todo.
|
|
892
|
-
const usUrl = usUrlFor(id, live?.url);
|
|
893
|
-
if (!usUrl) {
|
|
948
|
+
const usUrl = id ? usUrlFor(id, live?.url) : null;
|
|
949
|
+
if (id && !usUrl) {
|
|
894
950
|
warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
|
|
895
951
|
warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
|
|
896
952
|
}
|
|
897
953
|
const body = composePrBody(readFileSync(tplPath, "utf8"), {
|
|
898
|
-
id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
|
|
954
|
+
id, version, ac_hash, status, usUrl, usTitle: live?.title, commits, noUsReason: id ? null : scope.reason,
|
|
899
955
|
branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
|
|
900
956
|
});
|
|
901
|
-
|
|
957
|
+
// Sin US el título sale del último commit: describe lo que hay adentro, en vez de
|
|
958
|
+
// pedirle prestada la historia a otro (issue #31).
|
|
959
|
+
const title = prTitle(opts, id, live?.title, commits[0] || branch);
|
|
902
960
|
const forge = detectForge(parseRemote(remote)?.host);
|
|
903
961
|
const tool = forgeTool(forge);
|
|
904
962
|
|
|
905
963
|
// 4. Mostrar y pedir confirmación (acción hacia afuera).
|
|
906
964
|
process.stdout.write(`\n ── Pull Request a crear ──────────────────────────────\n`);
|
|
907
965
|
process.stdout.write(` título: ${title}\n de: ${branch}\n a: ${base}\n`);
|
|
966
|
+
process.stdout.write(` US: ${id || C.dim("(sin US)")} ${C.dim(`— ${usWhy}`)}\n`);
|
|
908
967
|
process.stdout.write(` forge: ${forge} (${tool})${opts.assignee ? `\n asignar: ${opts.assignee}` : ""}${opts.draft ? "\n draft: sí" : ""}\n`);
|
|
909
968
|
process.stdout.write(` ─────────────────────────────────────────────────────\n\n${body}\n`);
|
|
910
969
|
process.stdout.write(` ─────────────────────────────────────────────────────\n`);
|
|
@@ -1219,7 +1278,7 @@ async function cmdInit(repo, opts) {
|
|
|
1219
1278
|
let installOpenspec = opts.openspec === true;
|
|
1220
1279
|
if (!hasOpenspec && opts.openspec === undefined && rl) {
|
|
1221
1280
|
process.stdout.write("\n OpenSpec es el motor recomendado del CÓMO: convierte la US en design + tasks\n");
|
|
1222
|
-
process.stdout.write(
|
|
1281
|
+
process.stdout.write(` (${opsxHint(want)}). No está en este repo — la trazabilidad de dai anda igual sin\n`);
|
|
1223
1282
|
process.stdout.write(" él, pero para el flujo completo conviene tenerlo.\n");
|
|
1224
1283
|
installOpenspec = await askYesNo(rl, "¿Instalar el CLI de OpenSpec ahora? (después ejecutas `openspec init` tú)", false);
|
|
1225
1284
|
}
|
|
@@ -1322,7 +1381,7 @@ async function cmdInit(repo, opts) {
|
|
|
1322
1381
|
.filter(Boolean).join(",") || "claude,github-copilot,cursor";
|
|
1323
1382
|
const osHint = "para sumarlo después: npm i -g @fission-ai/openspec@latest && openspec init --tools " + osTools;
|
|
1324
1383
|
if (hasOpenspec) {
|
|
1325
|
-
ok(
|
|
1384
|
+
ok(`OpenSpec: ya inicializado en el repo — ${opsxHint(want)}`);
|
|
1326
1385
|
} else if (openspecPartial) {
|
|
1327
1386
|
warn("OpenSpec: hay una carpeta openspec/ a medias. Reinicializa: rm -rf openspec && openspec init --tools " + osTools + " --force");
|
|
1328
1387
|
} else if (installOpenspec) {
|
|
@@ -1335,7 +1394,7 @@ async function cmdInit(repo, opts) {
|
|
|
1335
1394
|
try {
|
|
1336
1395
|
info(`OpenSpec: inicializando en el repo (--tools ${osTools})…`);
|
|
1337
1396
|
runNpmTool("openspec", ["init", "--tools", osTools, "--force"], { stdio: "inherit", cwd: repo === "." ? process.cwd() : repo });
|
|
1338
|
-
ok(
|
|
1397
|
+
ok(`OpenSpec: instalado e inicializado — genera design/tasks con ${opsxHint(want)}`);
|
|
1339
1398
|
} catch {
|
|
1340
1399
|
warn("OpenSpec: el CLI está pero falló `openspec init`. Ejecuta a mano en el repo:");
|
|
1341
1400
|
process.stdout.write(` openspec init --tools ${osTools} --force\n`);
|
|
@@ -1601,6 +1660,43 @@ function cmdDoctor() {
|
|
|
1601
1660
|
else warn("falta constitución Cursor (dai-constitution.mdc)");
|
|
1602
1661
|
}
|
|
1603
1662
|
|
|
1663
|
+
// OpenSpec: los comandos que arman el CÓMO. Se reportan acá porque su nombre CAMBIA
|
|
1664
|
+
// según el asistente, y con la forma equivocada el agente no falla — no encuentra nada,
|
|
1665
|
+
// no avisa, y se pone a improvisar salteándose el gate. Verlo escrito ahorra la tarde.
|
|
1666
|
+
const OPSX_IDS = ["explore", "propose", "apply", "archive"];
|
|
1667
|
+
const opsxPath = (kind, id) => join(cwd, ...OPSX_COMMAND_FILE[kind].replace("<id>", id).split("/"));
|
|
1668
|
+
// Solo los asistentes configurados EN ESTE REPO: los comandos opsx son archivos del
|
|
1669
|
+
// repo, así que tener las skills globales no dice nada sobre ellos. Avisar por los otros
|
|
1670
|
+
// dos le pone dos warnings falsos a quien configuró uno solo, que es el caso normal.
|
|
1671
|
+
const localActive = ASSISTANTS.filter((a) => skillNames.some((n) => existsSync(join(a.local, n))));
|
|
1672
|
+
if (localActive.length && existsSync(join(cwd, "openspec"))) {
|
|
1673
|
+
info("OpenSpec — los comandos van en el chat del asistente, no en la terminal:");
|
|
1674
|
+
for (const a of localActive) {
|
|
1675
|
+
const faltan = OPSX_IDS.filter((id) => !existsSync(opsxPath(a.kind, id)));
|
|
1676
|
+
if (faltan.length === OPSX_IDS.length) {
|
|
1677
|
+
warn(`${a.label}: no hay comandos opsx. Generalos: openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force`);
|
|
1678
|
+
} else {
|
|
1679
|
+
const hay = OPSX_IDS.filter((id) => existsSync(opsxPath(a.kind, id)));
|
|
1680
|
+
ok(`${a.label}: ${hay.map((id) => opsxCommand(a.kind, id)).join(" · ")}`);
|
|
1681
|
+
if (faltan.length) warn(`${a.label}: faltan ${faltan.join(", ")} — regenerá con \`openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force\``);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
process.stdout.write(" (el nombre sale del archivo que genera OpenSpec: solo Claude usa los dos puntos)\n");
|
|
1685
|
+
// La versión importa para algo más que features: hasta OPENSPEC_MIN, los prompts que
|
|
1686
|
+
// OpenSpec genera para Copilot/Cursor decían `/opsx:apply` (la forma de Claude, que ahí
|
|
1687
|
+
// no existe) e invocaban herramientas que solo tiene Claude Code. El agente terminaba
|
|
1688
|
+
// nombrando comandos inexistentes y salteándose el gate de aprobación sin avisar.
|
|
1689
|
+
let osV = null;
|
|
1690
|
+
try { osV = String(runNpmTool("openspec", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) ?? "").trim(); } catch { /* sin CLI */ }
|
|
1691
|
+
if (!osV) info("el CLI de OpenSpec no está en el PATH (los comandos igual viven en el repo)");
|
|
1692
|
+
else if (compareVersions(osV, OPENSPEC_MIN) === -1) {
|
|
1693
|
+
warn(`OpenSpec ${osV} — actualiza a ${OPENSPEC_MIN} o mayor: npm i -g @fission-ai/openspec@latest`);
|
|
1694
|
+
process.stdout.write(" Hasta esa versión, los prompts de Copilot y Cursor nombraban los comandos en la\n");
|
|
1695
|
+
process.stdout.write(" forma de Claude y pedían herramientas que esos asistentes no tienen: el agente se\n");
|
|
1696
|
+
process.stdout.write(" saltea el gate de aprobación y se pone a implementar sin avisar.\n");
|
|
1697
|
+
} else ok(`OpenSpec ${osV}`);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1604
1700
|
info("adaptador de PM:");
|
|
1605
1701
|
const pm = process.env.DAI_PM || "md";
|
|
1606
1702
|
ok(`DAI_PM=${pm}`);
|
|
@@ -1649,23 +1745,23 @@ const { opts, pos } = parseFlags(rest);
|
|
|
1649
1745
|
switch (cmd) {
|
|
1650
1746
|
case "ac-hash": cmdAcHash(pos[0]); break;
|
|
1651
1747
|
case "ls": cmdLs(opts); break;
|
|
1652
|
-
case "link-us": cmdLinkUs(pos[0], opts).catch((e) =>
|
|
1653
|
-
case "check": (opts.ci ? cmdCheckCi(opts) : cmdCheck()).catch((e) =>
|
|
1654
|
-
case "stamp": cmdStamp(pos, opts).catch((e) =>
|
|
1655
|
-
case "update-us": cmdUpdateUs(pos[0], opts).catch((e) =>
|
|
1656
|
-
case "edit-us": cmdEditUs(pos[0], opts).catch((e) =>
|
|
1657
|
-
case "forge": cmdForge(pos[0], pos[1], opts).catch((e) =>
|
|
1658
|
-
case "publish": cmdPublish(pos[0], opts).catch((e) =>
|
|
1748
|
+
case "link-us": cmdLinkUs(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
|
|
1749
|
+
case "check": (opts.ci ? cmdCheckCi(opts) : cmdCheck()).catch((e) => failSoft(String(e.message))); break;
|
|
1750
|
+
case "stamp": cmdStamp(pos, opts).catch((e) => failSoft(String(e.message))); break;
|
|
1751
|
+
case "update-us": cmdUpdateUs(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
|
|
1752
|
+
case "edit-us": cmdEditUs(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
|
|
1753
|
+
case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => failSoft(String(e.message))); break;
|
|
1754
|
+
case "publish": cmdPublish(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
|
|
1659
1755
|
case "pr":
|
|
1660
|
-
case "mr": cmdPr(opts).catch((e) =>
|
|
1756
|
+
case "mr": cmdPr(opts).catch((e) => failSoft(String(e.message))); break; // `mr` = alias para GitLab (merge request)
|
|
1661
1757
|
case "done": cmdDone(opts); break;
|
|
1662
1758
|
case "archive": cmdArchive(pos[0], opts); break;
|
|
1663
|
-
case "install": cmdInstall(opts).catch((e) =>
|
|
1759
|
+
case "install": cmdInstall(opts).catch((e) => failSoft(String(e.message))); break; // alias de `dai skills install`
|
|
1664
1760
|
case "skills":
|
|
1665
|
-
if (pos[0] === "install" || pos[0] === undefined) cmdInstall(opts).catch((e) =>
|
|
1761
|
+
if (pos[0] === "install" || pos[0] === undefined) cmdInstall(opts).catch((e) => failSoft(String(e.message)));
|
|
1666
1762
|
else fail(`subcomando de skills desconocido: '${pos[0]}' (por ahora: install)`, 2);
|
|
1667
1763
|
break;
|
|
1668
|
-
case "init": cmdInit(pos[0], opts).catch((e) =>
|
|
1764
|
+
case "init": cmdInit(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
|
|
1669
1765
|
case "sync": cmdSync(pos[0], opts); break;
|
|
1670
1766
|
case "upgrade":
|
|
1671
1767
|
case "update": cmdUpgrade(opts); break;
|
|
@@ -1699,6 +1795,7 @@ switch (cmd) {
|
|
|
1699
1795
|
" done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
|
|
1700
1796
|
" 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" +
|
|
1701
1797
|
" pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
|
|
1798
|
+
" [--us <ID>] [--title t] la US la resuelve la branch; si hay varias, pregunta (sin TTY, falla)\n" +
|
|
1702
1799
|
" forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
|
|
1703
1800
|
" forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
|
|
1704
1801
|
" --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -106,6 +106,44 @@ export function validateSkill(md) {
|
|
|
106
106
|
// cada `/comando` con una copia vieja y sin templates.
|
|
107
107
|
export const stalePromptFiles = (skills) => skills.map((n) => `${n}.prompt.md`);
|
|
108
108
|
|
|
109
|
+
// OpenSpec nombra sus comandos DISTINTO según el asistente, y no es cosmético:
|
|
110
|
+
// Claude .claude/commands/opsx/<id>.md → /opsx:propose (namespace anidado)
|
|
111
|
+
// Copilot .github/prompts/opsx-<id>.prompt.md → /opsx-propose (nombre aplanado)
|
|
112
|
+
// Cursor .cursor/commands/opsx-<id>.md → /opsx-propose
|
|
113
|
+
// Tipear `/opsx:propose` en Copilot no matchea NADA: el agente no carga el workflow y se
|
|
114
|
+
// pone a improvisar — se saltea el gate propose → aprobación → apply sin avisarle a nadie.
|
|
115
|
+
// Decirle la forma correcta al dev es la diferencia entre el método y vibe coding.
|
|
116
|
+
export function opsxCommand(kind, name = "propose") {
|
|
117
|
+
return kind === "claude" ? `/opsx:${name}` : `/opsx-${name}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// El archivo del que sale ese nombre — lo que hay que mirar cuando el comando "no existe".
|
|
121
|
+
export const OPSX_COMMAND_FILE = {
|
|
122
|
+
claude: ".claude/commands/opsx/<id>.md",
|
|
123
|
+
copilot: ".github/prompts/opsx-<id>.prompt.md",
|
|
124
|
+
cursor: ".cursor/commands/opsx-<id>.md",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Desde esta versión, OpenSpec escribe el nombre del comando que ENTIENDE cada asistente
|
|
128
|
+
// dentro del cuerpo del prompt (antes todos decían `/opsx:apply`, la forma de Claude) y no
|
|
129
|
+
// invoca herramientas que solo existen en Claude Code (`AskUserQuestion`, `TodoWrite`).
|
|
130
|
+
// Con una anterior, un dev de Copilot ve un agente que le nombra comandos inexistentes y
|
|
131
|
+
// que se pasa de largo el gate de aprobación porque su única forma de preguntar no existe.
|
|
132
|
+
export const OPENSPEC_MIN = "1.10.0";
|
|
133
|
+
|
|
134
|
+
// El id con el que OpenSpec conoce a cada asistente en `openspec init --tools`.
|
|
135
|
+
export const OPENSPEC_TOOL = { claude: "claude", copilot: "github-copilot", cursor: "cursor" };
|
|
136
|
+
|
|
137
|
+
// La misma info, resumida para una sola línea de `dai init` (que puede haber configurado
|
|
138
|
+
// varios asistentes a la vez).
|
|
139
|
+
export function opsxHint(want, name = "propose") {
|
|
140
|
+
const dash = [want?.copilot && "Copilot", want?.cursor && "Cursor"].filter(Boolean).join(" y ");
|
|
141
|
+
const forms = [];
|
|
142
|
+
if (want?.claude) forms.push(`${opsxCommand("claude", name)} (Claude)`);
|
|
143
|
+
if (dash) forms.push(`${opsxCommand("copilot", name)} (${dash})`);
|
|
144
|
+
return forms.join(" · ") || opsxCommand("claude", name);
|
|
145
|
+
}
|
|
146
|
+
|
|
109
147
|
// Transforma un SKILL.md (Claude) en un SKILL.md de Cursor.
|
|
110
148
|
// Conserva name/description/body y ajusta solo el frontmatter.
|
|
111
149
|
export function skillToCursor(md) {
|
|
@@ -140,8 +178,10 @@ export function envFor(pm) {
|
|
|
140
178
|
"# La clave del PROYECTO (p. ej. PROJ), no la de un ticket (PROJ-123).\n" +
|
|
141
179
|
"DAI_JIRA_PROJECT=\n" +
|
|
142
180
|
"DAI_JIRA_ISSUETYPE=Story\n" +
|
|
143
|
-
"#
|
|
144
|
-
"
|
|
181
|
+
"# Solo si tu Jira exige campos propios AL CREAR una US (lo usa grill-user-story,\n" +
|
|
182
|
+
"# no hace falta para leerlas). El default ya es .dai/jira-fields.json; descomentá\n" +
|
|
183
|
+
"# solo para apuntar a otra ruta. Si el archivo no existe, se ignora.\n" +
|
|
184
|
+
"# DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n";
|
|
145
185
|
}
|
|
146
186
|
return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n";
|
|
147
187
|
}
|
|
@@ -230,6 +270,7 @@ export function constitution(kind) {
|
|
|
230
270
|
## Reglas
|
|
231
271
|
|
|
232
272
|
- **No vibe coding:** toda implementación arranca de una US con criterios testeables.
|
|
273
|
+
- **El diseño se aprueba antes de implementar:** cuando termines la propuesta (proposal / design / tasks), **para y pide aprobación explícita**. No sigas de largo porque "ya está claro": el CÓMO lo firma la persona, y una implementación que empieza antes de esa firma no tiene contra qué revisarse. Si no tienes una herramienta para preguntar, pregunta en texto plano y espera la respuesta.
|
|
233
274
|
- **TDD:** test primero, por la interfaz pública; sobrevive a un refactor.
|
|
234
275
|
- **El link se autora una vez** (\`implements.yaml\`); la cobertura se **deriva** (nunca a mano).
|
|
235
276
|
- **Verifica el comportamiento, no solo que compile:** que pase el chequeo estático o el build no prueba que funcione; ejercita el flujo real antes de darlo por hecho.
|