@dforce2055/dai 0.7.0 → 0.8.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.
@@ -0,0 +1,61 @@
1
+ // dai · fetch con diagnóstico de TLS (cero dependencias).
2
+ //
3
+ // En una red corporativa el proxy suele interceptar TLS con una CA propia. Node NO usa
4
+ // el trust store del sistema (trae su propia lista de CAs), así que falla justo donde el
5
+ // navegador anda — y el error crudo de undici no dice nada de eso.
6
+ //
7
+ // El fix es DECLARAR la CA (NODE_EXTRA_CA_CERTS), nunca apagar la verificación:
8
+ // NODE_TLS_REJECT_UNAUTHORIZED=0 hace que la conexión acepte CUALQUIER certificado, y
9
+ // por acá viajan los tokens del tracker. Este módulo existe para que el que se topa con
10
+ // el muro encuentre el camino correcto antes que el atajo peligroso.
11
+
12
+ const TLS_CODES = new Set([
13
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
14
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
15
+ "SELF_SIGNED_CERT_IN_CHAIN",
16
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
17
+ "CERT_HAS_EXPIRED",
18
+ "ERR_TLS_CERT_ALTNAME_INVALID",
19
+ ]);
20
+
21
+ // Extrae el código de error TLS de un fallo de fetch, o null si no es de TLS.
22
+ // undici anida el error real en `cause` (a veces dos niveles).
23
+ export function tlsErrorCode(e) {
24
+ for (let cur = e, depth = 0; cur && depth < 4; cur = cur.cause, depth++) {
25
+ const code = cur.code;
26
+ if (code && TLS_CODES.has(code)) return code;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export function tlsHint(host, code) {
32
+ return [
33
+ `no pude verificar el certificado TLS de ${host} (${code}).`,
34
+ "",
35
+ " Casi siempre es un proxy corporativo que intercepta TLS con su propia CA. Node no",
36
+ " usa el trust store de Windows/macOS, por eso el navegador anda y dai no.",
37
+ "",
38
+ " Solución: exportá la CA raíz de tu empresa a un .pem y declarásela a Node:",
39
+ " Windows (PowerShell): $env:NODE_EXTRA_CA_CERTS=\"C:\\ruta\\ca-empresa.pem\"",
40
+ " macOS / Linux: export NODE_EXTRA_CA_CERTS=/ruta/ca-empresa.pem",
41
+ "",
42
+ " NO uses NODE_TLS_REJECT_UNAUTHORIZED=0. Eso no arregla nada: apaga la verificación",
43
+ " entera, y tu token del tracker viajaría aceptando cualquier certificado — incluido",
44
+ " el de alguien haciéndose pasar por el tracker.",
45
+ ].join("\n");
46
+ }
47
+
48
+ // fetch, pero traduciendo el fallo de TLS a un error accionable.
49
+ export async function daiFetch(url, init) {
50
+ try {
51
+ return await fetch(url, init);
52
+ } catch (e) {
53
+ const code = tlsErrorCode(e);
54
+ if (!code) throw e;
55
+ let host = String(url);
56
+ try { host = new URL(url).host; } catch { /* url rara: mostramos lo que vino */ }
57
+ const err = new Error(tlsHint(host, code));
58
+ err.cause = e;
59
+ throw err;
60
+ }
61
+ }
@@ -0,0 +1,143 @@
1
+ // dai · campos obligatorios propios de Jira, declarados por issuetype (cero dependencias).
2
+ //
3
+ // El Jira de una empresa casi siempre exige campos propios (customfield_NNNNN) que el
4
+ // alta de un issue rechaza si faltan. Y su valor no es config fija: "Clasificación" es
5
+ // Mejora o Corrección SEGÚN LA US. Un default fijo publicaría todas iguales — un dato
6
+ // incorrecto en Jira, en silencio, que es peor que fallar.
7
+ //
8
+ // Así que se declaran acá, con nombre humano, forma y opciones válidas, y dai valida
9
+ // ANTES de llamar a la API: un typo da un error local con la lista, no un 400 críptico.
10
+ //
11
+ // .dai/jira-fields.json:
12
+ // {
13
+ // "Story": {
14
+ // "clasificacion": {
15
+ // "field": "customfield_10042", // el id de Jira (requerido)
16
+ // "shape": "select", // opcional — ver SHAPES
17
+ // "default": "Mejora", // opcional — si falta, --field es obligatorio
18
+ // "options": ["Mejora", "Corrección"] // opcional — si está, dai valida contra ella
19
+ // }
20
+ // },
21
+ // "Epic": { … }
22
+ // }
23
+ //
24
+ // Un campo DECLARADO tiene que resolver a un valor (default o --field): se declara porque
25
+ // Jira lo exige. Si es opcional, no lo declares.
26
+
27
+ // Cómo se envuelve el valor para la API de Jira:
28
+ // select → { "value": X } el caso típico de un desplegable
29
+ // text → X texto plano
30
+ // multi → [{ "value": X }, …] lista (el valor se parte por comas)
31
+ // raw → JSON.parse(X) escape hatch: cualquier forma que Jira pida
32
+ const SHAPES = new Set(["select", "text", "multi", "raw"]);
33
+
34
+ // JSON no admite comentarios, y este archivo lo edita gente que no escribe código. Las
35
+ // claves que empiezan con "_" son notas y se ignoran — así el molde se explica solo.
36
+ const isNote = (k) => k.startsWith("_");
37
+
38
+ // Si no se declara `shape`: con options es un desplegable; sin options, texto.
39
+ const shapeOf = (def) => def.shape || (Array.isArray(def.options) ? "select" : "text");
40
+
41
+ const optionsHint = (def) => (Array.isArray(def.options) && def.options.length ? `\n válidas: ${def.options.join(" | ")}` : "");
42
+
43
+ // Valida la estructura del archivo y lo devuelve. Los errores nombran la ruta exacta
44
+ // (`Story.clasificacion`) para que se arregle sin adivinar.
45
+ export function parseFieldsFile(text, path = ".dai/jira-fields.json") {
46
+ let json;
47
+ try { json = JSON.parse(text); }
48
+ catch (e) { throw new Error(`${path} no es JSON válido: ${e.message}`); }
49
+ if (json === null || typeof json !== "object" || Array.isArray(json)) {
50
+ throw new Error(`${path}: se espera un objeto { "<issuetype>": { "<alias>": {…} } }`);
51
+ }
52
+ for (const [type, entry] of Object.entries(json)) {
53
+ if (isNote(type)) continue; // JSON no tiene comentarios: las claves _x son notas
54
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
55
+ throw new Error(`${path}: '${type}' tiene que ser un objeto de campos`);
56
+ }
57
+ for (const [alias, def] of Object.entries(entry)) {
58
+ if (isNote(alias)) continue;
59
+ const at = `${path}: ${type}.${alias}`;
60
+ if (def === null || typeof def !== "object" || Array.isArray(def)) throw new Error(`${at} tiene que ser un objeto`);
61
+ if (typeof def.field !== "string" || !def.field.trim()) {
62
+ throw new Error(`${at}: falta 'field' (el id de Jira, p. ej. "customfield_10042")`);
63
+ }
64
+ if (def.shape !== undefined && !SHAPES.has(def.shape)) {
65
+ throw new Error(`${at}: shape '${def.shape}' desconocido (${[...SHAPES].join(" | ")})`);
66
+ }
67
+ if (def.options !== undefined && !Array.isArray(def.options)) {
68
+ throw new Error(`${at}: 'options' tiene que ser una lista`);
69
+ }
70
+ }
71
+ }
72
+ return json;
73
+ }
74
+
75
+ // `--field alias=valor` (repetible) → { alias: "valor" }.
76
+ export function parseFieldOverrides(input) {
77
+ const list = input === undefined ? [] : Array.isArray(input) ? input : [input];
78
+ const out = {};
79
+ for (const raw of list) {
80
+ if (typeof raw !== "string") throw new Error("--field espera 'alias=valor'");
81
+ const i = raw.indexOf("=");
82
+ if (i <= 0) throw new Error(`--field espera 'alias=valor' (recibí: '${raw}')`);
83
+ out[raw.slice(0, i).trim()] = raw.slice(i + 1);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // Los campos declarados para un issuetype. Exacto primero, después sin distinguir
89
+ // mayúsculas ("story" encuentra "Story"), y {} si no hay nada declarado.
90
+ function specFor(spec, issuetype) {
91
+ if (!spec || typeof spec !== "object") return {};
92
+ const lower = String(issuetype).toLowerCase();
93
+ const hit = Object.keys(spec).find((k) => !isNote(k) && (k === issuetype || k.toLowerCase() === lower));
94
+ if (!hit) return {};
95
+ return Object.fromEntries(Object.entries(spec[hit]).filter(([alias]) => !isNote(alias)));
96
+ }
97
+
98
+ function checkOptions(def, alias, value) {
99
+ if (!Array.isArray(def.options) || def.options.length === 0) return;
100
+ const values = shapeOf(def) === "multi" ? value.split(",").map((s) => s.trim()).filter(Boolean) : [value];
101
+ for (const v of values) {
102
+ if (!def.options.includes(v)) throw new Error(`'${v}' no es opción de '${alias}'.${optionsHint(def)}`);
103
+ }
104
+ }
105
+
106
+ function castValue(def, alias, value) {
107
+ switch (shapeOf(def)) {
108
+ case "select": return { value };
109
+ case "text": return value;
110
+ case "multi": return value.split(",").map((s) => s.trim()).filter(Boolean).map((v) => ({ value: v }));
111
+ case "raw":
112
+ try { return JSON.parse(value); }
113
+ catch (e) { throw new Error(`--field ${alias}: shape 'raw' espera JSON válido — ${e.message}`); }
114
+ default: throw new Error(`shape '${def.shape}' desconocido en '${alias}'`);
115
+ }
116
+ }
117
+
118
+ // Resuelve los campos declarados para este issuetype al payload que espera la API.
119
+ // Lanza (sin tocar la red) si un override no existe, si falta un valor obligatorio,
120
+ // o si el valor no está entre las opciones válidas.
121
+ export function resolveJiraFields({ spec, issuetype, overrides = {} }) {
122
+ const entry = specFor(spec, issuetype);
123
+ const aliases = Object.keys(entry);
124
+
125
+ for (const a of Object.keys(overrides)) {
126
+ if (Object.prototype.hasOwnProperty.call(entry, a)) continue;
127
+ throw new Error(aliases.length
128
+ ? `--field '${a}' no está declarado para el issuetype '${issuetype}'.\n declarados: ${aliases.join(" | ")}`
129
+ : `--field '${a}': no hay campos declarados para el issuetype '${issuetype}' (revisá el archivo de campos).`);
130
+ }
131
+
132
+ const out = {};
133
+ for (const [alias, def] of Object.entries(entry)) {
134
+ const raw = Object.prototype.hasOwnProperty.call(overrides, alias) ? overrides[alias] : def.default;
135
+ if (raw === undefined) {
136
+ throw new Error(`falta el valor de '${alias}' (lo exige el issuetype '${issuetype}').\n pasalo con: --field ${alias}=<valor>${optionsHint(def)}`);
137
+ }
138
+ const value = String(raw);
139
+ checkOptions(def, alias, value);
140
+ out[def.field] = castValue(def, alias, value);
141
+ }
142
+ return out;
143
+ }
@@ -7,9 +7,30 @@
7
7
  // hasher encuentre "## Criterios de aceptación"); al ESCRIBIR el stamp, componemos ADF.
8
8
 
9
9
  import { parseUS } from "./us.mjs";
10
+ import { daiFetch } from "./http.mjs";
10
11
 
11
12
  const trim = (b) => String(b || "").replace(/\/+$/, "");
12
13
 
14
+ // La clave del PROYECTO (PROJ), no la de un ticket (PROJ-42). Confundirlas es el
15
+ // error de config más común: `dai init` deja DAI_JIRA_PROJECT vacío y quien lo completa
16
+ // suele pegar la épica que tiene a mano. Jira responde un 400 que no lo explica.
17
+ export function assertProjectKey(key) {
18
+ if (!key) throw new Error("falta DAI_JIRA_PROJECT en el .env (la clave del proyecto donde crear el issue).");
19
+ const k = String(key).trim();
20
+ const m = k.match(/^([A-Za-z][A-Za-z0-9_]*)-\d+$/);
21
+ if (m) {
22
+ throw new Error(
23
+ `DAI_JIRA_PROJECT='${k}' es la clave de un ticket, no la del proyecto.\n` +
24
+ ` Usá solo la parte de adelante: DAI_JIRA_PROJECT=${m[1]}\n` +
25
+ ` Si lo que querías era colgar la US de esa épica: dai publish <us.md> --parent ${k}`,
26
+ );
27
+ }
28
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(k)) {
29
+ throw new Error(`DAI_JIRA_PROJECT='${k}' no parece una clave de proyecto (letras y números, empezando por letra — p. ej. PROJ).`);
30
+ }
31
+ return k;
32
+ }
33
+
13
34
  export function jiraIssueUrl(base, id) {
14
35
  return `${trim(base)}/rest/api/3/issue/${encodeURIComponent(id)}?fields=summary,description`;
15
36
  }
@@ -85,37 +106,56 @@ export function renderCoverageAdf(id, r) {
85
106
  return { type: "doc", version: 1, content };
86
107
  }
87
108
 
109
+ // Un 400 al crear casi siempre es un campo obligatorio del proyecto que no mandamos.
110
+ // El cuerpo de Jira nombra el customfield_NNNNN pero no dice qué hacer: lo decimos acá.
111
+ function createHint(status) {
112
+ if (status !== 400) return "";
113
+ return "\n\n Si el error nombra un 'customfield_NNNNN', tu proyecto exige un campo propio.\n" +
114
+ " Declaralo en .dai/jira-fields.json y volvé a publicar — no hace falta improvisar\n" +
115
+ " una llamada a mano:\n" +
116
+ ' { "Story": { "clasificacion": { "field": "customfield_NNNNN",\n' +
117
+ ' "options": ["Mejora", "Corrección"] } } }\n' +
118
+ " dai publish <us.md> --field clasificacion=Corrección";
119
+ }
120
+
88
121
  export function jiraAdapter(env) {
89
122
  const base = env.DAI_JIRA_BASE_URL;
90
123
  if (!base) throw new Error("falta DAI_JIRA_BASE_URL en el .env (backend jira).");
91
124
  return {
92
125
  kind: "jira",
93
126
  async fetchUS(id) {
94
- const res = await fetch(jiraIssueUrl(base, id), { headers: jiraAuthHeaders(env) });
127
+ const res = await daiFetch(jiraIssueUrl(base, id), { headers: jiraAuthHeaders(env) });
95
128
  if (res.status === 404) return null;
96
129
  if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
97
130
  return { id, ...parseUS(jiraIssueToText(await res.json())) };
98
131
  },
99
132
  async stamp(id, record) {
100
- const res = await fetch(jiraCommentUrl(base, id), {
133
+ const res = await daiFetch(jiraCommentUrl(base, id), {
101
134
  method: "POST", headers: jiraAuthHeaders(env),
102
135
  body: JSON.stringify({ body: renderCoverageAdf(id, record) }),
103
136
  });
104
137
  if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
105
138
  return `${trim(base)}/browse/${id}`;
106
139
  },
107
- async createUS({ title, descriptionMarkdown }) {
108
- const project = env.DAI_JIRA_PROJECT;
109
- const issuetype = env.DAI_JIRA_ISSUETYPE || "Story";
110
- if (!project) throw new Error("falta DAI_JIRA_PROJECT en el .env (la clave del proyecto donde crear el issue).");
111
- const res = await fetch(`${trim(base)}/rest/api/3/issue`, {
140
+ // `fields` son los campos propios del proyecto ya resueltos (ver jira-fields.mjs);
141
+ // `parent` cuelga la US de su épica. Ambos son opcionales: un Jira sin campos
142
+ // obligatorios sigue publicando igual que antes.
143
+ async createUS({ title, descriptionMarkdown, parent, issuetype, project, fields }) {
144
+ const key = assertProjectKey(project || env.DAI_JIRA_PROJECT);
145
+ const type = issuetype || env.DAI_JIRA_ISSUETYPE || "Story";
146
+ const payload = {
147
+ project: { key },
148
+ issuetype: { name: type },
149
+ summary: title,
150
+ description: markdownToAdf(descriptionMarkdown),
151
+ ...(fields || {}),
152
+ };
153
+ if (parent) payload.parent = { key: String(parent).trim() };
154
+ const res = await daiFetch(`${trim(base)}/rest/api/3/issue`, {
112
155
  method: "POST", headers: jiraAuthHeaders(env),
113
- body: JSON.stringify({ fields: {
114
- project: { key: project }, issuetype: { name: issuetype },
115
- summary: title, description: markdownToAdf(descriptionMarkdown),
116
- } }),
156
+ body: JSON.stringify({ fields: payload }),
117
157
  });
118
- if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
158
+ if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}${createHint(res.status)}`);
119
159
  const j = await res.json();
120
160
  return { id: j.key, url: `${trim(base)}/browse/${j.key}` };
121
161
  },
@@ -0,0 +1,99 @@
1
+ # ADR-0014 — Copilot lee SKILL.md nativo (Agent Skills)
2
+
3
+ - **Estado:** aceptado
4
+ - **Fecha:** 2026-07-15
5
+ - **Decide:** lead / arquitecto de la metodología
6
+ - **Modifica:** [ADR-0002](0002-agnostico-del-asistente.md) (la capa 3 del adaptador de Copilot)
7
+
8
+ ## Contexto
9
+
10
+ La ADR-0002 definió tres capas: contenido portable (los `SKILL.md`), un CLI determinista
11
+ y **adaptadores generados** por asistente. Para Copilot ese adaptador era
12
+ `skillToPrompt()` → `.github/prompts/<nombre>.prompt.md`, porque los prompt files eran
13
+ el único mecanismo que Copilot tenía. De ahí salía su consecuencia registrada: *"el
14
+ analista sin IDE cierra con Claude Desktop"*, ya que los prompt files son solo-IDE.
15
+
16
+ **Eso dejó de ser cierto.** En diciembre de 2025 GitHub adoptó **Agent Skills**, el
17
+ estándar abierto de `SKILL.md` — el mismo formato que dai ya usa como fuente. Hoy la
18
+ [documentación oficial](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills)
19
+ dice, literal:
20
+
21
+ > "Agent skills work with Copilot cloud agent, Copilot code review, the GitHub Copilot
22
+ > CLI, the GitHub Copilot app, and agent mode in Visual Studio Code and JetBrains IDEs."
23
+
24
+ Con rutas estándar: **`.github/skills`**, `.claude/skills` o `.agents/skills` por repo;
25
+ **`~/.copilot/skills`** o `~/.agents/skills` personales. El frontmatter requerido es
26
+ `name` + `description` — exactamente lo que `validateSkill()` ya exige.
27
+
28
+ Lo descubrimos con un analista funcional real que no lograba que Copilot viera sus
29
+ skills. Tres fallas encadenadas, todas nuestras:
30
+
31
+ 1. **`dai skills install` las dejaba en `~/.claude/skills`.** Esa ruta es válida como
32
+ skill personal *en VS Code*, pero **no** en el Copilot CLI ni en la app, que solo
33
+ miran `~/.copilot/skills`. El CLI encima warneaba *"Copilot no tiene skills
34
+ instalables"* — falso desde diciembre.
35
+ 2. **`skillToPrompt()` perdía los `templates/`.** Solo leía el `SKILL.md`, así que
36
+ `grill-user-story/templates/user-story.md` nunca viajaba y el prompt quedaba con un
37
+ link roto. Copilot, en cambio, *"automatically discovers all of the files in the
38
+ skill's directory"*.
39
+ 3. **La conversión borraba el `name:`** (había un test afirmándolo como correcto), que
40
+ en Agent Skills es **requerido** y es lo que define el `/comando`.
41
+
42
+ ## Decisión
43
+
44
+ **El adaptador de Copilot se elimina: dai le entrega el `SKILL.md` tal cual.**
45
+
46
+ - **`dai init --for copilot`** → `.github/skills/<nombre>/` como **copia cruda** (igual
47
+ que Claude), con sus `templates/` adentro. Ya no genera `.github/prompts/`.
48
+ - **`dai skills install --for copilot`** → `~/.copilot/skills/` (global) o
49
+ `.github/skills/` (local). Copilot **sí** tiene skills instalables.
50
+ - **`skillToPrompt()` se borra.** Queda `stalePromptFiles()`: `dai init` **elimina los
51
+ `.prompt.md` que generó dai** en versiones previas — si no, cada `/comando` aparecería
52
+ duplicado, con una copia vieja y sin templates. Solo borra los suyos; los prompt files
53
+ propios del equipo no se tocan.
54
+ - **`dai doctor` chequea Copilot** y, sobre todo, **solo reporta los asistentes que el
55
+ repo realmente usa**. Antes listaba los tres siempre: quien configuraba uno veía 14
56
+ warnings de los otros dos y leía "está todo roto" cuando estaba todo bien.
57
+ - **Cada asistente conserva SU directorio** (`.claude/skills`, `.github/skills`,
58
+ `.cursor/skills`), aunque Copilot sepa leer los tres. La simetría es predecible y
59
+ mantiene la capa 3 de la ADR-0002 donde sigue haciendo falta (Cursor).
60
+
61
+ ## Consecuencias
62
+
63
+ - **La conclusión de la ADR-0002 sobre el analista sin IDE queda revocada.** Un
64
+ funcional puede cerrar el ciclo con **Copilot CLI o la app de Copilot**, sin VS Code.
65
+ La tabla del README se corrige: la única superficie sin skills es el chat de
66
+ github.com.
67
+ - **Los `templates/` de las skills llegan a Copilot por primera vez.**
68
+ - **El frontmatter pasó a tener un lector estricto, y eso destapó un defecto viejo.**
69
+ Nuestro `parseFrontmatter` es un regex, no YAML: se tragaba cualquier cosa. Y la
70
+ conversión que borramos hacía `JSON.stringify(description)`, que citaba el valor —
71
+ o sea que **estaba tapando el bug sin querer**. Al entregar el `SKILL.md` crudo, lo
72
+ lee un parser YAML real, y `doc-to-backlog` y `grill-epic` **fallaron al cargar**: sus
73
+ descripciones tenían un `: ` suelto (*"…épicas finales: extrae…"*, *"…nivel
74
+ funcional/alcance: define…"*) que YAML interpreta como el arranque de un mapa. Las dos
75
+ skills que más necesita un analista funcional, invisibles.
76
+ Se citan las descripciones de las 7, y **`validateSkill` ahora valida que `name` y
77
+ `description` sean escalares YAML válidos** — el molde de `templates/skill.md` también
78
+ cita por defecto. La lección general: **un adaptador que "arregla" el input esconde el
79
+ defecto en la fuente**. Mientras convertíamos, el `SKILL.md` inválido era nuestro
80
+ secreto; entregándolo crudo, el contrato es real y hay que cumplirlo.
81
+ - **Menos código, no más:** se borra una transformación y su test. La capa 3 de la
82
+ ADR-0002 sigue siendo la decisión correcta — lo que cambió es que Copilot dejó de
83
+ necesitarla, no que la idea estuviera mal. Cuando el formato de una skill es un
84
+ estándar abierto, el mejor adaptador es ninguno.
85
+ - **Riesgo asumido:** un repo con `--for all` tendrá el mismo skill en `.claude/skills`
86
+ y `.github/skills`, y Copilot mira ambos. El estándar exige `name` único, así que
87
+ esperamos deduplicación por nombre; si en la práctica duplica, se resuelve entonces.
88
+ - **Migración:** `dai init` es idempotente y limpia solo. Un repo viejo se pone al día
89
+ con `dai init --for copilot` (o `dai sync`).
90
+
91
+ ## Alternativas descartadas
92
+
93
+ - **Mantener los `.prompt.md` "por compatibilidad".** Duplicaría cada `/comando` con una
94
+ copia peor. Copilot soporta skills en todas sus superficies desde enero; sostener el
95
+ camino viejo es sostener el bug.
96
+ - **Escribir solo `.claude/skills` y que Copilot lo levante** (lo hace: *"if you've
97
+ already set up skills for Claude Code in the `.claude/skills` directory, Copilot will
98
+ pick them up automatically"*). Rompe la simetría del adaptador y sorprende: pedir
99
+ `--for copilot` y no tener nada de Copilot en el repo es un mal contrato.
@@ -0,0 +1,104 @@
1
+ # ADR-0015 — `dai publish` en un Jira corporativo (campos propios, épicas, TLS)
2
+
3
+ - **Estado:** aceptado
4
+ - **Fecha:** 2026-07-15
5
+ - **Decide:** lead / arquitecto de la metodología
6
+
7
+ ## Contexto
8
+
9
+ `dai publish` funcionaba contra un Jira limpio y fallaba contra uno real. Lo vimos en
10
+ vivo: un analista funcional intentó publicar su primera US en el Jira de su empresa y
11
+ `createUS` mandaba solo `project`, `issuetype`, `summary` y `description`. El proyecto
12
+ exigía un campo propio (`Tipo de trabajo`, `customfield_10042`) → **400 en todos
13
+ los intentos**. Además el proxy corporativo intercepta TLS con su propia CA, y Node no
14
+ usa el trust store del sistema → `fetch` reventaba antes de llegar.
15
+
16
+ **Lo que pasó después es el verdadero motivo de esta ADR.** El asistente, bloqueado por
17
+ el CLI, improvisó: leyó el schema del campo por su cuenta, armó la llamada a la API de
18
+ Jira con `node -e`, y para pasar el proxy corrió `NODE_TLS_REJECT_UNAUTHORIZED=0` —
19
+ apagando la verificación de certificado **mientras mandaba el token de Jira**. Lo
20
+ reportó como *"nota técnica resuelta en el camino"*. Su propio razonamiento fue:
21
+ *"bypassing dai's limitation entirely"*.
22
+
23
+ Publicó bien, de casualidad. Pero eso es exactamente lo que la capa 2 de la
24
+ [ADR-0002](0002-agnostico-del-asistente.md) existe para evitar: *"el output es idéntico
25
+ con Claude, con Copilot, o sin ningún asistente"*. **Cuando el CLI no llega, el agente
26
+ inventa** — y el improviso siguiente puede no respetar el contrato del `ac_hash`, y ahí
27
+ la trazabilidad se rompe en silencio.
28
+
29
+ La lección: **un CLI que no cubre el caso real no es neutral, es una invitación a
30
+ puentearlo.** Cada hueco de la capa 2 se llena con inteligencia no determinista.
31
+
32
+ ## Decisión
33
+
34
+ **1. Los campos propios se declaran, con nombre humano y opciones válidas.**
35
+ `.dai/jira-fields.json` (o `DAI_JIRA_FIELDS_FILE`), por issuetype:
36
+
37
+ ```json
38
+ {
39
+ "Story": {
40
+ "clasificacion": {
41
+ "field": "customfield_10042",
42
+ "shape": "select",
43
+ "default": "Mejora",
44
+ "options": ["Mejora", "Corrección"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ - **Por issuetype**, porque en Jira corporativo los campos obligatorios de una Epic y de
51
+ una Story casi nunca son los mismos.
52
+ - **`default` + `--field alias=valor`**, porque el valor **no es config fija**: la
53
+ clasificación es Mejora o Corrección *según la US*. Un default fijo publicaría todas
54
+ iguales — un dato incorrecto en Jira, en silencio, que es peor que fallar.
55
+ - **`options` valida ANTES de la red.** Un typo da `'Mejraa' no es opción de
56
+ 'clasificacion' — válidas: Mejora | Corrección`, no un 400 críptico.
57
+ - **Un campo declarado tiene que resolver** (default u override): se declara porque Jira
58
+ lo exige. Si es opcional, no se declara.
59
+ - **`shape`** cubre las formas de Jira (`select` `{value:X}` · `text` X · `multi`
60
+ `[{value:X}]`), con **`raw`** como escape hatch para cualquier cosa rara: dai no
61
+ necesita entender todo Jira para no estorbar.
62
+ - **Sin archivo → `{}`.** Un Jira sin campos obligatorios publica igual que siempre.
63
+
64
+ **2. `--parent` y `--issuetype`.** `--parent` cuelga la US de su épica (`createUS` nunca
65
+ mandaba `parent`); `--issuetype Epic` permite que **`grill-epic` publique épicas por
66
+ CLI**, que hasta hoy no tenían fallback y quedaban como un `.md` para pegar a mano.
67
+
68
+ **3. Un fallo de TLS enseña el camino correcto, y desaconseja el peligroso.** `daiFetch`
69
+ traduce los códigos de certificado a un error que manda a **`NODE_EXTRA_CA_CERTS`** y
70
+ dice explícitamente por qué `NODE_TLS_REJECT_UNAUTHORIZED=0` no es una alternativa. dai
71
+ **nunca** baja la verificación por su cuenta.
72
+
73
+ **4. `DAI_JIRA_PROJECT` se valida.** `PROJ-42` es un ticket, no un proyecto — el
74
+ error de config más común. El mensaje da los dos caminos: `DAI_JIRA_PROJECT=PROJ`, o
75
+ `--parent PROJ-42` si lo que querías era colgarla de esa épica.
76
+
77
+ **5. La constitución prohíbe las dos maniobras**, para todos los asistentes:
78
+
79
+ > - **No bajes la seguridad para avanzar:** si una llamada falla por el certificado,
80
+ > declara la CA. Nunca `NODE_TLS_REJECT_UNAUTHORIZED=0`, `verify=False`, `-k`.
81
+ > - **Si el CLI no llega, para y dilo:** no improvises una llamada a la API por fuera.
82
+
83
+ ## Consecuencias
84
+
85
+ - **`dai publish` sirve en un Jira corporativo**, que es donde vive el usuario real.
86
+ - **`grill-epic` gana un fallback CLI**: MCP → `dai publish --issuetype Epic` → `.md`.
87
+ - **El caso que motivó el bypass ahora está cubierto**, que es la única forma honesta de
88
+ pedirle a un agente que no improvise: no dejarle el hueco.
89
+ - **`dai doctor`** valida la clave de proyecto y que el archivo de campos parsee. Sigue
90
+ **sin** verificar que el token sirva — eso es red, y lo dice `dai publish`. El chequeo
91
+ ahora lo admite en voz alta en vez de dar un ✓ engañoso.
92
+ - **ClickUp queda con el mismo hueco de TLS.** `daiFetch` está listo para adoptarse allá
93
+ con un import; no lo hicimos ahora por alcance.
94
+
95
+ ## Alternativas descartadas
96
+
97
+ - **Una sola variable `DAI_JIRA_FIELDS={"customfield_10042":{"value":"Mejora"}}`.**
98
+ Una línea ilegible, sin distinguir Story de Epic, sin validación, y con el valor fijo
99
+ — el problema original.
100
+ - **Resolver los nombres de campo contra `createmeta` de Jira.** Menos config, pero pide
101
+ red para armar el payload, y falla distinto según permisos. Un archivo explícito se lee
102
+ y se versiona.
103
+ - **Que dai reintente sin verificar TLS si el certificado falla.** Es exactamente lo que
104
+ hizo el agente. Automatizarlo sería institucionalizar el bug.
@@ -17,6 +17,10 @@ decisión cambia, se escribe un ADR nuevo que supersede al viejo. Molde en
17
17
  | [0009](0009-adaptador-cursor.md) | Adaptador nativo para Cursor (skills + rules) con `dai init`/`install`/`doctor` | propuesto |
18
18
  | [0010](0010-versionado-y-upgrade.md) | Versionado y upgrade: compatibilidad por semver, `doctor` version-drift, `dai sync` aditivo | propuesto |
19
19
  | [0011](0011-archive-gate-de-aprobacion.md) | `archive` es un gate de aprobación: `dai archive` (comando) lo corre el aprobador; `check`/`ls` saltean `archive/` | aceptado |
20
+ | [0012](0012-upgrade-self-update-del-cli.md) | `dai upgrade`: self-update del CLI + `dai sync` del scaffold | aceptado |
21
+ | [0013](0013-skills-externas-install-from.md) | `dai skills install --from`: skills externas por-stack, sin registro ni gatekeeping | aceptado |
22
+ | [0014](0014-copilot-agent-skills.md) | Copilot lee `SKILL.md` nativo (Agent Skills): se elimina el adaptador `.prompt.md` — modifica la 0002 | aceptado |
23
+ | [0015](0015-jira-corporativo.md) | `dai publish` en Jira corporativo: campos propios declarados, `--parent`/`--issuetype`, TLS con CA (nunca apagar la verificación) | aceptado |
20
24
 
21
25
  > Estas son las decisiones que cierran las "Decisiones abiertas" de
22
26
  > [`METODOLOGIA.md §7`](../METODOLOGIA.md) y las enmiendas al
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.7.0",
3
+ "version": "0.8.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/",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dai-review
3
- description: Revisa una Pull/Merge Request de un repo remoto (GitHub o GitLab) de forma consciente de la metodología dai, y deja un comentario estándar en español con errores y mejoras. Corre `dai check` (¿la US está atrasada?), valida el Definition of Done, hace el review de código (correctitud + calidad), compone el comentario estándar y lo postea — vía el MCP del forge si está disponible, o vía `dai forge comment` (token) si no. Invocar como /dai-review <URL-de-la-PR o número>. Usar en el paso 6 de SCRUM-CON-IA (code review), antes de que un partner humano firme.
3
+ description: "Revisa una Pull/Merge Request de un repo remoto (GitHub o GitLab) de forma consciente de la metodología dai, y deja un comentario estándar en español con errores y mejoras. Corre `dai check` (¿la US está atrasada?), valida el Definition of Done, hace el review de código (correctitud + calidad), compone el comentario estándar y lo postea — vía el MCP del forge si está disponible, o vía `dai forge comment` (token) si no. Invocar como /dai-review <URL-de-la-PR o número>. Usar en el paso 6 de SCRUM-CON-IA (code review), antes de que un partner humano firme."
4
4
  ---
5
5
 
6
6
  # dai-review — review de PR consciente de la metodología
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: doc-to-backlog
3
- description: Toma un documento de análisis (PDF, Word, o un archivo en Drive/SharePoint/ClickUp vía MCP) y produce un BACKLOG CANDIDATO — un mapa de épicas y User Stories propuestas — para que el funcional lo priorice y lo valide. NO emite US ni épicas finales: extrae candidatos marcados "sin validar" y hace handoff de cada sobreviviente a grill-epic / grill-user-story (modo refinar). Es la puerta de entrada del caso "llegué con un documento y quiero sacar el backlog". Invocar como /doc-to-backlog con el path o el link del documento. Usar antes de grill-epic / grill-user-story cuando el input es un doc grande.
3
+ description: "Toma un documento de análisis (PDF, Word, o un archivo en Drive/SharePoint/ClickUp vía MCP) y produce un BACKLOG CANDIDATO — un mapa de épicas y User Stories propuestas — para que el funcional lo priorice y lo valide. NO emite US ni épicas finales: extrae candidatos marcados \"sin validar\" y hace handoff de cada sobreviviente a grill-epic / grill-user-story (modo refinar). Es la puerta de entrada del caso \"llegué con un documento y quiero sacar el backlog\". Invocar como /doc-to-backlog con el path o el link del documento. Usar antes de grill-epic / grill-user-story cuando el input es un doc grande."
4
4
  ---
5
5
 
6
6
  # doc-to-backlog
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: grill-epic
3
- description: Interroga a un PO o analista funcional para producir una ÉPICA bien formada — un bloque grande de valor de negocio que se parte en varias User Stories — siguiendo el template del método. O toma una US que resultó demasiado grande y la promueve a épica. Se queda a nivel funcional/alcance: define el objetivo de negocio y la partición en US, NUNCA criterios de aceptación (esos viven en cada US) ni diseño técnico. Al terminar, publica la épica en Jira/ClickUp (o deja un .md) y hace handoff de cada US hija a grill-user-story. Invocar como /grill-epic, opcionalmente con un título, un ID/URL del tracker, o una US grande a promover. Usar cuando algo es demasiado grande para una sola US.
3
+ description: "Interroga a un PO o analista funcional para producir una ÉPICA bien formada — un bloque grande de valor de negocio que se parte en varias User Stories — siguiendo el template del método. O toma una US que resultó demasiado grande y la promueve a épica. Se queda a nivel funcional/alcance: define el objetivo de negocio y la partición en US, NUNCA criterios de aceptación (esos viven en cada US) ni diseño técnico. Al terminar, publica la épica en Jira/ClickUp (o deja un .md) y hace handoff de cada US hija a grill-user-story. Invocar como /grill-epic, opcionalmente con un título, un ID/URL del tracker, o una US grande a promover. Usar cuando algo es demasiado grande para una sola US."
4
4
  ---
5
5
 
6
6
  # grill-epic
@@ -60,10 +60,18 @@ la forma. Nunca reescribas el formato inline.
60
60
  1. **Armar la épica** con el formato de `templates/epica.md`: metadata (`ID`, autor,
61
61
  estado, US que la componen) + objetivo + alcance (in/out) + lista de US + métricas +
62
62
  dependencias.
63
- 2. **Publicar en el tracker** (según `DAI_PM` del `.env`: `jira` → MCP de Atlassian ·
64
- `clickup` MCP de ClickUp · `md`/sin token → `.md` para pegar a mano). **No asumas
65
- el tracker: lee `DAI_PM` primero.** Crear el ticket de épica; las US hijas se crean
66
- como tickets vinculados (o quedan listadas para crearse).
63
+ 2. **Publicar en el tracker.** **No asumas el tracker: lee `DAI_PM` del `.env` primero.**
64
+ Tres caminos, mismo contenido:
65
+ - **Con MCP** (`jira` MCP de Atlassian · `clickup` MCP de ClickUp): crea el ticket
66
+ de épica; las US hijas se crean como tickets vinculados (o quedan listadas).
67
+ - **Sin MCP, con token** (`DAI_PM=jira` + token en `.env`): escribe la épica como `.md`
68
+ y publícala con **`dai publish <epica.md> --issuetype Epic`**. Devuelve el key. Si el
69
+ proyecto exige campos propios, van con `--field alias=valor` (los declarados en
70
+ `.dai/jira-fields.json`; `dai doctor` te los lista). Después, cada US hija se cuelga
71
+ con `dai publish <us.md> --parent <KEY-de-la-épica>`.
72
+ - **Sin token** (o `DAI_PM=md`): deja el `.md` para pegar a mano, y avisa el motivo.
73
+ - Si `dai` falla, **para y reporta el error tal cual**. No improvises una llamada a la
74
+ API del tracker por fuera: publicaría igual pero rompería el link en silencio.
67
75
  3. **Handoff.** Ofrecer pasar **cada US hija** por `/grill-user-story` para convertirla
68
76
  de "título en la lista" a US testeable con criterios. Ese es el paso que las hace
69
77
  linkeables (`dai link-us`).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: grill-intent
3
- description: Gate 0 of the SDD workflow — challenge the *problem* behind a clean user story before any spec is generated. Interrogates whether this is the right problem, for the right user, within constraints, and whether the story's implied solution is a premature jump. Produces intent.md and a verdict that can be "go to spec", "reframe", or "don't build". In the spirit of grill-me, specialized for problem-challenge. Invoke as /grill-intent on a user story (the output of grill-user-story). Use after grill-user-story and before openspec propose.
3
+ description: "Gate 0 of the SDD workflow — challenge the *problem* behind a clean user story before any spec is generated. Interrogates whether this is the right problem, for the right user, within constraints, and whether the story's implied solution is a premature jump. Produces intent.md and a verdict that can be \"go to spec\", \"reframe\", or \"don't build\". In the spirit of grill-me, specialized for problem-challenge. Invoke as /grill-intent on a user story (the output of grill-user-story). Use after grill-user-story and before openspec propose."
4
4
  ---
5
5
 
6
6
  # grill-intent — Gate 0, challenge the problem
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: grill-user-story
3
- description: Interroga a un PO o analista funcional para producir una User Story funcional y testeable siguiendo el formato del modelo de trazabilidad — o pule una US vaga existente. Se queda a nivel funcional/usuario y se niega a derivar en diseño técnico o a emitir una US con criterios no testeables. Al terminar, PUBLICA la US en el tracker configurado del repo (Jira o ClickUp, según DAI_PM del .env) usando su MCP; si no hay MCP/token, deja un .md con el mismo formato para copiar y pegar. Invocar como /grill-user-story, opcionalmente con un título, un ID/URL del tracker, y/o una US rústica existente. Usar antes de opsx:propose, o cuando alguien dice "necesito una US", "convierte esto en una US como corresponde", o "esta historia está muy vaga".
3
+ description: "Interroga a un PO o analista funcional para producir una User Story funcional y testeable siguiendo el formato del modelo de trazabilidad — o pule una US vaga existente. Se queda a nivel funcional/usuario y se niega a derivar en diseño técnico o a emitir una US con criterios no testeables. Al terminar, PUBLICA la US en el tracker configurado del repo (Jira o ClickUp, según DAI_PM del .env) usando su MCP; si no hay MCP/token, deja un .md con el mismo formato para copiar y pegar. Invocar como /grill-user-story, opcionalmente con un título, un ID/URL del tracker, y/o una US rústica existente. Usar antes de opsx:propose, o cuando alguien dice \"necesito una US\", \"convierte esto en una US como corresponde\", o \"esta historia está muy vaga\"."
4
4
  ---
5
5
 
6
6
  # grill-user-story
@@ -65,11 +65,24 @@ La US se produce UNA vez con el formato de `../../templates/formato-us.md`. Lo q
65
65
  - Si NO hay MCP del tracker conectado (pero sí `DAI_PM=jira|clickup` + token en `.env`):
66
66
  escribe la US como `.md` (formato `formato-us.md`) y publícala con el comando:
67
67
  **`dai publish <ruta-del-md>`** → crea el issue/tarea vía REST y devuelve el key.
68
- (Jira necesita además `DAI_JIRA_PROJECT` en el `.env`.)
68
+ (Jira necesita además `DAI_JIRA_PROJECT` en el `.env` — la clave del **proyecto**,
69
+ `PROJ`, no la de un ticket.)
70
+ - **Si la US pertenece a una épica:** `dai publish <us.md> --parent <KEY-de-la-épica>`.
71
+ - **Si el proyecto exige campos propios** (típico en Jira corporativo): van con
72
+ `--field alias=valor`, repetible. Los alias son los de `.dai/jira-fields.json`, y
73
+ `dai doctor` te dice cuáles hay. Si el valor cambia según la US (p. ej. una
74
+ clasificación Mejora/Corrección), **pregúntaselo a la persona** durante el
75
+ interrogatorio — no lo elijas tú, es una decisión de negocio.
76
+ Ej.: `dai publish us.md --parent PROJ-42 --field clasificacion=Corrección`
69
77
  - Si tampoco hay token (o `DAI_PM=md`): deja solo el `.md` para que la persona lo
70
78
  pegue a mano en el tracker. Avisa el motivo.
71
79
  - El contenido es **idéntico** en los tres caminos (MCP / `dai publish` / manual).
72
- 4. **Estado.** Dejar la US en `pulida`. Ofrecer que el dev siga con `dai link-us <ID>` → `opsx:propose` (lado técnico).
80
+ 4. **Si `dai publish` falla, para y reporta el error tal cual.** No improvises una llamada
81
+ a la API del tracker por fuera, ni bajes la verificación TLS para pasar un proxy: el
82
+ atajo publica igual, pero el `## Criterios de aceptación` puede quedar mal formado y
83
+ **el link QUÉ↔CÓMO se rompe en silencio**. Si el error nombra un campo obligatorio que
84
+ falta, decláralo en `.dai/jira-fields.json` (molde en `.dai/templates/`) y reintenta.
85
+ 5. **Estado.** Dejar la US en `pulida`. Ofrecer que el dev siga con `dai link-us <ID>` → `opsx:propose` (lado técnico).
73
86
 
74
87
  ## Hand-off
75
88
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: link-us
3
- description: Del lado del dev — crea la branch desde el link de la User Story en Jira SIN margen de error y genera el archivo implements.yaml con el link a la US (id + version + ac_hash). Es la que hace que el link QUÉ↔CÓMO sea correcto por construcción, sin que el dev tipee el key a mano. Invocar como /link-us ABC-### (o con la URL del ticket de Jira). Usar al arrancar la implementación de una US, antes o junto con opsx:explore / opsx:propose.
3
+ description: "Del lado del dev — crea la branch desde el link de la User Story en Jira SIN margen de error y genera el archivo implements.yaml con el link a la US (id + version + ac_hash). Es la que hace que el link QUÉ↔CÓMO sea correcto por construcción, sin que el dev tipee el key a mano. Invocar como /link-us ABC-### (o con la URL del ticket de Jira). Usar al arrancar la implementación de una US, antes o junto con opsx:explore / opsx:propose."
4
4
  ---
5
5
 
6
6
  # link-us