@dforce2055/dai 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,132 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.13.1] — 2026-08-24
7
+
8
+ **Un dev en Windows no podía pushear contra el GitLab de su empresa. `ssh -T` le autenticaba
9
+ perfecto, `git push` moría con `Permission denied (publickey…)`. Dos días buscando el problema
10
+ en la clave, en el token y en los permisos del server: no estaba en ninguno de los tres, y dai
11
+ tapaba la única línea que lo decía.**
12
+
13
+ Su clave tenía passphrase. En Windows conviven dos `ssh.exe` — el de OpenSSH for Windows, que
14
+ habla con el servicio `ssh-agent`, y el que trae Git for Windows, que no lo ve. `ssh -T` usaba
15
+ el primero (la firma la hacía el agente, passphrase nunca), `git push` usaba el segundo y pedía
16
+ la passphrase por stderr. Ese prompt caía en un pipe de dai: el dev veía un cuelgue sin
17
+ explicación, ssh se rendía, caía a autenticación por password y lo único legible al final era
18
+ un error que acusa a la clave. Todo lo que hacía falta para resolverlo estaba en pantalla, y no
19
+ llegaba.
20
+
21
+ ### Arreglado
22
+ - **`dai pr` se tragaba lo que git y ssh preguntan.** El push corría con `stderr` en `pipe`
23
+ para no ensuciar la salida, pero git y ssh **preguntan por stderr**: la passphrase de una
24
+ clave, la confirmación de un host nuevo, el aviso del credential manager. Con el prompt
25
+ invisible el comando no se cuelga por un bug, se cuelga esperando una respuesta que nadie
26
+ sabe que tiene que dar. Ahora `stderr` va heredado y el push pregunta a la vista. El error de
27
+ git se lee en vivo, cuando todavía sirve, en vez de aparecer resumido después del fracaso.
28
+ - **La pista al fallar el push asumía que el remoto era HTTPS.** Decía siempre *"si es la
29
+ primera vez contra este remoto, autenticá pusheando a mano una vez"*. Eso arregla HTTPS,
30
+ donde el credential manager pide la credencial la primera vez. Contra un remoto **SSH** el
31
+ push a mano falla exactamente igual — así que la pista mandaba a repetir un comando condenado
32
+ y a seguir buscando en el lugar equivocado. Ahora el consejo depende del transporte: en SSH
33
+ aclara que el token de `gh`/`glab` no interviene en el push, que lo que hay que mirar es la
34
+ clave, y deriva a `dai doctor`.
35
+
36
+ ### Agregado
37
+ - **`dai doctor` — sección `forge`.** Reporta el remoto `origin` con su forge detectado y, en
38
+ Windows con remoto SSH, **qué `ssh.exe` va a usar git**: si es el suyo (el de Git for
39
+ Windows, que no llega al `ssh-agent`), lo advierte, explica el modo de falla y da el fix
40
+ (`git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"`). Respeta la
41
+ precedencia real de git —`GIT_SSH_COMMAND` > `GIT_SSH` > `core.sshCommand`— y avisa cuando
42
+ una variable de entorno está pisando un `core.sshCommand` que ya estaba bien: ese caso es
43
+ particularmente cruel, porque el dev arregla el config, no funciona, y mirando el
44
+ `.gitconfig` no hay nada que ver. Fuera de Windows, o con un remoto HTTPS, no opina.
45
+ Núcleo puro y testeado en `cli/lib/git-ssh.mjs` (14 tests); en `dai.mjs` queda solo el I/O.
46
+
47
+ ## [0.13.0] — 2026-08-21
48
+
49
+ **Un dev de backend en Windows siguió el tutorial al pie de la letra y el agente se puso a
50
+ programar sin escribir la propuesta. No era Windows ni era su setup: le estábamos diciendo mal
51
+ el nombre del comando.**
52
+
53
+ ### Arreglado
54
+ - **Los comandos de OpenSpec se documentaban solo en la forma de Claude Code.** OpenSpec
55
+ genera un archivo distinto por asistente, y el nombre del comando sale del archivo:
56
+ `.claude/commands/opsx/<id>.md` → `/opsx:propose`, pero
57
+ `.github/prompts/opsx-<id>.prompt.md` → `/opsx-propose` (Copilot) y
58
+ `.cursor/commands/opsx-<id>.md` → `/opsx-propose` (Cursor). O sea: **solo Claude usa los
59
+ dos puntos**, y el tutorial de setup del dev —que es el de Windows + Copilot— mostraba los
60
+ dos puntos en los tres pasos.
61
+ El síntoma no se parece en nada a la causa, y ahí está el daño: tipear `/opsx:propose` en
62
+ Copilot **no da error**. No matchea ningún comando, el workflow nunca se carga, y el agente
63
+ toma el texto suelto como una charla — se saltea el gate propose → aprobación → apply y
64
+ arranca a implementar. Se lee como "el bot hace lo que quiere" y se le echa la culpa al
65
+ asistente, al sistema operativo o al método. Es vibe coding servido por la documentación.
66
+ Ahora `dai init` imprime la forma que le toca a **tu** asistente (`opsxHint`), el tutorial
67
+ de Windows lo dice explícito con su propia entrada en *Cuando algo falla*, y la guía del
68
+ dev aclara que la forma con dos puntos es la de Claude.
69
+ - **`dai check` terminaba con código de error en Windows aunque el chequeo pasara**
70
+ (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94`).
71
+ Salía con `process.exit()` inmediatamente después del `fetch` al tracker, y en Windows eso
72
+ aborta el proceso mientras undici todavía está desarmando sus handles. Imprimía
73
+ `✅ al día` y devolvía distinto de cero igual: un gate verde reportado como rojo en CI o en
74
+ un hook de git — justo el modo de falla que apaga un gate.
75
+ Ahora el código de salida se fija con `process.exitCode` y el event loop drena solo. Cuesta
76
+ ~40 ms (los sockets keep-alive de undici están *unref'd*) y no cambia nada en macOS/Linux.
77
+ El mismo tratamiento va para los `.catch()` del dispatcher de **todos** los comandos que
78
+ salen a la red o spawnean npm (`link-us`, `stamp`, `update-us`, `edit-us`, `forge`,
79
+ `publish`, `pr`/`mr`, `install`, `init`), con `failSoft()`: ahí el comando ya terminó, así
80
+ que cortar de una no aportaba nada y podía tapar el mensaje de error con un stack de C.
81
+ Adentro de un comando `fail()` sigue saliendo de una — ahí sí hay que no volver.
82
+
83
+ ### Agregado
84
+ - **Regla nueva en la constitución: *el diseño se aprueba antes de implementar*.** Al terminar
85
+ la propuesta el agente para y pide aprobación explícita; si no tiene una herramienta para
86
+ preguntar, pregunta en texto plano y espera. Vale para los tres asistentes. Es el borde
87
+ QUÉ↔CÓMO, que sí es dominio de dai: sin esa firma, la implementación no tiene contra qué
88
+ revisarse — y el gate no puede depender de que el asistente de turno tenga la herramienta
89
+ correcta. Los repos ya inicializados la reciben con `dai sync`.
90
+ - **`dai doctor` avisa si OpenSpec está por debajo de 1.10.0.** Hasta esa versión, los prompts
91
+ que OpenSpec generaba para Copilot y Cursor nombraban los comandos en la forma de Claude
92
+ (`/opsx:apply`, que ahí no existe) e invocaban `AskUserQuestion`/`TodoWrite`, que solo tiene
93
+ Claude Code. El agente terminaba nombrando comandos inexistentes y salteándose el gate de
94
+ aprobación porque su única forma de preguntar no existía. Está arreglado upstream
95
+ ([#727](https://github.com/Fission-AI/OpenSpec/issues/727),
96
+ [#1307](https://github.com/Fission-AI/OpenSpec/issues/1307),
97
+ [#1103](https://github.com/Fission-AI/OpenSpec/issues/1103)), pero un equipo que instaló
98
+ antes se queda con la versión vieja y el síntoma no se parece a la causa.
99
+ - **`dai doctor` reporta los comandos de OpenSpec**, con la forma que le toca a **este** repo:
100
+ `✓ Copilot: /opsx-explore · /opsx-propose · /opsx-apply · /opsx-archive`. Si no están
101
+ generados, dice el comando exacto para generarlos —con el tool id que espera OpenSpec, que
102
+ para Copilot es `github-copilot` y no `copilot`—. Solo mira los asistentes configurados en
103
+ el repo: tener las skills instaladas globalmente no dice nada de los comandos, que son
104
+ archivos versionados.
105
+
106
+ ### Cambiado
107
+ - **`introduces` se cierra al TERMINAR de implementar, y lo normal es que lo escriba el
108
+ agente.** El tutorial del dev pedía completarlo justo después de `dai link-us` — cuando
109
+ todavía no se puede saber qué capacidades técnicas va a introducir el change; al empezar
110
+ sería adivinar. Y la skill `link-us` decía explícito "dejar `introduces` para que el dev lo
111
+ liste", cargándole a mano un dato que sabe mejor quien acaba de implementar.
112
+ La frase "el único archivo que se autora **a mano**" empujaba el malentendido: lo que dice
113
+ el ADR-0004 es que es el único registro **autorado** —se escribe, no se deriva—, y `link-us`
114
+ ya resuelve `id`, `version`, `ac_hash`, `change`, `repo` y `autor` por construcción.
115
+ Ahora lo dicen igual el archivo generado, la skill, su template, el tutorial (con el paso de
116
+ cierre después de `/opsx-apply`), la guía del dev, el glosario y el ejemplo end-to-end. El
117
+ **DoD suma el ítem**: `introduces` cerrado, sin el placeholder `<capacidad-tecnica>`.
118
+ - **El DoD nombra los comandos de OpenSpec sin atarlos a un asistente.** Decía
119
+ `opsx:apply` → `opsx:archive`, la forma de Claude, en un template que viaja a los tres.
120
+ - **`DAI_JIRA_FIELDS_FILE` sale comentada en el `.env.dai` que genera `dai init`.** Apuntaba
121
+ al mismo valor que ya usa el CLI por defecto, así que no aportaba nada — pero hacía creer
122
+ que faltaba un archivo obligatorio, y un dev terminó pidiendo los `customfield_*` de la
123
+ empresa para un archivo que **solo hace falta para crear** US, no para leerlas.
124
+
125
+ ### Interno
126
+ - **325 tests** (+6 desde 0.12.0): `opsxCommand` y `opsxHint` —que solo Claude lleva los dos
127
+ puntos, y la línea combinada de `dai init` cuando el repo configura varios asistentes—, el
128
+ archivo y el tool id de OpenSpec por asistente (`github-copilot`, que no es `copilot`),
129
+ `OPENSPEC_MIN`, que `DAI_JIRA_FIELDS_FILE` salga comentada, y que la regla nueva de la
130
+ constitución llegue a los tres asistentes.
131
+
6
132
  ## [0.12.0] — 2026-08-13
7
133
 
8
134
  **La PR deja de robarle la US a otro. `dai pr` resolvía el link recorriendo todo el repo y
@@ -611,6 +737,8 @@ ClickUp y Jira Cloud.
611
737
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
612
738
  `files` de npm sin tests ni secretos.
613
739
 
740
+ [0.13.1]: https://github.com/dforce2055/dai/releases/tag/v0.13.1
741
+ [0.13.0]: https://github.com/dforce2055/dai/releases/tag/v0.13.0
614
742
  [0.12.0]: https://github.com/dforce2055/dai/releases/tag/v0.12.0
615
743
  [0.11.0]: https://github.com/dforce2055/dai/releases/tag/v0.11.0
616
744
  [0.10.0]: https://github.com/dforce2055/dai/releases/tag/v0.10.0
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.12.0
1
+ 0.13.1
package/cli/dai.mjs CHANGED
@@ -28,11 +28,12 @@ import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs"
28
28
  import { trackerUrl } from "./lib/tracker-url.mjs";
29
29
  import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
30
30
  import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
31
+ import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
31
32
  import { dirsEqual } from "./lib/fsutil.mjs";
32
33
  import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
33
- import { versionDrift, planUpgrade } from "./lib/semver.mjs";
34
+ import { versionDrift, planUpgrade, compareVersions } from "./lib/semver.mjs";
34
35
  import { parseSource } from "./lib/skills-source.mjs";
35
- import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles } from "./lib/bootstrap.mjs";
36
+ import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles, opsxHint, opsxCommand, OPSX_COMMAND_FILE, OPENSPEC_TOOL, OPENSPEC_MIN } from "./lib/bootstrap.mjs";
36
37
  import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/jira-fields.mjs";
37
38
  import { assertProjectKey } from "./lib/pm-jira.mjs";
38
39
  import { flattenImplements, stampScope, prScope, matchBranchToImplements, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
@@ -45,6 +46,13 @@ const HERE = dirname(fileURLToPath(import.meta.url));
45
46
  process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); throw e; });
46
47
 
47
48
  function fail(msg, code = 1) { process.stderr.write("dai: " + msg + "\n"); process.exit(code); }
49
+
50
+ // Igual que fail(), pero sin process.exit(). Lo usan los `.catch()` del dispatcher: ahí el
51
+ // comando YA terminó, así que cortar de una no aporta nada y en Windows cuesta caro — salir
52
+ // de golpe después de un fetch (o de un spawn de npm) aborta el proceso con un assert de
53
+ // libuv y tapa el mensaje de error con un stack de C. Ver la nota en cmdCheckCi.
54
+ // Adentro de un comando `fail()` sigue siendo lo correcto: ahí sí hay que no volver.
55
+ function failSoft(msg, code = 1) { process.stderr.write("dai: " + msg + "\n"); process.exitCode = code; }
48
56
  const ok = (m) => process.stdout.write(`✓ ${m}\n`);
49
57
  const info = (m) => process.stdout.write(`› ${m}\n`);
50
58
  const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
@@ -219,6 +227,13 @@ function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return
219
227
  // Quién decide es requiresLink(), leyendo el nombre de la branch.
220
228
  //
221
229
  // Salidas: 0 = pasa · 1 = falta el link · 2 = hay link pero el QUÉ cambió (atrasado)
230
+ //
231
+ // El código de salida se fija con `process.exitCode`, NO con `process.exit()`. En Windows,
232
+ // salir de golpe justo después de un fetch aborta el proceso con un assert de libuv
233
+ // (`!(handle->flags & UV_HANDLE_CLOSING)`, src\win\async.c) mientras undici todavía está
234
+ // desarmando sus handles. El gate imprimía "al día" y devolvía distinto de cero igual: un
235
+ // verde reportado como rojo en CI o en un hook. Dejar que el loop drene solo cuesta ~40ms
236
+ // (los sockets keep-alive de undici están unref'd) y no cambia nada en macOS/Linux.
222
237
  async function cmdCheckCi(opts = {}) {
223
238
  const branch = opts.branch || process.env.DAI_CI_BRANCH || ciBranch() || gitBranch();
224
239
  const { required, reason } = requiresLink(branch);
@@ -227,7 +242,7 @@ async function cmdCheckCi(opts = {}) {
227
242
  info(`branch '${branch || "(desconocida)"}' — ${reason}`);
228
243
  if (!required) {
229
244
  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); }
245
+ else { ok("gate OK — esta branch no requiere US."); return; }
231
246
  }
232
247
  if (required && rows.length === 0) {
233
248
  const ids = trackerKeysIn(branch);
@@ -236,7 +251,7 @@ async function cmdCheckCi(opts = {}) {
236
251
  ` Crealo: dai link-us ${ids[0] || "<ID-DE-LA-US>"}\n` +
237
252
  " Si NO implementa una US (tooling, deps, docs), renombrá la branch con un\n" +
238
253
  " prefijo exento — chore/, docs/, ci/ — según governance/branch-naming.md.\n");
239
- process.exit(1);
254
+ process.exitCode = 1; return;
240
255
  }
241
256
 
242
257
  // Hay link: que además esté al día contra la US viva. Sin token/backend eso no se
@@ -244,7 +259,7 @@ async function cmdCheckCi(opts = {}) {
244
259
  // con --no-network (o sin adaptador utilizable) valida el link y no más.
245
260
  if (opts.noNetwork) {
246
261
  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);
262
+ process.exitCode = 0; return;
248
263
  }
249
264
  loadDaiEnv();
250
265
  let adapter;
@@ -252,7 +267,7 @@ async function cmdCheckCi(opts = {}) {
252
267
  catch (e) {
253
268
  warn(`no puedo comparar contra la US viva: ${e.message}`);
254
269
  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);
270
+ process.exitCode = 0; return;
256
271
  }
257
272
  let worst = 0;
258
273
  for (const r of rows) {
@@ -271,7 +286,7 @@ async function cmdCheckCi(opts = {}) {
271
286
  }
272
287
  }
273
288
  if (worst === 0) ok(`gate OK — ${rows.length} US linkeada(s) y al día.`);
274
- process.exit(worst);
289
+ process.exitCode = worst;
275
290
  }
276
291
 
277
292
  // La branch real en CI: en una PR, HEAD es un merge commit detached, así que
@@ -285,6 +300,7 @@ function ciBranch() {
285
300
  }
286
301
 
287
302
  // ── check ──────────────────────────────────────────────────────────────────
303
+ // `process.exitCode` en vez de `process.exit()`: ver la nota en cmdCheckCi.
288
304
  async function cmdCheck() {
289
305
  loadDaiEnv();
290
306
  const adapter = getAdapter(process.env);
@@ -312,7 +328,7 @@ async function cmdCheck() {
312
328
  for (const id of atrasadas) process.stdout.write(` dai link-us ${id} --resync # re-estampa el ac_hash contra la US viva\n`);
313
329
  process.stdout.write(" Después, revisa si tu implementación cubre el criterio nuevo.\n");
314
330
  }
315
- process.exit(worst);
331
+ process.exitCode = worst;
316
332
  }
317
333
 
318
334
  // ── stamp ──────────────────────────────────────────────────────────────────
@@ -975,11 +991,21 @@ async function cmdPr(opts) {
975
991
  // stdin heredado + GIT_TERMINAL_PROMPT=1: la PRIMERA vez contra un remoto HTTPS
976
992
  // corporativo, git/credential-manager necesita poder pedir la credencial. Con stdin
977
993
  // ignorado (el default de git()) el login no completaba y el push fallaba en seco.
978
- git(["push", "-u", "origin", branch], { stdio: ["inherit", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
994
+ //
995
+ // stderr heredado, y esto no es cosmético: git y ssh PREGUNTAN por stderr. Con
996
+ // stderr en 'pipe' el prompt de una clave con passphrase ("Enter passphrase for
997
+ // key …") caía en el pipe, invisible: el dev veía un cuelgue sin explicación, ssh
998
+ // se rendía y terminaba cayendo a autenticación por password, y lo único que se
999
+ // llegaba a leer era un "Permission denied (publickey…)" que acusa a la clave
1000
+ // cuando la clave estaba bien. Que pregunte a la vista.
1001
+ git(["push", "-u", "origin", branch], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
979
1002
  } catch (e) {
980
- const err = String(e.stderr || e.message || "").trim();
1003
+ // git ya imprimió su error en vivo (stderr heredado); acá solo va la pista, y esa
1004
+ // pista depende del transporte: "pushea a mano una vez" arregla HTTPS y no arregla
1005
+ // nada en SSH, donde el push a mano falla igual (lib/git-ssh.mjs).
1006
+ const err = String(e.stderr || "").trim();
981
1007
  if (err) process.stderr.write(" " + err.split("\n").join("\n ") + "\n");
982
- 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`);
1008
+ for (const l of pushFailureHint(remote, branch)) process.stdout.write(` ${l}\n`);
983
1009
  fail(`no pude pushear la branch '${branch}'.`, 1);
984
1010
  }
985
1011
 
@@ -1263,7 +1289,7 @@ async function cmdInit(repo, opts) {
1263
1289
  let installOpenspec = opts.openspec === true;
1264
1290
  if (!hasOpenspec && opts.openspec === undefined && rl) {
1265
1291
  process.stdout.write("\n OpenSpec es el motor recomendado del CÓMO: convierte la US en design + tasks\n");
1266
- process.stdout.write(" (comandos /opsx:*). No está en este repo — la trazabilidad de dai anda igual sin\n");
1292
+ process.stdout.write(` (${opsxHint(want)}). No está en este repo — la trazabilidad de dai anda igual sin\n`);
1267
1293
  process.stdout.write(" él, pero para el flujo completo conviene tenerlo.\n");
1268
1294
  installOpenspec = await askYesNo(rl, "¿Instalar el CLI de OpenSpec ahora? (después ejecutas `openspec init` tú)", false);
1269
1295
  }
@@ -1366,7 +1392,7 @@ async function cmdInit(repo, opts) {
1366
1392
  .filter(Boolean).join(",") || "claude,github-copilot,cursor";
1367
1393
  const osHint = "para sumarlo después: npm i -g @fission-ai/openspec@latest && openspec init --tools " + osTools;
1368
1394
  if (hasOpenspec) {
1369
- ok("OpenSpec: ya inicializado en el repo");
1395
+ ok(`OpenSpec: ya inicializado en el repo — ${opsxHint(want)}`);
1370
1396
  } else if (openspecPartial) {
1371
1397
  warn("OpenSpec: hay una carpeta openspec/ a medias. Reinicializa: rm -rf openspec && openspec init --tools " + osTools + " --force");
1372
1398
  } else if (installOpenspec) {
@@ -1379,7 +1405,7 @@ async function cmdInit(repo, opts) {
1379
1405
  try {
1380
1406
  info(`OpenSpec: inicializando en el repo (--tools ${osTools})…`);
1381
1407
  runNpmTool("openspec", ["init", "--tools", osTools, "--force"], { stdio: "inherit", cwd: repo === "." ? process.cwd() : repo });
1382
- ok("OpenSpec: instalado e inicializado — genera design/tasks con /opsx:*");
1408
+ ok(`OpenSpec: instalado e inicializado — genera design/tasks con ${opsxHint(want)}`);
1383
1409
  } catch {
1384
1410
  warn("OpenSpec: el CLI está pero falló `openspec init`. Ejecuta a mano en el repo:");
1385
1411
  process.stdout.write(` openspec init --tools ${osTools} --force\n`);
@@ -1645,6 +1671,43 @@ function cmdDoctor() {
1645
1671
  else warn("falta constitución Cursor (dai-constitution.mdc)");
1646
1672
  }
1647
1673
 
1674
+ // OpenSpec: los comandos que arman el CÓMO. Se reportan acá porque su nombre CAMBIA
1675
+ // según el asistente, y con la forma equivocada el agente no falla — no encuentra nada,
1676
+ // no avisa, y se pone a improvisar salteándose el gate. Verlo escrito ahorra la tarde.
1677
+ const OPSX_IDS = ["explore", "propose", "apply", "archive"];
1678
+ const opsxPath = (kind, id) => join(cwd, ...OPSX_COMMAND_FILE[kind].replace("<id>", id).split("/"));
1679
+ // Solo los asistentes configurados EN ESTE REPO: los comandos opsx son archivos del
1680
+ // repo, así que tener las skills globales no dice nada sobre ellos. Avisar por los otros
1681
+ // dos le pone dos warnings falsos a quien configuró uno solo, que es el caso normal.
1682
+ const localActive = ASSISTANTS.filter((a) => skillNames.some((n) => existsSync(join(a.local, n))));
1683
+ if (localActive.length && existsSync(join(cwd, "openspec"))) {
1684
+ info("OpenSpec — los comandos van en el chat del asistente, no en la terminal:");
1685
+ for (const a of localActive) {
1686
+ const faltan = OPSX_IDS.filter((id) => !existsSync(opsxPath(a.kind, id)));
1687
+ if (faltan.length === OPSX_IDS.length) {
1688
+ warn(`${a.label}: no hay comandos opsx. Generalos: openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force`);
1689
+ } else {
1690
+ const hay = OPSX_IDS.filter((id) => existsSync(opsxPath(a.kind, id)));
1691
+ ok(`${a.label}: ${hay.map((id) => opsxCommand(a.kind, id)).join(" · ")}`);
1692
+ if (faltan.length) warn(`${a.label}: faltan ${faltan.join(", ")} — regenerá con \`openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force\``);
1693
+ }
1694
+ }
1695
+ process.stdout.write(" (el nombre sale del archivo que genera OpenSpec: solo Claude usa los dos puntos)\n");
1696
+ // La versión importa para algo más que features: hasta OPENSPEC_MIN, los prompts que
1697
+ // OpenSpec genera para Copilot/Cursor decían `/opsx:apply` (la forma de Claude, que ahí
1698
+ // no existe) e invocaban herramientas que solo tiene Claude Code. El agente terminaba
1699
+ // nombrando comandos inexistentes y salteándose el gate de aprobación sin avisar.
1700
+ let osV = null;
1701
+ try { osV = String(runNpmTool("openspec", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) ?? "").trim(); } catch { /* sin CLI */ }
1702
+ if (!osV) info("el CLI de OpenSpec no está en el PATH (los comandos igual viven en el repo)");
1703
+ else if (compareVersions(osV, OPENSPEC_MIN) === -1) {
1704
+ warn(`OpenSpec ${osV} — actualiza a ${OPENSPEC_MIN} o mayor: npm i -g @fission-ai/openspec@latest`);
1705
+ process.stdout.write(" Hasta esa versión, los prompts de Copilot y Cursor nombraban los comandos en la\n");
1706
+ process.stdout.write(" forma de Claude y pedían herramientas que esos asistentes no tienen: el agente se\n");
1707
+ process.stdout.write(" saltea el gate de aprobación y se pone a implementar sin avisar.\n");
1708
+ } else ok(`OpenSpec ${osV}`);
1709
+ }
1710
+
1648
1711
  info("adaptador de PM:");
1649
1712
  const pm = process.env.DAI_PM || "md";
1650
1713
  ok(`DAI_PM=${pm}`);
@@ -1676,6 +1739,44 @@ function cmdDoctor() {
1676
1739
  : warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
1677
1740
  }
1678
1741
 
1742
+ // ── forge: el CÓMO sale del repo por acá ─────────────────────────────────────
1743
+ // Si el push no sale, no hay PR; sin PR no hay link QUÉ↔CÓMO. El chequeo del cliente
1744
+ // ssh existe porque su modo de fallar miente: ver lib/git-ssh.mjs.
1745
+ const remote = gitRemote();
1746
+ info("forge:");
1747
+ if (!remote) warn("no hay remoto 'origin' — `dai pr` no tiene dónde publicar la branch");
1748
+ else {
1749
+ const p = parseRemote(remote);
1750
+ ok(`remoto origin: ${remote}${p ? ` (${detectForge(p.host)})` : ""}`);
1751
+ let sshConfig = null;
1752
+ try { sshConfig = git(["config", "--get", "core.sshCommand"]) || null; } catch { /* sin valor: git sale 1 */ }
1753
+ const d = diagnoseGitSsh({
1754
+ platform: process.platform,
1755
+ remote,
1756
+ config: sshConfig,
1757
+ env: { GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND, GIT_SSH: process.env.GIT_SSH },
1758
+ hasWindowsOpenSsh: existsSync(WINDOWS_OPENSSH),
1759
+ });
1760
+ if (d.status === "ok") ok(`git usa el OpenSSH de Windows (ve al ssh-agent)`);
1761
+ if (d.status === "ok-por-env") {
1762
+ ok(`git usa el OpenSSH de Windows (ve al ssh-agent)`);
1763
+ warn(`pero viene de ${d.source}, no del config: se pierde al cerrar la terminal.`);
1764
+ process.stdout.write(` Para que quede: git config --global core.sshCommand "${WINDOWS_OPENSSH}"\n`);
1765
+ }
1766
+ if (d.status === "bundled" || d.status === "otro-ssh") {
1767
+ warn(d.status === "bundled"
1768
+ ? "git usa su propio ssh (Git for Windows), que NO ve al ssh-agent de Windows."
1769
+ : `git usa ${d.bin} (por ${d.source}), que probablemente no vea al ssh-agent de Windows.`);
1770
+ process.stdout.write(` Si tu clave tiene passphrase, cada push te la va a pedir — y si el prompt no llega,\n`);
1771
+ process.stdout.write(` ssh cae a password y falla con "Permission denied (publickey…)", que acusa a la clave.\n`);
1772
+ if (d.fix) process.stdout.write(` → git config --global core.sshCommand "${d.fix}"\n`);
1773
+ if (d.shadowed) process.stdout.write(` Ojo: ${d.source} está seteada y pisa tu core.sshCommand. Límpiala primero.\n`);
1774
+ }
1775
+ if (d.status === "bundled-sin-openssh") {
1776
+ warn("git usa su propio ssh y no encontré el OpenSSH de Windows: si el push pide passphrase, instálalo (Configuración → Características opcionales → Cliente OpenSSH).");
1777
+ }
1778
+ }
1779
+
1679
1780
  // ── version-drift del scaffold vs el CLI (ADR-0010) ──────────────────────────
1680
1781
  if (existsSync(join(process.cwd(), ".dai", "VERSION"))) { info("versión del scaffold:"); reportDrift(); }
1681
1782
  }
@@ -1693,23 +1794,23 @@ const { opts, pos } = parseFlags(rest);
1693
1794
  switch (cmd) {
1694
1795
  case "ac-hash": cmdAcHash(pos[0]); break;
1695
1796
  case "ls": cmdLs(opts); break;
1696
- case "link-us": cmdLinkUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1697
- case "check": (opts.ci ? cmdCheckCi(opts) : cmdCheck()).catch((e) => fail(String(e.message))); break;
1698
- case "stamp": cmdStamp(pos, opts).catch((e) => fail(String(e.message))); break;
1699
- case "update-us": cmdUpdateUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1700
- case "edit-us": cmdEditUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
1701
- case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
1702
- case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
1797
+ case "link-us": cmdLinkUs(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
1798
+ case "check": (opts.ci ? cmdCheckCi(opts) : cmdCheck()).catch((e) => failSoft(String(e.message))); break;
1799
+ case "stamp": cmdStamp(pos, opts).catch((e) => failSoft(String(e.message))); break;
1800
+ case "update-us": cmdUpdateUs(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
1801
+ case "edit-us": cmdEditUs(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
1802
+ case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => failSoft(String(e.message))); break;
1803
+ case "publish": cmdPublish(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
1703
1804
  case "pr":
1704
- case "mr": cmdPr(opts).catch((e) => fail(String(e.message))); break; // `mr` = alias para GitLab (merge request)
1805
+ case "mr": cmdPr(opts).catch((e) => failSoft(String(e.message))); break; // `mr` = alias para GitLab (merge request)
1705
1806
  case "done": cmdDone(opts); break;
1706
1807
  case "archive": cmdArchive(pos[0], opts); break;
1707
- case "install": cmdInstall(opts).catch((e) => fail(String(e.message))); break; // alias de `dai skills install`
1808
+ case "install": cmdInstall(opts).catch((e) => failSoft(String(e.message))); break; // alias de `dai skills install`
1708
1809
  case "skills":
1709
- if (pos[0] === "install" || pos[0] === undefined) cmdInstall(opts).catch((e) => fail(String(e.message)));
1810
+ if (pos[0] === "install" || pos[0] === undefined) cmdInstall(opts).catch((e) => failSoft(String(e.message)));
1710
1811
  else fail(`subcomando de skills desconocido: '${pos[0]}' (por ahora: install)`, 2);
1711
1812
  break;
1712
- case "init": cmdInit(pos[0], opts).catch((e) => fail(String(e.message))); break;
1813
+ case "init": cmdInit(pos[0], opts).catch((e) => failSoft(String(e.message))); break;
1713
1814
  case "sync": cmdSync(pos[0], opts); break;
1714
1815
  case "upgrade":
1715
1816
  case "update": cmdUpgrade(opts); break;
@@ -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.
@@ -0,0 +1,107 @@
1
+ // dai · qué cliente SSH usa git, y si ese cliente llega al agente (Windows).
2
+ //
3
+ // En Windows conviven dos ssh.exe: el de OpenSSH for Windows (C:\Windows\System32\
4
+ // OpenSSH), que habla con el servicio `ssh-agent`, y el que trae Git for Windows
5
+ // (MSYS), que NO lo ve. Con una clave con passphrase el síntoma es desconcertante:
6
+ // `ssh -T` autentica (la firma la hace el agente) y `git push` pide la passphrase o,
7
+ // si nadie la contesta, cae a autenticación por password y muere con
8
+ // "Permission denied (publickey…)" — un error que apunta al lugar equivocado y se
9
+ // come una mañana buscando el problema en la clave, en el token o en el server.
10
+ //
11
+ // Núcleo puro: el I/O (git config, existsSync) vive en dai.mjs.
12
+
13
+ export const WINDOWS_OPENSSH = "C:/Windows/System32/OpenSSH/ssh.exe";
14
+
15
+ // ¿Por dónde habla el remoto? Decide qué consejo tiene sentido cuando el push falla.
16
+ export function remoteTransport(remote) {
17
+ const r = String(remote ?? "").trim();
18
+ if (!r) return null;
19
+ if (/^ssh:\/\//i.test(r)) return "ssh";
20
+ if (/^https?:\/\//i.test(r)) return "https";
21
+ if (/^[\w.-]+@[^:]+:/.test(r)) return "ssh"; // scp-like: git@host:org/repo.git
22
+ return null; // git://, file://, ruta local…
23
+ }
24
+
25
+ // El binario de un core.sshCommand / GIT_SSH_COMMAND, que puede traer argumentos y
26
+ // comillas: `"C:/Program Files/Git/usr/bin/ssh.exe" -v` → la ruta sola.
27
+ export function sshBinaryOf(cmd) {
28
+ const s = String(cmd ?? "").trim();
29
+ if (!s) return null;
30
+ if (s[0] === '"' || s[0] === "'") {
31
+ const end = s.indexOf(s[0], 1);
32
+ return end === -1 ? s.slice(1) : s.slice(1, end);
33
+ }
34
+ const sp = s.search(/\s/);
35
+ return sp === -1 ? s : s.slice(0, sp);
36
+ }
37
+
38
+ // ¿Esa ruta es el OpenSSH de Windows? Normalizado: mayúsculas y `\` vs `/` varían
39
+ // según quién haya escrito el config (git acepta las dos formas).
40
+ export function isWindowsOpenSsh(bin) {
41
+ const p = String(bin ?? "").replace(/\\/g, "/").toLowerCase();
42
+ return /(^|\/)system32\/openssh\/ssh(\.exe)?$/.test(p);
43
+ }
44
+
45
+ // Diagnóstico del cliente ssh que va a usar git. Entradas ya resueltas por el
46
+ // llamador; acá no se toca el disco ni se lanza un proceso.
47
+ // platform — process.platform
48
+ // remote — url de origin (o null)
49
+ // config — `git config --get core.sshCommand` (o null si no está)
50
+ // env — { GIT_SSH_COMMAND, GIT_SSH }
51
+ // hasWindowsOpenSsh — existsSync(WINDOWS_OPENSSH)
52
+ //
53
+ // status:
54
+ // n/a → no aplica (no es Windows, o el remoto no habla SSH)
55
+ // ok → apunta al OpenSSH de Windows por core.sshCommand
56
+ // ok-por-env → apunta bien, pero desde una env var: se pierde al cerrar la terminal
57
+ // otro-ssh → apunta a otro ssh (el de MSYS, plink…)
58
+ // bundled → nadie lo configuró: git usa el suyo, que no ve el agente
59
+ // bundled-sin-openssh → igual, pero no hay OpenSSH de Windows que recomendar
60
+ export function diagnoseGitSsh({ platform, remote, config, env = {}, hasWindowsOpenSsh = false } = {}) {
61
+ if (platform !== "win32") return { status: "n/a", reason: "no-windows" };
62
+ const transport = remoteTransport(remote);
63
+ if (transport !== "ssh") return { status: "n/a", reason: transport ? "remoto-https" : "sin-remoto" };
64
+
65
+ // Precedencia real de git: GIT_SSH_COMMAND > GIT_SSH > core.sshCommand.
66
+ const [source, raw] =
67
+ env.GIT_SSH_COMMAND ? ["GIT_SSH_COMMAND", env.GIT_SSH_COMMAND]
68
+ : env.GIT_SSH ? ["GIT_SSH", env.GIT_SSH]
69
+ : config ? ["core.sshCommand", config]
70
+ : ["default", null];
71
+
72
+ // Una env var pisando un core.sshCommand que ya estaba bien: el dev "arregló" el
73
+ // config, no funciona, y no hay forma de verlo mirando el .gitconfig.
74
+ const shadowed = source !== "default" && source !== "core.sshCommand" && Boolean(config);
75
+
76
+ if (source === "default") {
77
+ return hasWindowsOpenSsh
78
+ ? { status: "bundled", source, bin: null, fix: WINDOWS_OPENSSH, shadowed }
79
+ : { status: "bundled-sin-openssh", source, bin: null, fix: null, shadowed };
80
+ }
81
+
82
+ const bin = sshBinaryOf(raw);
83
+ if (isWindowsOpenSsh(bin)) {
84
+ return { status: source === "core.sshCommand" ? "ok" : "ok-por-env", source, bin, fix: null, shadowed };
85
+ }
86
+ return { status: "otro-ssh", source, bin, fix: hasWindowsOpenSsh ? WINDOWS_OPENSSH : null, shadowed };
87
+ }
88
+
89
+ // Qué decirle al dev cuando `git push` falla. El consejo de "pusheá a mano una vez"
90
+ // solo aplica a HTTPS, donde el credential manager pide la credencial la primera vez;
91
+ // contra un remoto SSH el push a mano falla EXACTAMENTE igual, así que mandarlo por ahí
92
+ // es hacerle perder el tiempo mientras el problema real (la clave, el agente) sigue ahí.
93
+ export function pushFailureHint(remote, branch) {
94
+ if (remoteTransport(remote) === "ssh") {
95
+ return [
96
+ `El remoto habla SSH: en el push no interviene el token de gh/glab, solo tu clave.`,
97
+ `Prueba a mano — así ves lo que ssh pregunta (p. ej. la passphrase de tu clave):`,
98
+ ` git push -u origin ${branch}`,
99
+ `Si te la pide en cada push, tu clave está en un agente que git no ve: dai doctor`,
100
+ ];
101
+ }
102
+ return [
103
+ `Si es la primera vez contra este remoto, autentica pusheando a mano una vez:`,
104
+ ` git push -u origin ${branch}`,
105
+ `y vuelve a ejecutar: dai pr`,
106
+ ];
107
+ }
@@ -41,7 +41,9 @@ export function extractTitle(md) {
41
41
 
42
42
  // Render del implements.yaml (schema ADR-0004).
43
43
  export function renderImplementsYaml({ change, repo, id, version = "v1", ac_hash, autor }) {
44
- return `# Link QUÉ↔CÓMO · scaffoldeado por dai link-us. El ÚNICO link autorado a mano.
44
+ return `# Link QUÉ↔CÓMO · scaffoldeado por dai link-us. El ÚNICO link AUTORADO del método:
45
+ # lo escribe alguien, no se deriva. Todo lo de abajo ya está resuelto salvo 'introduces',
46
+ # que se completa AL TERMINAR de implementar — recién ahí se sabe qué se introdujo.
45
47
  # Schema: docs/adr/0004-ubicacion-y-schema-implements.md
46
48
  change: ${change}
47
49
  repo: ${repo}
@@ -52,7 +54,8 @@ implements:
52
54
  ac_hash: ${ac_hash}
53
55
 
54
56
  introduces:
55
- - <capacidad-tecnica> # completar: specs técnicas nuevas de este change
57
+ - <capacidad-tecnica> # al cerrar la implementación: capacidades técnicas nuevas
58
+ # (o borrá el bloque si no hay ninguna)
56
59
 
57
60
  autor: ${autor}
58
61
  `;
@@ -157,7 +157,7 @@ $ dai link-us ABC-482 --us us.md --change finalizar-compra
157
157
  ```
158
158
 
159
159
  ```yaml
160
- # implements.yaml — el ÚNICO link autorado a mano (schema ADR-0004)
160
+ # implements.yaml — el ÚNICO link AUTORADO: se escribe, no se deriva (schema ADR-0004)
161
161
  change: finalizar-compra
162
162
  repo: frontend
163
163
 
package/docs/glosario.md CHANGED
@@ -20,7 +20,7 @@
20
20
  | Término | Qué es |
21
21
  |---|---|
22
22
  | **Link (QUÉ↔CÓMO)** | La relación entre un requerimiento y su implementación. |
23
- | **`implements`** | La declaración `implements: <id>@<version>` en el código. El **único** link autorado a mano. |
23
+ | **`implements`** | La declaración `implements: <id>@<version>` en el código. El **único** link autorado (se escribe, no se deriva): lo scaffoldea `dai link-us` y se cierra al terminar de implementar. |
24
24
  | **`implements.yaml`** | El archivo, en el change del repo, que contiene ese link. Lo genera `link-us`. Ejemplo lleno + árbol de dónde vive entre los artefactos de OpenSpec: [ADR-0004](adr/0004-ubicacion-y-schema-implements.md). |
25
25
  | **Trazabilidad inversa / cobertura** | El mapa "quién implementó este QUÉ". **Se genera, nunca se escribe.** |
26
26
  | **Índice / router** | La tabla central que dice qué ID vive en qué repos. Es un router, **no un almacén**: no guarda el detalle. |
package/docs/guias/dev.md CHANGED
@@ -7,7 +7,10 @@
7
7
 
8
8
  - El **CÓMO**: diseño técnico, modelo de datos, arquitectura de la solución.
9
9
  - Las **tareas técnicas** (las derivas tú, desde la US, con OpenSpec).
10
- - El **link** (`implements.yaml`): es el **único** que se autora a mano ([Art. 9](../MANIFIESTO.md#art-9)).
10
+ - El **link** (`implements.yaml`): es el **único** que se autora, no se deriva ([Art. 9](../MANIFIESTO.md#art-9)).
11
+ `dai link-us` lo scaffoldea con el `id`/`version`/`ac_hash` ya resueltos; lo único que queda
12
+ abierto es `introduces`, y se cierra **al terminar de implementar** —normalmente lo completa
13
+ el agente que implementó, y tú lo revisas en la PR—.
11
14
  - El **código** y su **spec técnica**.
12
15
 
13
16
  ## Lo que NO tocas
@@ -88,3 +91,10 @@ que está junto a tu `implements.yaml`.
88
91
  - `dai check` · `dai pr` · `dai stamp` · `dai done` (limpieza, opcional)
89
92
  - `dai update-us` — empuja al tracker una US que refinaste implementando
90
93
  - `definition-of-done.md`
94
+
95
+ > **Ojo con el nombre de los comandos `opsx`.** Acá se escriben en la forma de Claude Code
96
+ > (`/opsx:apply`). En **Copilot y Cursor** los mismos comandos van **con guion**:
97
+ > `/opsx-apply`. No es un alias: es el nombre del archivo que genera OpenSpec para cada
98
+ > asistente. Con la forma equivocada el agente no encuentra el comando, no carga el workflow
99
+ > y se pone a improvisar sin avisar. `dai init` te dice cuál te toca.
100
+
@@ -135,7 +135,7 @@ dai init --for copilot --pm jira
135
135
  ```
136
136
 
137
137
  Te va a preguntar si quieres instalar **OpenSpec**: responde **`s`**. Es el motor del CÓMO —
138
- convierte la US en `design.md` + `tasks.md` con los comandos `/opsx:*`. Un dev sí lo usa.
138
+ convierte la US en `design.md` + `tasks.md` con los comandos `/opsx-*`. Un dev sí lo usa.
139
139
 
140
140
  Lo que deja:
141
141
 
@@ -262,28 +262,40 @@ Trae la US de Jira, calcula el `ac_hash` de sus criterios y deja dos cosas:
262
262
  ✓ archivo: openspec/changes/finalizar-la-compra-del-carrito/implements.yaml (ac_hash 380d814b)
263
263
  ```
264
264
 
265
- Ese `implements.yaml` es **el único archivo que se autora a mano** en todo el método
266
- ([ADR-0004](../adr/0004-ubicacion-y-schema-implements.md)):
265
+ Ese `implements.yaml` es el **único registro autorado** del método
266
+ ([ADR-0004](../adr/0004-ubicacion-y-schema-implements.md)): el resto de la trazabilidad se
267
+ **deriva**, este se escribe. "Autorado" significa que lo escribe alguien —no que lo tipees tú
268
+ ahora—, y `dai link-us` ya te lo dejó casi entero:
267
269
 
268
270
  ```yaml
269
271
  change: finalizar-la-compra-del-carrito
270
272
  repo: tienda
271
273
 
272
274
  implements:
273
- - id: PROJ-125
274
- version: v1
275
- ac_hash: 380d814b
275
+ - id: PROJ-125 # ← ya está: lo puso link-us desde el ID que validó
276
+ version: v1 # ← ya está
277
+ ac_hash: 380d814b # ← ya está: calculado sobre los criterios de la US
276
278
 
277
279
  introduces:
278
- - <capacidad-tecnica> # completar: specs técnicas nuevas de este change
280
+ - <capacidad-tecnica> # lo ÚNICO pendiente, y NO se completa ahora
279
281
 
280
- autor: tu.nombre
282
+ autor: tu.nombre # ← ya está
281
283
  ```
282
284
 
283
- Completa `introduces` con las capacidades técnicas nuevas del change (o bórralo si no hay).
285
+ > **No completes `introduces` todavía.** Es lo que este change **introduce** en el repo: las
286
+ > capacidades técnicas nuevas. Recién al terminar de implementar se sabe cuáles son —al
287
+ > empezar sería adivinar—, así que se completa **al cerrar la implementación**, no acá.
288
+
289
+ **Y normalmente no lo escribes tú: lo escribe el agente.** Cuando `/opsx-apply` termina de
290
+ aplicar las tareas, el que implementó es el que sabe qué capacidades quedaron: pídele que
291
+ complete `introduces` (o que borre el bloque si el change no introdujo ninguna) como último
292
+ paso. **Tú lo revisas en la MR** — el archivo va versionado con el código justamente para que
293
+ se lea en la revisión, igual que cualquier otro cambio.
284
294
 
285
295
  > **El key no se tipea nunca a mano.** La rama y el `implements.yaml` salen los dos del mismo
286
- > ID validado: por eso el link no puede quedar mal escrito.
296
+ > ID validado: por eso el link no puede quedar mal escrito. El campo que sí requiere criterio
297
+ > humano —`introduces`— es el único que queda abierto, y no es un dato del tracker: es tu
298
+ > lectura de lo que el change agregó.
287
299
 
288
300
  > **Si te dice que la US no tiene criterios de aceptación**, frena: no es un problema de tu
289
301
  > setup. Esa US no cumple el [DoR](../../templates/definition-of-ready.md) y vuelve al PO.
@@ -291,14 +303,36 @@ Completa `introduces` con las capacidades técnicas nuevas del change (o bórral
291
303
  ### 2. Arma el CÓMO y programa
292
304
 
293
305
  ```
294
- /opsx:explore → entender el terreno
295
- /opsx:propose → design.md + tasks.md sobre la rama ya linkeada
296
- /opsx:apply → el agente implementa las tareas con test primero (/tdd)
306
+ /opsx-explore → entender el terreno
307
+ /opsx-propose → design.md + tasks.md sobre la rama ya linkeada
308
+ /opsx-apply → el agente implementa las tareas con test primero (/tdd)
297
309
  ```
298
310
 
311
+ > **En Copilot los comandos van con guion, no con dos puntos.** OpenSpec nombra sus comandos
312
+ > según el asistente: en Claude Code son `/opsx:propose` (namespace anidado), y en Copilot y
313
+ > Cursor son `/opsx-propose`, por el nombre del archivo que generan
314
+ > (`.github/prompts/opsx-propose.prompt.md`). Mucha documentación —la de OpenSpec incluida—
315
+ > muestra solo la forma de Claude.
316
+ >
317
+ > Si tipeas `/opsx:propose` en Copilot **no falla con un error**: no encuentra nada, se queda
318
+ > con tu texto suelto y se pone a improvisar. Se ve como un agente que "hace lo que quiere",
319
+ > que se saltea el gate y que arranca a programar sin haber escrito la propuesta. Si te pasa
320
+ > eso, lo primero que hay que mirar es cómo escribiste el comando.
321
+
299
322
  Tú validas el diseño, decides qué comportamientos importa testear y **revisas lo que escribió
300
323
  el agente**: eres responsable del código, no la IA.
301
324
 
325
+ Cuando `/opsx-apply` termina, cierra el link — es el momento en que ya se sabe qué introdujo
326
+ el change:
327
+
328
+ ```
329
+ Completa `introduces` en el implements.yaml con las capacidades técnicas que agregó este
330
+ change (o borra el bloque si no agregó ninguna).
331
+ ```
332
+
333
+ Revisa lo que puso. `introduces` no es un dato del tracker: es la lectura de lo que el change
334
+ agregó al repo, y viaja versionado para que se lea en la MR.
335
+
302
336
  ### 3. Commitea y comprueba el link
303
337
 
304
338
  ```powershell
@@ -382,12 +416,58 @@ chat de Copilot en modo *Agent*, escribiendo `/` adelante.
382
416
  |---|---|---|
383
417
  | Los comandos de **dai** | **PowerShell**, parado en el repositorio | `dai link-us` · `dai check` · `dai mr` · `dai stamp` · `dai done` |
384
418
  | Las **skills** (empiezan con `/`) | El **chat de Copilot**, modo *Agent* | `/link-us` · `/tdd` · `/dai-review` · `/grill-intent` |
385
- | Los comandos de **OpenSpec** | El **chat de Copilot** | `/opsx:explore` · `/opsx:propose` · `/opsx:apply` |
419
+ | Los comandos de **OpenSpec** | El **chat de Copilot** | `/opsx-explore` · `/opsx-propose` · `/opsx-apply` (con **guion**: ver arriba) |
386
420
 
387
421
  ---
388
422
 
389
423
  ## Cuando algo falla
390
424
 
425
+ ### El agente no respeta los pasos: implementa sin escribir la propuesta
426
+
427
+ Síntoma: le pides `/opsx:explore` o `/opsx:propose` y el agente te explica el método, te pide
428
+ que le pegues el ticket a mano, o directamente se pone a escribir código sin haber generado
429
+ `proposal.md` / `design.md` / `tasks.md`. Hay que frenarlo a mano en cada paso.
430
+
431
+ Casi siempre es **el nombre del comando**. En Copilot son `/opsx-explore`, `/opsx-propose`,
432
+ `/opsx-apply` — **con guion**. Con los dos puntos, Copilot no encuentra ningún comando, no
433
+ avisa, y toma tu texto como una charla suelta: el workflow nunca se cargó. Comprueba qué
434
+ tienes disponible:
435
+
436
+ ```powershell
437
+ dai doctor
438
+ ```
439
+
440
+ En el apartado **OpenSpec** te dice los comandos exactos de tu asistente:
441
+
442
+ ```
443
+ › OpenSpec — los comandos van en el chat del asistente, no en la terminal:
444
+ ✓ Copilot: /opsx-explore · /opsx-propose · /opsx-apply · /opsx-archive
445
+ ```
446
+
447
+ Si dice que no hay comandos, falta inicializar OpenSpec:
448
+
449
+ ```powershell
450
+ openspec init --tools github-copilot --force
451
+ ```
452
+
453
+ Después **reinicia VS Code**: los comandos nuevos no aparecen hasta que se recarga.
454
+
455
+ Con el nombre correcto el agente frena al terminar la propuesta y espera tu aprobación antes
456
+ de implementar.
457
+
458
+ Si aun así se pasa de largo —o si te nombra comandos con dos puntos que no existen—, mira la
459
+ versión de OpenSpec: `dai doctor` te avisa si está vieja. Hasta la **1.10.0**, los prompts que
460
+ OpenSpec generaba para Copilot pedían herramientas que Copilot no tiene, y su única forma de
461
+ frenar a preguntarte no existía. Actualiza:
462
+
463
+ ```powershell
464
+ npm i -g @fission-ai/openspec@latest
465
+ openspec init --tools github-copilot --force
466
+ ```
467
+
468
+ En cualquier caso, **la aprobación del diseño es tuya**: si ves que arrancó a programar sin
469
+ que hayas dicho que sí, páralo y pídele la propuesta.
470
+
391
471
  ### `dai link-us` dice que no encontró la US en jira
392
472
 
393
473
  Por orden de frecuencia:
@@ -455,6 +535,26 @@ $env:NODE_EXTRA_CA_CERTS="C:\ruta\ca-empresa.pem"
455
535
  > lo proponga un asistente. Eso no arregla nada: **apaga la verificación entera**, y por esa
456
536
  > conexión viaja tu token de Jira.
457
537
 
538
+ ### `dai check` imprime el ✅ y después tira `Assertion failed … UV_HANDLE_CLOSING`
539
+
540
+ ```
541
+ ✅ PROJ-125 al día (v1)
542
+ Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94
543
+ ```
544
+
545
+ El resultado que ves es el bueno: el chequeo terminó bien. Lo que revienta después es el
546
+ **cierre del proceso** de Node en Windows, desarmando la conexión que se usó para consultar
547
+ Jira. Es un problema de Node, no de tu configuración ni de tu US.
548
+
549
+ Ya está corregido en dai. Actualiza:
550
+
551
+ ```powershell
552
+ dai upgrade
553
+ ```
554
+
555
+ Importaba arreglarlo porque el proceso terminaba con un código de error aunque el chequeo
556
+ hubiera pasado: un `dai check` verde se reportaba rojo en el CI o en un hook de git.
557
+
458
558
  ### El CI falla con "falta el link"
459
559
 
460
560
  Es el gate de [`governance/ci-rules.md`](https://github.com/dforce2055/dai/blob/main/governance/ci-rules.md),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Metodología de desarrollo asistido por IA — CLI de acciones deterministas (trazabilidad QUÉ↔CÓMO).",
5
5
  "repository": { "type": "git", "url": "git+https://github.com/dforce2055/dai.git" },
6
6
  "homepage": "https://dforce2055.github.io/dai/",
@@ -25,7 +25,7 @@ Es la contraparte técnica de `grill-user-story`: donde esa produce el QUÉ (en
25
25
  - Nombre: `feature/ABC-###-<slug>` donde `<slug>` sale del título (minúsculas, sin acentos, `-` como separador).
26
26
  - Base según convención del repo (`main` o `develop`). Verificar que la branch no exista ya.
27
27
  - No permitir crear la branch si el key no fue validado en el paso 1.
28
- 4. **Generar el link.** Crear `openspec/changes/<change-id>/implements.yaml` a partir de [templates/implements.yaml](templates/implements.yaml), completando `id`, `version`, `ac_hash`, `repo` y `autor`. Dejar `introduces` para que el dev liste las capacidades técnicas nuevas.
28
+ 4. **Generar el link.** Crear `openspec/changes/<change-id>/implements.yaml` a partir de [templates/implements.yaml](templates/implements.yaml), completando `id`, `version`, `ac_hash`, `repo` y `autor`. Dejar `introduces` con el placeholder: **no se completa ahora**. Al arrancar no se sabe qué capacidades técnicas va a introducir el change — eso se sabe al terminar de implementarlo, y ahí lo completa quien implementó (agente o dev), con el dev revisándolo en la PR.
29
29
  5. **Hand-off.** Ofrecer seguir con `opsx:explore` → `opsx:propose` para armar el change (proposal/design/tasks) sobre la branch ya creada y linkeada.
30
30
 
31
31
  ## Guardrails (por qué esta skill existe)
@@ -1,5 +1,6 @@
1
1
  # Link QUÉ↔CÓMO · lo genera link-us / `dai link-us`, lo versiona git junto al código.
2
- # Es el ÚNICO link autorado a mano. La cobertura inversa la deriva `dai stamp`.
2
+ # Es el ÚNICO link AUTORADO (se escribe, no se deriva): lo scaffoldea `link-us` y se cierra
3
+ # al terminar de implementar. La cobertura inversa la deriva `dai stamp`.
3
4
  # Schema: docs/adr/0004-ubicacion-y-schema-implements.md
4
5
 
5
6
  change: <change-id> # ← identidad del CÓMO (nombre local del change/spec). Auto-contenido.
@@ -11,6 +12,7 @@ implements: # FORWARD: qué QUÉ del negocio cumple este ch
11
12
  ac_hash: <autogenerado> # ← lo calcula `dai ac-hash`. Dispara el ⚠️ si el QUÉ cambia.
12
13
 
13
14
  introduces: # capacidades TÉCNICAS nuevas que agrega este change (opcional)
14
- - <capacidad-tecnica> # p. ej. guard-carrito-vacio
15
+ - <capacidad-tecnica> # p. ej. guard-carrito-vacio. Se completa AL TERMINAR de
16
+ # implementar, no al crear el link: recién ahí se sabe.
15
17
 
16
18
  autor: <dev> # quién implementa
@@ -24,6 +24,9 @@
24
24
  - [ ] Existe `implements.yaml` con `id`, `version` y `ac_hash`. *(Art. 9)*
25
25
  - [ ] El **`ac_hash` coincide** con el de la US vigente (no se implementó una versión atrasada). *(Art. 11)*
26
26
  - [ ] La rama sigue la convención (`feature/ABC-###-<slug>`) → ver `governance/branch-naming.md`.
27
+ - [ ] **`introduces` está cerrado**: lista las capacidades técnicas que el change agregó, o el
28
+ bloque se borró porque no agregó ninguna. No queda el placeholder `<capacidad-tecnica>`:
29
+ es el único campo que se completa **al terminar**, y sin cerrarlo el link queda a medias.
27
30
 
28
31
  ### Revisión
29
32
  - [ ] Pasó el **primer pase de IA** (`dai-review`): sin problemas de correctitud.
@@ -31,7 +34,9 @@
31
34
  - [ ] Cumple los estándares del repo (lint, tipos, convenciones).
32
35
 
33
36
  ### Cierre
34
- - [ ] El change se promovió (`opsx:apply` → `opsx:archive`) si aplica.
37
+ - [ ] El change se promovió (los comandos `apply` → `archive` de OpenSpec) si aplica.
38
+ *(Se escriben `/opsx:apply` en Claude Code y `/opsx-apply` en Copilot y Cursor —
39
+ `dai doctor` te dice cuál usa este repo.)*
35
40
  - [ ] El **CI estampó la cobertura** en el gestor (no la escribió una persona). *(Art. 10)*
36
41
  - [ ] La US quedó en estado **implementada**.
37
42