@dforce2055/dai 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +11 -1
- package/VERSION +1 -1
- package/cli/dai.mjs +431 -20
- package/cli/lib/bootstrap.mjs +5 -2
- package/cli/lib/branch-scope.mjs +144 -0
- package/cli/lib/forge-api.mjs +57 -0
- package/cli/lib/pm-adapter.mjs +16 -2
- package/cli/lib/pm-clickup.mjs +16 -1
- package/cli/lib/pm-jira.mjs +21 -1
- package/cli/lib/us-format.mjs +147 -0
- package/docs/EJEMPLO-END-TO-END.md +26 -3
- package/docs/METODOLOGIA.md +5 -0
- package/docs/PROBAR.md +12 -1
- package/docs/SCRUM-CON-IA.md +2 -2
- package/docs/adr/0018-alcance-de-stamp-y-gate-de-ci.md +183 -0
- package/docs/adr/README.md +1 -0
- package/docs/detalle/01-refinamiento.md +14 -0
- package/docs/detalle/07-merge-trazabilidad.md +13 -2
- package/docs/glosario.md +1 -1
- package/docs/guias/dev.md +21 -0
- package/docs/guias/po.md +40 -1
- package/governance/ci-rules.md +47 -8
- package/package.json +1 -1
- package/skills/dai-review/SKILL.md +1 -1
- package/skills/grill-epic/SKILL.md +2 -2
- package/skills/grill-intent/SKILL.md +1 -1
- package/skills/grill-user-story/SKILL.md +58 -10
- package/templates/ci-dai-gate.yml +50 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// dai · qué US toca esta branch (governance/branch-naming.md, ADR-0004).
|
|
2
|
+
//
|
|
3
|
+
// Dos preguntas que el CLI se hacía a ojo y respondía mal:
|
|
4
|
+
// 1. `dai stamp` — ¿cuál de las US del repo estoy cerrando? (issue #22: estampaba TODAS,
|
|
5
|
+
// archivadas incluidas, así que un sprint entero recibía un comentario por PR).
|
|
6
|
+
// 2. `dai check --ci` — ¿esta branch está OBLIGADA a tener implements.yaml? Una `chore/`
|
|
7
|
+
// o una `fix/` sin US no lo está, y bloquearla sería mentirle al equipo.
|
|
8
|
+
//
|
|
9
|
+
// Todo acá es puro: entra el nombre de la branch y la lista de implements, sale la
|
|
10
|
+
// decisión. La red, git y los prompts viven en dai.mjs.
|
|
11
|
+
|
|
12
|
+
import { isPlaceholderId } from "./implements.mjs";
|
|
13
|
+
|
|
14
|
+
// Prefijos que NO son trabajo de producto: no exigen link (branch-naming.md).
|
|
15
|
+
// `fix/` es "corrección sobre una US ya implementada": puede llevar ID o no, y muchas
|
|
16
|
+
// veces es un hotfix sin ticket. Se exige link solo si el nombre trae el ID.
|
|
17
|
+
const EXEMPT = new Set(["chore", "docs", "ci", "build", "test", "refactor", "style", "release", "hotfix", "revert"]);
|
|
18
|
+
const ALWAYS = new Set(["feature", "feat"]);
|
|
19
|
+
|
|
20
|
+
// El tipo de branch = lo que hay antes de la primera `/`. Sin `/` → "" (main, develop…).
|
|
21
|
+
export function branchType(branch) {
|
|
22
|
+
const s = String(branch ?? "").trim();
|
|
23
|
+
const i = s.indexOf("/");
|
|
24
|
+
return i === -1 ? "" : s.slice(0, i).toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Los ids candidatos que aparecen en el nombre de la branch, en orden de aparición.
|
|
28
|
+
// Cubre las dos formas que emite `link-us` (ver slugify/branchName en link-us.mjs):
|
|
29
|
+
// feature/ABC-482-titulo → Jira-like (LETRAS-números)
|
|
30
|
+
// feature/86acme482-titulo → ClickUp-like (alfanumérico, sin guion)
|
|
31
|
+
// Devuelve TODOS los candidatos porque el segmento alfanumérico es ambiguo: `historia`
|
|
32
|
+
// también matchea. Quién decide es matchBranchToImplements, comparando contra las US reales.
|
|
33
|
+
export function branchIdCandidates(branch) {
|
|
34
|
+
const s = String(branch ?? "").trim();
|
|
35
|
+
const tail = s.includes("/") ? s.slice(s.indexOf("/") + 1) : s;
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const m of tail.matchAll(/[A-Za-z][A-Za-z0-9_]*-\d+|[A-Za-z0-9]+/g)) out.push(m[0]);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Un key de tracker con pinta de tal: MAYÚSCULAS + guion + números (ABC-482). El case
|
|
42
|
+
// importa — es lo único que separa un key real de una palabra del slug con un número
|
|
43
|
+
// pegado: `feat/issues-22-26` no implementa la US "issues-22", y sugerírsela al dev es
|
|
44
|
+
// mandarlo a crear un link inventado. Los ids de ClickUp (`86acme482`) no matchean acá:
|
|
45
|
+
// no llevan guion, y para esos el nombre de la branch no alcanza — se resuelve comparando
|
|
46
|
+
// contra las US que el repo declara (matchBranchToImplements).
|
|
47
|
+
const KEYISH = /^[A-Z][A-Z0-9_]*-\d+$/;
|
|
48
|
+
|
|
49
|
+
// Los keys de tracker que nombra la branch (0, 1 o varios).
|
|
50
|
+
export function trackerKeysIn(branch) {
|
|
51
|
+
return branchIdCandidates(branch).filter((c) => KEYISH.test(c));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ¿Esta branch tiene que declarar una US? (gate de CI, issue #26)
|
|
55
|
+
// feature/ → siempre sí
|
|
56
|
+
// chore/, docs/, ci/… → nunca
|
|
57
|
+
// fix/ y el resto → solo si el nombre trae un ID con pinta de key de tracker
|
|
58
|
+
// `main`/`develop`/sin branch → no (no es una branch de trabajo).
|
|
59
|
+
export function requiresLink(branch) {
|
|
60
|
+
const t = branchType(branch);
|
|
61
|
+
if (ALWAYS.has(t)) return { required: true, reason: `'${t}/' es trabajo de producto: requiere US` };
|
|
62
|
+
if (EXEMPT.has(t)) return { required: false, reason: `'${t}/' está exenta de US (governance/branch-naming.md)` };
|
|
63
|
+
if (t === "") return { required: false, reason: "sin prefijo tipo/, no es una branch de trabajo: sin gate de link" };
|
|
64
|
+
// Tipo desconocido (fix/, spike/, lo que el repo use): si nombró un ID, lo tomamos
|
|
65
|
+
// como intención de implementar una US y se lo exigimos. Si no, no inventamos.
|
|
66
|
+
const keyish = trackerKeysIn(branch).length > 0;
|
|
67
|
+
return keyish
|
|
68
|
+
? { required: true, reason: `'${t}/' con un ID en el nombre: se toma como trabajo de producto` }
|
|
69
|
+
: { required: false, reason: `'${t}/' sin ID en el nombre: no exige US (governance/branch-naming.md)` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Aplana los implements descubiertos a filas { path, change, repo, id, version, ac_hash },
|
|
73
|
+
// salteando los placeholders de plantilla (ABC-###).
|
|
74
|
+
export function flattenImplements(found) {
|
|
75
|
+
const rows = [];
|
|
76
|
+
for (const f of found || []) {
|
|
77
|
+
for (const im of f.implements || []) {
|
|
78
|
+
if (isPlaceholderId(im.id)) continue;
|
|
79
|
+
rows.push({ path: f.path, change: f.change, repo: f.repo, id: im.id, version: im.version, ac_hash: im.ac_hash });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ¿Cuál de estas US es la de la branch? Compara sin distinguir mayúsculas, porque el
|
|
86
|
+
// slug de la branch va en minúsculas y el key de Jira en mayúsculas (ABC-482 → abc-482).
|
|
87
|
+
export function matchBranchToImplements(branch, rows) {
|
|
88
|
+
const cands = branchIdCandidates(branch).map((c) => c.toLowerCase());
|
|
89
|
+
if (cands.length === 0) return [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const hit = [];
|
|
92
|
+
for (const c of cands) {
|
|
93
|
+
for (const r of rows) {
|
|
94
|
+
if (String(r.id).toLowerCase() === c && !seen.has(r.path + " " + r.id)) {
|
|
95
|
+
seen.add(r.path + " " + r.id);
|
|
96
|
+
hit.push(r);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return hit;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// La decisión completa de `dai stamp`: qué estampar y por qué.
|
|
104
|
+
//
|
|
105
|
+
// { mode, targets, candidates, reason }
|
|
106
|
+
//
|
|
107
|
+
// mode "explicit" → los ids que pidió el usuario (dai stamp ABC-482)
|
|
108
|
+
// mode "all" → --all: todo, archivadas incluidas (el comportamiento viejo)
|
|
109
|
+
// mode "branch" → la branch nombra una US del repo: esa
|
|
110
|
+
// mode "only" → hay una sola US viva: esa
|
|
111
|
+
// mode "ambiguous"→ varias y ninguna pista: NO estampa, pide confirmación
|
|
112
|
+
// mode "none" → no hay nada que estampar
|
|
113
|
+
//
|
|
114
|
+
// `rows` viene de flattenImplements sobre el discover SIN archivados; `allRows` incluye
|
|
115
|
+
// los archivados y solo se usa para --all y para resolver un id explícito de un change
|
|
116
|
+
// ya archivado (estampar después del merge es exactamente ese caso).
|
|
117
|
+
export function stampScope({ branch, rows, allRows = rows, ids = [], all = false }) {
|
|
118
|
+
if (all) {
|
|
119
|
+
return { mode: "all", targets: allRows, candidates: allRows, reason: "--all: todas las US del repo (archivadas incluidas)" };
|
|
120
|
+
}
|
|
121
|
+
if (ids.length) {
|
|
122
|
+
const want = ids.map((i) => String(i).toLowerCase());
|
|
123
|
+
const targets = allRows.filter((r) => want.includes(String(r.id).toLowerCase()));
|
|
124
|
+
// `missing` conserva la grafía que tipeó el usuario: si le devolvemos su ABC-404 en
|
|
125
|
+
// minúsculas, lo primero que hace es dudar de si el problema era el case.
|
|
126
|
+
const missing = ids.filter((i) => !allRows.some((r) => String(r.id).toLowerCase() === String(i).toLowerCase()));
|
|
127
|
+
return { mode: "explicit", targets, candidates: allRows, missing, reason: `ids pedidos: ${ids.join(", ")}` };
|
|
128
|
+
}
|
|
129
|
+
if (rows.length === 0) return { mode: "none", targets: [], candidates: [], reason: "no hay implements.yaml vivos para estampar" };
|
|
130
|
+
|
|
131
|
+
const hit = matchBranchToImplements(branch, rows);
|
|
132
|
+
if (hit.length === 1) {
|
|
133
|
+
return { mode: "branch", targets: hit, candidates: rows, reason: `la branch '${branch}' nombra ${hit[0].id}` };
|
|
134
|
+
}
|
|
135
|
+
if (rows.length === 1) {
|
|
136
|
+
return { mode: "only", targets: rows, candidates: rows, reason: "es la única US viva del repo" };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
mode: "ambiguous",
|
|
140
|
+
targets: [],
|
|
141
|
+
candidates: hit.length > 1 ? hit : rows,
|
|
142
|
+
reason: `hay ${hit.length > 1 ? hit.length : rows.length} US vivas y la branch '${branch}' no dice cuál`,
|
|
143
|
+
};
|
|
144
|
+
}
|
package/cli/lib/forge-api.mjs
CHANGED
|
@@ -72,6 +72,10 @@ export function inlinePosition(ref, c, { diffRefs } = {}) {
|
|
|
72
72
|
return { body: c.body, position };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// La variable de entorno que autentica contra este forge, y su valor.
|
|
76
|
+
export function tokenVar(ref) { return ref.forge === "gitlab" ? "GITLAB_TOKEN" : "GITHUB_TOKEN"; }
|
|
77
|
+
export function tokenFor(ref, env = process.env) { return String(env[tokenVar(ref)] || "").trim(); }
|
|
78
|
+
|
|
75
79
|
export function authHeaders(ref, env = process.env) {
|
|
76
80
|
if (ref.forge === "github") {
|
|
77
81
|
return { Authorization: `Bearer ${env.GITHUB_TOKEN || ""}`, Accept: "application/vnd.github+json", "User-Agent": "dai" };
|
|
@@ -79,6 +83,59 @@ export function authHeaders(ref, env = process.env) {
|
|
|
79
83
|
return { "PRIVATE-TOKEN": env.GITLAB_TOKEN || "" };
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
// Traduce el fallo del forge a algo accionable (issue #24).
|
|
87
|
+
//
|
|
88
|
+
// El mensaje viejo era `¿token? ¿ref correcta?` para TODO: sin token, token vencido,
|
|
89
|
+
// token sin scope, PR inexistente y repo privado caían en la misma frase. Costó una
|
|
90
|
+
// sesión de diagnóstico equivocado. Cada causa tiene una acción distinta, así que se
|
|
91
|
+
// nombran por separado — y "no hay token" se detecta ANTES de salir a la red.
|
|
92
|
+
//
|
|
93
|
+
// Ojo con el 404 de GitHub: en un repo privado, un token sin permiso devuelve 404 (no
|
|
94
|
+
// 403) para no filtrar la existencia del repo. Por eso el 404 nombra las dos causas.
|
|
95
|
+
export function describeForgeError(ref, { status = null, body = "", env = process.env, cause = null } = {}) {
|
|
96
|
+
const v = tokenVar(ref);
|
|
97
|
+
const has = tokenFor(ref, env) !== "";
|
|
98
|
+
const where = `${ref.projectPath}#${ref.number} (${ref.host})`;
|
|
99
|
+
const setIt = ` Configuralo en tu .env.dai (o exportalo en la shell): ${v}=<token>`;
|
|
100
|
+
|
|
101
|
+
if (!has) {
|
|
102
|
+
return `no hay ${v} configurado, así que la llamada al forge salió sin credencial.\n` +
|
|
103
|
+
`${setIt}\n` +
|
|
104
|
+
(ref.forge === "github"
|
|
105
|
+
? " Token: https://github.com/settings/tokens · scope 'repo' (o fine-grained con Pull requests: read+write)."
|
|
106
|
+
: " Token: <tu-gitlab>/-/user_settings/personal_access_tokens · scope 'api'.");
|
|
107
|
+
}
|
|
108
|
+
if (status === 401) {
|
|
109
|
+
return `el forge rechazó tu ${v} (401): el token existe pero NO es válido — vencido, revocado, o mal copiado.\n` +
|
|
110
|
+
` Probalo: ${ref.forge === "github"
|
|
111
|
+
? "curl -sI -H \"Authorization: Bearer $GITHUB_TOKEN\" https://api.github.com/user"
|
|
112
|
+
: `curl -sI -H "PRIVATE-TOKEN: $GITLAB_TOKEN" https://${ref.host}/api/v4/user`}\n` +
|
|
113
|
+
` Si da 200, el token sirve y el problema es otro. Si da 401, generá uno nuevo.\n${setIt}`;
|
|
114
|
+
}
|
|
115
|
+
if (status === 403) {
|
|
116
|
+
const rate = /rate limit|api rate/i.test(String(body));
|
|
117
|
+
return rate
|
|
118
|
+
? `el forge te frenó por rate limit (403). Esperá unos minutos, o usá un ${v} con más cuota.`
|
|
119
|
+
: `tu ${v} es válido pero NO tiene permiso sobre ${where} (403).\n` +
|
|
120
|
+
` Le falta scope (github: 'repo' / fine-grained con Pull requests read+write · gitlab: 'api'),\n` +
|
|
121
|
+
" o tu usuario no tiene acceso a ese repo.";
|
|
122
|
+
}
|
|
123
|
+
if (status === 404) {
|
|
124
|
+
return `el forge no encontró ${where} (404). Dos causas posibles, y no se distinguen desde afuera:\n` +
|
|
125
|
+
" 1. La PR/MR no existe con ese número en ese repo (revisá la ref).\n" +
|
|
126
|
+
` 2. El repo es privado y tu ${v} no tiene permiso — GitHub devuelve 404, no 403, para no filtrar que existe.`;
|
|
127
|
+
}
|
|
128
|
+
if (status != null) return `el forge respondió ${status} sobre ${where}.${body ? `\n ${String(body).slice(0, 400)}` : ""}`;
|
|
129
|
+
return `no pude hablar con ${ref.host}${cause ? `: ${cause}` : ""}. ¿Hay red / proxy / VPN de por medio?`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// El error que tiran getPR/postComment/postReview lleva el status pegado al mensaje
|
|
133
|
+
// (`forge 401: {...}`). Esto lo vuelve a separar para poder explicarlo.
|
|
134
|
+
export function parseForgeError(e) {
|
|
135
|
+
const m = String(e?.message || e || "").match(/^forge (\d{3}): ([\s\S]*)$/);
|
|
136
|
+
return m ? { status: Number(m[1]), body: m[2] } : { status: null, body: "", cause: String(e?.message || e || "") };
|
|
137
|
+
}
|
|
138
|
+
|
|
82
139
|
// Comentario estándar de dai-review (mismo formato lo emita el CLI o la skill).
|
|
83
140
|
export function renderReviewComment(r) {
|
|
84
141
|
const { us, version, checkStatus, dod, errors = [], improvements = [], good = [] } = r;
|
package/cli/lib/pm-adapter.mjs
CHANGED
|
@@ -7,12 +7,17 @@
|
|
|
7
7
|
// clickup — REST v2 (pm-clickup.mjs)
|
|
8
8
|
//
|
|
9
9
|
// Interfaz (fetchUS/stamp pueden ser sync o async — el CLI siempre await-ea):
|
|
10
|
-
// fetchUS(id) → { id, title, spec_version, ac_hash, url } | null
|
|
10
|
+
// fetchUS(id) → { id, title, spec_version, ac_hash, url, raw } | null
|
|
11
11
|
// stamp(id, record) → destino donde quedó la cobertura
|
|
12
|
+
// createUS({...}) → { id, url } (opcional: `dai publish`)
|
|
13
|
+
// updateUS(id, {...}) → { id, url } (opcional: `dai update-us`)
|
|
12
14
|
// kind → nombre del backend
|
|
13
15
|
//
|
|
14
16
|
// `url` es la URL web canónica de la US según el tracker (opcional: null si el backend
|
|
15
17
|
// no la sabe, como md). El CLI la prefiere sobre la derivada — ver tracker-url.mjs.
|
|
18
|
+
// `raw` es el markdown COMPLETO de la US tal como vive en el backend: es lo que
|
|
19
|
+
// `dai edit-us` baja y te abre. Sin esto solo teníamos el parseo (título + hash), que
|
|
20
|
+
// alcanza para detectar drift pero no para editar.
|
|
16
21
|
|
|
17
22
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
18
23
|
import { join, dirname } from "node:path";
|
|
@@ -32,7 +37,8 @@ function mdAdapter(env) {
|
|
|
32
37
|
fetchUS(id) {
|
|
33
38
|
const p = join(dir, `${id}.md`);
|
|
34
39
|
if (!existsSync(p)) return null;
|
|
35
|
-
|
|
40
|
+
const raw = readFileSync(p, "utf8");
|
|
41
|
+
return { id, ...parseUS(raw), raw };
|
|
36
42
|
},
|
|
37
43
|
stamp(id, record) {
|
|
38
44
|
const p = join(dir, `${id}.coverage.md`);
|
|
@@ -47,6 +53,14 @@ function mdAdapter(env) {
|
|
|
47
53
|
writeFileSync(p, descriptionMarkdown.startsWith("# ") ? descriptionMarkdown : `# ${title}\n\n${descriptionMarkdown}`);
|
|
48
54
|
return { id, url: p };
|
|
49
55
|
},
|
|
56
|
+
// Sin tracker, "actualizar la US" es pisar el .md canónico. Se exige que exista:
|
|
57
|
+
// si no, `dai update-us` estaría creando una US con el id equivocado en silencio.
|
|
58
|
+
updateUS(id, { title, descriptionMarkdown }) {
|
|
59
|
+
const p = join(dir, `${id}.md`);
|
|
60
|
+
if (!existsSync(p)) throw new Error(`no existe ${p} — con DAI_PM=md, update-us actualiza el .md canónico. Creá la US primero (dai publish).`);
|
|
61
|
+
writeFileSync(p, descriptionMarkdown.startsWith("# ") ? descriptionMarkdown : `# ${title}\n\n${descriptionMarkdown}`);
|
|
62
|
+
return { id, url: p };
|
|
63
|
+
},
|
|
50
64
|
};
|
|
51
65
|
}
|
|
52
66
|
|
package/cli/lib/pm-clickup.mjs
CHANGED
|
@@ -30,8 +30,9 @@ export function clickupAdapter(env) {
|
|
|
30
30
|
if (res.status === 404) return null;
|
|
31
31
|
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
32
32
|
const j = await res.json();
|
|
33
|
+
const raw = clickupTaskToText(j);
|
|
33
34
|
// `url` es la canónica (/t/<team_id>/<id>): la sabe ClickUp, no la deducimos.
|
|
34
|
-
return { id, ...parseUS(
|
|
35
|
+
return { id, ...parseUS(raw), url: j.url || null, raw };
|
|
35
36
|
},
|
|
36
37
|
async stamp(id, record) {
|
|
37
38
|
const res = await fetch(clickupCommentUrl(id), {
|
|
@@ -41,6 +42,20 @@ export function clickupAdapter(env) {
|
|
|
41
42
|
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
42
43
|
return `task ${id} (comentario)`;
|
|
43
44
|
},
|
|
45
|
+
// PUT /task/<id>: ClickUp acepta `markdown_content` para pisar la descripción.
|
|
46
|
+
// Solo mandamos lo que cambió — un PUT con `name` vacío renombraría la tarea.
|
|
47
|
+
async updateUS(id, { title, descriptionMarkdown }) {
|
|
48
|
+
const payload = {};
|
|
49
|
+
if (title) payload.name = title;
|
|
50
|
+
if (descriptionMarkdown != null) payload.markdown_content = descriptionMarkdown;
|
|
51
|
+
const res = await fetch(`${API}/task/${encodeURIComponent(id)}`, {
|
|
52
|
+
method: "PUT", headers: clickupAuthHeaders(env), body: JSON.stringify(payload),
|
|
53
|
+
});
|
|
54
|
+
if (res.status === 404) throw new Error(`clickup: no existe la tarea '${id}' (404). Revisá el id.`);
|
|
55
|
+
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
56
|
+
const j = await res.json().catch(() => ({}));
|
|
57
|
+
return { id, url: j.url || null };
|
|
58
|
+
},
|
|
44
59
|
async createUS({ title, descriptionMarkdown }) {
|
|
45
60
|
const list = env.DAI_CLICKUP_LIST_ID;
|
|
46
61
|
if (!list) throw new Error("falta DAI_CLICKUP_LIST_ID en el .env.dai (la lista donde crear la tarea).");
|
package/cli/lib/pm-jira.mjs
CHANGED
|
@@ -143,7 +143,8 @@ export function jiraAdapter(env) {
|
|
|
143
143
|
const res = await daiFetch(jiraIssueUrl(base, id), { headers: jiraAuthHeaders(env) });
|
|
144
144
|
if (res.status === 404) return null;
|
|
145
145
|
if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
|
|
146
|
-
|
|
146
|
+
const raw = jiraIssueToText(await res.json());
|
|
147
|
+
return { id, ...parseUS(raw), url: `${trim(base)}/browse/${id}`, raw };
|
|
147
148
|
},
|
|
148
149
|
async stamp(id, record) {
|
|
149
150
|
const res = await daiFetch(jiraCommentUrl(base, id), {
|
|
@@ -153,6 +154,25 @@ export function jiraAdapter(env) {
|
|
|
153
154
|
if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
|
|
154
155
|
return `${trim(base)}/browse/${id}`;
|
|
155
156
|
},
|
|
157
|
+
// PUT /issue/<id>: Jira responde 204 SIN cuerpo, así que la URL la componemos
|
|
158
|
+
// nosotros. Solo se mandan los campos que cambian — un PUT con summary vacío
|
|
159
|
+
// borraría el título de la US.
|
|
160
|
+
async updateUS(id, { title, descriptionMarkdown, fields }) {
|
|
161
|
+
const payload = { ...(fields || {}) };
|
|
162
|
+
if (title) payload.summary = title;
|
|
163
|
+
if (descriptionMarkdown != null) payload.description = markdownToAdf(descriptionMarkdown);
|
|
164
|
+
if (Object.keys(payload).length === 0) throw new Error("nada que actualizar (ni título, ni descripción, ni campos).");
|
|
165
|
+
const res = await daiFetch(`${trim(base)}/rest/api/3/issue/${encodeURIComponent(id)}`, {
|
|
166
|
+
method: "PUT", headers: jiraAuthHeaders(env), body: JSON.stringify({ fields: payload }),
|
|
167
|
+
});
|
|
168
|
+
if (res.status === 404) throw new Error(`jira: no existe el issue '${id}' (404), o tu usuario no lo ve.`);
|
|
169
|
+
if (res.status === 403) throw new Error(`jira: sin permiso para editar '${id}' (403). ¿Tu usuario puede editar issues en ese proyecto?`);
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
const body = await res.text();
|
|
172
|
+
throw new Error(`jira ${res.status}: ${body}${createHint(res.status, body)}`);
|
|
173
|
+
}
|
|
174
|
+
return { id, url: `${trim(base)}/browse/${id}` };
|
|
175
|
+
},
|
|
156
176
|
// `fields` son los campos propios del proyecto ya resueltos (ver jira-fields.mjs);
|
|
157
177
|
// `parent` cuelga la US de su épica. Ambos son opcionales: un Jira sin campos
|
|
158
178
|
// obligatorios sigue publicando igual que antes.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// dai · validación del formato de una US y manejo de `spec_version`.
|
|
2
|
+
//
|
|
3
|
+
// El molde canónico vive en templates/formato-us.md. Acá está la parte MECÁNICA de ese
|
|
4
|
+
// molde: ¿tiene título? ¿tiene criterios? ¿los criterios son Gherkin completo?
|
|
5
|
+
//
|
|
6
|
+
// Lo que NO hace: juzgar si un criterio es *bueno*. Eso es criterio (ADR-0002) y vive en
|
|
7
|
+
// la skill /grill-user-story, que te interroga. Acá solo se verifica lo que una máquina
|
|
8
|
+
// puede verificar sin opinar — y por eso casi todo es WARNING, no error: dai es una
|
|
9
|
+
// herramienta, no un mandato. Bloquean únicamente las tres cosas sin las cuales el link
|
|
10
|
+
// QUÉ↔CÓMO no existe: título, sección de criterios, y al menos un criterio.
|
|
11
|
+
|
|
12
|
+
import { extractAcBlock } from "./ac-hash.mjs";
|
|
13
|
+
import { extractTitle } from "./link-us.mjs";
|
|
14
|
+
|
|
15
|
+
// Limpia el marcado editorial de una línea para poder mirarla (mismo espíritu que
|
|
16
|
+
// normalizeAcBlock, pero conservando las líneas: acá importa la ESTRUCTURA).
|
|
17
|
+
const strip = (line) => line
|
|
18
|
+
.replace(/^\s*>\s?/, "")
|
|
19
|
+
.replace(/^\s*[-*+]\s+\[[ xX]\]\s*/, "")
|
|
20
|
+
.replace(/^\s*[-*+]\s+/, "")
|
|
21
|
+
.replace(/^\s*\d+[.)]\s+/, "")
|
|
22
|
+
.replace(/[*_`]+/g, "")
|
|
23
|
+
.trim();
|
|
24
|
+
|
|
25
|
+
const GHERKIN = { dado: /^dado\b/i, cuando: /^cuando\b/i, entonces: /^entonces\b/i };
|
|
26
|
+
|
|
27
|
+
// Parte el bloque de criterios en criterios individuales.
|
|
28
|
+
//
|
|
29
|
+
// Soporta las dos formas que se ven en la práctica:
|
|
30
|
+
// a) etiquetadas — `- [ ] **AC-1** —` seguida de las tres líneas Gherkin
|
|
31
|
+
// b) sueltas — una tanda de `- Dado … / - Cuando … / - Entonces …`
|
|
32
|
+
// En (b) cada `Dado` abre un criterio nuevo, que es como se leen naturalmente.
|
|
33
|
+
export function splitCriteria(block) {
|
|
34
|
+
const lines = (block || "").split(/\r?\n/).map(strip).filter((l) => l !== "");
|
|
35
|
+
const hasLabels = lines.some((l) => /^AC[-\s]?\d+\b/i.test(l));
|
|
36
|
+
const out = [];
|
|
37
|
+
let cur = null;
|
|
38
|
+
const open = (label) => { cur = { label: label || null, lines: [] }; out.push(cur); };
|
|
39
|
+
|
|
40
|
+
for (const l of lines) {
|
|
41
|
+
const label = l.match(/^(AC[-\s]?\d+)\b\s*[—:\-]?\s*(.*)$/i);
|
|
42
|
+
if (hasLabels && label) {
|
|
43
|
+
open(label[1]);
|
|
44
|
+
if (label[2]) cur.lines.push(label[2]);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!hasLabels && GHERKIN.dado.test(l)) { open(null); }
|
|
48
|
+
if (!cur) open(null);
|
|
49
|
+
cur.lines.push(l);
|
|
50
|
+
}
|
|
51
|
+
return out.map((c, i) => {
|
|
52
|
+
const text = c.lines.join(" ");
|
|
53
|
+
return {
|
|
54
|
+
label: c.label || `#${i + 1}`,
|
|
55
|
+
text,
|
|
56
|
+
dado: c.lines.some((l) => GHERKIN.dado.test(l)),
|
|
57
|
+
cuando: c.lines.some((l) => GHERKIN.cuando.test(l)),
|
|
58
|
+
entonces: c.lines.some((l) => GHERKIN.entonces.test(l)),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const isGherkin = (c) => c.dado && c.cuando && c.entonces;
|
|
64
|
+
|
|
65
|
+
// Palabras que delatan que el criterio se metió en el CÓMO. El QUÉ es funcional: si un
|
|
66
|
+
// criterio habla de endpoints o de tablas, el PO está diseñando la solución y ya no
|
|
67
|
+
// describe el comportamiento observable (Art. 1). Es un aviso, no un bloqueo: a veces
|
|
68
|
+
// el dominio realmente usa esas palabras.
|
|
69
|
+
const TECNICAS = /\b(endpoint|API REST|base de datos|tabla SQL|migraci[oó]n|componente React|microservicio|query|schema|foreign key|índice|index)\b/i;
|
|
70
|
+
|
|
71
|
+
// ¿Esta US está lista para viajar al tracker?
|
|
72
|
+
// → { ok, errors, warnings, criteria, title, acHashable }
|
|
73
|
+
// `ok` es false SOLO si hay errors. Los warnings se muestran y se siguen (salvo --strict).
|
|
74
|
+
export function validateUS(md) {
|
|
75
|
+
const errors = [], warnings = [];
|
|
76
|
+
const title = extractTitle(md);
|
|
77
|
+
if (!title) errors.push("falta el título: la US tiene que empezar con un '# Título'.");
|
|
78
|
+
else if (title.split(/\s+/).length > 10) {
|
|
79
|
+
warnings.push(`el título tiene ${title.split(/\s+/).length} palabras — el molde pide 3 a 6 (de ahí sale el nombre de la branch).`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const block = extractAcBlock(md);
|
|
83
|
+
if (block == null) {
|
|
84
|
+
errors.push("falta la sección '## Criterios de aceptación'. Sin criterios no hay ac_hash, y sin ac_hash no hay link QUÉ↔CÓMO.");
|
|
85
|
+
return { ok: false, errors, warnings, criteria: [], title, acHashable: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const criteria = splitCriteria(block);
|
|
89
|
+
if (criteria.length === 0) {
|
|
90
|
+
errors.push("la sección 'Criterios de aceptación' está vacía.");
|
|
91
|
+
return { ok: false, errors, warnings, criteria, title, acHashable: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const c of criteria) {
|
|
95
|
+
if (!isGherkin(c)) {
|
|
96
|
+
const falta = [!c.dado && "Dado", !c.cuando && "Cuando", !c.entonces && "Entonces"].filter(Boolean).join(" / ");
|
|
97
|
+
warnings.push(`${c.label}: no es Gherkin completo — falta ${falta}. Un criterio sin las tres partes es difícil de volver un test.`);
|
|
98
|
+
}
|
|
99
|
+
if (TECNICAS.test(c.text)) {
|
|
100
|
+
warnings.push(`${c.label}: menciona implementación (${c.text.match(TECNICAS)[0]}). El QUÉ describe comportamiento observable; el CÓMO lo decide el dev.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ok: errors.length === 0, errors, warnings, criteria, title, acHashable: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── spec_version ─────────────────────────────────────────────────────────────
|
|
107
|
+
// El número que COMUNICA el cambio del QUÉ; el ac_hash es el que lo DETECTA
|
|
108
|
+
// (METODOLOGIA §4). Sube cuando el cambio es material, y eso lo sabe el PO: dai
|
|
109
|
+
// mirando el hash no puede distinguir un criterio nuevo de un typo corregido.
|
|
110
|
+
|
|
111
|
+
export const parseSpecVersion = (md) => {
|
|
112
|
+
const m = String(md || "").match(/spec[_ ]version[^\n]*?\b(v\d+)\b/i);
|
|
113
|
+
return m ? m[1] : null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const bumpSpecVersion = (v) => {
|
|
117
|
+
const n = Number(String(v || "v1").replace(/^v/i, "")) || 1;
|
|
118
|
+
return `v${n + 1}`;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Reescribe el spec_version en el markdown. Si la US no declara ninguno (típico de una
|
|
122
|
+
// traída de un tracker que nunca usó el molde), lo INSERTA justo debajo del título — no
|
|
123
|
+
// al final, donde nadie lo ve, ni en una tabla que no existe.
|
|
124
|
+
export function setSpecVersion(md, version) {
|
|
125
|
+
const text = String(md || "");
|
|
126
|
+
if (/spec[_ ]version[^\n]*?\bv\d+\b/i.test(text)) {
|
|
127
|
+
return text.replace(/(spec[_ ]version[^\n]*?\b)v\d+\b/i, `$1${version}`);
|
|
128
|
+
}
|
|
129
|
+
const lines = text.split(/\r?\n/);
|
|
130
|
+
const i = lines.findIndex((l) => /^#\s+\S/.test(l));
|
|
131
|
+
const stamp = `> **spec_version:** ${version}`;
|
|
132
|
+
if (i === -1) return `${stamp}\n\n${text}`;
|
|
133
|
+
lines.splice(i + 1, 0, "", stamp);
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Render de los hallazgos para la terminal. Devuelve las líneas ya formateadas.
|
|
138
|
+
export function renderValidation({ errors, warnings, criteria }) {
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const e of errors) out.push(` ✗ ${e}`);
|
|
141
|
+
for (const w of warnings) out.push(` ⚠ ${w}`);
|
|
142
|
+
if (errors.length === 0) {
|
|
143
|
+
const g = criteria.filter(isGherkin).length;
|
|
144
|
+
out.push(` ✓ formato válido — ${criteria.length} criterio(s)${criteria.length ? `, ${g} en Gherkin completo` : ""}`);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
@@ -269,8 +269,8 @@ aprobación o rechazo:
|
|
|
269
269
|
## Paso 7 — Merge: la trazabilidad se estampa sola
|
|
270
270
|
|
|
271
271
|
Al mergear, se corre `dai stamp` (el dev, o el CI si está automatizado — ADR-0003).
|
|
272
|
-
|
|
273
|
-
con links a la implementación:
|
|
272
|
+
Deduce que la US de esta rama es `ABC-482`, lee el `implements.yaml` y **estampa la
|
|
273
|
+
cobertura inversa** en el ticket, con links a la implementación:
|
|
274
274
|
|
|
275
275
|
```
|
|
276
276
|
ABC-482 · implementado por (lo estampó dai stamp)
|
|
@@ -312,7 +312,30 @@ AC-3 verdes → **US aceptada**. Cero sorpresas: si el QUÉ hubiera cambiado, el
|
|
|
312
312
|
|
|
313
313
|
Dos sprints después, el PO agrega un criterio a `ABC-482` (ahora exige **avisar al
|
|
314
314
|
comprador qué productos quedaron sin stock, sin cancelar el resto del carrito**).
|
|
315
|
-
|
|
315
|
+
|
|
316
|
+
Lo hace con `dai edit-us ABC-482`: la baja del tracker, la abre en su editor, valida el
|
|
317
|
+
formato cuando guarda, y le pregunta si el cambio es material o editorial.
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
── formato de la US (.dai/us/ABC-482.md) ────────────
|
|
321
|
+
✓ formato válido — 4 criterio(s), 4 en Gherkin completo
|
|
322
|
+
|
|
323
|
+
Cambiaron los criterios (ac_hash 7f3a9c2e → b81d40f5).
|
|
324
|
+
s = cambio material: subo spec_version a v2 y los repos con v1 se marcan ATRASADOS
|
|
325
|
+
n = cambio editorial (typo, redacción): se queda en v1
|
|
326
|
+
¿Subo spec_version a v2? (S/n) s
|
|
327
|
+
|
|
328
|
+
── ABC-482 · qué cambia en jira ────────────
|
|
329
|
+
título: Finalizar la compra del carrito (sin cambios)
|
|
330
|
+
criterios: 4
|
|
331
|
+
version: v1 → v2
|
|
332
|
+
ac_hash: 7f3a9c2e → b81d40f5
|
|
333
|
+
──────────────────────────────────────
|
|
334
|
+
Esto PISA la US ABC-482 en jira. ¿Guardo? (s/N) s
|
|
335
|
+
✓ ABC-482 actualizada en jira → https://acme.atlassian.net/browse/ABC-482
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
La US sube a **v2** → cambia el `ac_hash`.
|
|
316
339
|
|
|
317
340
|
```
|
|
318
341
|
ABC-482 · implementado por
|
package/docs/METODOLOGIA.md
CHANGED
|
@@ -83,6 +83,11 @@ cambio.
|
|
|
83
83
|
- **`ac_hash`** lo calcula la máquina para **detectar**. Es el hash del bloque de
|
|
84
84
|
*Criterios de aceptación* normalizado (whitespace colapsado, orden estable).
|
|
85
85
|
|
|
86
|
+
> El reparto no es decorativo: la máquina sabe **que** algo cambió, la persona sabe **si
|
|
87
|
+
> importa**. Un typo corregido mueve el hash igual que un criterio nuevo, y solo un humano
|
|
88
|
+
> distingue el uno del otro. Por eso `dai edit-us` / `dai update-us` **proponen** subir el
|
|
89
|
+
> `spec_version` cuando el hash se mueve, y esperan un sí o un no — nunca lo deciden solos.
|
|
90
|
+
|
|
86
91
|
Cuando el QUÉ evoluciona, cambia el `ac_hash`; todos los CÓMO que declaran el hash
|
|
87
92
|
viejo quedan marcados **atrasados** solos. El funcional ve "el backend todavía no
|
|
88
93
|
tomó mi cambio"; el dev ve "el QUÉ que implementé cambió". Nadie avisa a nadie — el
|
package/docs/PROBAR.md
CHANGED
|
@@ -41,11 +41,18 @@ dai link-us finalizar-la-compra-del-carrito # branch + implements.yaml
|
|
|
41
41
|
git add -A && git commit -m "feat: guard carrito vacío"
|
|
42
42
|
dai check # ✅ al día
|
|
43
43
|
|
|
44
|
-
# 4. La demo del ⚠️:
|
|
44
|
+
# 4. La demo del ⚠️: cambia el QUÉ y mira cómo se detecta solo
|
|
45
|
+
dai edit-us finalizar-la-compra-del-carrito # la trae, la abres, valida, pregunta si sube spec_version
|
|
45
46
|
dai check # ⚠️ ATRASADO (exit 1) → sugiere: dai link-us <id> --resync
|
|
47
|
+
|
|
48
|
+
# 5. Estampa la cobertura
|
|
46
49
|
dai stamp # con md, deja .dai/us/<slug>.coverage.md
|
|
47
50
|
```
|
|
48
51
|
|
|
52
|
+
> En el paso 4, `dai edit-us` abre tu `$EDITOR`. Si no tienes uno configurado te pide
|
|
53
|
+
> editar el archivo y volver, así que también funciona con el archivo abierto en tu IDE.
|
|
54
|
+
> Agrega un criterio, guarda, y responde `s` a "¿subo spec_version?" para ver el ⚠️.
|
|
55
|
+
|
|
49
56
|
Con esto ya viste el ciclo entero. Ahora conéctalo a tu tracker real.
|
|
50
57
|
|
|
51
58
|
## Paso 2 — contra tu tracker real (ClickUp)
|
|
@@ -75,6 +82,9 @@ dai check # ✅ al día
|
|
|
75
82
|
# → edita un criterio de la tarea en ClickUp (en el navegador). Después:
|
|
76
83
|
dai check # ⚠️ ATRASADO (lo detectó solo)
|
|
77
84
|
dai stamp # deja un COMENTARIO en la tarea con la cobertura
|
|
85
|
+
|
|
86
|
+
# ¿Editar la US sin salir de la terminal? La trae, la abres, valida y la devuelve:
|
|
87
|
+
dai edit-us 86cxyz --dry-run # el preview completo, sin escribir en ClickUp
|
|
78
88
|
```
|
|
79
89
|
|
|
80
90
|
> El `.env.dai` **no se versiona**: el token no se commitea, y el `.env` del equipo no se
|
|
@@ -86,6 +96,7 @@ dai stamp # deja un COMENTARIO en la tarea con la cobertu
|
|
|
86
96
|
|---|---|
|
|
87
97
|
| `no encontré la US <id>` | ID mal, o el token no ve esa tarea |
|
|
88
98
|
| `clickup 401` | token inválido o expirado |
|
|
99
|
+
| `edit-us` dice que la US no tiene el formato mínimo | le falta el `# Título` o la sección `## Criterios de aceptación`. Sin criterios no hay `ac_hash` y no hay link |
|
|
89
100
|
| `check` siempre da ⚠️ | editaste la US entre `link-us` y `check` (esperado), **o** los criterios no están bajo el heading `Criterios de aceptación` |
|
|
90
101
|
| `sin US` en `check` | la tarea no tiene el bloque de criterios en la descripción |
|
|
91
102
|
| branch/commit vacíos en el stamp | falta el remoto git (`git remote add origin …`) |
|
package/docs/SCRUM-CON-IA.md
CHANGED
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
| **Clásico** | El PO parte épicas en Historias de Usuario y les pone criterios de aceptación. |
|
|
51
51
|
| **Dolor** | US vagas, no testeables ("el usuario quiere un botón"). El malentendido se descubre tarde, ya implementado. |
|
|
52
52
|
| **Con IA** | `grill-user-story` **interroga** al PO hasta que la US es testeable por construcción (INVEST + Gherkin); y después el Gate 0 de `grill-intent` desafía el *problema* detrás (veredicto: a-spec / reframe / descartar). La IA no *escribe* la US: la *saca a preguntas*. La US nace con ID estable y criterios hasheables. |
|
|
53
|
-
| **Humano (HITL)** | El PO responde y decide. La IA no inventa requerimientos: los pule. |
|
|
54
|
-
| **Herramienta** | `grill-user-story`, `grill-intent`, `formato-us.md` |
|
|
53
|
+
| **Humano (HITL)** | El PO responde y decide. La IA no inventa requerimientos: los pule. Si la US ya existe y hay que retocarla, `dai edit-us <ID>` la baja del tracker, valida el formato al guardar y **pregunta** si el cambio es material (sube `spec_version`) o editorial. |
|
|
54
|
+
| **Herramienta** | `grill-user-story`, `grill-intent`, `formato-us.md`, `dai edit-us` |
|
|
55
55
|
| **Detalle →** | [`detalle/01-refinamiento.md`](detalle/01-refinamiento.md) |
|
|
56
56
|
|
|
57
57
|
#### 2. Sprint Planning: comprometer US y derivar tareas
|