@dforce2055/dai 0.13.2 → 0.14.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/.env.dai.example CHANGED
@@ -12,6 +12,21 @@ DAI_PM=md
12
12
  # Si dai no puede saber el link, avisa y deja la PR sin él — nunca escribe el id pelado.
13
13
  # DAI_TRACKER_URL_TEMPLATE=https://jira.miempresa.com/browse/{id}
14
14
 
15
+ # ── Flujo de branches (dai pr · dai done) ────────────────────────────────────
16
+ # Las DOS ramas de vida larga del repo. No se configura "la base" de las PR: la base sale
17
+ # del TIPO de branch, y con eso dai deja de adivinar.
18
+ # feature/ · fix/ → PR contra DAI_BRANCH_DEV
19
+ # release/ · hotfix/ → PR contra DAI_BRANCH_PROD, con confirmación explícita
20
+ #
21
+ # Rama que INTEGRA el desarrollo (la que se despliega a test). Sin declarar, dai usa la
22
+ # rama default del remoto (origin/HEAD) y avisa que la está adivinando.
23
+ # DAI_BRANCH_DEV=testing
24
+
25
+ # Rama que DESPLIEGA A PRODUCCIÓN. `dai pr` la marca en el preview y pide confirmación
26
+ # antes de publicar; con --yes hace falta --to-prod. Sin declarar, dai no marca ninguna
27
+ # rama como producción: no adivina cuál es.
28
+ # DAI_BRANCH_PROD=main
29
+
15
30
  # ── Backend md (local, offline) ──────────────────────────────────────────────
16
31
  # Carpeta donde viven las US como <ID>.md (p. ej. ABC-482.md).
17
32
  DAI_MD_US_DIR=.dai/us
package/CHANGELOG.md CHANGED
@@ -3,6 +3,116 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.14.0] — 2026-09-08
7
+
8
+ **`dai pr` proponía mergear a `main` en un repo donde `main` despliega a producción, y el
9
+ preview no lo destacaba de ninguna forma. Tirando de ese hilo apareció que el problema no
10
+ era el default: era pedirle a alguien que configure "la base", cuando la base no es una
11
+ constante — es consecuencia del tipo de branch. Y de yapa, el hallazgo más caro de la
12
+ versión: `dai <comando> --help` no imprimía ayuda, ejecutaba el comando.**
13
+
14
+ ### Cambiado
15
+ - **La base de una PR sale del mapa de ramas del repo, no de un `main` fijo.** Se declaran
16
+ las **dos ramas de vida larga** en el `.env.dai` —`DAI_BRANCH_DEV` (la que integra) y
17
+ `DAI_BRANCH_PROD` (la que despliega a producción)— y `dai pr` deriva la base del **tipo de
18
+ branch**: `feature/` y `fix/` integran, `release/` y `hotfix/` van contra producción.
19
+ `--base` gana siempre. Sin nada declarado, dai cae a la rama default del remoto
20
+ (`origin/HEAD`) **y avisa que la está adivinando** — antes decía `main` sin más.
21
+ `dai done` usa el mismo mapa (tenía el mismo `main` hardcodeado) y `dai doctor` lo reporta.
22
+ El preview ahora dice **de dónde salió** la base, que era la mitad que faltaba
23
+ ([#46](https://github.com/dforce2055/dai/issues/46)).
24
+ - **Apuntarle a producción pide confirmación explícita.** Si la base es `DAI_BRANCH_PROD`,
25
+ el preview la marca `⚠️ DESPLIEGA A PRODUCCIÓN` y hay que **escribir el nombre de la rama**
26
+ para seguir; con `--yes` hace falta `--to-prod`. Sin la variable declarada dai no marca
27
+ ninguna rama como producción: no adivina cuál es, y un gate inventado sobre una suposición
28
+ es peor que no tenerlo.
29
+ - **`dai <comando> --help` imprime ayuda en vez de ejecutar el comando.** Vale para todos:
30
+ `dai help`, `dai --help`, `dai -h`, `dai help <cmd>`, `dai <cmd> --help`, `dai <cmd> -h` y
31
+ `dai <cmd> help`. Siempre por `stdout` y siempre con código 0; un comando desconocido sigue
32
+ saliendo por `stderr` con código ≠ 0, que es lo que deja `dai foo --help` usable dentro de
33
+ un script. Cada comando tiene ayuda propia (qué hace, uso, opciones, ejemplo).
34
+
35
+ ### Corregido
36
+ - **`dai pr` dejaba una MR con el diff al día y la descripción vieja.** Con una MR ya abierta
37
+ pusheaba la branch, fallaba al crear porque la MR existía, y la única señal era el comando
38
+ crudo del forge. Quedaba una MR que **miente sobre lo que contiene**, que es peor que un
39
+ error porque parece que salió bien. Ahora la detecta **antes** de pushear
40
+ (`gh pr list --head` / `glab mr list --source-branch`), el preview dice
41
+ `── Pull Request a ACTUALIZAR (#12) ──` y actualiza título y descripción. Si la detección no
42
+ pudo correr y el forge responde *"already exists"*, la busca y la actualiza igual; si tampoco
43
+ puede, lo dice con todas las letras: *"NO toqué su descripción: quedó la vieja"*. También
44
+ avisa si la MR abierta apunta a otra base que la pedida ([#46](https://github.com/dforce2055/dai/issues/46)).
45
+ - **`dai link-us` estampaba `version: v1` en una US que declaraba `v4`.** El regex del
46
+ `spec_version` exigía separador y la US lo escribía pegado (`specversion`) — y estaba
47
+ **duplicado en dos módulos**, que es exactamente por qué se podía arreglar en uno y seguir
48
+ roto en el otro. Ahora vive en un solo lugar y tolera `spec_version`, `spec version`,
49
+ `spec-version` y `specversion`. Sin `spec_version` declarado **no se inventa un `v1`**: queda
50
+ `pendiente` con aviso, porque ese número se publica en el cuerpo de la PR y se estampa en el
51
+ tracker como si fuera un dato. `dai check` además avisa cuando el número del link no coincide
52
+ con el de la US viva ([#46](https://github.com/dforce2055/dai/issues/46)).
53
+ - **`dai pr` no podía abrir la PR de un repo sin US.** En un repo que no se trackea a sí mismo
54
+ con User Stories —el de dai, sin ir más lejos— una branch `fix/` sin ID moría pidiendo un
55
+ link que no puede existir, y aconsejaba renombrarla a `chore/`, que para un fix es el consejo
56
+ equivocado. Ahora, si la branch no exige link **y** el repo no declara ninguna US, la PR sale
57
+ "Sin US" con el motivo. Una `feature/` sin link sigue fallando: ahí falta de verdad.
58
+ - **`.env.dai` no estaba en el `.gitignore` de este repo**, aunque `dai init` lo agrega en todos
59
+ los que scaffoldea. Faltaba justo en el que se publica en npm.
60
+
61
+ ### Interno
62
+ - **408 tests** (+41): el mapa de ramas y la derivación por tipo de branch, la detección y
63
+ actualización de una PR existente en los dos forges, las variantes del `spec_version`, y la
64
+ convención de ayuda — con un test que recorre los `case` del dispatcher y **falla si alguno se
65
+ agrega sin ayuda**, para que la convención no dependa de acordarse.
66
+
67
+ ## [0.13.3] — 2026-09-02
68
+
69
+ **Una PR de dai se abría diciendo, en el mismo párrafo, dos cosas que no encajaban: que el
70
+ spec estaba "verificado con dai check: ✅ al día", y a continuación una explicación general
71
+ del método. Tirando de ese hilo aparecieron dos bugs distintos, y los dos eran dai afirmando
72
+ cosas que no le constaban.**
73
+
74
+ ### Arreglado
75
+ - **El relleno del estado reescribía la prosa del template.** `dai pr` hacía un replace
76
+ **global** de `verificado con `dai check` ✅`, y esa frase estaba dos veces en el molde: en
77
+ el dato (`## 🔗 Implementa`) y en la prosa que explica el método. Así que a una oración
78
+ general —igual en todas las PRs— dai le insertaba el estado de *esta* PR, y quedaba
79
+ publicado: *"verificado con `dai check`: ✅ al día. Sin esto, el código no sabe a qué QUÉ
80
+ responde…"*. Ni doctrina ni dato. Ahora el relleno se acota a la sección; sin la sección
81
+ (un template ajeno con otra forma) cae al body entero, porque rellenar de más es
82
+ recuperable y publicar `ABC-###` no.
83
+ - **`dai pr` decía "❓ sin US" cuando el tracker no contestaba** — dos líneas debajo del id de
84
+ la US que sí existe. `coverageStatus` colapsaba en un mismo `sin-us` dos cosas que no
85
+ significan lo mismo: *el tracker contestó y la US no está* y *no hubo respuesta* (sin red,
86
+ sin token, 5xx, certificado corporativo). Los adaptadores ya distinguían los dos casos
87
+ (`404 → null`, cualquier otro error → `throw`), y el gate de CI también con su try/catch
88
+ propio; lo que rompía la distinción era un `.catch(() => null)` en `dai pr`. Hay un estado
89
+ nuevo, **`sin-respuesta`** (`⚠️ no verificado`), y la PR dice *"no verificado (el tracker no
90
+ respondió)"*, que es lo único cierto ([#43](https://github.com/dforce2055/dai/issues/43)).
91
+ - **`dai: fetch failed` era todo lo que se llegaba a leer.** Es el mensaje pelado de undici
92
+ cuando no hay red, el host no resuelve o el certificado no valida: no dice qué se estaba
93
+ consultando, ni contra qué, ni qué mirar — indistinguible de un bug de dai, el mismo modo de
94
+ falla que el push por SSH de la 0.13.1. Ahora el error nombra la US, el backend y el host, y
95
+ distingue red / credencial / error del tracker, con el próximo paso en cada caso.
96
+ - **`dai check` ya no se detenía en la primera US** que no pudiera consultar: lo reporta, sigue
97
+ con las demás y sale ≠ 0. **`dai stamp`** explicita que no estampa un estado que no pudo
98
+ verificar — escribir en el tracker de todo el equipo no se deshace.
99
+
100
+ ### Cambiado
101
+ - **El molde de PR adelgaza: la doctrina pasa a comentario HTML.** El encabezado tenía cuatro
102
+ bloques y un solo dato — la doctrina de los dos activos, una línea que repetía la de arriba
103
+ (*"este PR está atado a la US vía implements.yaml"*) y la instrucción de borrar la sección si
104
+ no hay US, que `dai pr` resuelve solo desde la 0.13.2. Nada de eso decía algo sobre *esa* PR,
105
+ y repetirlo a la vista en cada una entrena a saltear el principio del cuerpo, que es
106
+ justamente donde va la descripción. Sigue estando para quien edite el molde, invisible al
107
+ renderizar; y la doctrina vive donde se lee una vez y no quinientas: `docs/glosario.md`,
108
+ `docs/guias/dev.md`, `governance/ci-rules.md`. Los repos ya inicializados lo reciben con
109
+ `dai sync`.
110
+
111
+ ### Interno
112
+ - **367 tests** (+9): los dos caminos de la consulta (la US que no está y la que no se pudo
113
+ consultar), el mensaje de error por tipo de falla, y que el relleno del estado no toque la
114
+ prosa que lo rodea.
115
+
6
116
  ## [0.13.2] — 2026-09-02
7
117
 
8
118
  **Una PR se publicaba con la descripción vacía y la lista de cambios diciendo "Cambio 1,
@@ -808,6 +918,8 @@ ClickUp y Jira Cloud.
808
918
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
809
919
  `files` de npm sin tests ni secretos.
810
920
 
921
+ [0.14.0]: https://github.com/dforce2055/dai/releases/tag/v0.14.0
922
+ [0.13.3]: https://github.com/dforce2055/dai/releases/tag/v0.13.3
811
923
  [0.13.2]: https://github.com/dforce2055/dai/releases/tag/v0.13.2
812
924
  [0.13.1]: https://github.com/dforce2055/dai/releases/tag/v0.13.1
813
925
  [0.13.0]: https://github.com/dforce2055/dai/releases/tag/v0.13.0
package/README.md CHANGED
@@ -202,13 +202,14 @@ flowchart TD
202
202
  | `dai link-us <ID> --resync` | re-estampa el `ac_hash` contra la US viva (tras un ⚠️ de check) |
203
203
  | `dai check` | compara tu código vs la US viva → ✅ al día / ⚠️ atrasado (exit code = gate de PR) |
204
204
  | `dai ls [--json]` | lista las US que implementa el repo + su link al tracker |
205
- | `dai pr [--assignee u] [--base b] [--draft] [--yes] [--us ID] [--title t] [--description t\|--description-file f] [--changes t\|--changes-file f]` · 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. **La descripción la escribís vos (o tu agente) con `--description`**: dai llena la US, los commits y los links, pero no inventa el propósito de un cambio — si "Descripción" o "Cambios realizados" quedarían con el molde del template, con `--yes` o sin TTY **no publica** y te dice qué falta |
205
+ | `dai pr [--assignee u] [--base b] [--draft] [--yes] [--us ID] [--title t] [--description t\|--description-file f] [--changes t\|--changes-file f]` · alias **`dai mr`** | crea **o actualiza** TU PR/MR precargada: propone la branch base **según el tipo de rama** (`feature/`/`fix/` `DAI_BRANCH_DEV` · `release/`/`hotfix/` → `DAI_BRANCH_PROD`; `--base` gana, y si no hay nada declarado cae a la rama default del remoto **avisando que adivina**), muestra el texto y confirma antes de publicar. Contra la rama de producción pide una **confirmación explícita** (escribir el nombre de la rama; con `--yes` hace falta `--to-prod`). Si la branch **ya tiene una PR/MR abierta**, actualiza su título y su descripción en lugar de fallar dejando el diff al día y el body viejo. 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. **La descripción la escribís vos (o tu agente) con `--description`**: dai llena la US, los commits y los links, pero no inventa el propósito de un cambio — si "Descripción" o "Cambios realizados" quedarían con el molde del template, con `--yes` o sin TTY **no publica** y te dice qué falta |
206
206
  | `dai stamp` | estampa la cobertura inversa en el tracker (branch + commit-ancla) |
207
- | `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
+ | `dai done [--base b] [--force]` | cierra la US: vuelve a la base (se resuelve igual que en `dai pr`), `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 |
208
208
  | `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` |
209
209
  | `dai forge review <ref> --from <review.json>` `[--dry-run\|--yes]` | **review inline**: un resumen + un comentario anclado a cada `archivo:línea`, clasificado low/medium/high. **Valida cada posición contra el diff** (descarta lo que el modelo inventó) antes de postear; sin `--yes` muestra el preview y no postea nada. Modo desatendido: `--min-severity`/`--min-confidence`/`--max-comments`. El review sale con `event: COMMENT`, nunca `APPROVE` ([ADR-0016](docs/adr/0016-review-inline.md)) |
210
210
  | `dai forge comment <ref> --body-file <f>` · `dai forge pr <ref>` | comentar / leer una PR/MR (GitHub/GitLab) — el fallback simple, sin anclar |
211
211
  | `dai ac-hash <us.md>` | calcula el hash de los criterios de aceptación de una US |
212
+ | `dai help [<comando>]` · `dai <comando> --help` | ayuda del CLI. Pedir ayuda **nunca ejecuta el comando**: sale por `stdout` y termina con 0. Valen `--help`, `-h` y `dai <comando> help` — las tres formas, en todos los comandos |
212
213
  | `dai doctor` · `dai docs <dest>` · `dai version` | diagnóstico del entorno (incluye **version-drift** del scaffold) · copiar la doc (sin los assets del sitio; los links a las capturas apuntan al sitio publicado) · versión (`dai version` avisa si tu repo quedó atrás) |
213
214
 
214
215
  > **🆕 Mantené tu repo al día — `dai sync`.** Las skills, la constitución y los templates son un
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.13.2
1
+ 0.14.0
package/cli/dai.mjs CHANGED
@@ -22,12 +22,14 @@ import { acHash } from "./lib/ac-hash.mjs";
22
22
  import { discoverImplements, isPlaceholderId } from "./lib/implements.mjs";
23
23
  import { isValidKey, slugify, branchName, extractTitle, renderImplementsYaml } from "./lib/link-us.mjs";
24
24
  import { loadDaiEnv } from "./lib/env.mjs";
25
- import { getAdapter, coverageStatus, statusLabel } from "./lib/pm-adapter.mjs";
25
+ import { getAdapter, coverageStatus, statusLabel, fetchLiveUS } from "./lib/pm-adapter.mjs";
26
26
  import { branchUrl, commitUrl, parseRemote, detectForge } from "./lib/forge-url.mjs";
27
27
  import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs";
28
28
  import { trackerUrl } from "./lib/tracker-url.mjs";
29
29
  import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
30
30
  import { composePrBody, prTitle, forgeTool, bodyGaps } from "./lib/pr.mjs";
31
+ import { resolveBase, isProdBranch, branchFlow, baseHint, parseOriginHead } from "./lib/branch-flow.mjs";
32
+ import { listPrCmd, parsePrList, updatePrCmd, isAlreadyExistsError, describeUpdate } from "./lib/pr-remote.mjs";
31
33
  import { absolutizeSiteLinks } from "./lib/docs-links.mjs";
32
34
  import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
33
35
  import { dirsEqual } from "./lib/fsutil.mjs";
@@ -39,7 +41,8 @@ import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/j
39
41
  import { assertProjectKey } from "./lib/pm-jira.mjs";
40
42
  import { flattenImplements, stampScope, prScope, matchBranchToImplements, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
41
43
  import { describeForgeError, parseForgeError } from "./lib/forge-api.mjs";
42
- import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion } from "./lib/us-format.mjs";
44
+ import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion, PENDING_VERSION } from "./lib/us-format.mjs";
45
+ import { isHelpToken, wantsHelp, helpTopic, helpFor, globalUsage } from "./lib/help.mjs";
43
46
 
44
47
  const HERE = dirname(fileURLToPath(import.meta.url));
45
48
 
@@ -150,13 +153,14 @@ function cmdLs(opts) {
150
153
  async function cmdLinkUs(key, opts) {
151
154
  if (!isValidKey(key)) fail(`key inválido: '${key}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
152
155
 
153
- let title, hash, version = "v1";
156
+ let title, hash, version = null;
154
157
  if (opts.us) {
155
158
  // Fuente local: un .md con la US.
156
159
  const md = readFileSync(opts.us, "utf8");
157
160
  hash = acHash(md);
158
161
  if (hash == null) fail(`la US en ${opts.us} no tiene una sección 'Criterios de aceptación' con criterios testeables → sin ac_hash.\n Agregá los criterios bajo '## Criterios de aceptación', o corré /grill-user-story para pulir la US.`, 2);
159
162
  title = opts.title || extractTitle(md);
163
+ version = parseSpecVersion(md);
160
164
  } else {
161
165
  // Fuente tracker: traer la US del adaptador (mismo hash que usará `dai check`).
162
166
  loadDaiEnv();
@@ -166,7 +170,16 @@ async function cmdLinkUs(key, opts) {
166
170
  hash = us.ac_hash;
167
171
  if (hash == null) fail(`la US ${key} no tiene una sección 'Criterios de aceptación' con criterios testeables → sin ac_hash, no se puede linkear.\n Agregá la sección en el tracker, o corré /grill-user-story ${key} para pulir la US (te interroga y la re-publica).`, 2);
168
172
  title = opts.title || us.title;
169
- version = us.spec_version || "v1";
173
+ version = us.spec_version;
174
+ }
175
+
176
+ // Sin spec_version NO se inventa un `v1`: ese número se publica en la PR y se estampa en
177
+ // el tracker como si fuera un dato, y `dai check` lo reporta con un ✅ que da por buena
178
+ // una versión que no existe (issue #46). Un placeholder visible dice la verdad.
179
+ if (!version) {
180
+ version = PENDING_VERSION;
181
+ warn(`la US ${key} no declara spec_version — el link queda con 'version: ${PENDING_VERSION}'.`);
182
+ process.stdout.write(` Agregá la fila 'spec_version | v1' a la metadata de la US y re-estampá: dai link-us ${key} --resync\n`);
170
183
  }
171
184
 
172
185
  // ── modo resync: re-estampar el ac_hash en el implements.yaml existente ──────
@@ -222,6 +235,14 @@ function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return
222
235
  function resolveRev(ref) {
223
236
  try { git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); return ref; } catch { return null; }
224
237
  }
238
+ // La rama default del remoto (`origin/HEAD`). Es mejor fallback que un `main` fijo, pero
239
+ // sigue siendo un fallback: en un repo con ramas de ambiente, la default del remoto suele
240
+ // ser justo la de producción. Por eso el preview siempre dice de dónde salió la base.
241
+ function originHeadBranch() {
242
+ try { return parseOriginHead(git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])); }
243
+ catch { return null; }
244
+ }
245
+
225
246
  // Un texto que se puede pasar inline (`--description "…"`) o por archivo
226
247
  // (`--description-file notas.md`). Un agente casi siempre quiere el archivo: markdown
227
248
  // multilínea no sobrevive entero a la línea de comandos.
@@ -297,7 +318,7 @@ async function cmdCheckCi(opts = {}) {
297
318
  try { live = await adapter.fetchUS(r.id); } catch (e) { netErr = String(e.message).split("\n")[0]; }
298
319
  if (netErr) { warn(`${r.id}: no pude leer la US (${netErr}) — no bloqueo por un problema de red/credencial.`); continue; }
299
320
  const status = coverageStatus(r.ac_hash, live?.ac_hash);
300
- if (status === "al-dia") ok(`${r.id} al día (${r.version})`);
321
+ if (status === "al-dia") { ok(`${r.id} al día (${r.version})`); versionDriftHint(r, live); }
301
322
  else if (status === "atrasado") {
302
323
  process.stderr.write(`✗ gate: ${r.id} ATRASADO — implementaste ${r.ac_hash}, la US viva es ${live.ac_hash}.\n` +
303
324
  ` El QUÉ cambió. Resincronizá y revisá que lo cubras: dai link-us ${r.id} --resync\n`);
@@ -321,6 +342,18 @@ function ciBranch() {
321
342
  (e.GITHUB_REF_NAME && !/^\d+\/merge$/.test(e.GITHUB_REF_NAME) ? e.GITHUB_REF_NAME : null) || null;
322
343
  }
323
344
 
345
+ // El ac_hash dice si el QUÉ cambió; el `version` del link es el número que se PUBLICA
346
+ // (en el body de la PR, en el stamp del tracker). Que coincidan no es cosmético: un link
347
+ // al día con `version: v1` contra una US en v4 estampa una versión que no existe, y el ✅
348
+ // lo hace pasar por verificado (issue #46). No cambia el exit code — el hash manda.
349
+ function versionDriftHint(im, live) {
350
+ const declared = String(im?.version ?? "").trim();
351
+ const real = String(live?.spec_version ?? "").trim();
352
+ if (!real || declared === real) return;
353
+ warn(`${im.id}: el link declara version '${declared || "(vacío)"}' y la US viva dice '${real}'.`);
354
+ process.stdout.write(` Ese número se publica en la PR y se estampa en el tracker. Corregilo: dai link-us ${im.id} --resync\n`);
355
+ }
356
+
324
357
  // ── check ──────────────────────────────────────────────────────────────────
325
358
  // `process.exitCode` en vez de `process.exit()`: ver la nota en cmdCheckCi.
326
359
  async function cmdCheck() {
@@ -332,13 +365,21 @@ async function cmdCheck() {
332
365
  for (const f of found) for (const im of f.implements || []) {
333
366
  if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
334
367
  n++;
335
- const live = await adapter.fetchUS(im.id);
336
- const status = coverageStatus(im.ac_hash, live?.ac_hash);
337
- if (status === "al-dia") process.stdout.write(`✅ ${im.id} al día (${im.version})\n`);
368
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, im.id);
369
+ const status = coverageStatus(im.ac_hash, live?.ac_hash, { unreachable });
370
+ if (status === "al-dia") {
371
+ process.stdout.write(`✅ ${im.id} al día (${im.version})\n`);
372
+ versionDriftHint(im, live);
373
+ }
338
374
  else if (status === "atrasado") {
339
375
  process.stdout.write(`⚠️ ${im.id} ATRASADO: implementaste ${im.ac_hash}, la US viva es ${live.ac_hash}${live.spec_version ? ` (${live.spec_version})` : ""}\n`);
340
376
  atrasadas.push(im.id);
341
377
  worst = Math.max(worst, 1);
378
+ } else if (status === "sin-respuesta") {
379
+ // No es "no hay US": es "no pude preguntar". Antes moría acá con `fetch failed` y sin
380
+ // chequear las demás; ahora lo dice, sigue, y sale ≠ 0 porque no pudo verificar nada.
381
+ process.stdout.write(`⚠️ ${reason}\n`);
382
+ worst = Math.max(worst, 2);
342
383
  } else {
343
384
  process.stdout.write(`❓ ${im.id}: no encontré la US (backend ${adapter.kind}). ¿Falta el .md o el token?\n`);
344
385
  worst = Math.max(worst, 2);
@@ -395,7 +436,11 @@ async function cmdStamp(ids = [], opts = {}) {
395
436
  }
396
437
 
397
438
  for (const r of targets) {
398
- const live = await adapter.fetchUS(r.id);
439
+ // Estampar es ESCRIBIR en el tracker de todo el equipo, y no se deshace: si no se pudo
440
+ // verificar el estado, no se estampa. (Hoy ya frenaba, por la excepción sin atrapar; acá
441
+ // queda explícito, con el motivo, y cubierto por un test.)
442
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, r.id);
443
+ if (unreachable) fail(`${reason}\n No estampo un estado que no pude verificar.`, 2);
399
444
  const status = coverageStatus(r.ac_hash, live?.ac_hash);
400
445
  const record = {
401
446
  repo: r.repo, change: r.change, version: r.version, ac_hash: r.ac_hash, status,
@@ -824,8 +869,13 @@ async function cmdUpdateUs(id, opts = {}) {
824
869
 
825
870
  // ── done: cierra una US — vuelve a la base, actualiza y borra la branch local ──
826
871
  function cmdDone(opts) {
827
- const base = opts.base || "main";
872
+ loadDaiEnv();
873
+ // Misma resolución que `dai pr`: el `main` fijo mandaba a checkout+pull a la rama
874
+ // equivocada en cualquier repo que integre contra develop/testing (issue #46).
875
+ if (opts.base === true) fail("--base necesita el nombre de una branch (ej: --base develop).", 1);
876
+ const baseFlag = Array.isArray(opts.base) ? opts.base[opts.base.length - 1] : (typeof opts.base === "string" ? opts.base : null);
828
877
  const branch = gitBranch();
878
+ const { base, source: baseSource } = resolveBase({ flag: baseFlag, branch, env: process.env, originHead: originHeadBranch() });
829
879
  if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
830
880
  if (branch === base) fail(`ya estás en '${base}' — nada que cerrar.`, 1);
831
881
 
@@ -848,7 +898,7 @@ function cmdDone(opts) {
848
898
  ).map((r) => r.id);
849
899
 
850
900
  // Ir a la base y actualizar.
851
- info(`Cambiando a '${base}' y actualizando…`);
901
+ info(`Cambiando a '${base}' y actualizando… ${C.dim(`(base: ${baseSource})`)}`);
852
902
  try { git(["checkout", base]); } catch (e) { fail(`no pude cambiar a '${base}': ${String(e.message).split("\n")[0]}`, 1); }
853
903
  try { git(["fetch", "--prune"]); } catch { /* sin remoto */ }
854
904
  try { git(["pull", "--ff-only"]); } catch { warn(`no pude hacer 'pull --ff-only' en '${base}' (¿divergió?). Revisa a mano.`); }
@@ -870,6 +920,23 @@ function cmdDone(opts) {
870
920
  info("La branch remota (si existe) la maneja el forge (auto-delete on merge) o bórrala tú.");
871
921
  }
872
922
 
923
+ // El comando del forge, listo para copiar y pegar. Cuando dai no llega, deja al dev
924
+ // parado exactamente donde estaba, no un paso atrás.
925
+ function shellHint(tool, cmd) {
926
+ return ` Comando listo para correr a mano:\n ${tool} ${cmd.map((c) => /\s/.test(c) ? `'${c}'` : c).join(" ")}\n`;
927
+ }
928
+
929
+ // La PR/MR abierta de esta branch, si la hay.
930
+ // { number, url, title, base } → existe · null → no hay · undefined → no se pudo saber
931
+ // El `undefined` importa: sin binario, sin auth o con un glab viejo, dai no puede afirmar
932
+ // que NO existe, así que sigue por el camino de crear (que también sabe reconocerla).
933
+ function findExistingPr(tool, branch) {
934
+ try {
935
+ const out = execFileSync(tool, listPrCmd(tool, branch), { encoding: "utf8", cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
936
+ return parsePrList(tool, out);
937
+ } catch { return undefined; }
938
+ }
939
+
873
940
  // ── pr: crea TU PROPIA PR/MR precargada desde el template + el link ────────────
874
941
  // (Distinto de dai-review, que revisa la PR de OTRO. Tu PR la creas y revisas tú.)
875
942
  async function cmdPr(opts) {
@@ -897,9 +964,18 @@ async function cmdPr(opts) {
897
964
  };
898
965
  const closeRl = () => { if (_rl) { _rl.close(); _rl = null; } };
899
966
 
900
- // Elegir la branch base (default main). Se pregunta justo después del aviso de cambios.
901
- let base = opts.base;
902
- if (!base) { const ans = await ask(" ¿Contra qué branch va la PR? (main) "); base = ans || "main"; }
967
+ // Elegir la branch base. El default NO es `main` fijo: sale de la config del repo
968
+ // (DAI_BRANCH_DEV / DAI_BRANCH_PROD) o de la rama default del remoto, y el preview lo dice.
969
+ // Con `main` hardcodeado, en un repo donde main DESPLIEGA A PRODUCCIÓN la MR quedaba
970
+ // proponiendo un merge a PRO y nada lo destacaba (issue #46).
971
+ if (opts.base === true) { closeRl(); fail("--base necesita el nombre de una branch (ej: --base develop).", 1); }
972
+ const baseFlag = Array.isArray(opts.base) ? opts.base[opts.base.length - 1] : (typeof opts.base === "string" ? opts.base : null);
973
+ let { base, source: baseSource, reason: baseWhy } = resolveBase({ flag: baseFlag, branch, env: process.env, originHead: originHeadBranch() });
974
+ if (!baseFlag) {
975
+ const ans = await ask(` ¿Contra qué branch va la PR? (${base}) `);
976
+ if (ans) { base = ans; baseSource = "lo respondiste vos"; baseWhy = null; }
977
+ }
978
+ const toProd = isProdBranch(base, process.env);
903
979
 
904
980
  // La base puede no existir LOCAL (clones con --single-branch, repos donde el dev
905
981
  // trabaja sobre develop y la base es main, corporativos con la base solo en origin).
@@ -960,8 +1036,14 @@ async function cmdPr(opts) {
960
1036
 
961
1037
  // 2. Estado de trazabilidad (dai check) contra la US viva.
962
1038
  const adapter = getAdapter(process.env);
963
- const live = id ? await Promise.resolve(adapter.fetchUS(id)).catch(() => null) : null;
964
- const status = id ? coverageStatus(ac_hash, live?.ac_hash) : null;
1039
+ // El `.catch(() => null)` que había acá convertía "el tracker no contestó" en "no hay US",
1040
+ // y esa afirmación se PUBLICABA en el cuerpo de la PR, al lado del id de la US que sí está.
1041
+ const { us: live, unreachable, reason } = await fetchLiveUS(adapter, id);
1042
+ if (unreachable) {
1043
+ warn(reason);
1044
+ warn("la PR va a decir que no se pudo verificar contra el tracker — no que no hay US.");
1045
+ }
1046
+ const status = id ? coverageStatus(ac_hash, live?.ac_hash, { unreachable }) : null;
965
1047
  if (status === "atrasado") {
966
1048
  warn(`la US ${id} está ATRASADA respecto de tu implementación (${ac_hash} ≠ ${live?.ac_hash}).`);
967
1049
  warn(`resincroniza antes de abrir la PR: dai link-us ${id} --resync`);
@@ -997,13 +1079,40 @@ async function cmdPr(opts) {
997
1079
  const forge = detectForge(parseRemote(remote)?.host);
998
1080
  const tool = forgeTool(forge);
999
1081
 
1082
+ // ¿Ya hay una PR/MR abierta para esta branch? Si la hay, esto es una ACTUALIZACIÓN, y
1083
+ // hay que decirlo ANTES de confirmar: el bug del issue #46 era pushear (el diff quedaba
1084
+ // al día), fallar al crear, y dejar la MR con la descripción vieja sin avisar.
1085
+ // null → no hay ninguna abierta · undefined → no se pudo saber (sin binario, sin auth)
1086
+ const existing = findExistingPr(tool, branch);
1087
+
1000
1088
  // 4. Mostrar y pedir confirmación (acción hacia afuera).
1001
- process.stdout.write(`\n ── Pull Request a crear ──────────────────────────────\n`);
1002
- process.stdout.write(` título: ${title}\n de: ${branch}\n a: ${base}\n`);
1089
+ const accion = existing ? `a ACTUALIZAR (#${existing.number})` : "a crear";
1090
+ process.stdout.write(`\n ── Pull Request ${accion} ──────────────────────────────\n`);
1091
+ process.stdout.write(` título: ${title}\n de: ${branch}\n`);
1092
+ process.stdout.write(` a: ${base} ${C.dim(`(${baseSource}${baseWhy ? ` — ${baseWhy}` : ""})`)}${toProd ? ` ${C.b("⚠️ DESPLIEGA A PRODUCCIÓN")}` : ""}\n`);
1003
1093
  process.stdout.write(` US: ${id || C.dim("(sin US)")} ${C.dim(`— ${usWhy}`)}\n`);
1004
1094
  process.stdout.write(` forge: ${forge} (${tool})${opts.assignee ? `\n asignar: ${opts.assignee}` : ""}${opts.draft ? "\n draft: sí" : ""}\n`);
1005
1095
  process.stdout.write(` ─────────────────────────────────────────────────────\n\n${body}\n`);
1006
1096
  process.stdout.write(` ─────────────────────────────────────────────────────\n`);
1097
+ const hint = baseHint(baseSource, base);
1098
+ if (hint) info(hint);
1099
+ if (existing) for (const l of describeUpdate(existing, { base, tool })) warn(l);
1100
+
1101
+ // 4a. Gate de producción: proponer un merge a la rama que despliega a PRO no puede salir
1102
+ // de un default ni colarse con un `--yes` puesto por costumbre. Que lo diga alguien.
1103
+ if (toProd) {
1104
+ warn(`'${base}' está declarada como rama de PRODUCCIÓN (DAI_BRANCH_PROD).`);
1105
+ if (!opts.toProd) {
1106
+ if (opts.yes || !process.stdin.isTTY) {
1107
+ closeRl();
1108
+ fail(`no publico una PR contra producción sin que lo digas explícitamente.\n` +
1109
+ ` Si es a propósito: dai pr --base ${base} --to-prod --yes\n` +
1110
+ ` Si era otra la base: dai pr --base <rama-de-integracion>`, 1);
1111
+ }
1112
+ const a = await ask(` Escribí '${base}' para confirmar que esta PR va a PRODUCCIÓN (Enter = cancelar): `);
1113
+ if (String(a ?? "").trim() !== base) { closeRl(); info("Cancelado — no se creó la PR."); return; }
1114
+ }
1115
+ }
1007
1116
 
1008
1117
  // 4b. Gate: una PR con el molde del template sin llenar no se puede revisar.
1009
1118
  // Pasaba en repos reales — "Descripción" con el comentario HTML (que no se renderiza:
@@ -1029,8 +1138,8 @@ async function cmdPr(opts) {
1029
1138
  // Archivo de paso para gh/glab: en el temp del sistema, NO en el repo (no lo ensucia).
1030
1139
  const bodyFile = join(mkdtempSync(join(tmpdir(), "dai-pr-")), "body.md");
1031
1140
  if (!opts.yes) {
1032
- if (!process.stdin.isTTY) { closeRl(); writeFileSync(bodyFile, body); info(`Body guardado en ${bodyFile}. Revisa y re-ejecuta con --yes para crear.`); return; }
1033
- const a = (await ask(` ¿Publico la branch y creo el PR con ${tool}? (s/N) `) || "").toLowerCase();
1141
+ if (!process.stdin.isTTY) { closeRl(); writeFileSync(bodyFile, body); info(`Body guardado en ${bodyFile}. Revisa y re-ejecuta con --yes para ${existing ? "actualizar" : "crear"}.`); return; }
1142
+ const a = (await ask(` ¿Publico la branch y ${existing ? `actualizo la PR/MR #${existing.number}` : `creo el PR`} con ${tool}? (s/N) `) || "").toLowerCase();
1034
1143
  closeRl();
1035
1144
  if (!["s", "si", "sí", "y", "yes"].includes(a)) {
1036
1145
  writeFileSync(bodyFile, body);
@@ -1066,6 +1175,29 @@ async function cmdPr(opts) {
1066
1175
  fail(`no pude pushear la branch '${branch}'.`, 1);
1067
1176
  }
1068
1177
 
1178
+ // Actualizar la PR/MR que ya está abierta: mismo body, misma disciplina. La alternativa
1179
+ // —fallar y dejarla con la descripción vieja— es la que rompía el issue #46.
1180
+ const doUpdate = (number) => {
1181
+ const ucmd = updatePrCmd(tool, { number, title, body, bodyFile });
1182
+ try {
1183
+ info(`Actualizando la PR/MR #${number} con ${tool}…`);
1184
+ const out = execFileSync(tool, ucmd, { encoding: "utf8", cwd: process.cwd() });
1185
+ process.stdout.write(out);
1186
+ ok(`PR/MR #${number} actualizada: título + descripción${existing?.url ? ` — ${existing.url}` : ""}.`);
1187
+ try { rmSync(bodyFile); } catch { /* noop */ }
1188
+ return true;
1189
+ } catch (e) {
1190
+ const msg = String(e.stderr || e.message || "");
1191
+ warn(`no pude actualizar la PR/MR #${number} con ${tool}. El body quedó en ${bodyFile}.`);
1192
+ if (msg.trim()) process.stdout.write(` ${tool} dijo:\n ${msg.trim().split("\n").join("\n ")}\n`);
1193
+ process.stdout.write(shellHint(tool, ucmd));
1194
+ process.stdout.write(` Si preferís no editarla: cerrá la PR/MR y volvé a correr \`dai pr\`.\n`);
1195
+ process.exitCode = 1;
1196
+ return false;
1197
+ }
1198
+ };
1199
+ if (existing) { doUpdate(existing.number); return; }
1200
+
1069
1201
  const cmd = tool === "gh"
1070
1202
  ? ["pr", "create", "--title", title, "--body-file", bodyFile, "--base", base,
1071
1203
  ...(opts.assignee ? ["--assignee", opts.assignee] : []), ...(opts.draft ? ["--draft"] : [])]
@@ -1079,7 +1211,18 @@ async function cmdPr(opts) {
1079
1211
  try { rmSync(bodyFile); } catch { /* noop */ }
1080
1212
  } catch (e) {
1081
1213
  const msg = String(e.stderr || e.message || "");
1082
- const manual = ` Comando listo para correr a mano:\n ${tool} ${cmd.map((c) => /\s/.test(c) ? `'${c}'` : c).join(" ")}\n`;
1214
+ const manual = shellHint(tool, cmd);
1215
+ if (isAlreadyExistsError(msg) || isAlreadyExistsError(String(e.stdout || ""))) {
1216
+ // La detección de arriba no la vio (glab viejo, `--output json` no soportado, sin
1217
+ // permiso de lectura). El forge sí sabe que existe: se busca de nuevo y se actualiza.
1218
+ warn("el forge dice que YA hay una PR/MR abierta para esta branch, así que no se creó otra.");
1219
+ const found = findExistingPr(tool, branch);
1220
+ if (found) { doUpdate(found.number); return; }
1221
+ warn(`tampoco pude averiguar su número con ${tool}, así que NO toqué su descripción: quedó la vieja.`);
1222
+ process.stdout.write(` Abrila y pegá el body de ${bodyFile}, o cerrala y volvé a correr \`dai pr\`.\n`);
1223
+ process.exitCode = 1;
1224
+ return;
1225
+ }
1083
1226
  if (e.code === "ENOENT") {
1084
1227
  // El binario del forge no está instalado (el caso más común detrás de "no salió la MR").
1085
1228
  const doc = tool === "glab" ? "https://gitlab.com/gitlab-org/cli/-/releases" : "https://cli.github.com";
@@ -1854,6 +1997,20 @@ function cmdDoctor() {
1854
1997
  }
1855
1998
  }
1856
1999
 
2000
+ // ── flujo de branches: contra qué integra este repo, y qué rama es producción ──
2001
+ // Sin esto declarado, `dai pr` adivina la base — y en un repo con ramas de ambiente
2002
+ // adivinar significa proponer un merge a producción sin que nada lo destaque (issue #46).
2003
+ info("flujo de branches (dai pr · dai done):");
2004
+ const flow = branchFlow(process.env);
2005
+ if (flow.dev) ok(`integración: ${flow.dev} (DAI_BRANCH_DEV) — ahí van las PR de feature/ y fix/`);
2006
+ else {
2007
+ const { base: adivinada, source: src } = resolveBase({ env: process.env, originHead: originHeadBranch() });
2008
+ warn(`sin DAI_BRANCH_DEV: las PR van a '${adivinada}', que sale de ${src}, no de tu config.`);
2009
+ process.stdout.write(" Declaralo una vez en el .env.dai: DAI_BRANCH_DEV=<rama-que-integra>\n");
2010
+ }
2011
+ if (flow.prod) ok(`producción: ${flow.prod} (DAI_BRANCH_PROD) — ahí van release/ y hotfix/, con confirmación explícita`);
2012
+ else info("sin DAI_BRANCH_PROD: dai no marca ninguna rama como producción (no adivina cuál es)");
2013
+
1857
2014
  // ── version-drift del scaffold vs el CLI (ADR-0010) ──────────────────────────
1858
2015
  if (existsSync(join(process.cwd(), ".dai", "VERSION"))) { info("versión del scaffold:"); reportDrift(); }
1859
2016
  }
@@ -1866,8 +2023,21 @@ function cmdVersion() {
1866
2023
 
1867
2024
  let [cmd, ...rest] = process.argv.slice(2);
1868
2025
  if (cmd === "--version" || cmd === "-v") cmd = "version";
1869
- if (cmd === "--help" || cmd === "-h") cmd = "help";
1870
2026
  const { opts, pos } = parseFlags(rest);
2027
+
2028
+ // ── Convención de ayuda (vale para TODOS los comandos) ────────────────────────
2029
+ // Pedir ayuda nunca ejecuta nada: sale por stdout y termina con 0. Antes el `--help`
2030
+ // caía en `opts` y el comando corría igual — `dai stamp --help` dejaba un comentario en
2031
+ // el tracker y `dai pr --help` publicaba una branch. Los agentes lo pisan seguido, porque
2032
+ // probar `<cmd> --help` antes de usar un comando es exactamente lo que hay que hacer.
2033
+ if (isHelpToken(cmd) || cmd === undefined || wantsHelp({ opts, pos })) {
2034
+ const { text, known } = helpFor(helpTopic(isHelpToken(cmd) ? null : cmd, pos));
2035
+ if (known) { process.stdout.write(text); process.exit(0); }
2036
+ process.stderr.write(`dai: no conozco el comando '${helpTopic(isHelpToken(cmd) ? null : cmd, pos)}'.\n\n`);
2037
+ process.stderr.write(text);
2038
+ process.exit(1);
2039
+ }
2040
+
1871
2041
  switch (cmd) {
1872
2042
  case "ac-hash": cmdAcHash(pos[0]); break;
1873
2043
  case "ls": cmdLs(opts); break;
@@ -1895,53 +2065,10 @@ switch (cmd) {
1895
2065
  case "doctor": cmdDoctor(); break;
1896
2066
  case "version": cmdVersion(); break;
1897
2067
  default:
1898
- process.stderr.write(
1899
- "Uso: dai <comando> [args]\n\n" +
1900
- "Trazabilidad:\n" +
1901
- " ac-hash <us.md> calcula el ac_hash (ADR-0001)\n" +
1902
- " ls [--json] lista lo que implementa el repo (ADR-0005)\n" +
1903
- " publish <us.md> crea la US en el tracker (Jira/ClickUp/md) y devuelve el key\n" +
1904
- " [--parent KEY] la cuelga de su épica · [--issuetype T] p. ej. Epic\n" +
1905
- " [--field alias=valor] campos propios que exige tu Jira (.dai/jira-fields.json); repetible\n" +
1906
- " link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
1907
- " link-us <KEY> --resync re-estampa el ac_hash contra la US viva (tras un ⚠️ de check)\n" +
1908
- " edit-us <KEY> trae la US del tracker, la abrís en tu editor, valida el formato,\n" +
1909
- " muestra qué cambia y la guarda (para el PO)\n" +
1910
- " [--no-editor] no abre $EDITOR (para skills/scripts que ya escribieron el .md)\n" +
1911
- " [--bump | --no-bump] decide el spec_version sin preguntar (sin TTY no se toca y avisa)\n" +
1912
- " update-us <KEY> [--us <md>] empuja al tracker un .md que ya escribiste + re-estampa el ac_hash\n" +
1913
- " [--dry-run] [--yes] sin --yes muestra el diff y pide confirmación · [--no-resync]\n" +
1914
- " [--strict] las advertencias de formato también frenan · [--no-bump] no toca spec_version\n" +
1915
- " check compara vs la US viva → atrasado (ADR-0003)\n" +
1916
- " check --ci gate de CI: exige el link según branch-naming (chore/ y docs/ exentas)\n" +
1917
- " [--branch b] la branch a evaluar (en CI se detecta sola) · [--no-network]\n" +
1918
- " salidas: 0 pasa · 1 falta el link · 2 el QUÉ cambió\n" +
1919
- " stamp [<ID>…] [--all] estampa la cobertura en el tracker (ADR-0005)\n" +
1920
- " sin ID: la US de esta branch; si hay varias, pregunta\n" +
1921
- " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
1922
- " 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" +
1923
- " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
1924
- " [--us <ID>] [--title t] la US la resuelve la branch; si hay varias, pregunta (sin TTY, falla)\n" +
1925
- " --description <texto> QUÉ resuelve la PR y por qué → sección 'Descripción' (o --description-file <f>)\n" +
1926
- " --changes <texto> detalle de 'Cambios realizados' (default: los commits) (o --changes-file <f>)\n" +
1927
- " sin descripción y sin commits, con --yes o sin TTY, dai NO publica: la PR\n" +
1928
- " saldría con el molde del template y no se podría revisar\n" +
1929
- " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
1930
- " forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
1931
- " --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
1932
- " Sin --yes no postea nada: muestra el preview y valida que cada hallazgo apunte al diff.\n\n" +
1933
- "Instalación:\n" +
1934
- " skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
1935
- " skills install --from <git-url|npm:pkg|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
1936
- " init [<repo>] scaffolder interactivo del repo (asistente, gestor, OpenSpec)\n" +
1937
- " --for <asistentes> claude|copilot|cursor (combinables con coma) · o both|all (default all)\n" +
1938
- " ej: --for claude,cursor · --for copilot · --for all\n" +
1939
- " --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
1940
- " sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env.dai ni OpenSpec)\n" +
1941
- " upgrade [--check] [--dry-run] (alias: update) actualiza el CLI global a la última (npm i -g …@latest) y avisa si el repo quedó atrasado (ADR-0012)\n" +
1942
- " docs <destino> documentación conceptual → <destino>\n" +
1943
- " doctor diagnóstico del entorno\n\n" +
1944
- " (config: .env.dai — ver .env.dai.example)\n"
1945
- );
1946
- process.exit(cmd && cmd !== "help" ? 1 : 0);
2068
+ // Comando desconocido: la ayuda global por stderr y salida ≠ 0 (la ayuda PEDIDA sale
2069
+ // por stdout y con 0, arriba). Distinguirlos es lo que deja `dai foo --help` usable
2070
+ // en un script sin tener que adivinar de dónde leer.
2071
+ process.stderr.write(`dai: no conozco el comando '${cmd}'.\n\n`);
2072
+ process.stderr.write(globalUsage());
2073
+ process.exit(1);
1947
2074
  }