@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 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.11.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."); process.exit(0); }
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.exit(1);
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.exit(0);
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.exit(0);
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.exit(worst);
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.exit(worst);
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
- const usIds = [];
806
- for (const f of discoverImplements(process.cwd())) for (const im of f.implements || []) if (!isPlaceholderId(im.id)) usIds.push(im.id);
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
- return (await _rl.question(q)).trim();
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 la branch actual.
867
- const found = discoverImplements(process.cwd());
868
- let entry = null;
869
- for (const f of found) for (const im of f.implements || []) {
870
- if (!isPlaceholderId(im.id)) { entry = { f, im }; break; }
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
- if (!entry) fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
873
- const { id, version, ac_hash } = entry.im;
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
- const title = prTitle(opts, id, live?.title);
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(" (comandos /opsx:*). No está en este repo — la trazabilidad de dai anda igual sin\n");
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("OpenSpec: ya inicializado en el repo");
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("OpenSpec: instalado e inicializado — genera design/tasks con /opsx:*");
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) => fail(String(e.message))); break;
1653
- case "check": (opts.ci ? cmdCheckCi(opts) : cmdCheck()).catch((e) => fail(String(e.message))); break;
1654
- case "stamp": cmdStamp(pos, opts).catch((e) => fail(String(e.message))); break;
1655
- case "update-us": cmdUpdateUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1656
- case "edit-us": cmdEditUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1657
- case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
1658
- case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
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) => fail(String(e.message))); break; // `mr` = alias para GitLab (merge request)
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) => fail(String(e.message))); break; // alias de `dai skills install`
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) => fail(String(e.message)));
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) => fail(String(e.message))); break;
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" +
@@ -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
- "# Campos propios que tu Jira exige al crear. Si el archivo no existe, se ignora.\n" +
144
- "DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n";
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.