@dforce2055/dai 0.1.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.example +30 -0
- package/CHANGELOG.md +46 -0
- package/CODE_OF_CONDUCT.md +37 -0
- package/CONTRIBUTING.md +66 -0
- package/LICENSE +674 -0
- package/README.md +288 -0
- package/SECURITY.md +37 -0
- package/VERSION +1 -0
- package/cli/dai.mjs +692 -0
- package/cli/lib/ac-hash.mjs +74 -0
- package/cli/lib/args.mjs +23 -0
- package/cli/lib/bootstrap.mjs +74 -0
- package/cli/lib/env.mjs +23 -0
- package/cli/lib/forge-api.mjs +96 -0
- package/cli/lib/forge-url.mjs +61 -0
- package/cli/lib/fsutil.mjs +24 -0
- package/cli/lib/implements.mjs +94 -0
- package/cli/lib/link-us.mjs +59 -0
- package/cli/lib/pm-adapter.mjs +59 -0
- package/cli/lib/pm-clickup.mjs +54 -0
- package/cli/lib/pm-jira.mjs +123 -0
- package/cli/lib/pr.mjs +53 -0
- package/cli/lib/us.mjs +36 -0
- package/docs/EJEMPLO-END-TO-END.md +330 -0
- package/docs/MANIFIESTO.md +114 -0
- package/docs/METODOLOGIA.md +254 -0
- package/docs/PROBAR.md +91 -0
- package/docs/SCRUM-CON-IA.md +190 -0
- package/docs/adr/0001-contrato-ac-hash.md +86 -0
- package/docs/adr/0002-agnostico-del-asistente.md +87 -0
- package/docs/adr/0003-deteccion-y-estampado-son-comandos.md +73 -0
- package/docs/adr/0004-ubicacion-y-schema-implements.md +94 -0
- package/docs/adr/0005-superficie-comandos-y-stamp.md +65 -0
- package/docs/adr/0006-distribucion-y-licencia.md +59 -0
- package/docs/adr/0007-modelo-de-autenticacion.md +63 -0
- package/docs/adr/README.md +19 -0
- package/docs/detalle/01-refinamiento.md +33 -0
- package/docs/detalle/02-planning.md +27 -0
- package/docs/detalle/03-ramas.md +32 -0
- package/docs/detalle/04-tdd.md +35 -0
- package/docs/detalle/05-smoke.md +32 -0
- package/docs/detalle/06-code-review.md +34 -0
- package/docs/detalle/07-merge-trazabilidad.md +33 -0
- package/docs/detalle/08-daily.md +29 -0
- package/docs/detalle/09-review.md +25 -0
- package/docs/detalle/10-retro.md +27 -0
- package/docs/detalle/README.md +20 -0
- package/docs/glosario.md +79 -0
- package/docs/guias/dev.md +66 -0
- package/docs/guias/lead.md +53 -0
- package/docs/guias/po.md +50 -0
- package/governance/branch-naming.md +36 -0
- package/governance/ci-rules.md +57 -0
- package/governance/commit-convention.md +76 -0
- package/index.html +479 -0
- package/install.sh +19 -0
- package/manifest.yaml +76 -0
- package/package.json +55 -0
- package/skills/dai-review/SKILL.md +78 -0
- package/skills/doc-to-backlog/SKILL.md +70 -0
- package/skills/doc-to-backlog/templates/backlog-candidato.md +49 -0
- package/skills/grill-epic/SKILL.md +76 -0
- package/skills/grill-intent/SKILL.md +43 -0
- package/skills/grill-intent/templates/intent.md +36 -0
- package/skills/grill-user-story/SKILL.md +76 -0
- package/skills/grill-user-story/templates/user-story.md +61 -0
- package/skills/link-us/SKILL.md +42 -0
- package/skills/link-us/templates/implements.yaml +16 -0
- package/skills/tdd/SKILL.md +109 -0
- package/skills/tdd/deep-modules.md +33 -0
- package/skills/tdd/interface-design.md +31 -0
- package/skills/tdd/mocking.md +59 -0
- package/skills/tdd/refactoring.md +10 -0
- package/skills/tdd/tests.md +61 -0
- package/templates/adr.md +43 -0
- package/templates/commit-msg +48 -0
- package/templates/definition-of-done.md +50 -0
- package/templates/definition-of-ready.md +51 -0
- package/templates/epica.md +62 -0
- package/templates/formato-us.md +129 -0
- package/templates/pull-request.md +62 -0
package/cli/dai.mjs
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dai · CLI de acciones deterministas de la metodología (ADR-0002, capa 2).
|
|
3
|
+
// Lo mecánico vive acá, no en la inteligencia del asistente: así el output es
|
|
4
|
+
// idéntico con Claude, con Copilot, o sin ningún asistente.
|
|
5
|
+
//
|
|
6
|
+
// Uso:
|
|
7
|
+
// dai ac-hash <us.md> Calcula el ac_hash de una US (ADR-0001). Sin archivo, stdin.
|
|
8
|
+
// dai ls [--json] [--root <d>] Lista las US que implementa el repo (ADR-0005).
|
|
9
|
+
// dai link-us <KEY> --us <md> Crea branch + implements.yaml atados al KEY (ADR-0004).
|
|
10
|
+
// [--repo r] [--change c] [--title t] [--autor a] [--base b] [--dry-run]
|
|
11
|
+
// dai version
|
|
12
|
+
//
|
|
13
|
+
// El link del tracker sale de DAI_TRACKER_URL_TEMPLATE (p. ej. "https://jira/browse/{id}").
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, existsSync, rmSync, cpSync, readdirSync, statSync } from "node:fs";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, join, basename, relative } from "node:path";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { createInterface } from "node:readline/promises";
|
|
21
|
+
import { acHash } from "./lib/ac-hash.mjs";
|
|
22
|
+
import { discoverImplements, isPlaceholderId } from "./lib/implements.mjs";
|
|
23
|
+
import { isValidKey, slugify, branchName, extractTitle, renderImplementsYaml } from "./lib/link-us.mjs";
|
|
24
|
+
import { loadEnv } from "./lib/env.mjs";
|
|
25
|
+
import { getAdapter, coverageStatus, statusLabel } from "./lib/pm-adapter.mjs";
|
|
26
|
+
import { branchUrl, commitUrl, parseRemote, detectForge } from "./lib/forge-url.mjs";
|
|
27
|
+
import { parsePrRef, getPR, postComment } from "./lib/forge-api.mjs";
|
|
28
|
+
import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
|
|
29
|
+
import { dirsEqual } from "./lib/fsutil.mjs";
|
|
30
|
+
import { parseFlags } from "./lib/args.mjs";
|
|
31
|
+
import { skillToPrompt, constitution, envFor } from "./lib/bootstrap.mjs";
|
|
32
|
+
|
|
33
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
|
|
35
|
+
// No crashear si la salida se pipea a algo que cierra temprano (p. ej. `| head`).
|
|
36
|
+
process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); throw e; });
|
|
37
|
+
|
|
38
|
+
function fail(msg, code = 1) { process.stderr.write("dai: " + msg + "\n"); process.exit(code); }
|
|
39
|
+
const ok = (m) => process.stdout.write(`✓ ${m}\n`);
|
|
40
|
+
const info = (m) => process.stdout.write(`› ${m}\n`);
|
|
41
|
+
const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
|
|
42
|
+
const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
|
|
43
|
+
const SKILLS_DIR = process.env.CLAUDE_SKILLS_DIR || join(homedir(), ".claude", "skills");
|
|
44
|
+
function git(args, opts = {}) {
|
|
45
|
+
// stderr en 'pipe' (no 'inherit') para no filtrar errores de git a la salida;
|
|
46
|
+
// quedan en e.stderr para quien quiera inspeccionarlos.
|
|
47
|
+
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts }).trim();
|
|
48
|
+
}
|
|
49
|
+
// En Windows los binarios instalados por npm (npm, openspec) son shims `.cmd` que
|
|
50
|
+
// execFileSync no resuelve solo. git/gh/glab son `.exe` y se resuelven normal.
|
|
51
|
+
const npmBin = (name) => (process.platform === "win32" ? `${name}.cmd` : name);
|
|
52
|
+
function trackerUrl(id) {
|
|
53
|
+
const tpl = process.env.DAI_TRACKER_URL_TEMPLATE;
|
|
54
|
+
return tpl ? tpl.replace("{id}", id) : id;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── ac-hash ─────────────────────────────────────────────────────────────────
|
|
58
|
+
function cmdAcHash(arg) {
|
|
59
|
+
const md = arg ? readFileSync(arg, "utf8") : readFileSync(0, "utf8");
|
|
60
|
+
const h = acHash(md);
|
|
61
|
+
if (h == null) fail("la US no tiene bloque de 'Criterios de aceptación'.", 2);
|
|
62
|
+
process.stdout.write(h + "\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── ls ──────────────────────────────────────────────────────────────────────
|
|
66
|
+
function cmdLs(opts) {
|
|
67
|
+
const root = opts.root || process.cwd();
|
|
68
|
+
const found = discoverImplements(root);
|
|
69
|
+
const rows = [];
|
|
70
|
+
for (const f of found) {
|
|
71
|
+
for (const im of f.implements || []) {
|
|
72
|
+
if (isPlaceholderId(im.id)) continue; // saltea plantillas sin completar
|
|
73
|
+
rows.push({ change: f.change, repo: f.repo, id: im.id, version: im.version,
|
|
74
|
+
ac_hash: im.ac_hash, link: trackerUrl(im.id) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (opts.json) { process.stdout.write(JSON.stringify(rows, null, 2) + "\n"); return; }
|
|
78
|
+
if (rows.length === 0) { process.stdout.write("No hay implements.yaml en " + root + "\n"); return; }
|
|
79
|
+
// Ancho de la columna CHANGE adaptado al contenido (mín 8, máx 40).
|
|
80
|
+
const cw = Math.min(40, Math.max(8, ...rows.map((r) => String(r.change ?? "").length)));
|
|
81
|
+
const w = (s, n) => { const t = String(s ?? ""); return (t.length > n ? t.slice(0, n - 1) + "…" : t).padEnd(n); };
|
|
82
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
83
|
+
process.stdout.write(pad("CHANGE", cw + 2) + pad("US", 12) + pad("VER", 6) + pad("AC_HASH", 11) + "LINK\n");
|
|
84
|
+
for (const r of rows) {
|
|
85
|
+
process.stdout.write(w(r.change, cw) + " " + pad(r.id, 12) + pad(r.version, 6) + pad(r.ac_hash, 11) + (r.link || "") + "\n");
|
|
86
|
+
}
|
|
87
|
+
const repo = rows[0]?.repo ? ` en ${rows[0].repo}` : "";
|
|
88
|
+
process.stdout.write(`${rows.length} US implementada(s)${repo}\n`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── link-us ───────────────────────────────────────────────────────────────────
|
|
92
|
+
async function cmdLinkUs(key, opts) {
|
|
93
|
+
if (!isValidKey(key)) fail(`key inválido: '${key}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
|
|
94
|
+
|
|
95
|
+
let title, hash, version = "v1";
|
|
96
|
+
if (opts.us) {
|
|
97
|
+
// Fuente local: un .md con la US.
|
|
98
|
+
const md = readFileSync(opts.us, "utf8");
|
|
99
|
+
hash = acHash(md);
|
|
100
|
+
if (hash == null) fail("la US no tiene 'Criterios de aceptación' → no se puede calcular ac_hash.", 2);
|
|
101
|
+
title = opts.title || extractTitle(md);
|
|
102
|
+
} else {
|
|
103
|
+
// Fuente tracker: traer la US del adaptador (mismo hash que usará `dai check`).
|
|
104
|
+
loadEnv();
|
|
105
|
+
const adapter = getAdapter(process.env);
|
|
106
|
+
const us = await adapter.fetchUS(key);
|
|
107
|
+
if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.`, 2);
|
|
108
|
+
hash = us.ac_hash;
|
|
109
|
+
if (hash == null) fail(`la US ${key} no tiene 'Criterios de aceptación' → sin ac_hash.`, 2);
|
|
110
|
+
title = opts.title || us.title;
|
|
111
|
+
version = us.spec_version || "v1";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── modo resync: re-estampar el ac_hash en el implements.yaml existente ──────
|
|
115
|
+
if (opts.resync) {
|
|
116
|
+
const target = discoverImplements(process.cwd())
|
|
117
|
+
.find((f) => (f.implements || []).some((im) => im.id === key));
|
|
118
|
+
if (!target) fail(`no encontré un implements.yaml que implemente ${key}. ¿Estás en la branch correcta?`, 1);
|
|
119
|
+
const prev = (target.implements.find((im) => im.id === key) || {}).ac_hash;
|
|
120
|
+
if (prev === hash) { ok(`${key} ya estaba al día (${hash}). Nada que resincronizar.`); return; }
|
|
121
|
+
let txt = readFileSync(target.path, "utf8");
|
|
122
|
+
txt = txt.replace(/^(\s*ac_hash:\s*).*$/m, `$1${hash}`).replace(/^(\s*version:\s*).*$/m, `$1${version}`);
|
|
123
|
+
if (opts.dryRun) { process.stdout.write(`[dry-run] ${target.path}: ac_hash ${prev} → ${hash} (version ${version})\n`); return; }
|
|
124
|
+
writeFileSync(target.path, txt);
|
|
125
|
+
ok(`${key} resincronizado: ac_hash ${prev} → ${hash} (${version}) en ${target.path}`);
|
|
126
|
+
warn("revisa si tu implementación cubre el criterio nuevo, y ejecuta tus tests.");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!title) fail("no pude extraer el título de la US; pasa --title.", 1);
|
|
131
|
+
const branch = branchName(key, title);
|
|
132
|
+
const change = opts.change || slugify(title);
|
|
133
|
+
const repo = opts.repo || gitRepoName() || basename(process.cwd());
|
|
134
|
+
const autor = opts.autor || gitUser() || "<dev>";
|
|
135
|
+
const yamlPath = join("openspec", "changes", change, "implements.yaml");
|
|
136
|
+
const yaml = renderImplementsYaml({ change, repo, id: key, version, ac_hash: hash, autor });
|
|
137
|
+
|
|
138
|
+
if (opts.dryRun) {
|
|
139
|
+
process.stdout.write(`[dry-run]\n branch: ${branch}\n archivo: ${yamlPath}\n ac_hash: ${hash}\n\n${yaml}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Efectos: crear branch + escribir el implements.yaml
|
|
143
|
+
const base = opts.base ? [opts.base] : [];
|
|
144
|
+
try { git(["checkout", "-b", branch, ...base]); }
|
|
145
|
+
catch (e) { fail(`no pude crear la branch '${branch}': ${String(e.message).split("\n")[0]}`, 1); }
|
|
146
|
+
if (existsSync(yamlPath)) fail(`ya existe ${yamlPath} — no lo piso.`, 1);
|
|
147
|
+
mkdirSync(dirname(yamlPath), { recursive: true });
|
|
148
|
+
writeFileSync(yamlPath, yaml);
|
|
149
|
+
process.stdout.write(`✓ branch: ${branch}\n✓ archivo: ${yamlPath} (ac_hash ${hash})\n`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function gitRepoName() {
|
|
153
|
+
try { return basename(git(["remote", "get-url", "origin"])).replace(/\.git$/, ""); }
|
|
154
|
+
catch { return null; }
|
|
155
|
+
}
|
|
156
|
+
function gitUser() {
|
|
157
|
+
try { return git(["config", "user.name"]); } catch { return null; }
|
|
158
|
+
}
|
|
159
|
+
function gitRemote() { try { return git(["remote", "get-url", "origin"]); } catch { return null; } }
|
|
160
|
+
function gitBranch() { try { return git(["rev-parse", "--abbrev-ref", "HEAD"]); } catch { return null; } }
|
|
161
|
+
function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return null; } }
|
|
162
|
+
|
|
163
|
+
// ── check ──────────────────────────────────────────────────────────────────
|
|
164
|
+
async function cmdCheck() {
|
|
165
|
+
loadEnv();
|
|
166
|
+
const adapter = getAdapter(process.env);
|
|
167
|
+
const found = discoverImplements(process.cwd());
|
|
168
|
+
let worst = 0, n = 0;
|
|
169
|
+
const atrasadas = [];
|
|
170
|
+
for (const f of found) for (const im of f.implements || []) {
|
|
171
|
+
if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
|
|
172
|
+
n++;
|
|
173
|
+
const live = await adapter.fetchUS(im.id);
|
|
174
|
+
const status = coverageStatus(im.ac_hash, live?.ac_hash);
|
|
175
|
+
if (status === "al-dia") process.stdout.write(`✅ ${im.id} al día (${im.version})\n`);
|
|
176
|
+
else if (status === "atrasado") {
|
|
177
|
+
process.stdout.write(`⚠️ ${im.id} ATRASADO: implementaste ${im.ac_hash}, la US viva es ${live.ac_hash}${live.spec_version ? ` (${live.spec_version})` : ""}\n`);
|
|
178
|
+
atrasadas.push(im.id);
|
|
179
|
+
worst = Math.max(worst, 1);
|
|
180
|
+
} else {
|
|
181
|
+
process.stdout.write(`❓ ${im.id}: no encontré la US (backend ${adapter.kind}). ¿Falta el .md o el token?\n`);
|
|
182
|
+
worst = Math.max(worst, 2);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (n === 0) process.stdout.write("No hay implements.yaml para chequear.\n");
|
|
186
|
+
if (atrasadas.length) {
|
|
187
|
+
process.stdout.write("\n El QUÉ cambió desde que lo implementaste. Para resincronizar:\n");
|
|
188
|
+
for (const id of atrasadas) process.stdout.write(` dai link-us ${id} --resync # re-estampa el ac_hash contra la US viva\n`);
|
|
189
|
+
process.stdout.write(" Después, revisa si tu implementación cubre el criterio nuevo.\n");
|
|
190
|
+
}
|
|
191
|
+
process.exit(worst);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── stamp ──────────────────────────────────────────────────────────────────
|
|
195
|
+
async function cmdStamp() {
|
|
196
|
+
loadEnv();
|
|
197
|
+
const adapter = getAdapter(process.env);
|
|
198
|
+
const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
|
|
199
|
+
const found = discoverImplements(process.cwd());
|
|
200
|
+
let n = 0;
|
|
201
|
+
for (const f of found) for (const im of f.implements || []) {
|
|
202
|
+
if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
|
|
203
|
+
n++;
|
|
204
|
+
const live = await adapter.fetchUS(im.id);
|
|
205
|
+
const status = coverageStatus(im.ac_hash, live?.ac_hash);
|
|
206
|
+
const record = {
|
|
207
|
+
repo: f.repo, change: f.change, version: im.version, ac_hash: im.ac_hash, status,
|
|
208
|
+
branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
|
|
209
|
+
};
|
|
210
|
+
const where = await adapter.stamp(im.id, record);
|
|
211
|
+
process.stdout.write(`✓ ${im.id} → ${where} (${statusLabel(status)})\n`);
|
|
212
|
+
}
|
|
213
|
+
if (n === 0) process.stdout.write("No hay implements.yaml para estampar.\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── forge (review) ───────────────────────────────────────────────────────────
|
|
217
|
+
async function cmdForge(sub, ref, opts) {
|
|
218
|
+
loadEnv();
|
|
219
|
+
const pr = parsePrRef(ref, gitRemote());
|
|
220
|
+
if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
|
|
221
|
+
if (sub === "pr") {
|
|
222
|
+
process.stdout.write(JSON.stringify(await getPR(pr, process.env), null, 2) + "\n");
|
|
223
|
+
} else if (sub === "comment") {
|
|
224
|
+
const body = opts.bodyFile ? readFileSync(opts.bodyFile, "utf8") : opts.body;
|
|
225
|
+
if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
|
|
226
|
+
const res = await postComment(pr, body, process.env);
|
|
227
|
+
process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
|
|
228
|
+
} else {
|
|
229
|
+
fail("uso: dai forge <pr|comment> <ref> [--body-file f | --body t]", 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── publish: crea la US en el tracker desde un .md (fallback del MCP) ──────────
|
|
234
|
+
async function cmdPublish(file) {
|
|
235
|
+
if (!file) fail("uso: dai publish <archivo-us.md>", 1);
|
|
236
|
+
loadEnv();
|
|
237
|
+
const md = readFileSync(file, "utf8");
|
|
238
|
+
const title = extractTitle(md);
|
|
239
|
+
if (!title) fail("no pude extraer el título de la US (falta un '# Título').", 1);
|
|
240
|
+
const adapter = getAdapter(process.env);
|
|
241
|
+
if (typeof adapter.createUS !== "function") fail(`el backend '${adapter.kind}' no soporta crear US.`, 1);
|
|
242
|
+
const r = await adapter.createUS({ title, descriptionMarkdown: md });
|
|
243
|
+
ok(`US publicada en ${adapter.kind}: ${r.id}${r.url ? ` → ${r.url}` : ""}`);
|
|
244
|
+
info(`Próximo paso (el dev abre el CÓMO): dai link-us ${r.id}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── done: cierra una US — vuelve a la base, actualiza y borra la branch local ──
|
|
248
|
+
function cmdDone(opts) {
|
|
249
|
+
const base = opts.base || "main";
|
|
250
|
+
const branch = gitBranch();
|
|
251
|
+
if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
|
|
252
|
+
if (branch === base) fail(`ya estás en '${base}' — nada que cerrar.`, 1);
|
|
253
|
+
|
|
254
|
+
// Redes de seguridad: sin cambios sueltos, sin commits sin pushear.
|
|
255
|
+
const dirty = (() => { try { return git(["status", "--porcelain"]).length > 0; } catch { return false; } })();
|
|
256
|
+
if (dirty) fail("tienes cambios sin commitear. Haz commit o stashea antes de `dai done`.", 1);
|
|
257
|
+
let remoteExists = false;
|
|
258
|
+
try { git(["rev-parse", "--verify", `origin/${branch}`]); remoteExists = true; } catch { /* sin branch remota */ }
|
|
259
|
+
if (remoteExists) {
|
|
260
|
+
const ahead = Number(git(["rev-list", "--count", `origin/${branch}..${branch}`]) || "0");
|
|
261
|
+
if (ahead > 0) fail(`tienes ${ahead} commit(s) sin pushear en '${branch}'. Pushea antes (o espera el merge).`, 1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// La US que se cierra (informativo) — leerla ANTES de cambiar de branch.
|
|
265
|
+
const usIds = [];
|
|
266
|
+
for (const f of discoverImplements(process.cwd())) for (const im of f.implements || []) if (!isPlaceholderId(im.id)) usIds.push(im.id);
|
|
267
|
+
|
|
268
|
+
// Ir a la base y actualizar.
|
|
269
|
+
info(`Cambiando a '${base}' y actualizando…`);
|
|
270
|
+
try { git(["checkout", base]); } catch (e) { fail(`no pude cambiar a '${base}': ${String(e.message).split("\n")[0]}`, 1); }
|
|
271
|
+
try { git(["fetch", "--prune"]); } catch { /* sin remoto */ }
|
|
272
|
+
try { git(["pull", "--ff-only"]); } catch { warn(`no pude hacer 'pull --ff-only' en '${base}' (¿divergió?). Revisa a mano.`); }
|
|
273
|
+
|
|
274
|
+
// Chequeo ESTRICTO de merge: la branch tiene que ser ancestro de la base actualizada.
|
|
275
|
+
let merged = false;
|
|
276
|
+
try { git(["merge-base", "--is-ancestor", branch, "HEAD"]); merged = true; } catch { merged = false; }
|
|
277
|
+
if (!merged && !opts.force) {
|
|
278
|
+
warn(`'${branch}' NO está mergeada en '${base}'. No la borro — usa 'dai done --force' si estás seguro.`);
|
|
279
|
+
ok(`Quedaste en '${base}', actualizado. La branch '${branch}' se conserva.`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
git(["branch", merged ? "-d" : "-D", branch]);
|
|
284
|
+
ok(`Borrada la branch local '${branch}'${merged ? "" : " (forzado, sin merge)"}.`);
|
|
285
|
+
} catch (e) { fail(`no pude borrar '${branch}': ${String(e.message).split("\n")[0]}`, 1); }
|
|
286
|
+
|
|
287
|
+
ok(`Listo — en '${base}', actualizado${usIds.length ? `. US cerrada: ${usIds.join(", ")}` : ""}.`);
|
|
288
|
+
info("La branch remota (si existe) la maneja el forge (auto-delete on merge) o bórrala tú.");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── pr: crea TU PROPIA PR/MR precargada desde el template + el link ────────────
|
|
292
|
+
// (Distinto de dai-review, que revisa la PR de OTRO. Tu PR la creas y revisas tú.)
|
|
293
|
+
async function cmdPr(opts) {
|
|
294
|
+
loadEnv();
|
|
295
|
+
const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
|
|
296
|
+
if (!remote) fail("no hay remoto git 'origin'. Configúralo para crear la PR.", 1);
|
|
297
|
+
if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
|
|
298
|
+
|
|
299
|
+
// 0. Estado del repo: primero avisar cambios sueltos; después elegir la base.
|
|
300
|
+
const dirty = (() => { try { return git(["status", "--porcelain"]).length > 0; } catch { return false; } })();
|
|
301
|
+
if (dirty) {
|
|
302
|
+
warn("tienes cambios SIN commitear — NO van a entrar en la PR. Haz commit lo que falte antes de crearla.");
|
|
303
|
+
if (!opts.yes && !process.stdin.isTTY) fail("working tree sucio (usa --yes para ignorarlo, pero haz commit primero).", 1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Un solo readline para todo el flujo interactivo (base + confirmación).
|
|
307
|
+
let _rl = null;
|
|
308
|
+
const ask = async (q) => {
|
|
309
|
+
if (!process.stdin.isTTY) return null; // no interactivo → sin preguntas
|
|
310
|
+
if (!_rl) _rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
311
|
+
return (await _rl.question(q)).trim();
|
|
312
|
+
};
|
|
313
|
+
const closeRl = () => { if (_rl) { _rl.close(); _rl = null; } };
|
|
314
|
+
|
|
315
|
+
// Elegir la branch base (default main). Se pregunta justo después del aviso de cambios.
|
|
316
|
+
let base = opts.base;
|
|
317
|
+
if (!base) { const ans = await ask(" ¿Contra qué branch va la PR? (main) "); base = ans || "main"; }
|
|
318
|
+
|
|
319
|
+
// Sin commits sobre la base no hay PR.
|
|
320
|
+
let ahead = null;
|
|
321
|
+
try { ahead = Number(git(["rev-list", "--count", `${base}..HEAD`])); } catch { /* base no existe local */ }
|
|
322
|
+
if (ahead === 0) {
|
|
323
|
+
fail(`no hay commits en '${branch}' por encima de '${base}'. Una PR necesita cambios: haz commit primero (git commit).`, 1);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 1. Resolver el link (US) de la branch actual.
|
|
327
|
+
const found = discoverImplements(process.cwd());
|
|
328
|
+
let entry = null;
|
|
329
|
+
for (const f of found) for (const im of f.implements || []) {
|
|
330
|
+
if (!isPlaceholderId(im.id)) { entry = { f, im }; break; }
|
|
331
|
+
}
|
|
332
|
+
if (!entry) fail("no encontré un implements.yaml con una US real. Ejecuta `dai link-us` primero.", 1);
|
|
333
|
+
const { id, version, ac_hash } = entry.im;
|
|
334
|
+
|
|
335
|
+
// 2. Estado de trazabilidad (dai check) contra la US viva.
|
|
336
|
+
const adapter = getAdapter(process.env);
|
|
337
|
+
const live = await Promise.resolve(adapter.fetchUS(id)).catch(() => null);
|
|
338
|
+
const status = coverageStatus(ac_hash, live?.ac_hash);
|
|
339
|
+
if (status === "atrasado") {
|
|
340
|
+
warn(`la US ${id} está ATRASADA respecto de tu implementación (${ac_hash} ≠ ${live?.ac_hash}).`);
|
|
341
|
+
warn(`resincroniza antes de abrir la PR: dai link-us ${id} --resync`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 3. Componer el body desde el template.
|
|
345
|
+
const tplPath = existsSync(join(process.cwd(), ".github", "pull_request_template.md"))
|
|
346
|
+
? join(process.cwd(), ".github", "pull_request_template.md")
|
|
347
|
+
: join(ROOT, "templates", "pull-request.md");
|
|
348
|
+
// Commits de la branch (para precargar "Cambios realizados").
|
|
349
|
+
let commits = [];
|
|
350
|
+
try { commits = git(["log", `${base}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* base local ausente */ }
|
|
351
|
+
const body = composePrBody(readFileSync(tplPath, "utf8"), {
|
|
352
|
+
id, version, ac_hash, status, usUrl: trackerUrl(id), usTitle: live?.title, commits,
|
|
353
|
+
branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
|
|
354
|
+
});
|
|
355
|
+
const title = prTitle(opts, id, live?.title);
|
|
356
|
+
const forge = detectForge(parseRemote(remote)?.host);
|
|
357
|
+
const tool = forgeTool(forge);
|
|
358
|
+
|
|
359
|
+
// 4. Mostrar y pedir confirmación (acción hacia afuera).
|
|
360
|
+
process.stdout.write(`\n ── Pull Request a crear ──────────────────────────────\n`);
|
|
361
|
+
process.stdout.write(` título: ${title}\n de: ${branch}\n a: ${base}\n`);
|
|
362
|
+
process.stdout.write(` forge: ${forge} (${tool})${opts.assignee ? `\n asignar: ${opts.assignee}` : ""}${opts.draft ? "\n draft: sí" : ""}\n`);
|
|
363
|
+
process.stdout.write(` ─────────────────────────────────────────────────────\n\n${body}\n`);
|
|
364
|
+
process.stdout.write(` ─────────────────────────────────────────────────────\n`);
|
|
365
|
+
|
|
366
|
+
// Archivo de paso para gh/glab: en el temp del sistema, NO en el repo (no lo ensucia).
|
|
367
|
+
const bodyFile = join(mkdtempSync(join(tmpdir(), "dai-pr-")), "body.md");
|
|
368
|
+
if (!opts.yes) {
|
|
369
|
+
if (!process.stdin.isTTY) { closeRl(); writeFileSync(bodyFile, body); info(`Body guardado en ${bodyFile}. Revisa y re-ejecuta con --yes para crear.`); return; }
|
|
370
|
+
const a = (await ask(` ¿Publico la branch y creo el PR con ${tool}? (s/N) `) || "").toLowerCase();
|
|
371
|
+
closeRl();
|
|
372
|
+
if (!["s", "si", "sí", "y", "yes"].includes(a)) {
|
|
373
|
+
writeFileSync(bodyFile, body);
|
|
374
|
+
warn(`cancelado. Guardé el body en ${bodyFile} por si quieres editarlo o crearla a mano.`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
closeRl();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 5. Publicar la branch + crear el PR/MR.
|
|
382
|
+
writeFileSync(bodyFile, body);
|
|
383
|
+
try {
|
|
384
|
+
info(`Publicando la branch ${branch}…`);
|
|
385
|
+
git(["push", "-u", "origin", branch]);
|
|
386
|
+
} catch (e) { fail(`no pude pushear la branch: ${String(e.message).split("\n")[0]}`, 1); }
|
|
387
|
+
|
|
388
|
+
const cmd = tool === "gh"
|
|
389
|
+
? ["pr", "create", "--title", title, "--body-file", bodyFile, "--base", base,
|
|
390
|
+
...(opts.assignee ? ["--assignee", opts.assignee] : []), ...(opts.draft ? ["--draft"] : [])]
|
|
391
|
+
: ["mr", "create", "--title", title, "--description", body, "--target-branch", base, "--source-branch", branch,
|
|
392
|
+
...(opts.assignee ? ["--assignee", opts.assignee] : []), ...(opts.draft ? ["--draft"] : []), "--yes"];
|
|
393
|
+
try {
|
|
394
|
+
info(`Creando el PR/MR con ${tool}…`);
|
|
395
|
+
const out = execFileSync(tool, cmd, { encoding: "utf8", cwd: process.cwd() });
|
|
396
|
+
process.stdout.write(out);
|
|
397
|
+
ok("PR/MR creada. Revísala tú y asigna el reviewer si falta.");
|
|
398
|
+
try { rmSync(bodyFile); } catch { /* noop */ }
|
|
399
|
+
} catch (e) {
|
|
400
|
+
const msg = String(e.stderr || e.message || "");
|
|
401
|
+
if (/no history in common|no commits between|not found.*base|base.*not found/i.test(msg)) {
|
|
402
|
+
warn(`la branch no comparte historia con '${base}' en el remoto (o '${base}' no existe allá).`);
|
|
403
|
+
process.stdout.write(` Suele pasar cuando el repo local y el remoto son distintos. Empuja la base primero:\n git push origin ${base}\n y vuelve a correr: dai pr\n`);
|
|
404
|
+
} else {
|
|
405
|
+
warn(`no pude crear la PR con ${tool} (¿instalado y autenticado?). El body quedó en ${bodyFile}.`);
|
|
406
|
+
process.stdout.write(` Comando listo para correr a mano:\n ${tool} ${cmd.map((c) => /\s/.test(c) ? `'${c}'` : c).join(" ")}\n`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── install: skills → ~/.claude/skills o <repo>/.claude/skills ────────────────
|
|
412
|
+
async function cmdInstall(opts) {
|
|
413
|
+
const skillsSrc = join(ROOT, "skills");
|
|
414
|
+
const skills = readdirSync(skillsSrc).filter((n) => statSync(join(skillsSrc, n)).isDirectory());
|
|
415
|
+
const interactive = process.stdin.isTTY && !opts.global && opts.local === undefined;
|
|
416
|
+
const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
417
|
+
|
|
418
|
+
const scopeFor = async (name) => {
|
|
419
|
+
if (opts.global) return { scope: "global" };
|
|
420
|
+
if (opts.local !== undefined) return { scope: "local", repo: typeof opts.local === "string" ? opts.local : process.cwd() };
|
|
421
|
+
const a = (await rl.question(`¿Dónde instalar '${name}'? [g]lobal · [l]ocal · [s]altar (g) `)).trim().toLowerCase();
|
|
422
|
+
return a === "l" ? { scope: "local", repo: process.cwd() } : a === "s" ? { scope: "skip" } : { scope: "global" };
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
info(`Instalando skills de dai`);
|
|
426
|
+
for (const name of skills) {
|
|
427
|
+
const { scope, repo } = await scopeFor(name);
|
|
428
|
+
if (scope === "skip") { warn(`salto ${name}`); continue; }
|
|
429
|
+
const targetDir = scope === "local" ? join(repo, ".claude", "skills") : SKILLS_DIR;
|
|
430
|
+
const src = join(skillsSrc, name), target = join(targetDir, name);
|
|
431
|
+
if (existsSync(target)) {
|
|
432
|
+
if (dirsEqual(src, target)) { ok(`${name} — ya instalada e idéntica, salto`); continue; }
|
|
433
|
+
if (!opts.force) {
|
|
434
|
+
if (!interactive) { warn(`${name} existe y difiere — salto (usa --force)`); continue; }
|
|
435
|
+
const a = (await rl.question(` ${name} existe y DIFIERE. ¿[p]isar · [s]altar? (s) `)).trim().toLowerCase();
|
|
436
|
+
if (a !== "p") { warn(`salto ${name}`); continue; }
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (opts.dryRun) { info(`[dry-run] ${name} → ${targetDir}`); continue; }
|
|
440
|
+
rmSync(target, { recursive: true, force: true });
|
|
441
|
+
mkdirSync(targetDir, { recursive: true });
|
|
442
|
+
cpSync(src, target, { recursive: true });
|
|
443
|
+
ok(`${name} → ${targetDir}`);
|
|
444
|
+
}
|
|
445
|
+
if (rl) rl.close();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── helpers de prompt interactivo ─────────────────────────────────────────────
|
|
449
|
+
async function askMenu(rl, title, choices, def) {
|
|
450
|
+
process.stdout.write(`\n ${title}\n`);
|
|
451
|
+
choices.forEach((c, i) => process.stdout.write(` ${i + 1}) ${c.label}${c.value === def ? " · por defecto" : ""}\n`));
|
|
452
|
+
const a = (await rl.question(" → ")).trim().toLowerCase();
|
|
453
|
+
if (!a) return def;
|
|
454
|
+
const n = Number(a);
|
|
455
|
+
if (Number.isInteger(n) && n >= 1 && n <= choices.length) return choices[n - 1].value;
|
|
456
|
+
return choices.find((c) => c.value === a)?.value || def;
|
|
457
|
+
}
|
|
458
|
+
async function askYesNo(rl, q, def = false) {
|
|
459
|
+
const a = (await rl.question(` ${q} ${def ? "(S/n)" : "(s/N)"} `)).trim().toLowerCase();
|
|
460
|
+
if (!a) return def;
|
|
461
|
+
return ["s", "si", "sí", "y", "yes"].includes(a);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Asegura que ciertas entradas estén en el .gitignore del repo (crea o agrega).
|
|
465
|
+
function ensureGitignored(repo, entries) {
|
|
466
|
+
const gi = join(repo, ".gitignore");
|
|
467
|
+
const cur = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
468
|
+
const have = new Set(cur.split(/\r?\n/).map((s) => s.trim()));
|
|
469
|
+
const add = entries.filter((e) => !have.has(e));
|
|
470
|
+
if (add.length === 0) return false;
|
|
471
|
+
writeFileSync(gi, cur + (cur && !cur.endsWith("\n") ? "\n" : "") + "\n# dai — secretos, no commitear\n" + add.join("\n") + "\n");
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── init: scaffolder interactivo del repo ─────────────────────────────────────
|
|
476
|
+
async function cmdInit(repo, opts) {
|
|
477
|
+
repo = repo || "."; // por defecto, el directorio actual (como git init / npm init)
|
|
478
|
+
if (!existsSync(repo)) fail(`no existe el directorio: ${repo}`);
|
|
479
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
480
|
+
|
|
481
|
+
process.stdout.write("\n dai · configurar este repo para desarrollo asistido por IA\n");
|
|
482
|
+
|
|
483
|
+
// Preguntas primero (después cerramos readline para liberar stdin a los instaladores).
|
|
484
|
+
let forOpt = typeof opts.for === "string" ? opts.for.toLowerCase() : null;
|
|
485
|
+
if (!forOpt && rl) forOpt = await askMenu(rl, "¿Para qué asistente de IA preparo el repo? (genera las skills en su formato)", [
|
|
486
|
+
{ value: "both", label: "Claude + Copilot — equipo mixto (recomendado ante la duda)" },
|
|
487
|
+
{ value: "claude", label: "Solo Claude (Code / Desktop)" },
|
|
488
|
+
{ value: "copilot", label: "Solo Copilot (en VS Code / JetBrains)" },
|
|
489
|
+
], "both");
|
|
490
|
+
forOpt = forOpt || "both";
|
|
491
|
+
if (!["claude", "copilot", "both"].includes(forOpt)) fail(`--for inválido: '${forOpt}' (claude|copilot|both)`);
|
|
492
|
+
|
|
493
|
+
let pm = typeof opts.pm === "string" ? opts.pm.toLowerCase() : null;
|
|
494
|
+
if (!pm && rl) pm = await askMenu(rl, "¿Dónde van a vivir las User Stories?", [
|
|
495
|
+
{ value: "md", label: "Archivos .md locales — sin credenciales, ideal para probar" },
|
|
496
|
+
{ value: "clickup", label: "ClickUp — necesita un token" }, { value: "jira", label: "Jira — necesita un token" },
|
|
497
|
+
], "md");
|
|
498
|
+
pm = pm || "md";
|
|
499
|
+
if (!["md", "clickup", "jira"].includes(pm)) fail(`--pm inválido: '${pm}' (md|clickup|jira)`);
|
|
500
|
+
|
|
501
|
+
// OpenSpec "presente" = init COMPLETA, no solo la carpeta (una openspec/ vacía o a
|
|
502
|
+
// medias no cuenta — nos pasó al anidar el instalador). Buscamos su config.
|
|
503
|
+
const openspecDir = join(repo, "openspec");
|
|
504
|
+
const hasOpenspec = existsSync(join(openspecDir, "config.yaml")) ||
|
|
505
|
+
existsSync(join(openspecDir, "project.md"));
|
|
506
|
+
const openspecPartial = existsSync(openspecDir) && !hasOpenspec; // carpeta pero sin init
|
|
507
|
+
let installOpenspec = opts.openspec === true;
|
|
508
|
+
if (!hasOpenspec && opts.openspec === undefined && rl) {
|
|
509
|
+
process.stdout.write("\n OpenSpec es el motor recomendado del CÓMO: convierte la US en design + tasks\n");
|
|
510
|
+
process.stdout.write(" (comandos /opsx:*). No está en este repo — la trazabilidad de dai anda igual sin\n");
|
|
511
|
+
process.stdout.write(" él, pero para el flujo completo conviene tenerlo.\n");
|
|
512
|
+
installOpenspec = await askYesNo(rl, "¿Instalar el CLI de OpenSpec ahora? (después ejecutas `openspec init` tú)", false);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (rl) rl.close();
|
|
516
|
+
|
|
517
|
+
// ── Generación ───────────────────────────────────────────────────────────────
|
|
518
|
+
const wantClaude = forOpt !== "copilot";
|
|
519
|
+
const wantCopilot = forOpt !== "claude";
|
|
520
|
+
process.stdout.write("\n Configurando…\n\n");
|
|
521
|
+
|
|
522
|
+
const dai = join(repo, ".dai");
|
|
523
|
+
for (const sub of ["templates", "governance"]) {
|
|
524
|
+
const src = join(ROOT, sub);
|
|
525
|
+
if (existsSync(src)) { mkdirSync(join(dai, sub), { recursive: true }); cpSync(src, join(dai, sub), { recursive: true }); }
|
|
526
|
+
}
|
|
527
|
+
writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
|
|
528
|
+
ok(".dai/ moldes (templates) + reglas (governance) del método");
|
|
529
|
+
|
|
530
|
+
cpSync(join(ROOT, ".env.example"), join(repo, ".env.example"));
|
|
531
|
+
const envPath = join(repo, ".env");
|
|
532
|
+
if (existsSync(envPath)) warn(".env ya existe — lo dejo como está");
|
|
533
|
+
else { writeFileSync(envPath, envFor(pm)); ok(`.env listo, DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
|
|
534
|
+
if (ensureGitignored(repo, [".env"])) ok(".gitignore .env agregado (los tokens NO se commitean)");
|
|
535
|
+
|
|
536
|
+
mkdirSync(join(repo, ".github"), { recursive: true });
|
|
537
|
+
cpSync(join(ROOT, "templates", "pull-request.md"), join(repo, ".github", "pull_request_template.md"));
|
|
538
|
+
ok(".github/ pull_request_template.md — molde de PR atado al link");
|
|
539
|
+
|
|
540
|
+
const skillsSrc = join(ROOT, "skills");
|
|
541
|
+
const skills = readdirSync(skillsSrc).filter((n) => statSync(join(skillsSrc, n)).isDirectory());
|
|
542
|
+
|
|
543
|
+
if (wantClaude) {
|
|
544
|
+
const dir = join(repo, ".claude", "skills");
|
|
545
|
+
mkdirSync(dir, { recursive: true });
|
|
546
|
+
for (const name of skills) cpSync(join(skillsSrc, name), join(dir, name), { recursive: true });
|
|
547
|
+
writeFileSync(join(repo, "CLAUDE.md"), constitution("claude"));
|
|
548
|
+
ok(`Claude: .claude/skills/ (${skills.length}) + CLAUDE.md → conoce el método y las skills`);
|
|
549
|
+
}
|
|
550
|
+
if (wantCopilot) {
|
|
551
|
+
const pdir = join(repo, ".github", "prompts");
|
|
552
|
+
mkdirSync(pdir, { recursive: true });
|
|
553
|
+
for (const name of skills) writeFileSync(join(pdir, `${name}.prompt.md`), skillToPrompt(readFileSync(join(skillsSrc, name, "SKILL.md"), "utf8")));
|
|
554
|
+
writeFileSync(join(repo, ".github", "copilot-instructions.md"), constitution("copilot"));
|
|
555
|
+
ok(`Copilot: .github/prompts/ (${skills.length}) + copilot-instructions.md`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── OpenSpec ───────────────────────────────────────────────────────────────
|
|
559
|
+
// OpenSpec tiene modo NO-interactivo (`openspec init --tools <lista> --force`),
|
|
560
|
+
// así que sí lo inicializamos nosotros — mapeando --for a sus tools. (Antes se
|
|
561
|
+
// dejaba a medias porque se intentaba correr su modo interactivo anidado.)
|
|
562
|
+
process.stdout.write("\n");
|
|
563
|
+
const openspecPresent = () => { try { execFileSync(npmBin("openspec"), ["--version"], { stdio: "ignore" }); return true; } catch { return false; } };
|
|
564
|
+
const osTools = { claude: "claude", copilot: "github-copilot", both: "claude,github-copilot" }[forOpt] || "claude,github-copilot";
|
|
565
|
+
const osHint = "para sumarlo después: npm i -g @fission-ai/openspec@latest && openspec init --tools " + osTools;
|
|
566
|
+
if (hasOpenspec) {
|
|
567
|
+
ok("OpenSpec: ya inicializado en el repo");
|
|
568
|
+
} else if (openspecPartial) {
|
|
569
|
+
warn("OpenSpec: hay una carpeta openspec/ a medias. Reinicializa: rm -rf openspec && openspec init --tools " + osTools + " --force");
|
|
570
|
+
} else if (installOpenspec) {
|
|
571
|
+
let cliOk = openspecPresent();
|
|
572
|
+
if (!cliOk) {
|
|
573
|
+
try { info("OpenSpec: instalando el CLI (npm i -g @fission-ai/openspec)…"); execFileSync(npmBin("npm"), ["install", "-g", "@fission-ai/openspec@latest"], { stdio: "inherit" }); cliOk = openspecPresent(); }
|
|
574
|
+
catch { cliOk = false; }
|
|
575
|
+
}
|
|
576
|
+
if (cliOk) {
|
|
577
|
+
try {
|
|
578
|
+
info(`OpenSpec: inicializando en el repo (--tools ${osTools})…`);
|
|
579
|
+
execFileSync(npmBin("openspec"), ["init", "--tools", osTools, "--force"], { stdio: "inherit", cwd: repo === "." ? process.cwd() : repo });
|
|
580
|
+
ok("OpenSpec: instalado e inicializado — genera design/tasks con /opsx:*");
|
|
581
|
+
} catch {
|
|
582
|
+
warn("OpenSpec: el CLI está pero falló `openspec init`. Ejecuta a mano en el repo:");
|
|
583
|
+
process.stdout.write(` openspec init --tools ${osTools} --force\n`);
|
|
584
|
+
}
|
|
585
|
+
} else {
|
|
586
|
+
warn("OpenSpec: no pude instalar el CLI. " + osHint);
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
warn("OpenSpec: omitido — " + osHint);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Próximos pasos ─────────────────────────────────────────────────────────
|
|
593
|
+
process.stdout.write("\n ✔ Repo configurado. Próximos pasos:\n");
|
|
594
|
+
process.stdout.write(pm === "md"
|
|
595
|
+
? " 1. Crea tu primera US en .dai/us/<ID>.md (criterios bajo '## Criterios de aceptación')\n"
|
|
596
|
+
: ` 1. Completa el token de ${pm} en .env, y verifica con: dai doctor\n`);
|
|
597
|
+
process.stdout.write(" 2. dai link-us <ID> → crea la branch + el link a la US\n");
|
|
598
|
+
process.stdout.write(" 3. Implementa con test primero, después: dai check\n");
|
|
599
|
+
process.stdout.write(" Guía paso a paso: docs/PROBAR.md\n\n");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── docs: documentación conceptual → <destino> ────────────────────────────────
|
|
603
|
+
function cmdDocs(dest) {
|
|
604
|
+
if (!dest) fail("uso: dai docs <destino>");
|
|
605
|
+
mkdirSync(dest, { recursive: true });
|
|
606
|
+
cpSync(join(ROOT, "docs"), dest, { recursive: true });
|
|
607
|
+
ok(`documentación copiada a ${dest}`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ── doctor: diagnóstico ───────────────────────────────────────────────────────
|
|
611
|
+
function cmdDoctor() {
|
|
612
|
+
loadEnv();
|
|
613
|
+
info(`dai doctor — versión v${readFileSync(join(ROOT, "VERSION"), "utf8").trim()}`);
|
|
614
|
+
|
|
615
|
+
// Una skill sirve si está en el repo actual (.claude/skills, la puso `dai init`)
|
|
616
|
+
// O global (~/.claude/skills, la puso `dai install`). Reportamos dónde.
|
|
617
|
+
const localDir = join(process.cwd(), ".claude", "skills");
|
|
618
|
+
info("skills (repo local / global ~/.claude/skills):");
|
|
619
|
+
for (const name of readdirSync(join(ROOT, "skills"))) {
|
|
620
|
+
const local = existsSync(join(localDir, name)), global = existsSync(join(SKILLS_DIR, name));
|
|
621
|
+
if (local && global) ok(`${name} (local + global)`);
|
|
622
|
+
else if (local) ok(`${name} (local, este repo)`);
|
|
623
|
+
else if (global) ok(`${name} (global)`);
|
|
624
|
+
else warn(`${name} — no instalada (dai init en el repo, o dai install global)`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
info("adaptador de PM:");
|
|
628
|
+
const pm = process.env.DAI_PM || "md";
|
|
629
|
+
ok(`DAI_PM=${pm}`);
|
|
630
|
+
if (pm === "jira") {
|
|
631
|
+
if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en el .env");
|
|
632
|
+
if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en el .env");
|
|
633
|
+
if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en el .env"); else ok("token de Jira presente");
|
|
634
|
+
process.env.DAI_JIRA_PROJECT ? ok(`proyecto=${process.env.DAI_JIRA_PROJECT} (para dai publish)`)
|
|
635
|
+
: warn("DAI_JIRA_PROJECT vacío — solo hace falta para `dai publish` (crear issues)");
|
|
636
|
+
}
|
|
637
|
+
if (pm === "clickup") {
|
|
638
|
+
if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en el .env"); else ok("token de ClickUp presente");
|
|
639
|
+
process.env.DAI_CLICKUP_LIST_ID ? ok(`lista=${process.env.DAI_CLICKUP_LIST_ID} (para dai publish)`)
|
|
640
|
+
: warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── version ───────────────────────────────────────────────────────────────────
|
|
645
|
+
function cmdVersion() {
|
|
646
|
+
process.stdout.write(`dai v${readFileSync(join(HERE, "..", "VERSION"), "utf8").trim()}\n`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let [cmd, ...rest] = process.argv.slice(2);
|
|
650
|
+
if (cmd === "--version" || cmd === "-v") cmd = "version";
|
|
651
|
+
if (cmd === "--help" || cmd === "-h") cmd = "help";
|
|
652
|
+
const { opts, pos } = parseFlags(rest);
|
|
653
|
+
switch (cmd) {
|
|
654
|
+
case "ac-hash": cmdAcHash(pos[0]); break;
|
|
655
|
+
case "ls": cmdLs(opts); break;
|
|
656
|
+
case "link-us": cmdLinkUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
657
|
+
case "check": cmdCheck().catch((e) => fail(String(e.message))); break;
|
|
658
|
+
case "stamp": cmdStamp().catch((e) => fail(String(e.message))); break;
|
|
659
|
+
case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
|
|
660
|
+
case "publish": cmdPublish(pos[0]).catch((e) => fail(String(e.message))); break;
|
|
661
|
+
case "pr": cmdPr(opts).catch((e) => fail(String(e.message))); break;
|
|
662
|
+
case "done": cmdDone(opts); break;
|
|
663
|
+
case "install": cmdInstall(opts).catch((e) => fail(String(e.message))); break;
|
|
664
|
+
case "init": cmdInit(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
665
|
+
case "docs": cmdDocs(pos[0]); break;
|
|
666
|
+
case "doctor": cmdDoctor(); break;
|
|
667
|
+
case "version": cmdVersion(); break;
|
|
668
|
+
default:
|
|
669
|
+
process.stderr.write(
|
|
670
|
+
"Uso: dai <comando> [args]\n\n" +
|
|
671
|
+
"Trazabilidad:\n" +
|
|
672
|
+
" ac-hash <us.md> calcula el ac_hash (ADR-0001)\n" +
|
|
673
|
+
" ls [--json] lista lo que implementa el repo (ADR-0005)\n" +
|
|
674
|
+
" publish <us.md> crea la US en el tracker (Jira/ClickUp/md) y devuelve el key\n" +
|
|
675
|
+
" link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
|
|
676
|
+
" link-us <KEY> --resync re-estampa el ac_hash contra la US viva (tras un ⚠️ de check)\n" +
|
|
677
|
+
" check compara vs la US viva → atrasado (ADR-0003)\n" +
|
|
678
|
+
" stamp estampa la cobertura en el tracker (ADR-0005)\n" +
|
|
679
|
+
" done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
|
|
680
|
+
" pr [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
|
|
681
|
+
" forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n\n" +
|
|
682
|
+
"Instalación:\n" +
|
|
683
|
+
" install [--global | --local <repo>] [--force] [--dry-run] skills → Claude\n" +
|
|
684
|
+
" init [<repo>] scaffolder interactivo del repo (asistente, gestor, OpenSpec)\n" +
|
|
685
|
+
" --for claude|copilot|both para qué asistente preparar el repo (default both; ante duda, both)\n" +
|
|
686
|
+
" --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
|
|
687
|
+
" docs <destino> documentación conceptual → <destino>\n" +
|
|
688
|
+
" doctor diagnóstico del entorno\n\n" +
|
|
689
|
+
" (config: .env — ver .env.example)\n"
|
|
690
|
+
);
|
|
691
|
+
process.exit(cmd && cmd !== "help" ? 1 : 0);
|
|
692
|
+
}
|