@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.
@@ -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
+ }
@@ -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;
@@ -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
- return { id, ...parseUS(readFileSync(p, "utf8")) };
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
 
@@ -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(clickupTaskToText(j)), url: j.url || null };
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).");
@@ -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
- return { id, ...parseUS(jiraIssueToText(await res.json())), url: `${trim(base)}/browse/${id}` };
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
- Lee el `implements.yaml` y **estampa la cobertura inversa** en el ticket `ABC-482`,
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
- Sube la US a **v2** → cambia el `ac_hash`.
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
@@ -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 ⚠️: edita el criterio en .dai/us/<slug>.md y vuelve a chequear
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 …`) |
@@ -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