@dforce2055/dai 0.3.1 → 0.5.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,50 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.5.0] — 2026-07-10
7
+
8
+ **`archive` en el flujo** ([ADR-0011](docs/adr/0011-archive-gate-de-aprobacion.md)): cerrar el CÓMO
9
+ del lado de las specs canónicas, atado a la aprobación de la PR.
10
+
11
+ ### Agregado
12
+ - **`dai archive [<change>]`**: funde los delta specs del change en las specs canónicas
13
+ (`openspec/specs/`) y lo archiva. Lo corre el **aprobador** de la PR, en la branch, al aprobar
14
+ (el fold viaja en la PR → elude la base protegida). Detecta el change activo por su `implements.yaml`
15
+ o le pasás el nombre; envuelve `openspec archive --yes` (mecánico → comando, no skill). Flag `--skip-specs`.
16
+
17
+ ### Cambiado
18
+ - **`dai check` y `dai ls` saltean `openspec/changes/archive/`**: un change shippeado ya no genera
19
+ ⚠️ de drift falso ni aparece en el listado. `discoverImplements` acepta `includeArchived` (default
20
+ `true`); `check`/`ls` lo pasan `false`. `stamp`/`done` mantienen el default (lo necesitan post-merge).
21
+
22
+ ### Interno
23
+ - **110 tests** (+1 desde 0.4.0: filtro `includeArchived`).
24
+
25
+ ## [0.4.0] — 2026-07-10
26
+
27
+ **Versionado y upgrade** ([ADR-0010](docs/adr/0010-versionado-y-upgrade.md)): mantené tu repo al
28
+ día con el CLI sin pisar nada. Las copias scaffoldeadas (skills, constitución, templates) son un
29
+ caché derivable — ahora la máquina te avisa cuando quedaron atrás y las refresca sola.
30
+
31
+ ### Agregado
32
+ - **`dai sync`**: refresca skills, constitución, templates y PR template a la versión del CLI —
33
+ **aditivo** (conserva tu `CLAUDE.md` propio vía bloque delimitado), sin tocar el `.env` ni OpenSpec.
34
+ Detecta los asistentes del repo o acepta `--for`; `--dry-run` muestra qué cambiaría.
35
+ - **`dai doctor` · version-drift**: compara `.dai/VERSION` (scaffold del repo) vs el CLI y avisa con
36
+ color + `⬆️` (misma major → refresh opcional con `dai sync`; major distinta → revisar migración;
37
+ repo más nuevo → actualizar el CLI).
38
+ - **`dai version`**: además de la versión, muestra el estado de drift si estás en un repo con dai
39
+ (chequeo liviano). `dai --version` fuera de un repo dai queda limpio (solo la versión).
40
+ - **`lib/semver.mjs`** (comparación de versiones, cero dependencias).
41
+
42
+ ### Interno
43
+ - **Golden vectors de `ac_hash`**: pineados como inmutables dentro de la línea major — blindan el
44
+ contrato ([ADR-0001](docs/adr/0001-contrato-ac-hash.md)) que hace seguros a los minors/patches y a `dai sync`.
45
+ - **109 tests** (+4 desde 0.3.1: semver ×3, golden vectors ×1).
46
+
47
+ > Diferido a un futuro major (ya diseñado en el ADR-0010): `dai migrate` + `MIGRATION.md` y estampar
48
+ > `schema:` en el `implements.yaml`.
49
+
6
50
  ## [0.3.1] — 2026-07-10
7
51
 
8
52
  Pulido de la experiencia de `dai init` y `dai link-us`, y un ejemplo de US listo para probar.
@@ -122,6 +166,8 @@ ClickUp y Jira Cloud.
122
166
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
123
167
  `files` de npm sin tests ni secretos.
124
168
 
169
+ [0.5.0]: https://github.com/dforce2055/dai/releases/tag/v0.5.0
170
+ [0.4.0]: https://github.com/dforce2055/dai/releases/tag/v0.4.0
125
171
  [0.3.1]: https://github.com/dforce2055/dai/releases/tag/v0.3.1
126
172
  [0.3.0]: https://github.com/dforce2055/dai/releases/tag/v0.3.0
127
173
  [0.2.0]: https://github.com/dforce2055/dai/releases/tag/v0.2.0
package/README.md CHANGED
@@ -158,6 +158,7 @@ flowchart TD
158
158
  |---|---|
159
159
  | `dai init [<repo>]` | scaffolder interactivo del repo. Flags: `--for claude\|copilot\|both\|cursor\|all` (asistente, default `all`) · `--pm md\|jira\|clickup` (tracker) · `--openspec` |
160
160
  | `dai install [--global \| --local <repo>] [--force] [--dry-run] [--for claude\|cursor\|all]` | instala/actualiza skills de IA en Claude y/o Cursor (`--for all` por defecto). `--force` re-copia aunque ya existan. Ej: `dai install --local . --for cursor --force` · `dai install --global --for all --force` |
161
+ | `dai sync [--dry-run] [--for <asistentes>]` | **refresca** skills, constitución, templates y PR template a la versión del CLI — **aditivo** (no pisa tu `CLAUDE.md`), no toca el `.env` ni OpenSpec. Detecta los asistentes del repo o pasás `--for`. `--dry-run` muestra qué cambiaría ([ADR-0010](docs/adr/0010-versionado-y-upgrade.md)) |
161
162
  | `dai publish <us.md>` | crea la US en el tracker (Jira/ClickUp/md) desde un `.md` y devuelve el key. Es el fallback del MCP para publicar sin el asistente |
162
163
  | `dai link-us <ID> [--us <md>]` | crea branch + `implements.yaml`; sin `--us` trae la US del tracker |
163
164
  | `dai link-us <ID> --resync` | re-estampa el `ac_hash` contra la US viva (tras un ⚠️ de check) |
@@ -166,9 +167,17 @@ flowchart TD
166
167
  | `dai pr [--assignee u] [--base b] [--draft] [--yes]` | crea TU PR/MR precargada: pregunta la branch base (default `main`), muestra el texto y confirma antes de publicar |
167
168
  | `dai stamp` | estampa la cobertura inversa en el tracker (branch + commit-ancla) |
168
169
  | `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 |
170
+ | `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` |
169
171
  | `dai forge comment <ref> --body-file <f>` · `dai forge pr <ref>` | comentar / leer una PR/MR (GitHub/GitLab) |
170
172
  | `dai ac-hash <us.md>` | calcula el hash de los criterios de aceptación de una US |
171
- | `dai doctor` · `dai docs <dest>` · `dai --version` | diagnóstico del entorno · copiar la doc · versión |
173
+ | `dai doctor` · `dai docs <dest>` · `dai version` | diagnóstico del entorno (incluye **version-drift** del scaffold) · copiar la doc · versión (`dai version` avisa si tu repo quedó atrás) |
174
+
175
+ > **🆕 Mantené tu repo al día — `dai sync`.** Las skills, la constitución y los templates son un
176
+ > *caché derivable* del CLI. Cuando actualizás `dai` (`npm i -g @dforce2055/dai`), **`dai doctor` y
177
+ > `dai version` te avisan solos** si tu scaffold quedó atrás — con color y un `⬆️` —, y **`dai sync`**
178
+ > lo refresca: **aditivo** (conserva tu `CLAUDE.md` propio), sin tocar el `.env` ni OpenSpec. Probá sin
179
+ > riesgo con `dai sync --dry-run`. El versionado es semver: patch/minor no rompen nada; solo un major
180
+ > pediría migración. ([ADR-0010](docs/adr/0010-versionado-y-upgrade.md))
172
181
 
173
182
  Skills (se invocan en el asistente): `/doc-to-backlog` · `/grill-intent` · `/grill-epic` · `/grill-user-story` · `/link-us` ·
174
183
  `/tdd` · `/dai-review`. Config del tracker (`md`\|`jira`\|`clickup`) y tokens: en `.env` —
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.1
1
+ 0.5.0
package/cli/dai.mjs CHANGED
@@ -28,6 +28,7 @@ import { parsePrRef, getPR, postComment } from "./lib/forge-api.mjs";
28
28
  import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
29
29
  import { dirsEqual } from "./lib/fsutil.mjs";
30
30
  import { parseFlags, parseAssistants, isAssistantToken } from "./lib/args.mjs";
31
+ import { versionDrift } from "./lib/semver.mjs";
31
32
  import { skillToPrompt, skillToCursor, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore } from "./lib/bootstrap.mjs";
32
33
 
33
34
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -39,6 +40,10 @@ function fail(msg, code = 1) { process.stderr.write("dai: " + msg + "\n"); proce
39
40
  const ok = (m) => process.stdout.write(`✓ ${m}\n`);
40
41
  const info = (m) => process.stdout.write(`› ${m}\n`);
41
42
  const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
43
+ // Color ANSI mínimo — solo si es TTY y no está NO_COLOR (así no ensucia pipes/CI).
44
+ const _color = process.stdout.isTTY && !process.env.NO_COLOR;
45
+ const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
46
+ const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m) };
42
47
  const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
43
48
  const CLAUDE_SKILLS_DIR = process.env.CLAUDE_SKILLS_DIR || join(homedir(), ".claude", "skills");
44
49
  const CURSOR_SKILLS_DIR = process.env.CURSOR_SKILLS_DIR || join(homedir(), ".cursor", "skills");
@@ -66,7 +71,7 @@ function cmdAcHash(arg) {
66
71
  // ── ls ──────────────────────────────────────────────────────────────────────
67
72
  function cmdLs(opts) {
68
73
  const root = opts.root || process.cwd();
69
- const found = discoverImplements(root);
74
+ const found = discoverImplements(root, { includeArchived: false });
70
75
  const rows = [];
71
76
  for (const f of found) {
72
77
  for (const im of f.implements || []) {
@@ -165,7 +170,7 @@ function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return
165
170
  async function cmdCheck() {
166
171
  loadEnv();
167
172
  const adapter = getAdapter(process.env);
168
- const found = discoverImplements(process.cwd());
173
+ const found = discoverImplements(process.cwd(), { includeArchived: false });
169
174
  let worst = 0, n = 0;
170
175
  const atrasadas = [];
171
176
  for (const f of found) for (const im of f.implements || []) {
@@ -677,6 +682,146 @@ function cmdDocs(dest) {
677
682
  ok(`documentación copiada a ${dest}`);
678
683
  }
679
684
 
685
+ // ── archive: funde los delta specs del change en los specs canónicos y lo archiva ─
686
+ // Lo corre el APROBADOR de la PR, en la branch, al aprobar: es el gate de aprobación
687
+ // (el fold va atado a la aprobación, no a la autoría). Envuelve `openspec archive
688
+ // <change> --yes` — mecánico, va al CLI y no a una skill (ADR-0002). El fold queda
689
+ // sin commitear para que el aprobador lo revise y lo incluya en la PR antes de mergear.
690
+ function cmdArchive(changeArg, opts) {
691
+ const repo = process.cwd();
692
+ if (!existsSync(join(repo, "openspec"))) fail("no hay OpenSpec en este repo (falta openspec/). Nada que archivar.", 2);
693
+
694
+ let change = changeArg;
695
+ if (!change) {
696
+ // Detectar el change ACTIVO (no archivado) por su implements.yaml.
697
+ const active = discoverImplements(repo, { includeArchived: false })
698
+ .filter((f) => /[/\\]openspec[/\\]changes[/\\]/.test(f.path))
699
+ .map((f) => basename(dirname(f.path)));
700
+ const uniq = [...new Set(active)];
701
+ if (uniq.length === 0) fail("no encontré un change activo para archivar (¿ya está archivado, o falta `dai link-us`?).", 2);
702
+ if (uniq.length > 1) fail(`hay varios changes activos: ${uniq.join(", ")}.\n Pasá cuál: dai archive <change>`, 2);
703
+ change = uniq[0];
704
+ }
705
+
706
+ info(`archivando '${change}' — funde los delta specs en openspec/specs/ y mueve el change a archive/…`);
707
+ const args = ["archive", change, "--yes"];
708
+ if (opts.skipSpecs) args.push("--skip-specs");
709
+ try {
710
+ execFileSync(npmBin("openspec"), args, { stdio: "inherit", cwd: repo });
711
+ } catch (e) {
712
+ fail(`openspec archive falló (¿tasks incompletas? ¿change inexistente?): ${String(e.message).split("\n")[0]}`, 1);
713
+ }
714
+ process.stdout.write("\n");
715
+ ok(`'${change}' archivado. Revisá los cambios (specs fundidas + change en archive/) y commitealos en la PR antes de mergear.`);
716
+ }
717
+
718
+ // ── sync: refresca las copias scaffoldeadas a la versión del CLI (ADR-0010) ───
719
+ // Las copias (skills, constitución, templates, PR template) son un CACHÉ derivable
720
+ // del CLI: `dai sync` las re-genera a la versión instalada, aditivo (no pisa la
721
+ // constitución propia del proyecto). NO toca el `.env` ni OpenSpec. Opt-in.
722
+ function cmdSync(repo, opts) {
723
+ repo = repo || ".";
724
+ if (!existsSync(repo)) fail(`no existe el directorio: ${repo}`, 2);
725
+ const daiDir = join(repo, ".dai");
726
+ if (!existsSync(daiDir)) fail("este repo no tiene dai (falta .dai/). Corré `dai init` primero.", 2);
727
+
728
+ // Asistentes: --for override, o detectar los que ya están en el repo.
729
+ let want;
730
+ if (typeof opts.for === "string") {
731
+ try { want = parseAssistants(opts.for); } catch (e) { fail(`--for ${e.message}`); }
732
+ } else {
733
+ want = {
734
+ claude: existsSync(join(repo, ".claude", "skills")),
735
+ copilot: existsSync(join(repo, ".github", "prompts")),
736
+ cursor: existsSync(join(repo, ".cursor", "skills")),
737
+ };
738
+ if (!want.claude && !want.copilot && !want.cursor)
739
+ fail("no detecté asistentes instalados (.claude/.cursor/.github/prompts). Pasá --for.", 2);
740
+ }
741
+
742
+ const cliV = readFileSync(join(ROOT, "VERSION"), "utf8").trim();
743
+ const repoV = existsSync(join(daiDir, "VERSION")) ? readFileSync(join(daiDir, "VERSION"), "utf8").trim() : "?";
744
+ const dry = !!opts.dryRun;
745
+ const forStr = [want.claude && "claude", want.copilot && "copilot", want.cursor && "cursor"].filter(Boolean).join("+");
746
+ info(`dai sync — ${repoV} → v${cliV} · asistentes: ${forStr}${dry ? " [dry-run]" : ""}`);
747
+
748
+ const skillsSrc = join(ROOT, "skills");
749
+ const skills = readdirSync(skillsSrc).filter((n) => statSync(join(skillsSrc, n)).isDirectory());
750
+ const step = (label, fn) => { if (dry) info(`[dry-run] ${label}`); else { fn(); ok(label); } };
751
+
752
+ // 1. .dai/ (templates + governance) + VERSION
753
+ step(`.dai/ moldes + governance → v${cliV}`, () => {
754
+ for (const sub of ["templates", "governance"]) {
755
+ const src = join(ROOT, sub);
756
+ if (existsSync(src)) { mkdirSync(join(daiDir, sub), { recursive: true }); cpSync(src, join(daiDir, sub), { recursive: true }); }
757
+ }
758
+ writeFileSync(join(daiDir, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
759
+ });
760
+
761
+ // 2. PR template
762
+ step(".github/ pull_request_template.md", () => {
763
+ mkdirSync(join(repo, ".github"), { recursive: true });
764
+ cpSync(join(ROOT, "templates", "pull-request.md"), join(repo, ".github", "pull_request_template.md"));
765
+ });
766
+
767
+ // 3. skills + constitución por asistente (aditivo: upsertBlock no pisa lo del proyecto)
768
+ if (want.claude) step(`Claude: .claude/skills/ (${skills.length}) + CLAUDE.md (bloque dai)`, () => {
769
+ const dir = join(repo, ".claude", "skills"); mkdirSync(dir, { recursive: true });
770
+ for (const name of skills) cpSync(join(skillsSrc, name), join(dir, name), { recursive: true });
771
+ const cPath = join(repo, "CLAUDE.md"), cCur = existsSync(cPath) ? readFileSync(cPath, "utf8") : "";
772
+ writeFileSync(cPath, upsertBlock(cCur, constitution("claude")));
773
+ });
774
+ if (want.copilot) step(`Copilot: .github/prompts/ (${skills.length}) + copilot-instructions.md`, () => {
775
+ const pdir = join(repo, ".github", "prompts"); mkdirSync(pdir, { recursive: true });
776
+ for (const name of skills) writeFileSync(join(pdir, `${name}.prompt.md`), skillToPrompt(readFileSync(join(skillsSrc, name, "SKILL.md"), "utf8")));
777
+ const ciPath = join(repo, ".github", "copilot-instructions.md"), ciCur = existsSync(ciPath) ? readFileSync(ciPath, "utf8") : "";
778
+ writeFileSync(ciPath, upsertBlock(ciCur, constitution("copilot")));
779
+ });
780
+ if (want.cursor) step(`Cursor: .cursor/skills/ (${skills.length}) + .cursor/rules/dai-constitution.mdc`, () => {
781
+ const dir = join(repo, ".cursor", "skills"); mkdirSync(dir, { recursive: true });
782
+ for (const name of skills) {
783
+ const src = join(skillsSrc, name), target = join(dir, name);
784
+ cpSync(src, target, { recursive: true });
785
+ writeFileSync(join(target, "SKILL.md"), skillToCursor(readFileSync(join(src, "SKILL.md"), "utf8")));
786
+ }
787
+ mkdirSync(join(repo, ".cursor", "rules"), { recursive: true });
788
+ writeFileSync(join(repo, ".cursor", "rules", "dai-constitution.mdc"), constitutionCursorRule());
789
+ });
790
+
791
+ // 4. .gitignore: versiona los artefactos, deja fuera solo lo personal
792
+ const giPath = join(repo, ".gitignore");
793
+ const gi = reconcileGitignore(existsSync(giPath) ? readFileSync(giPath, "utf8") : "", want);
794
+ if (gi.changed) step(".gitignore ajustado (artefactos versionados; settings.local.json fuera)",
795
+ () => writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"));
796
+
797
+ if (dry) info("dry-run: nada escrito. Quitá --dry-run para aplicar.");
798
+ else { process.stdout.write("\n"); ok(`sync completo — .dai/ ahora en v${cliV}`); process.stdout.write(" (El .env y OpenSpec no se tocan: OpenSpec se actualiza aparte con `openspec`.)\n"); }
799
+ }
800
+
801
+ // Imprime el estado de version-drift del scaffold (ADR-0010) con color + ícono.
802
+ // Reutilizado por `dai doctor` y `dai version`. Devuelve el estado, o null si el
803
+ // directorio no tiene dai (.dai/VERSION). No imprime nada en ese caso.
804
+ function reportDrift(repo = process.cwd()) {
805
+ const vf = join(repo, ".dai", "VERSION");
806
+ if (!existsSync(vf)) return null;
807
+ const repoV = readFileSync(vf, "utf8").trim();
808
+ const cliV = readFileSync(join(ROOT, "VERSION"), "utf8").trim();
809
+ const status = versionDrift(repoV, cliV);
810
+ switch (status) {
811
+ case "current":
812
+ ok(`.dai/ al día con el CLI (v${repoV})`); break;
813
+ case "minor-behind":
814
+ process.stdout.write(`${C.b(C.y("⬆️ actualización disponible"))} — CLI ${C.b("v" + cliV)}, tu repo ${repoV}. Actualizá con ${C.cy("dai sync")} · probá con ${C.cy("dai sync --dry-run")}\n`); break;
815
+ case "major-behind":
816
+ process.stdout.write(`${C.b(C.r("⚠️ cambio MAYOR"))} — CLI ${C.b("v" + cliV)}, tu repo ${repoV}. Revisá el CHANGELOG/MIGRATION antes de ${C.cy("dai sync")}\n`); break;
817
+ case "cli-behind":
818
+ process.stdout.write(`${C.b(C.y("⚠️ CLI atrasado"))} — tu repo se scaffoldeó con v${repoV}, más nuevo que tu CLI (v${cliV}). Actualizá el CLI: ${C.cy("npm i -g @dforce2055/dai")}\n`); break;
819
+ default:
820
+ warn(`.dai/ VERSION ilegible: '${repoV}'`);
821
+ }
822
+ return status;
823
+ }
824
+
680
825
  // ── doctor: diagnóstico ───────────────────────────────────────────────────────
681
826
  function cmdDoctor() {
682
827
  loadEnv();
@@ -727,11 +872,15 @@ function cmdDoctor() {
727
872
  process.env.DAI_CLICKUP_LIST_ID ? ok(`lista=${process.env.DAI_CLICKUP_LIST_ID} (para dai publish)`)
728
873
  : warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
729
874
  }
875
+
876
+ // ── version-drift del scaffold vs el CLI (ADR-0010) ──────────────────────────
877
+ if (existsSync(join(process.cwd(), ".dai", "VERSION"))) { info("versión del scaffold:"); reportDrift(); }
730
878
  }
731
879
 
732
880
  // ── version ───────────────────────────────────────────────────────────────────
733
881
  function cmdVersion() {
734
882
  process.stdout.write(`dai v${readFileSync(join(HERE, "..", "VERSION"), "utf8").trim()}\n`);
883
+ reportDrift(); // en un repo con dai, avisa si el scaffold está atrasado (ADR-0010)
735
884
  }
736
885
 
737
886
  let [cmd, ...rest] = process.argv.slice(2);
@@ -748,8 +897,10 @@ switch (cmd) {
748
897
  case "publish": cmdPublish(pos[0]).catch((e) => fail(String(e.message))); break;
749
898
  case "pr": cmdPr(opts).catch((e) => fail(String(e.message))); break;
750
899
  case "done": cmdDone(opts); break;
900
+ case "archive": cmdArchive(pos[0], opts); break;
751
901
  case "install": cmdInstall(opts).catch((e) => fail(String(e.message))); break;
752
902
  case "init": cmdInit(pos[0], opts).catch((e) => fail(String(e.message))); break;
903
+ case "sync": cmdSync(pos[0], opts); break;
753
904
  case "docs": cmdDocs(pos[0]); break;
754
905
  case "doctor": cmdDoctor(); break;
755
906
  case "version": cmdVersion(); break;
@@ -765,6 +916,7 @@ switch (cmd) {
765
916
  " check compara vs la US viva → atrasado (ADR-0003)\n" +
766
917
  " stamp estampa la cobertura en el tracker (ADR-0005)\n" +
767
918
  " done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
919
+ " 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" +
768
920
  " pr [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
769
921
  " forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n\n" +
770
922
  "Instalación:\n" +
@@ -773,6 +925,7 @@ switch (cmd) {
773
925
  " --for <asistentes> claude|copilot|cursor (combinables con coma) · o both|all (default all)\n" +
774
926
  " ej: --for claude,cursor · --for copilot · --for all\n" +
775
927
  " --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
928
+ " sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env ni OpenSpec)\n" +
776
929
  " docs <destino> documentación conceptual → <destino>\n" +
777
930
  " doctor diagnóstico del entorno\n\n" +
778
931
  " (config: .env — ver .env.example)\n"
@@ -71,7 +71,10 @@ export function parseImplements(text) {
71
71
  }
72
72
 
73
73
  // Camina el árbol desde root y devuelve { path, ...parsed } por cada implements.yaml.
74
- export function discoverImplements(root) {
74
+ // `includeArchived: false` saltea `openspec/changes/archive/` — los changes shippeados
75
+ // no deben aparecer en `check`/`ls` (ADR-0010). Por defecto los incluye (stamp/done los
76
+ // necesitan post-merge).
77
+ export function discoverImplements(root, { includeArchived = true } = {}) {
75
78
  const found = [];
76
79
  const walk = (dir) => {
77
80
  let entries;
@@ -81,7 +84,9 @@ export function discoverImplements(root) {
81
84
  let st;
82
85
  try { st = statSync(full); } catch { continue; }
83
86
  if (st.isDirectory()) {
84
- if (!SKIP_DIRS.has(name)) walk(full);
87
+ if (SKIP_DIRS.has(name)) continue;
88
+ if (!includeArchived && name === "archive") continue;
89
+ walk(full);
85
90
  } else if (name === "implements.yaml") {
86
91
  try {
87
92
  found.push({ path: full, ...parseImplements(readFileSync(full, "utf8")) });
@@ -0,0 +1,30 @@
1
+ // dai · comparación mínima de versiones semver (X.Y.Z). Cero dependencias.
2
+ // Se usa para el chequeo de version-drift (`dai doctor`) y `dai sync` (ADR-0010).
3
+
4
+ export function parseVersion(v) {
5
+ const m = String(v ?? "").trim().match(/^(\d+)\.(\d+)\.(\d+)/);
6
+ return m ? { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) } : null;
7
+ }
8
+
9
+ // -1 / 0 / 1 (a<b / a==b / a>b), o null si alguna no parsea.
10
+ export function compareVersions(a, b) {
11
+ const pa = parseVersion(a), pb = parseVersion(b);
12
+ if (!pa || !pb) return null;
13
+ const d = (pa.major - pb.major) || (pa.minor - pb.minor) || (pa.patch - pb.patch);
14
+ return d < 0 ? -1 : d > 0 ? 1 : 0;
15
+ }
16
+
17
+ // Estado del scaffold del repo (repoV, de `.dai/VERSION`) frente al CLI instalado (cliV):
18
+ // current → iguales
19
+ // minor-behind → CLI adelante, MISMA major (refresh opcional con `dai sync`, nada roto)
20
+ // major-behind → CLI adelante, major DISTINTA (revisar CHANGELOG/MIGRATION antes)
21
+ // cli-behind → el repo se scaffoldeó con una versión más nueva que el CLI (actualizá el CLI)
22
+ // unknown → alguna versión no parseable
23
+ export function versionDrift(repoV, cliV) {
24
+ const r = parseVersion(repoV), c = parseVersion(cliV);
25
+ if (!r || !c) return "unknown";
26
+ const cmp = compareVersions(cliV, repoV);
27
+ if (cmp === 0) return "current";
28
+ if (cmp < 0) return "cli-behind";
29
+ return c.major > r.major ? "major-behind" : "minor-behind";
30
+ }
@@ -0,0 +1,68 @@
1
+ # ADR-0011 — `archive` es un gate de aprobación (`dai archive`)
2
+
3
+ - **Estado:** aceptado
4
+ - **Fecha:** 2026-07-10
5
+ - **Decide:** lead / arquitecto de la metodología
6
+
7
+ ## Contexto
8
+
9
+ OpenSpec tiene `archive`: cuando un change está completo, **funde sus delta specs** en las
10
+ specs canónicas (`openspec/specs/<capability>/spec.md`) y **mueve el change** a
11
+ `openspec/changes/archive/`. Pero en el flujo dai (`link-us → propose → apply/tdd → check
12
+ → pr → dai-review → merge → stamp → done`) el `archive` **no tenía lugar**: ni cuándo, ni
13
+ quién. Sin eso, las specs vivas del repo quedan desincronizadas de lo shippeado.
14
+
15
+ Dos preguntas de fondo: **¿quién lo corre?** y **¿en qué momento?**
16
+
17
+ ## Decisión
18
+
19
+ ### `archive` = el acto de aceptar el change en las specs canónicas → lo hace **quien aprueba la PR**
20
+
21
+ Fundir un change en las specs oficiales es **bendecir** ese cambio. Por eso lo ata a la
22
+ **aprobación**, no a la autoría: lo corre **quien aprueba la PR**, no el autor. Es un
23
+ **gate de aprobación**, no un paso de cierre del autor (un autor no debería fundir sus
24
+ propios cambios en la spec oficial de un equipo).
25
+
26
+ Ownership por **dial de ceremonia** (N1/N2/N3):
27
+
28
+ - **N1/N2 — `dai archive` (comando):** el aprobador lo corre **en la branch de la PR** al
29
+ aprobar → funde los deltas + mueve el change + lo deja **sin commitear** para revisarlo →
30
+ lo incluye en la PR → mergea normal. **Resuelve solo el problema de base protegida**: el
31
+ fold viaja dentro de la PR, no hay push directo a `main`.
32
+ - **N3 — workflow de CI opt-in** (roadmap): el merge aprobado dispara el `archive` solo.
33
+
34
+ ### `dai archive` es un **comando**, no una skill
35
+
36
+ Archivar es **mecánico** (detectar el change + `openspec archive <change> --yes`): sin
37
+ juicio → va al CLI, como `dai stamp`/`check`/`done` (ADR-0002). `dai-review` es skill
38
+ porque revisar código sí requiere juicio; archivar no. `dai archive` **envuelve**
39
+ `openspec archive`, detectando el change activo por su `implements.yaml` (reusa
40
+ `discoverImplements`); si hay varios, pide el nombre.
41
+
42
+ ### `dai check` y `dai ls` saltean `openspec/changes/archive/`
43
+
44
+ Un change archivado está **shippeado**: no debe aparecer en `check` (daría un ⚠️ de drift
45
+ falso si el PO edita esa US después) ni en `ls`. `discoverImplements` acepta
46
+ `includeArchived` (default `true`); `check`/`ls` lo pasan en `false`. **`dai stamp`/`done`/
47
+ `archive` mantienen el default** — sí necesitan encontrar el `implements.yaml` (p. ej.
48
+ `stamp` post-merge, cuando el change ya se archivó en la branch).
49
+
50
+ ## Consecuencias
51
+
52
+ - ✅ El `archive` queda **atado a la aprobación** — las specs canónicas reflejan solo lo bendecido.
53
+ - ✅ La variante `dai archive` (N1/N2) **elude la base protegida** sin tokens ni CI: el fold entra por la PR.
54
+ - ✅ `check`/`ls` dejan de reportar ruido de changes shippeados.
55
+ - ✅ Coherente con el ADN: es una **herramienta opcional** (no obliga), mecánica → CLI.
56
+ - ⚠️ Edge: en PRs desde **fork**, el aprobador puede no tener push a la branch del autor →
57
+ ahí cae al workflow de CI, o lo corre el autor.
58
+ - ⚠️ El **nudge** (que `/dai-review` recuerde archivar al aprobar) y el **workflow de CI**
59
+ quedan como follow-up (roadmap), igual que documentar el paso en `SCRUM-CON-IA`.
60
+
61
+ ## Alternativas consideradas
62
+
63
+ - **Que lo corra el autor, en el PR** — descartado: fundir en la spec oficial es un acto de
64
+ aprobación; el autor no debería bendecir su propio cambio. Va atado a quien aprueba.
65
+ - **Post-merge sobre la base (commit directo)** — descartado como default: choca con branch
66
+ protection (push directo a `main`). Queda para el workflow de CI (con follow-up PR o App token).
67
+ - **Una skill `/dai-archive`** — descartado: archivar es mecánico, no necesita un LLM (ADR-0002).
68
+ El comando puede invocarse desde el asistente igual, sin ser skill.
@@ -16,6 +16,7 @@ decisión cambia, se escribe un ADR nuevo que supersede al viejo. Molde en
16
16
  | [0008](0008-estrategia-de-i18n.md) | Estrategia de i18n: fuente única (español) + traducciones derivadas, `DAI_LANG` en el CLI, por fases | propuesto |
17
17
  | [0009](0009-adaptador-cursor.md) | Adaptador nativo para Cursor (skills + rules) con `dai init`/`install`/`doctor` | propuesto |
18
18
  | [0010](0010-versionado-y-upgrade.md) | Versionado y upgrade: compatibilidad por semver, `doctor` version-drift, `dai sync` aditivo | propuesto |
19
+ | [0011](0011-archive-gate-de-aprobacion.md) | `archive` es un gate de aprobación: `dai archive` (comando) lo corre el aprobador; `check`/`ls` saltean `archive/` | aceptado |
19
20
 
20
21
  > Estas son las decisiones que cierran las "Decisiones abiertas" de
21
22
  > [`METODOLOGIA.md §7`](../METODOLOGIA.md) y las enmiendas al
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Metodología de desarrollo asistido por IA — CLI de acciones deterministas (trazabilidad QUÉ↔CÓMO).",
5
5
  "repository": { "type": "git", "url": "git+https://github.com/dforce2055/dai.git" },
6
6
  "homepage": "https://dforce2055.github.io/dai/",