@dforce2055/dai 0.11.0 → 0.12.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 CHANGED
@@ -3,6 +3,66 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.12.0] — 2026-08-13
7
+
8
+ **La PR deja de robarle la US a otro. `dai pr` resolvía el link recorriendo todo el repo y
9
+ quedándose con el último `implements.yaml`; ahora lo resuelve la rama, que es la que sabe la
10
+ respuesta — y cuando no puede saberlo, pregunta en vez de elegir en silencio. Más el tutorial
11
+ de setup del dev, la contraparte del que ya tenía el funcional.**
12
+
13
+ ### Arreglado
14
+ - **`dai pr` armaba la PR con la US equivocada** (issues [#31](https://github.com/dforce2055/dai/issues/31),
15
+ [#32](https://github.com/dforce2055/dai/issues/32), [#33](https://github.com/dforce2055/dai/issues/33)).
16
+ Recorría **todos** los `implements.yaml` del repo —archivados incluidos, porque era el
17
+ único comando que no pasaba `{ includeArchived: false }`— y el `break` cortaba solo el
18
+ bucle interno, así que ganaba el **último** en orden de lectura. La rama, que la nombra el
19
+ propio `dai link-us`, no entraba en la decisión.
20
+ El síntoma es silencioso y por eso duele: la PR sale con el título, el link y el
21
+ `dai check ✅` de **otra** US. En repos reales convivieron dos PRs con el mismo título y
22
+ contenidos que no tenían nada que ver, y una PR de archivado apareció rotulada con la
23
+ historia de un compañero. Es exactamente el modo de falla que la constitución quiere
24
+ evitar — el link QUÉ↔CÓMO queda mal y nadie se entera, porque **nadie lee el
25
+ `implements.yaml` en la lista de PRs: leen el título**.
26
+ Ahora decide `prScope` (`cli/lib/branch-scope.mjs`), hermana de `stampScope`: la rama
27
+ nombra una US viva → esa; una sola US viva → esa; varias candidatas → **pregunta** con TTY
28
+ y **falla** sin TTY, listándolas.
29
+ - **Una rama exenta ya no hereda la US del repo.** Un `chore/`/`docs/`/`release/` que no
30
+ nombra ninguna US genera la PR **sin** US —título del último commit y la sección
31
+ *Implementa* diciendo que no hay historia— en lugar de colgarle la de otro o dejar el
32
+ placeholder `ABC-###` del template.
33
+ - **`dai done` anunciaba `US cerrada:` con todas las US del repo**, archivadas incluidas.
34
+ Cerrar una rama informaba el cierre de medio sprint. Mismo defecto de clase, en un mensaje.
35
+ - **Ctrl+D en las preguntas de `dai pr`** cancela en vez de cortar con `Aborted with Ctrl+D`.
36
+ Todas ellas preceden a una acción hacia afuera (push + PR): ahí abortar es lo seguro.
37
+
38
+ ### Agregado
39
+ - **`dai pr --us <ID>`** — el escape hatch explícito, y la única salida cuando hay ambigüedad
40
+ y no hay TTY (un pipeline). Acepta también un change ya archivado.
41
+ - **El preview dice de dónde salió la US**: `US: ABC-482 — la branch '…' nombra ABC-482`.
42
+ Cuando el título está mal, es lo único que lo delata.
43
+ - **[Tutorial de setup para desarrolladores (Windows)](docs/tutoriales/setup-dev.md)** — el
44
+ otro lado del que ya existía para el funcional: Node, dai, git + SSH + `glab`, las skills
45
+ en Copilot, OpenSpec, el `.env.dai` contra Jira, y el ciclo completo sobre una US real
46
+ (`link-us` → `check` → `mr` → `stamp` → `done`). El troubleshooting sale de lo que pasó de
47
+ verdad en Windows corporativo: el push HTTPS que necesita completar el credential manager,
48
+ el proxy con su propio certificado, el gate de CI.
49
+
50
+ ### Versionado
51
+
52
+ **Minor → 0.12.0.** `dai pr` **cambia de comportamiento**: donde antes elegía una US en
53
+ silencio, ahora pregunta (o falla), y una rama exenta genera la PR sin US. En el papel es
54
+ incompatible; en la práctica el comportamiento viejo era el bug de los issues #31/#32/#33.
55
+ Suma la flag `--us`, aditiva. El contrato del modelo (`ac_hash`, schema de `implements.yaml`)
56
+ queda intacto.
57
+
58
+ ### Interno
59
+ - **319 tests** (+12 desde 0.11.0): `prScope` ×10 —la rama manda sobre el orden de
60
+ directorio, los archivados no compiten, una `chore/` no hereda, la ambigüedad no se
61
+ resuelve sola— y el cuerpo/título de una PR sin US ×2.
62
+ - `requiresLink()` devuelve además `kind` (`always` / `exempt` / `untyped`): es lo que separa
63
+ "exenta por tipo" de "la rama no dice nada", y lo que `dai pr` necesitaba para no heredar
64
+ la US de otro sin romper el gate de CI.
65
+
6
66
  ## [0.11.0] — 2026-07-22
7
67
 
8
68
  **Ronda de fixes reportados usándola, más el eslabón que faltaba: editar el QUÉ. `dai stamp`
@@ -551,6 +611,7 @@ ClickUp y Jira Cloud.
551
611
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
552
612
  `files` de npm sin tests ni secretos.
553
613
 
614
+ [0.12.0]: https://github.com/dforce2055/dai/releases/tag/v0.12.0
554
615
  [0.11.0]: https://github.com/dforce2055/dai/releases/tag/v0.11.0
555
616
  [0.10.0]: https://github.com/dforce2055/dai/releases/tag/v0.10.0
556
617
  [0.9.0]: https://github.com/dforce2055/dai/releases/tag/v0.9.0
package/README.md CHANGED
@@ -201,7 +201,7 @@ flowchart TD
201
201
  | `dai link-us <ID> --resync` | re-estampa el `ac_hash` contra la US viva (tras un ⚠️ de check) |
202
202
  | `dai check` | compara tu código vs la US viva → ✅ al día / ⚠️ atrasado (exit code = gate de PR) |
203
203
  | `dai ls [--json]` | lista las US que implementa el repo + su link al tracker |
204
- | `dai pr [--assignee u] [--base b] [--draft] [--yes]` · alias **`dai mr`** | crea TU PR/MR precargada: pregunta la branch base (default `main`), muestra el texto y confirma antes de publicar. Detecta el forge (GitHub→PR con `gh` · GitLab→MR con `glab`); `mr` es el mismo comando, más natural en GitLab |
204
+ | `dai pr [--assignee u] [--base b] [--draft] [--yes] [--us ID] [--title t]` · alias **`dai mr`** | crea TU PR/MR precargada: pregunta la branch base (default `main`), muestra el texto y confirma antes de publicar. Detecta el forge (GitHub→PR con `gh` · GitLab→MR con `glab`); `mr` es el mismo comando, más natural en GitLab. **La US la resuelve la branch** (la nombra `dai link-us`): si hay varias vivas y ninguna coincide, **pregunta** en vez de elegir por vos (sin TTY falla pidiendo `--us <ID>`), y una branch `chore/`/`docs/` sale **sin US** en lugar de heredar la de otro |
205
205
  | `dai stamp` | estampa la cobertura inversa en el tracker (branch + commit-ancla) |
206
206
  | `dai done [--base main] [--force]` | cierra la US: vuelve a la base, `fetch --prune` + `pull`, y borra la branch local **si está mergeada** (chequeo estricto; `--force` la borra igual). Redes: no estar en la base, sin cambios sueltos, sin commits sin pushear |
207
207
  | `dai archive [<change>] [--skip-specs]` | **funde los delta specs del change en las specs canónicas** (`openspec/specs/`) y lo archiva. Lo corre el **aprobador** de la PR (gate de aprobación, [ADR-0011](docs/adr/0011-archive-gate-de-aprobacion.md)); detecta el change activo o le pasás el nombre. Envuelve `openspec archive` |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.11.0
1
+ 0.12.0
package/cli/dai.mjs CHANGED
@@ -35,7 +35,7 @@ import { parseSource } from "./lib/skills-source.mjs";
35
35
  import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles } from "./lib/bootstrap.mjs";
36
36
  import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/jira-fields.mjs";
37
37
  import { assertProjectKey } from "./lib/pm-jira.mjs";
38
- import { flattenImplements, stampScope, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
38
+ import { flattenImplements, stampScope, prScope, matchBranchToImplements, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
39
39
  import { describeForgeError, parseForgeError } from "./lib/forge-api.mjs";
40
40
  import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion } from "./lib/us-format.mjs";
41
41
 
@@ -802,8 +802,12 @@ function cmdDone(opts) {
802
802
  }
803
803
 
804
804
  // La US que se cierra (informativo) — leerla ANTES de cambiar de branch.
805
- const usIds = [];
806
- for (const f of discoverImplements(process.cwd())) for (const im of f.implements || []) if (!isPlaceholderId(im.id)) usIds.push(im.id);
805
+ // Solo la de ESTA branch: antes listaba todas las del repo (archivadas incluidas), así
806
+ // que cerrar una branch anunciaba el cierre de medio sprint el mismo defecto que
807
+ // `dai pr` en los issues #31/#32/#33, acá en un mensaje.
808
+ const usIds = matchBranchToImplements(
809
+ branch, flattenImplements(discoverImplements(process.cwd(), { includeArchived: false })),
810
+ ).map((r) => r.id);
807
811
 
808
812
  // Ir a la base y actualizar.
809
813
  info(`Cambiando a '${base}' y actualizando…`);
@@ -848,7 +852,10 @@ async function cmdPr(opts) {
848
852
  const ask = async (q) => {
849
853
  if (!process.stdin.isTTY) return null; // no interactivo → sin preguntas
850
854
  if (!_rl) _rl = createInterface({ input: process.stdin, output: process.stdout });
851
- return (await _rl.question(q)).trim();
855
+ // Ctrl+D (EOF) → "" = cancelar, no un crash: todas estas preguntas preceden a una
856
+ // acción hacia afuera (push + PR), y ahí abortar es la respuesta segura.
857
+ try { return (await _rl.question(q)).trim(); }
858
+ catch { process.stdout.write("\n"); return ""; }
852
859
  };
853
860
  const closeRl = () => { if (_rl) { _rl.close(); _rl = null; } };
854
861
 
@@ -863,19 +870,53 @@ async function cmdPr(opts) {
863
870
  fail(`no hay commits en '${branch}' por encima de '${base}'. Una PR necesita cambios: haz commit primero (git commit).`, 1);
864
871
  }
865
872
 
866
- // 1. Resolver el link (US) de la branch actual.
867
- const found = discoverImplements(process.cwd());
868
- let entry = null;
869
- for (const f of found) for (const im of f.implements || []) {
870
- if (!isPlaceholderId(im.id)) { entry = { f, im }; break; }
873
+ // 1. Resolver el link (US) de ESTA branch (issues #31, #32, #33).
874
+ // Antes se recorrían todos los implements.yaml del repo —archivados incluidos— y
875
+ // ganaba el último: la branch, que es la que sabe la respuesta, no entraba en la
876
+ // decisión. La PR salía titulada con la historia de otro y nadie se enteraba,
877
+ // porque el `dai check ✅` del body era el de esa otra US.
878
+ const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
879
+ const allRows = flattenImplements(discoverImplements(process.cwd()));
880
+ const scope = prScope({ branch, rows, allRows, ids: opts.us ? [String(opts.us)] : [] });
881
+ let entry = scope.target;
882
+
883
+ if (scope.mode === "explicit" && !entry) {
884
+ closeRl();
885
+ fail(`no encontré implements.yaml para '${opts.us}'.\n` +
886
+ ` US en este repo: ${allRows.map((r) => r.id).join(", ") || "(ninguna)"}`, 1);
887
+ }
888
+ if (scope.mode === "none") {
889
+ closeRl();
890
+ fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), renombrá la branch a `chore/…` o creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
871
891
  }
872
- if (!entry) fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
873
- const { id, version, ac_hash } = entry.im;
892
+ if (scope.mode === "ambiguous") {
893
+ // Elegir en silencio es EL bug: la PR sale publicada con el link QUÉ↔CÓMO de otra US.
894
+ warn(`${scope.reason}.`);
895
+ const listed = scope.candidates.map((r, i) => ` ${i + 1}) ${r.id} ${C.dim(`(${r.change})`)}`).join("\n");
896
+ process.stdout.write(` US vivas en el repo:\n${listed}\n`);
897
+ if (opts.yes || !process.stdin.isTTY) {
898
+ closeRl();
899
+ fail("no sé con qué US titular la PR. Decilo explícitamente: dai pr --us <ID>", 1);
900
+ }
901
+ const ans = await ask(` ¿Con qué US titulo la PR? (número, Enter=cancelar) `);
902
+ if (!ans) { closeRl(); info("Cancelado — no se creó la PR."); return; }
903
+ const n = Number(ans);
904
+ if (!Number.isInteger(n) || n < 1 || n > scope.candidates.length) {
905
+ closeRl();
906
+ fail(`respuesta inválida: '${ans}'. Se esperaba un número entre 1 y ${scope.candidates.length}.`, 1);
907
+ }
908
+ entry = scope.candidates[n - 1];
909
+ }
910
+
911
+ // De dónde salió la US: en el preview, el título sin justificación no delata nada
912
+ // cuando está mal (sugerencia del issue #32).
913
+ const usWhy = entry ? (scope.mode === "ambiguous" ? "la elegiste vos" : scope.reason) : scope.reason;
914
+ const { id, version, ac_hash } = entry || {};
874
915
 
875
916
  // 2. Estado de trazabilidad (dai check) contra la US viva.
876
917
  const adapter = getAdapter(process.env);
877
- const live = await Promise.resolve(adapter.fetchUS(id)).catch(() => null);
878
- const status = coverageStatus(ac_hash, live?.ac_hash);
918
+ const live = id ? await Promise.resolve(adapter.fetchUS(id)).catch(() => null) : null;
919
+ const status = id ? coverageStatus(ac_hash, live?.ac_hash) : null;
879
920
  if (status === "atrasado") {
880
921
  warn(`la US ${id} está ATRASADA respecto de tu implementación (${ac_hash} ≠ ${live?.ac_hash}).`);
881
922
  warn(`resincroniza antes de abrir la PR: dai link-us ${id} --resync`);
@@ -889,22 +930,25 @@ async function cmdPr(opts) {
889
930
  let commits = [];
890
931
  try { commits = git(["log", `${base}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* base local ausente */ }
891
932
  // La canónica del tracker (live.url) gana sobre la derivada; el template gana sobre todo.
892
- const usUrl = usUrlFor(id, live?.url);
893
- if (!usUrl) {
933
+ const usUrl = id ? usUrlFor(id, live?.url) : null;
934
+ if (id && !usUrl) {
894
935
  warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
895
936
  warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
896
937
  }
897
938
  const body = composePrBody(readFileSync(tplPath, "utf8"), {
898
- id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
939
+ id, version, ac_hash, status, usUrl, usTitle: live?.title, commits, noUsReason: id ? null : scope.reason,
899
940
  branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
900
941
  });
901
- const title = prTitle(opts, id, live?.title);
942
+ // Sin US el título sale del último commit: describe lo que hay adentro, en vez de
943
+ // pedirle prestada la historia a otro (issue #31).
944
+ const title = prTitle(opts, id, live?.title, commits[0] || branch);
902
945
  const forge = detectForge(parseRemote(remote)?.host);
903
946
  const tool = forgeTool(forge);
904
947
 
905
948
  // 4. Mostrar y pedir confirmación (acción hacia afuera).
906
949
  process.stdout.write(`\n ── Pull Request a crear ──────────────────────────────\n`);
907
950
  process.stdout.write(` título: ${title}\n de: ${branch}\n a: ${base}\n`);
951
+ process.stdout.write(` US: ${id || C.dim("(sin US)")} ${C.dim(`— ${usWhy}`)}\n`);
908
952
  process.stdout.write(` forge: ${forge} (${tool})${opts.assignee ? `\n asignar: ${opts.assignee}` : ""}${opts.draft ? "\n draft: sí" : ""}\n`);
909
953
  process.stdout.write(` ─────────────────────────────────────────────────────\n\n${body}\n`);
910
954
  process.stdout.write(` ─────────────────────────────────────────────────────\n`);
@@ -1699,6 +1743,7 @@ switch (cmd) {
1699
1743
  " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
1700
1744
  " archive [<change>] [--skip-specs] funde los delta specs del change en las specs canónicas y lo archiva (lo corre el aprobador en la PR)\n" +
1701
1745
  " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1746
+ " [--us <ID>] [--title t] la US la resuelve la branch; si hay varias, pregunta (sin TTY, falla)\n" +
1702
1747
  " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
1703
1748
  " forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
1704
1749
  " --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
@@ -56,17 +56,22 @@ export function trackerKeysIn(branch) {
56
56
  // chore/, docs/, ci/… → nunca
57
57
  // fix/ y el resto → solo si el nombre trae un ID con pinta de key de tracker
58
58
  // `main`/`develop`/sin branch → no (no es una branch de trabajo).
59
+ //
60
+ // `kind` distingue POR QUÉ no se exige, que no es lo mismo para todos los comandos:
61
+ // una `chore/` está exenta POR TIPO (el repo declara que ahí no hay producto), mientras
62
+ // que `mi-branch` simplemente no dice nada. `dai pr` usa esa diferencia para no colgarle
63
+ // la US viva del repo a una PR de archivado (issue #31).
59
64
  export function requiresLink(branch) {
60
65
  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" };
66
+ if (ALWAYS.has(t)) return { required: true, kind: "always", reason: `'${t}/' es trabajo de producto: requiere US` };
67
+ if (EXEMPT.has(t)) return { required: false, kind: "exempt", reason: `'${t}/' está exenta de US (governance/branch-naming.md)` };
68
+ if (t === "") return { required: false, kind: "untyped", reason: "sin prefijo tipo/, no es una branch de trabajo: sin gate de link" };
64
69
  // Tipo desconocido (fix/, spike/, lo que el repo use): si nombró un ID, lo tomamos
65
70
  // como intención de implementar una US y se lo exigimos. Si no, no inventamos.
66
71
  const keyish = trackerKeysIn(branch).length > 0;
67
72
  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)` };
73
+ ? { required: true, kind: "always", reason: `'${t}/' con un ID en el nombre: se toma como trabajo de producto` }
74
+ : { required: false, kind: "untyped", reason: `'${t}/' sin ID en el nombre: no exige US (governance/branch-naming.md)` };
70
75
  }
71
76
 
72
77
  // Aplana los implements descubiertos a filas { path, change, repo, id, version, ac_hash },
@@ -142,3 +147,54 @@ export function stampScope({ branch, rows, allRows = rows, ids = [], all = false
142
147
  reason: `hay ${hit.length > 1 ? hit.length : rows.length} US vivas y la branch '${branch}' no dice cuál`,
143
148
  };
144
149
  }
150
+
151
+ // La decisión de `dai pr`: ¿con qué US se titula y se linkea ESTA PR?
152
+ //
153
+ // { mode, target, candidates, reason, missing }
154
+ //
155
+ // mode "explicit" → el id que pidió el usuario (dai pr --us ABC-482)
156
+ // mode "branch" → la branch nombra una US viva del repo: esa
157
+ // mode "only" → hay una sola US viva y la branch no está exenta: esa
158
+ // mode "exempt" → branch exenta por tipo (chore/, docs/…) que no nombra US: PR SIN US
159
+ // mode "ambiguous" → varias candidatas y ninguna pista: NO elige, pregunta
160
+ // mode "none" → la branch pide US y el repo no tiene ninguna viva
161
+ //
162
+ // Es la misma pregunta que resuelve stampScope, con dos diferencias que importan
163
+ // (issues #31, #32, #33 — antes `dai pr` recorría TODOS los implements.yaml del repo,
164
+ // archivados incluidos, y se quedaba con el último):
165
+ //
166
+ // 1. Una PR implementa UNA US, no cuatro: no hay modo "all", y el resultado es un
167
+ // único `target`.
168
+ // 2. Una branch exenta NO hereda la US viva del repo. Una PR de `chore/archive-specs`
169
+ // titulada con la historia de un compañero es peor que una sin título lindo: la
170
+ // lista de PRs es la única superficie donde el equipo lee la trazabilidad.
171
+ //
172
+ // `rows` viene del discover SIN archivados; `allRows` incluye los archivados y solo se
173
+ // usa para resolver un id explícito (reabrir la PR de un change ya archivado) y para
174
+ // listar qué US conoce el repo cuando el id pedido no está.
175
+ export function prScope({ branch, rows, allRows = rows, ids = [] }) {
176
+ if (ids.length) {
177
+ const want = String(ids[0]).toLowerCase();
178
+ const target = allRows.find((r) => String(r.id).toLowerCase() === want) || null;
179
+ // `missing` conserva la grafía del usuario: devolverle su ABC-404 en minúsculas lo
180
+ // manda a dudar del case en vez de del id.
181
+ return { mode: "explicit", target, candidates: allRows, missing: target ? [] : [ids[0]], reason: `pediste --us ${ids[0]}` };
182
+ }
183
+
184
+ const hit = matchBranchToImplements(branch, rows);
185
+ if (hit.length === 1) {
186
+ return { mode: "branch", target: hit[0], candidates: rows, reason: `la branch '${branch}' nombra ${hit[0].id}` };
187
+ }
188
+ if (hit.length > 1) {
189
+ return { mode: "ambiguous", target: null, candidates: hit, reason: `la branch '${branch}' nombra ${hit.length} US vivas` };
190
+ }
191
+
192
+ // La branch no nombra ninguna US viva.
193
+ const req = requiresLink(branch);
194
+ if (req.kind === "exempt") {
195
+ return { mode: "exempt", target: null, candidates: rows, reason: `${req.reason} y su nombre no nombra ninguna US` };
196
+ }
197
+ if (rows.length === 0) return { mode: "none", target: null, candidates: [], reason: "no hay implements.yaml vivo en el repo" };
198
+ if (rows.length === 1) return { mode: "only", target: rows[0], candidates: rows, reason: "es la única US viva del repo" };
199
+ return { mode: "ambiguous", target: null, candidates: rows, reason: `hay ${rows.length} US vivas y la branch '${branch}' no dice cuál` };
200
+ }
package/cli/lib/pr.mjs CHANGED
@@ -18,6 +18,7 @@ export function replaceSection(body, heading, content) {
18
18
  // placeholders que encuentra y deja el resto para que el humano lo edite.
19
19
  export function composePrBody(template, d) {
20
20
  let b = template;
21
+ if (!d.id) return composePrBodyNoUs(b, d);
21
22
  b = b.replace(/`ABC-###`/g, `\`${d.id}\``);
22
23
  b = b.replace(/@ `vX`/g, `@ \`${d.version}\``);
23
24
  b = b.replace(/`<hash>`/g, `\`${d.ac_hash}\``);
@@ -32,6 +33,21 @@ export function composePrBody(template, d) {
32
33
  return upsertLinksBlock(b, d);
33
34
  }
34
35
 
36
+ // PR de una branch exenta (chore/, docs/, release/…): no implementa una US y no se le
37
+ // exige link. Lo que NO puede pasar es que salga con la US de otro ni con el placeholder
38
+ // `ABC-###` del template — las dos cosas pasaron en repos reales (issues #31, #33).
39
+ // Se dice explícitamente que no hay US, y por qué.
40
+ function composePrBodyNoUs(template, d) {
41
+ let b = replaceSection(template, "🔗 Implementa",
42
+ `- **Sin US:** esta PR no implementa una User Story.\n` +
43
+ (d.noUsReason ? `- **Motivo:** ${d.noUsReason}.\n` : "") +
44
+ `- No se le exige link (\`governance/branch-naming.md\`).`);
45
+ if (d.commits && d.commits.length) {
46
+ b = replaceSection(b, "Cambios realizados", d.commits.map((c) => `- [x] ${c}`).join("\n"));
47
+ }
48
+ return upsertLinksBlock(b, d);
49
+ }
50
+
35
51
  // ── Bloque de enlaces ────────────────────────────────────────────────────────
36
52
  // Delimitado y regenerable a propósito. Con el comentario suelto de antes, cualquier
37
53
  // agente que reescribiera "Enlaces relacionados" se lo llevaba puesto sin dejar rastro
@@ -70,8 +86,12 @@ export function upsertLinksBlock(body, d) {
70
86
  }
71
87
 
72
88
  // Título del PR: el pasado a mano, o "<ID>: <título de la US>", o solo el ID.
73
- export function prTitle(opts, id, usTitle) {
89
+ // Sin US (branch exenta) cae al `fallback` — el subject del último commit: describe
90
+ // lo que hay adentro. Inventar un título con la US de otro es el bug de los issues
91
+ // #31/#33, y el título es lo ÚNICO que se ve en la lista de PRs.
92
+ export function prTitle(opts, id, usTitle, fallback = "") {
74
93
  if (opts.title) return opts.title;
94
+ if (!id) return fallback;
75
95
  if (usTitle) return `${id}: ${usTitle}`;
76
96
  return id;
77
97
  }
@@ -2,6 +2,15 @@
2
2
 
3
3
  Guías de **setup operativo** — lo que haces una vez por máquina para trabajar con dai.
4
4
 
5
+ ## De cero a publicar
6
+
7
+ - [**Setup para analistas funcionales y PMs (Windows)**](./setup-funcional) — el camino
8
+ completo sin git ni repositorio: Node, dai, las skills en Copilot, el token de Jira y una
9
+ épica + US de prueba publicadas de verdad.
10
+ - [**Setup para desarrolladores (Windows)**](./setup-dev) — el otro lado: Node, dai, git + SSH
11
+ + `glab`, las skills en Copilot, OpenSpec y el ciclo completo sobre una US real
12
+ (`link-us` → `check` → `mr` → `stamp`).
13
+
5
14
  ## Preparar el entorno
6
15
 
7
16
  - [**Configurar git**](./configurar-git) — tu identidad (nombre + correo) para que los