@dforce2055/dai 0.6.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.
@@ -4,29 +4,79 @@
4
4
 
5
5
  const REPO_URL = "https://github.com/dforce2055/dai";
6
6
 
7
+ // Un escalar YAML "plano" (sin comillas) no puede contener `: ` ni ` #`, ni empezar con
8
+ // un indicador: YAML lo leería como un mapa, un comentario o una estructura.
9
+ //
10
+ // Esto importa porque el parser de acá es un regex, no YAML — se traga cualquier cosa.
11
+ // Mientras dai CONVERTÍA el SKILL.md para Copilot y Cursor, el `JSON.stringify` de la
12
+ // conversión citaba el valor y tapaba el problema sin querer. Al entregar el SKILL.md
13
+ // crudo (ADR-0014) lo lee un parser YAML de verdad, y ahí una descripción con un `: `
14
+ // suelto tira abajo la skill entera. Le pasó a `doc-to-backlog` y a `grill-epic`.
15
+ const YAML_INDICATORS = /^[-?:,[\]{}#&*!|>'"%@`]/;
16
+ export function yamlScalarIssue(raw) {
17
+ const v = String(raw ?? "").trim();
18
+ if (v === "") return "está vacío";
19
+ const quoted = (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"));
20
+ if (quoted && v.length >= 2) return null; // citado: YAML acepta lo que sea adentro
21
+ if (v.includes(": ")) return "contiene ': ' sin comillas (YAML lo lee como un mapa)";
22
+ if (v.includes(" #")) return "contiene ' #' sin comillas (YAML lo lee como un comentario)";
23
+ if (YAML_INDICATORS.test(v)) return `empieza con '${v[0]}', que YAML reserva`;
24
+ return null;
25
+ }
26
+
27
+ // El valor crudo de una clave del frontmatter, tal cual está escrito (con comillas si
28
+ // las tiene). Es lo que hay que validar: `parseFrontmatter` ya las saca.
29
+ export function rawFrontmatterValue(md, key) {
30
+ const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
31
+ if (!m) return null;
32
+ const line = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
33
+ return line ? line[1].trim() : null;
34
+ }
35
+
36
+ // Saca las comillas de un escalar YAML citado. Sin comillas, lo devuelve tal cual.
37
+ function unquoteScalar(s) {
38
+ if (s == null) return null;
39
+ const v = s.trim();
40
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
41
+ try { return JSON.parse(v); } catch { return v.slice(1, -1); }
42
+ }
43
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
44
+ return v;
45
+ }
46
+
7
47
  // Parsea el frontmatter YAML de un SKILL.md → { name, description, body }.
48
+ // Devuelve los valores YA sin comillas, para que quien los reserialice (skillToCursor)
49
+ // no los cite dos veces.
8
50
  export function parseFrontmatter(md) {
9
51
  const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
10
52
  if (!m) return { name: null, description: null, body: md.trim() };
11
53
  const fm = m[1];
12
- const name = (fm.match(/^name:\s*(.+)$/m) || [])[1]?.trim() || null;
13
- const description = (fm.match(/^description:\s*(.+)$/m) || [])[1]?.trim() || null;
54
+ const name = unquoteScalar((fm.match(/^name:\s*(.+)$/m) || [])[1]) || null;
55
+ const description = unquoteScalar((fm.match(/^description:\s*(.+)$/m) || [])[1]) || null;
14
56
  return { name, description, body: m[2].trim() };
15
57
  }
16
58
 
17
- // Transforma un SKILL.md (Claude) en un prompt file de Copilot (.prompt.md).
18
- // Cambia el frontmatter; el cuerpo (la lógica) es el mismo.
19
- export function skillToPrompt(md) {
20
- const { description, body } = parseFrontmatter(md);
21
- const fm = [
22
- "---",
23
- "mode: agent",
24
- description ? `description: ${JSON.stringify(description)}` : null,
25
- "---",
26
- ].filter((x) => x !== null).join("\n");
27
- return `${fm}\n\n${body}\n`;
59
+ // Valida el contrato MÍNIMO de un SKILL.md para que dai lo ingiera y los asistentes lo
60
+ // carguen: frontmatter con `name` y `description`, y que ambos sean YAML válido — si no,
61
+ // el asistente descarta la skill entera. Devuelve null si está OK, o el motivo.
62
+ // NO valida el contenido de la skill — eso es criterio del equipo (ADR-0013).
63
+ export function validateSkill(md) {
64
+ const { name, description } = parseFrontmatter(md);
65
+ if (!name && !description) return "sin frontmatter (falta name y description)";
66
+ if (!name) return "falta 'name' en el frontmatter";
67
+ if (!description) return "falta 'description' en el frontmatter";
68
+ for (const key of ["name", "description"]) {
69
+ const issue = yamlScalarIssue(rawFrontmatterValue(md, key));
70
+ if (issue) return `'${key}' no es YAML válido: ${issue} — citá el valor con comillas dobles`;
71
+ }
72
+ return null;
28
73
  }
29
74
 
75
+ // Los archivos de Copilot que dai generaba ANTES de que Copilot adoptara Agent Skills
76
+ // (ADR-0014). `dai init` los borra al reencontrarlos, para que no queden duplicando
77
+ // cada `/comando` con una copia vieja y sin templates.
78
+ export const stalePromptFiles = (skills) => skills.map((n) => `${n}.prompt.md`);
79
+
30
80
  // Transforma un SKILL.md (Claude) en un SKILL.md de Cursor.
31
81
  // Conserva name/description/body y ajusta solo el frontmatter.
32
82
  export function skillToCursor(md) {
@@ -48,7 +98,17 @@ export function envFor(pm) {
48
98
  return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\nDAI_TRACKER_URL_TEMPLATE=https://app.clickup.com/t/{id}\n";
49
99
  }
50
100
  if (pm === "jira") {
51
- return head + "DAI_PM=jira\nDAI_JIRA_BASE_URL=\nDAI_JIRA_EMAIL=\nDAI_JIRA_TOKEN=\nDAI_JIRA_PROJECT=\nDAI_JIRA_ISSUETYPE=Story\nDAI_TRACKER_URL_TEMPLATE=\n";
101
+ return head +
102
+ "DAI_PM=jira\n" +
103
+ "DAI_JIRA_BASE_URL=\n" +
104
+ "DAI_JIRA_EMAIL=\n" +
105
+ "DAI_JIRA_TOKEN=\n" +
106
+ "# La clave del PROYECTO (p. ej. PROJ), no la de un ticket (PROJ-123).\n" +
107
+ "DAI_JIRA_PROJECT=\n" +
108
+ "DAI_JIRA_ISSUETYPE=Story\n" +
109
+ "# Campos propios que tu Jira exige al crear. Si el archivo no existe, se ignora.\n" +
110
+ "DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n" +
111
+ "DAI_TRACKER_URL_TEMPLATE=\n";
52
112
  }
53
113
  return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n";
54
114
  }
@@ -112,7 +172,7 @@ export function reconcileGitignore(text, want) {
112
172
  // copilot-instructions.md (Copilot). Mismo núcleo, distinto encabezado.
113
173
  export function constitution(kind) {
114
174
  const head = kind === "copilot"
115
- ? "# Instrucciones de Copilot para este repo\n\nEste repo sigue la metodología **dai**. Aplica estas reglas en todo lo que generes.\n\n> **Superficie:** los prompts de dai (`.github/prompts/`) se invocan solo en VS Code /\n> JetBrains, o como custom agents en el Copilot CLI — no en la app standalone ni en\n> github.com. El CLI `dai` corre en cualquier terminal."
175
+ ? "# Instrucciones de Copilot para este repo\n\nEste repo sigue la metodología **dai**. Aplica estas reglas en todo lo que generes.\n\n> **Superficie:** las skills de dai (`.github/skills/`) se invocan con `/nombre-skill`, o\n> cuando el agente las detecta por su descripción. Funcionan en el Copilot CLI, en la app,\n> y en modo agente de VS Code / JetBrains (ADR-0014). El CLI `dai` corre en cualquier\n> terminal."
116
176
  : kind === "cursor"
117
177
  ? "# Constitución del proyecto (dai)\n\nEste repo sigue la metodología **dai**. Estas reglas gobiernan todo el trabajo.\n\n> **Superficie:** las skills de dai (`.cursor/skills/`) se invocan con `/nombre-skill`\n> o cuando el agente las detecta por descripción. El CLI `dai` corre en cualquier terminal."
118
178
  : "# Constitución del proyecto (dai)\n\nEste repo sigue la metodología **dai**. Estas reglas gobiernan todo el trabajo.";
@@ -133,6 +193,8 @@ export function constitution(kind) {
133
193
  - **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.
134
194
  - **La IA confirma antes de construir:** el asistente declara que entendió esta constitución y la va a obedecer antes de generar código.
135
195
  - **Secretos:** en \`.env\` (nunca commiteados). git por **SSH**, APIs por **token scopeado**.
196
+ - **No bajes la seguridad para avanzar:** si una llamada falla por el certificado, declara la CA (\`NODE_EXTRA_CA_CERTS\`). **Nunca** \`NODE_TLS_REJECT_UNAUTHORIZED=0\`, \`verify=False\`, \`-k\` ni equivalentes: apagan la verificación de toda la conexión, y por ahí viajan los tokens.
197
+ - **Si el CLI no llega, para y dilo:** cuando \`dai\` no cubre un caso, repórtalo — no improvises una llamada a la API por fuera. El atajo publica igual, pero rompe el link QUÉ↔CÓMO en silencio y nadie se entera hasta que la trazabilidad ya está mal.
136
198
  - **Docs vivas:** una constitución o arquitectura desactualizada es un defecto, no documentación.
137
199
  - Separa el QUÉ (funcional) del CÓMO (técnico); no mezcles.
138
200
 
@@ -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,26 @@
1
+ // dai · resolución de una fuente de skills externas (`dai skills install --from`).
2
+ // Núcleo puro (sin fs ni red): clasifica el argumento en git URL o path local y
3
+ // separa el ref (`#branch`/`#tag`). Ver ADR-0013.
4
+
5
+ // Devuelve { type: 'git'|'path', location, ref }.
6
+ // git → URL clonable. `host/org/repo` (sin esquema) se normaliza a https://.
7
+ // path → ruta local (relativa o absoluta). El `ref` se ignora aguas abajo.
8
+ // ref → lo que va después de '#', o null.
9
+ export function parseSource(src) {
10
+ const raw = String(src ?? "").trim();
11
+ if (!raw) throw new Error("fuente vacía (pasá un git URL o un path)");
12
+
13
+ // Separar el ref (#branch/tag). El scp de git (git@host:org/repo) no usa '#'.
14
+ let ref = null, loc = raw;
15
+ const hash = raw.lastIndexOf("#");
16
+ if (hash > 0) { ref = raw.slice(hash + 1) || null; loc = raw.slice(0, hash); }
17
+
18
+ // Path local explícito (./ ../ / ~/).
19
+ if (/^(\.\.?\/|\/|~\/)/.test(loc)) return { type: "path", location: loc, ref };
20
+ // git por sintaxis de URL (https, ssh, scp git@host:…).
21
+ if (/^(https?:\/\/|ssh:\/\/|git@|[\w.-]+@)/.test(loc)) return { type: "git", location: loc, ref };
22
+ // host/org/repo (p.ej. github.com/org/skills) → git https.
23
+ if (/^[\w.-]+\.[\w.-]+\/.+/.test(loc)) return { type: "git", location: "https://" + loc, ref };
24
+ // Resto: path local relativo sin ./.
25
+ return { type: "path", location: loc, ref };
26
+ }
@@ -0,0 +1,56 @@
1
+ # ADR-0013 — `dai skills install --from` (skills externas por-stack)
2
+
3
+ - **Estado:** aceptado
4
+ - **Fecha:** 2026-07-14
5
+ - **Decide:** lead / arquitecto de la metodología
6
+
7
+ ## Contexto
8
+
9
+ dai distribuye **sus** skills (las bundleadas: `grill-*`, `link-us`, `tdd`, …) e
10
+ instala/convierte para Claude, Cursor y Copilot. Pero los equipos tienen skills
11
+ **propias de su stack** (.NET, Java, Rust, …) que hoy no tienen lugar: o las copian
12
+ a mano por repo (duplicación, sin conversión a los 3 asistentes), o las meten en el
13
+ paquete dai — que **rompe el ADN**: dai es agnóstico del stack, opina solo en su
14
+ dominio (trazabilidad/distribución), no sobre *qué* dicen tus skills.
15
+
16
+ Falta una forma de que dai **convierta e instale** skills externas, sin volverse
17
+ dueño ni gatekeeper de ellas.
18
+
19
+ ## Decisión
20
+
21
+ Agregamos **`dai skills install --from <git-url|path>[#ref]`**: instala skills
22
+ **externas** desde un repo/dir (con estructura `skills/<nombre>/SKILL.md`, la misma
23
+ de dai), **convertidas para los 3 asistentes** (Claude copia · Cursor `skillToCursor`
24
+ · Copilot `skillToPrompt` → `.github/prompts/`).
25
+
26
+ - **`dai skills` es el namespace canónico** de las operaciones de skills, consistente
27
+ con `dai forge <verb>`. `dai skills install` (sin `--from`) instala las de dai;
28
+ **`dai install` queda como alias** silencioso (backward-compatible).
29
+ - **Self-service, one-off, sin registro.** No hay autorización central ni persistencia:
30
+ no existe `.dai/sources`, dai **no lleva registro** de qué repos usan skills externas.
31
+ El equipo las suma **bajo su propio criterio**.
32
+ - **`dai sync` NO las toca** — sigue siendo **solo** de las skills de dai. Si el equipo
33
+ actualiza sus skills, re-corre `--from`.
34
+ - **Colisión** con una skill built-in de dai → **warn + skip** (no se pisa la
35
+ metodología; renombran, p. ej. `tdd-dotnet`).
36
+
37
+ ## Consecuencias
38
+
39
+ - **Más fácil:** cada equipo suma sus skills por-stack sin tocar dai ni pedir permiso,
40
+ y las escribe **una vez** (dai las sirve a Claude/Cursor/Copilot). El namespace
41
+ `dai skills` deja lugar a crecer (`skills list`, …) sin comandos sueltos.
42
+ - **Se acepta pagar:** es **one-off** (dai no mantiene esas skills al día; re-corrés
43
+ `--from`). **Sin registro** → dai no sabe qué repos tienen skills externas, a
44
+ propósito: cero gatekeeping, bajo riesgo del equipo. Las fuentes remotas dependen de
45
+ git/red, y la **auth se delega en git** (público sin más; privado por SSH o credential
46
+ helper — dai no autentica): *si podés `git clone` la fuente, dai instala desde ahí*.
47
+
48
+ ## Alternativas consideradas
49
+
50
+ - **Persistido (`.dai/sources`) + integrado a `dai sync`** — descartado: obliga a dai a
51
+ **registrar y mantener** fuentes externas (gatekeeping que el equipo no quiere), y
52
+ acopla `dai sync` —que debe ser solo de dai— a repos ajenos.
53
+ - **Meter las skills de stack en el paquete dai** — descartado: rompe el ADN (dai
54
+ agnóstico del stack).
55
+ - **Comando suelto `dai install --from` sin namespace** — descartado: `dai skills <verb>`
56
+ es más claro y consistente con `dai forge <verb>`; `dai install` queda como alias.
@@ -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.