@dforce2055/dai 0.12.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,91 @@
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
+
6
91
  ## [0.12.0] — 2026-08-13
7
92
 
8
93
  **La PR deja de robarle la US a otro. `dai pr` resolvía el link recorriendo todo el repo y
@@ -611,6 +696,7 @@ ClickUp y Jira Cloud.
611
696
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
612
697
  `files` de npm sin tests ni secretos.
613
698
 
699
+ [0.13.0]: https://github.com/dforce2055/dai/releases/tag/v0.13.0
614
700
  [0.12.0]: https://github.com/dforce2055/dai/releases/tag/v0.12.0
615
701
  [0.11.0]: https://github.com/dforce2055/dai/releases/tag/v0.11.0
616
702
  [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.0
package/cli/dai.mjs CHANGED
@@ -30,9 +30,9 @@ 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
38
  import { flattenImplements, stampScope, prScope, matchBranchToImplements, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
@@ -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 ──────────────────────────────────────────────────────────────────
@@ -1263,7 +1278,7 @@ async function cmdInit(repo, opts) {
1263
1278
  let installOpenspec = opts.openspec === true;
1264
1279
  if (!hasOpenspec && opts.openspec === undefined && rl) {
1265
1280
  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");
1281
+ process.stdout.write(` (${opsxHint(want)}). No está en este repo — la trazabilidad de dai anda igual sin\n`);
1267
1282
  process.stdout.write(" él, pero para el flujo completo conviene tenerlo.\n");
1268
1283
  installOpenspec = await askYesNo(rl, "¿Instalar el CLI de OpenSpec ahora? (después ejecutas `openspec init` tú)", false);
1269
1284
  }
@@ -1366,7 +1381,7 @@ async function cmdInit(repo, opts) {
1366
1381
  .filter(Boolean).join(",") || "claude,github-copilot,cursor";
1367
1382
  const osHint = "para sumarlo después: npm i -g @fission-ai/openspec@latest && openspec init --tools " + osTools;
1368
1383
  if (hasOpenspec) {
1369
- ok("OpenSpec: ya inicializado en el repo");
1384
+ ok(`OpenSpec: ya inicializado en el repo — ${opsxHint(want)}`);
1370
1385
  } else if (openspecPartial) {
1371
1386
  warn("OpenSpec: hay una carpeta openspec/ a medias. Reinicializa: rm -rf openspec && openspec init --tools " + osTools + " --force");
1372
1387
  } else if (installOpenspec) {
@@ -1379,7 +1394,7 @@ async function cmdInit(repo, opts) {
1379
1394
  try {
1380
1395
  info(`OpenSpec: inicializando en el repo (--tools ${osTools})…`);
1381
1396
  runNpmTool("openspec", ["init", "--tools", osTools, "--force"], { stdio: "inherit", cwd: repo === "." ? process.cwd() : repo });
1382
- ok("OpenSpec: instalado e inicializado — genera design/tasks con /opsx:*");
1397
+ ok(`OpenSpec: instalado e inicializado — genera design/tasks con ${opsxHint(want)}`);
1383
1398
  } catch {
1384
1399
  warn("OpenSpec: el CLI está pero falló `openspec init`. Ejecuta a mano en el repo:");
1385
1400
  process.stdout.write(` openspec init --tools ${osTools} --force\n`);
@@ -1645,6 +1660,43 @@ function cmdDoctor() {
1645
1660
  else warn("falta constitución Cursor (dai-constitution.mdc)");
1646
1661
  }
1647
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
+
1648
1700
  info("adaptador de PM:");
1649
1701
  const pm = process.env.DAI_PM || "md";
1650
1702
  ok(`DAI_PM=${pm}`);
@@ -1693,23 +1745,23 @@ const { opts, pos } = parseFlags(rest);
1693
1745
  switch (cmd) {
1694
1746
  case "ac-hash": cmdAcHash(pos[0]); break;
1695
1747
  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;
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;
1703
1755
  case "pr":
1704
- 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)
1705
1757
  case "done": cmdDone(opts); break;
1706
1758
  case "archive": cmdArchive(pos[0], opts); break;
1707
- 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`
1708
1760
  case "skills":
1709
- 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)));
1710
1762
  else fail(`subcomando de skills desconocido: '${pos[0]}' (por ahora: install)`, 2);
1711
1763
  break;
1712
- 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;
1713
1765
  case "sync": cmdSync(pos[0], opts); break;
1714
1766
  case "upgrade":
1715
1767
  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.
@@ -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.0",
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