@dforce2055/dai 0.13.2 → 0.13.3

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,55 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.13.3] — 2026-09-02
7
+
8
+ **Una PR de dai se abría diciendo, en el mismo párrafo, dos cosas que no encajaban: que el
9
+ spec estaba "verificado con dai check: ✅ al día", y a continuación una explicación general
10
+ del método. Tirando de ese hilo aparecieron dos bugs distintos, y los dos eran dai afirmando
11
+ cosas que no le constaban.**
12
+
13
+ ### Arreglado
14
+ - **El relleno del estado reescribía la prosa del template.** `dai pr` hacía un replace
15
+ **global** de `verificado con `dai check` ✅`, y esa frase estaba dos veces en el molde: en
16
+ el dato (`## 🔗 Implementa`) y en la prosa que explica el método. Así que a una oración
17
+ general —igual en todas las PRs— dai le insertaba el estado de *esta* PR, y quedaba
18
+ publicado: *"verificado con `dai check`: ✅ al día. Sin esto, el código no sabe a qué QUÉ
19
+ responde…"*. Ni doctrina ni dato. Ahora el relleno se acota a la sección; sin la sección
20
+ (un template ajeno con otra forma) cae al body entero, porque rellenar de más es
21
+ recuperable y publicar `ABC-###` no.
22
+ - **`dai pr` decía "❓ sin US" cuando el tracker no contestaba** — dos líneas debajo del id de
23
+ la US que sí existe. `coverageStatus` colapsaba en un mismo `sin-us` dos cosas que no
24
+ significan lo mismo: *el tracker contestó y la US no está* y *no hubo respuesta* (sin red,
25
+ sin token, 5xx, certificado corporativo). Los adaptadores ya distinguían los dos casos
26
+ (`404 → null`, cualquier otro error → `throw`), y el gate de CI también con su try/catch
27
+ propio; lo que rompía la distinción era un `.catch(() => null)` en `dai pr`. Hay un estado
28
+ nuevo, **`sin-respuesta`** (`⚠️ no verificado`), y la PR dice *"no verificado (el tracker no
29
+ respondió)"*, que es lo único cierto ([#43](https://github.com/dforce2055/dai/issues/43)).
30
+ - **`dai: fetch failed` era todo lo que se llegaba a leer.** Es el mensaje pelado de undici
31
+ cuando no hay red, el host no resuelve o el certificado no valida: no dice qué se estaba
32
+ consultando, ni contra qué, ni qué mirar — indistinguible de un bug de dai, el mismo modo de
33
+ falla que el push por SSH de la 0.13.1. Ahora el error nombra la US, el backend y el host, y
34
+ distingue red / credencial / error del tracker, con el próximo paso en cada caso.
35
+ - **`dai check` ya no se detenía en la primera US** que no pudiera consultar: lo reporta, sigue
36
+ con las demás y sale ≠ 0. **`dai stamp`** explicita que no estampa un estado que no pudo
37
+ verificar — escribir en el tracker de todo el equipo no se deshace.
38
+
39
+ ### Cambiado
40
+ - **El molde de PR adelgaza: la doctrina pasa a comentario HTML.** El encabezado tenía cuatro
41
+ bloques y un solo dato — la doctrina de los dos activos, una línea que repetía la de arriba
42
+ (*"este PR está atado a la US vía implements.yaml"*) y la instrucción de borrar la sección si
43
+ no hay US, que `dai pr` resuelve solo desde la 0.13.2. Nada de eso decía algo sobre *esa* PR,
44
+ y repetirlo a la vista en cada una entrena a saltear el principio del cuerpo, que es
45
+ justamente donde va la descripción. Sigue estando para quien edite el molde, invisible al
46
+ renderizar; y la doctrina vive donde se lee una vez y no quinientas: `docs/glosario.md`,
47
+ `docs/guias/dev.md`, `governance/ci-rules.md`. Los repos ya inicializados lo reciben con
48
+ `dai sync`.
49
+
50
+ ### Interno
51
+ - **367 tests** (+9): los dos caminos de la consulta (la US que no está y la que no se pudo
52
+ consultar), el mensaje de error por tipo de falla, y que el relleno del estado no toque la
53
+ prosa que lo rodea.
54
+
6
55
  ## [0.13.2] — 2026-09-02
7
56
 
8
57
  **Una PR se publicaba con la descripción vacía y la lista de cambios diciendo "Cambio 1,
@@ -808,6 +857,7 @@ ClickUp y Jira Cloud.
808
857
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
809
858
  `files` de npm sin tests ni secretos.
810
859
 
860
+ [0.13.3]: https://github.com/dforce2055/dai/releases/tag/v0.13.3
811
861
  [0.13.2]: https://github.com/dforce2055/dai/releases/tag/v0.13.2
812
862
  [0.13.1]: https://github.com/dforce2055/dai/releases/tag/v0.13.1
813
863
  [0.13.0]: https://github.com/dforce2055/dai/releases/tag/v0.13.0
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.13.2
1
+ 0.13.3
package/cli/dai.mjs CHANGED
@@ -22,7 +22,7 @@ import { acHash } from "./lib/ac-hash.mjs";
22
22
  import { discoverImplements, isPlaceholderId } from "./lib/implements.mjs";
23
23
  import { isValidKey, slugify, branchName, extractTitle, renderImplementsYaml } from "./lib/link-us.mjs";
24
24
  import { loadDaiEnv } from "./lib/env.mjs";
25
- import { getAdapter, coverageStatus, statusLabel } from "./lib/pm-adapter.mjs";
25
+ import { getAdapter, coverageStatus, statusLabel, fetchLiveUS } from "./lib/pm-adapter.mjs";
26
26
  import { branchUrl, commitUrl, parseRemote, detectForge } from "./lib/forge-url.mjs";
27
27
  import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs";
28
28
  import { trackerUrl } from "./lib/tracker-url.mjs";
@@ -332,13 +332,18 @@ async function cmdCheck() {
332
332
  for (const f of found) for (const im of f.implements || []) {
333
333
  if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
334
334
  n++;
335
- const live = await adapter.fetchUS(im.id);
336
- const status = coverageStatus(im.ac_hash, live?.ac_hash);
335
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, im.id);
336
+ const status = coverageStatus(im.ac_hash, live?.ac_hash, { unreachable });
337
337
  if (status === "al-dia") process.stdout.write(`✅ ${im.id} al día (${im.version})\n`);
338
338
  else if (status === "atrasado") {
339
339
  process.stdout.write(`⚠️ ${im.id} ATRASADO: implementaste ${im.ac_hash}, la US viva es ${live.ac_hash}${live.spec_version ? ` (${live.spec_version})` : ""}\n`);
340
340
  atrasadas.push(im.id);
341
341
  worst = Math.max(worst, 1);
342
+ } else if (status === "sin-respuesta") {
343
+ // No es "no hay US": es "no pude preguntar". Antes moría acá con `fetch failed` y sin
344
+ // chequear las demás; ahora lo dice, sigue, y sale ≠ 0 porque no pudo verificar nada.
345
+ process.stdout.write(`⚠️ ${reason}\n`);
346
+ worst = Math.max(worst, 2);
342
347
  } else {
343
348
  process.stdout.write(`❓ ${im.id}: no encontré la US (backend ${adapter.kind}). ¿Falta el .md o el token?\n`);
344
349
  worst = Math.max(worst, 2);
@@ -395,7 +400,11 @@ async function cmdStamp(ids = [], opts = {}) {
395
400
  }
396
401
 
397
402
  for (const r of targets) {
398
- const live = await adapter.fetchUS(r.id);
403
+ // Estampar es ESCRIBIR en el tracker de todo el equipo, y no se deshace: si no se pudo
404
+ // verificar el estado, no se estampa. (Hoy ya frenaba, por la excepción sin atrapar; acá
405
+ // queda explícito, con el motivo, y cubierto por un test.)
406
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, r.id);
407
+ if (unreachable) fail(`${reason}\n No estampo un estado que no pude verificar.`, 2);
399
408
  const status = coverageStatus(r.ac_hash, live?.ac_hash);
400
409
  const record = {
401
410
  repo: r.repo, change: r.change, version: r.version, ac_hash: r.ac_hash, status,
@@ -960,8 +969,14 @@ async function cmdPr(opts) {
960
969
 
961
970
  // 2. Estado de trazabilidad (dai check) contra la US viva.
962
971
  const adapter = getAdapter(process.env);
963
- const live = id ? await Promise.resolve(adapter.fetchUS(id)).catch(() => null) : null;
964
- const status = id ? coverageStatus(ac_hash, live?.ac_hash) : null;
972
+ // El `.catch(() => null)` que había acá convertía "el tracker no contestó" en "no hay US",
973
+ // y esa afirmación se PUBLICABA en el cuerpo de la PR, al lado del id de la US que sí está.
974
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, id);
975
+ if (unreachable) {
976
+ warn(reason);
977
+ warn("la PR va a decir que no se pudo verificar contra el tracker — no que no hay US.");
978
+ }
979
+ const status = id ? coverageStatus(ac_hash, live?.ac_hash, { unreachable }) : null;
965
980
  if (status === "atrasado") {
966
981
  warn(`la US ${id} está ATRASADA respecto de tu implementación (${ac_hash} ≠ ${live?.ac_hash}).`);
967
982
  warn(`resincroniza antes de abrir la PR: dai link-us ${id} --resync`);
@@ -21,19 +21,36 @@
21
21
 
22
22
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
23
23
  import { join, dirname } from "node:path";
24
- import { parseUS, renderCoverage } from "./us.mjs";
24
+ import { parseUS, renderCoverage, explainFetchError } from "./us.mjs";
25
25
  import { slugify } from "./link-us.mjs";
26
26
  import { jiraAdapter } from "./pm-jira.mjs";
27
27
  import { clickupAdapter } from "./pm-clickup.mjs";
28
28
 
29
29
  // Re-export para compatibilidad (tests y CLI importan estos desde acá).
30
- export { parseUS, coverageStatus, statusLabel, renderCoverage } from "./us.mjs";
30
+ export { parseUS, coverageStatus, statusLabel, renderCoverage, explainFetchError } from "./us.mjs";
31
+
32
+ // Consulta la US preservando la diferencia entre "no existe" y "no pude preguntar". Los
33
+ // adaptadores ya la hacen —404 → null, cualquier otro error → throw— pero se perdía en cada
34
+ // caller: `dai pr` la borraba con un `.catch(() => null)` y publicaba "sin US" en la PR, y
35
+ // los demás morían con el `fetch failed` pelado de undici. El gate de CI era el único que la
36
+ // respetaba, con su try/catch propio; esto es ese criterio, compartido.
37
+ //
38
+ // → { us, unreachable, reason } · unreachable: no hubo respuesta, no sabemos nada
39
+ export async function fetchLiveUS(adapter, id) {
40
+ if (!id) return { us: null, unreachable: false, reason: null };
41
+ try {
42
+ return { us: await adapter.fetchUS(id), unreachable: false, reason: null };
43
+ } catch (e) {
44
+ return { us: null, unreachable: true, reason: explainFetchError(e, { kind: adapter.kind, endpoint: adapter.endpoint, id }) };
45
+ }
46
+ }
31
47
 
32
48
  // ── backend md (local, offline) ───────────────────────────────────────────────
33
49
  function mdAdapter(env) {
34
50
  const dir = env.DAI_MD_US_DIR || ".dai/us";
35
51
  return {
36
52
  kind: "md",
53
+ endpoint: dir,
37
54
  fetchUS(id) {
38
55
  const p = join(dir, `${id}.md`);
39
56
  if (!existsSync(p)) return null;
@@ -25,6 +25,7 @@ export function clickupAdapter(env) {
25
25
  if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env.dai (backend clickup).");
26
26
  return {
27
27
  kind: "clickup",
28
+ endpoint: "api.clickup.com",
28
29
  async fetchUS(id) {
29
30
  const res = await fetch(clickupTaskUrl(id), { headers: clickupAuthHeaders(env) });
30
31
  if (res.status === 404) return null;
@@ -139,6 +139,7 @@ export function jiraAdapter(env) {
139
139
  if (!base) throw new Error("falta DAI_JIRA_BASE_URL en el .env.dai (backend jira).");
140
140
  return {
141
141
  kind: "jira",
142
+ endpoint: trim(base),
142
143
  async fetchUS(id) {
143
144
  const res = await daiFetch(jiraIssueUrl(base, id), { headers: jiraAuthHeaders(env) });
144
145
  if (res.status === 404) return null;
package/cli/lib/pr.mjs CHANGED
@@ -2,7 +2,12 @@
2
2
  // Parte pura y testeable: rellena el template con los datos del link + git + check.
3
3
  // Los efectos (git push, gh/glab create) viven en dai.mjs.
4
4
 
5
- const EMOJI = { "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US" };
5
+ // Más explícito que el label del CLI a propósito: esto queda PUBLICADO en la PR, donde
6
+ // quien lee no tiene el contexto de la corrida que la creó.
7
+ const EMOJI = {
8
+ "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US",
9
+ "sin-respuesta": "⚠️ no verificado (el tracker no respondió)",
10
+ };
6
11
 
7
12
  // Las secciones que dai se compromete a entregar llenas. Si alguna sale con el molde
8
13
  // del template, la PR se publica vacía y el review no tiene qué mirar (era el bug:
@@ -115,12 +120,21 @@ export function composePrBody(template, d) {
115
120
  return upsertLinksBlock(b, d);
116
121
  }
117
122
 
123
+ // Los placeholders se rellenan DENTRO de la sección, no en todo el body: la misma frase
124
+ // ("verificado con `dai check` ✅") aparecía en la prosa que explica el método, así que un
125
+ // replace global le metía el estado de ESTA PR a una oración general y quedaba
126
+ // "verificado con dai check: ✅ al día. Sin esto, el código no sabe…". dai reescribiendo
127
+ // la prosa de otro es justo lo que el bloque de links ya evitaba con sus marcadores.
128
+ // Sin la sección (un template ajeno con otra forma) se cae al body entero: rellenar de más
129
+ // es recuperable, no rellenar deja la PR con `ABC-###` publicado.
118
130
  function fillUsHeader(template, d) {
119
- let b = template;
120
- b = b.replace(/`ABC-###`/g, `\`${d.id}\``);
121
- b = b.replace(/@ `vX`/g, `@ \`${d.version}\``);
122
- b = b.replace(/`<hash>`/g, `\`${d.ac_hash}\``);
123
- return b.replace(/verificado con `dai check` ✅/g, `verificado con \`dai check\`: ${EMOJI[d.status] || d.status}`);
131
+ const fill = (t) => t
132
+ .replace(/`ABC-###`/g, `\`${d.id}\``)
133
+ .replace(/@ `vX`/g, `@ \`${d.version}\``)
134
+ .replace(/`<hash>`/g, `\`${d.ac_hash}\``)
135
+ .replace(/verificado con `dai check` ✅/g, `verificado con \`dai check\`: ${EMOJI[d.status] || d.status}`);
136
+ const sec = sectionBody(template, "🔗 Implementa");
137
+ return sec == null ? fill(template) : replaceSection(template, "🔗 Implementa", fill(sec).trim());
124
138
  }
125
139
 
126
140
  // PR de una branch exenta (chore/, docs/, release/…): no implementa una US y no se le
package/cli/lib/us.mjs CHANGED
@@ -12,12 +12,22 @@ export function parseUS(raw) {
12
12
  }
13
13
 
14
14
  // Compara el hash estampado (implements.yaml) con el hash vivo de la US.
15
- export function coverageStatus(stampedHash, liveHash) {
15
+ //
16
+ // `sin-us` y `sin-respuesta` NO son lo mismo, y confundirlos es caro: el primero es una
17
+ // RESPUESTA del tracker (preguntamos y la US no está), el segundo es la AUSENCIA de
18
+ // respuesta (sin red, sin token, 5xx, certificado corporativo). Colapsados en un mismo
19
+ // `null`, dai terminaba AFIRMANDO que no había US cuando lo único cierto era que no había
20
+ // podido preguntar — y eso se publicaba en el cuerpo de una PR, al lado del id de la US.
21
+ export function coverageStatus(stampedHash, liveHash, { unreachable = false } = {}) {
22
+ if (unreachable) return "sin-respuesta";
16
23
  if (liveHash == null) return "sin-us";
17
24
  return stampedHash === liveHash ? "al-dia" : "atrasado";
18
25
  }
19
26
 
20
- const STATUS_LABEL = { "al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US" };
27
+ const STATUS_LABEL = {
28
+ "al-dia": "✅ al día", atrasado: "⚠️ atrasado",
29
+ "sin-us": "❓ sin US", "sin-respuesta": "⚠️ no verificado",
30
+ };
21
31
  export const statusLabel = (s) => STATUS_LABEL[s] || s;
22
32
 
23
33
  // Render de la cobertura como markdown (lo que un backend "estampa").
@@ -34,3 +44,25 @@ export function renderCoverage(id, r) {
34
44
  if (r.commitUrl) lines.push(`- commit: ${r.commit} → ${r.commitUrl} (ancla durable)`);
35
45
  return lines.join("\n") + "\n";
36
46
  }
47
+
48
+ // `fetch failed` es TODO lo que dice undici cuando no hay red, el host no resuelve, el DNS
49
+ // se cayó o el certificado no valida. Sin contexto es indistinguible de un bug de dai: el
50
+ // dev lee "dai: fetch failed" y no sabe ni qué se estaba consultando. Es el mismo modo de
51
+ // falla que el push por SSH de la 0.13.1 — el dato que resuelve el problema existe, y no
52
+ // llega. Acá se le pone alrededor qué, contra qué, y qué mirar.
53
+ export function explainFetchError(err, { kind, endpoint, id } = {}) {
54
+ const raw = String(err?.message ?? err ?? "").split("\n")[0] || "error desconocido";
55
+ const donde = [kind, endpoint].filter(Boolean).join(" · ");
56
+ const cabeza = `no pude consultar ${id ? `la US ${id}` : "el tracker"}${donde ? ` en ${donde}` : ""}: ${raw}`;
57
+ if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|certificate|self.signed/i.test(raw)) {
58
+ return `${cabeza}\n No llegó a haber respuesta. Revisá la red y el host del backend; si estás detrás de un\n` +
59
+ ` proxy corporativo, declará la CA con NODE_EXTRA_CA_CERTS (nunca NODE_TLS_REJECT_UNAUTHORIZED=0).\n` +
60
+ ` Diagnóstico: dai doctor`;
61
+ }
62
+ if (/\b40[13]\b|unauthorized|forbidden/i.test(raw)) {
63
+ return `${cabeza}\n El tracker rechazó las credenciales: revisá el token del .env.dai (¿venció?) y sus permisos.\n` +
64
+ ` Diagnóstico: dai doctor`;
65
+ }
66
+ if (/\b5\d\d\b/.test(raw)) return `${cabeza}\n El error es del tracker, no tuyo: probá de nuevo en un rato.`;
67
+ return cabeza;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
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/",
@@ -5,19 +5,23 @@
5
5
  Lo puede pre-llenar `dai`/una skill a partir del implements.yaml y el diff.
6
6
  -->
7
7
 
8
- > **Un PR en dai entrega dos activos, y el review cubre los dos:**
9
- > 1. **La implementación** — el código que resuelve la US.
10
- > 2. **El spec trazable** — el `implements.yaml` con el link a la US y el `@version`
11
- > (`ac_hash`) verificado con `dai check` ✅. Sin esto, el código no sabe *a qué QUÉ*
12
- > responde, y el CI bloquea el PR (ver `governance/ci-rules.md`).
13
-
14
8
  ## 🔗 Implementa
15
9
 
16
10
  - **US:** `ABC-###` @ `vX` · ac_hash: `<hash>` · verificado con `dai check` ✅
17
- - **Link:** este PR está atado a la US vía `implements.yaml`.
18
11
 
19
- > Si este PR no implementa una US (chore/fix sin ticket), borra esta sección y
20
- > aclara el motivo no se le exige link.
12
+ <!--
13
+ Un PR en dai entrega DOS activos, y el review cubre los dos: la implementación (el código
14
+ que resuelve la US) y el spec trazable (el `implements.yaml` con el link a la US y el
15
+ `@version`/`ac_hash` verificado). Sin el link, el código no sabe a qué QUÉ responde y el
16
+ CI bloquea el PR — ver `governance/ci-rules.md`.
17
+
18
+ ¿Chore o fix sin ticket? No hay nada que borrar: `dai pr` lo detecta por el nombre de la
19
+ branch y escribe "Sin US" con el motivo. No se le exige link.
20
+
21
+ Esto es un comentario a propósito: es doctrina del método, igual en las 500 PRs del repo.
22
+ Repetirla a la vista en cada una entrena a saltear el principio del cuerpo, que es
23
+ justamente donde va la descripción.
24
+ -->
21
25
 
22
26
  ## Descripción
23
27