@dforce2055/dai 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{.env.example → .env.dai.example} +3 -3
- package/CHANGELOG.md +148 -0
- package/README.md +47 -26
- package/VERSION +1 -1
- package/cli/dai.mjs +528 -61
- package/cli/lib/bootstrap.mjs +44 -9
- package/cli/lib/branch-scope.mjs +144 -0
- package/cli/lib/env.mjs +12 -0
- package/cli/lib/forge-api.mjs +57 -0
- package/cli/lib/pm-adapter.mjs +16 -2
- package/cli/lib/pm-clickup.mjs +18 -3
- package/cli/lib/pm-jira.mjs +23 -3
- package/cli/lib/skills-source.mjs +8 -0
- package/cli/lib/us-format.mjs +147 -0
- package/docs/EJEMPLO-END-TO-END.md +78 -46
- package/docs/MANIFIESTO.md +2 -2
- package/docs/METODOLOGIA.md +25 -15
- package/docs/PROBAR.md +25 -15
- package/docs/SCRUM-CON-IA.md +11 -11
- package/docs/adr/0003-deteccion-y-estampado-son-comandos.md +1 -1
- package/docs/adr/0006-distribucion-y-licencia.md +1 -1
- package/docs/adr/0013-skills-externas-install-from.md +11 -4
- package/docs/adr/0015-jira-corporativo.md +1 -1
- package/docs/adr/0017-env-dai.md +64 -0
- package/docs/adr/0018-alcance-de-stamp-y-gate-de-ci.md +183 -0
- package/docs/adr/README.md +2 -0
- package/docs/detalle/01-refinamiento.md +20 -5
- package/docs/detalle/03-ramas.md +2 -2
- package/docs/detalle/04-tdd.md +15 -9
- package/docs/detalle/06-code-review.md +8 -5
- package/docs/detalle/07-merge-trazabilidad.md +13 -2
- package/docs/detalle/08-daily.md +1 -1
- package/docs/detalle/README.md +1 -1
- package/docs/glosario.md +3 -3
- package/docs/guias/dev.md +31 -7
- package/docs/guias/index.md +12 -0
- package/docs/guias/lead.md +1 -1
- package/docs/guias/po.md +53 -8
- package/docs/index.md +35 -0
- package/docs/public/favicon.svg +12 -0
- package/docs/public/logo-link.svg +12 -0
- package/docs/public/logo.svg +12 -0
- package/docs/public/tutoriales/clickup-1-settings.png +0 -0
- package/docs/public/tutoriales/clickup-2-api.png +0 -0
- package/docs/public/tutoriales/clickup-3-generate-copy.png +0 -0
- package/docs/public/tutoriales/jira-1-avatar.png +0 -0
- package/docs/public/tutoriales/jira-2-seguridad-tokens.png +0 -0
- package/docs/public/tutoriales/jira-3-crear-token.png +0 -0
- package/docs/public/tutoriales/jira-4-nombre-vencimiento.png +0 -0
- package/docs/public/tutoriales/jira-5-copiar.png +0 -0
- package/docs/tutoriales/claves-ssh.md +93 -0
- package/docs/tutoriales/configurar-git.md +53 -0
- package/docs/tutoriales/index.md +18 -0
- package/docs/tutoriales/instalar-glab.md +74 -0
- package/docs/tutoriales/token-clickup.md +73 -0
- package/docs/tutoriales/token-jira.md +74 -0
- package/governance/ci-rules.md +47 -8
- package/package.json +9 -3
- package/skills/dai-review/SKILL.md +1 -1
- package/skills/grill-epic/SKILL.md +2 -2
- package/skills/grill-intent/SKILL.md +1 -1
- package/skills/grill-user-story/SKILL.md +58 -10
- package/templates/ci-dai-gate.yml +50 -0
package/cli/dai.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
21
21
|
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
|
-
import {
|
|
24
|
+
import { loadDaiEnv } from "./lib/env.mjs";
|
|
25
25
|
import { getAdapter, coverageStatus, statusLabel } 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";
|
|
@@ -35,6 +35,9 @@ import { parseSource } from "./lib/skills-source.mjs";
|
|
|
35
35
|
import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles } from "./lib/bootstrap.mjs";
|
|
36
36
|
import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/jira-fields.mjs";
|
|
37
37
|
import { assertProjectKey } from "./lib/pm-jira.mjs";
|
|
38
|
+
import { flattenImplements, stampScope, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
|
|
39
|
+
import { describeForgeError, parseForgeError } from "./lib/forge-api.mjs";
|
|
40
|
+
import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion } from "./lib/us-format.mjs";
|
|
38
41
|
|
|
39
42
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
40
43
|
|
|
@@ -48,8 +51,36 @@ const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
|
|
|
48
51
|
// Color ANSI mínimo — solo si es TTY y no está NO_COLOR (así no ensucia pipes/CI).
|
|
49
52
|
const _color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
50
53
|
const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
|
|
51
|
-
const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m) };
|
|
54
|
+
const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m), dim: (m) => paint("2", m) };
|
|
52
55
|
const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
|
|
56
|
+
// Rutas relativas al cwd en la salida: una ruta absoluta de 120 caracteres no se lee ni
|
|
57
|
+
// se copia. Si el archivo está fuera del cwd (`../otro`), se muestra tal cual.
|
|
58
|
+
const rel = (p) => { const r = relative(process.cwd(), p); return !r || r.startsWith("..") ? p : r; };
|
|
59
|
+
|
|
60
|
+
// Banner de bienvenida de `dai init`: el Sol de Mayo en bloques (cuerpo y rayos rectos en
|
|
61
|
+
// oro; rayos ondulados en celeste) al lado del título, más el preview de lo que se configura.
|
|
62
|
+
// Todo con caracteres — cero-dep. Degrada a ASCII sin color si no hay TTY o con NO_COLOR.
|
|
63
|
+
const initBanner = () => {
|
|
64
|
+
const sun = [
|
|
65
|
+
" █ ",
|
|
66
|
+
" ▒ ▄███▄ ▒ ",
|
|
67
|
+
"██ █████ ██",
|
|
68
|
+
" ▒ ▀███▀ ▒ ",
|
|
69
|
+
" █ ",
|
|
70
|
+
];
|
|
71
|
+
const paintSun = (line) => [...line].map((ch) => (ch === "▒" ? C.cy(ch) : ch === " " ? " " : C.y(ch))).join("");
|
|
72
|
+
const aside = ["", paint("1;33", "dai") + " · Desarrollo Asistido por IA", C.dim("La IA asiste; la persona firma."), "", ""];
|
|
73
|
+
let out = "\n";
|
|
74
|
+
for (let i = 0; i < sun.length; i++) out += " " + paintSun(sun[i]) + (aside[i] ? " " + aside[i] : "") + "\n";
|
|
75
|
+
out += "\n " + C.b("Esto va a configurar el repo:") + "\n";
|
|
76
|
+
for (const b of [
|
|
77
|
+
"Skills del método (grill · link-us · tdd · dai-review) en tu asistente",
|
|
78
|
+
"La constitución del proyecto — las reglas del trabajo",
|
|
79
|
+
"OpenSpec para el CÓMO (design / tasks) — opcional",
|
|
80
|
+
"Plantilla de PR + .env.dai para el tracker",
|
|
81
|
+
]) out += " " + C.cy("▸") + " " + b + "\n";
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
53
84
|
const CLAUDE_SKILLS_DIR = process.env.CLAUDE_SKILLS_DIR || join(homedir(), ".claude", "skills");
|
|
54
85
|
const CURSOR_SKILLS_DIR = process.env.CURSOR_SKILLS_DIR || join(homedir(), ".cursor", "skills");
|
|
55
86
|
// Copilot lee las skills personales de ~/.copilot/skills (NO de ~/.claude/skills, que
|
|
@@ -119,10 +150,10 @@ async function cmdLinkUs(key, opts) {
|
|
|
119
150
|
title = opts.title || extractTitle(md);
|
|
120
151
|
} else {
|
|
121
152
|
// Fuente tracker: traer la US del adaptador (mismo hash que usará `dai check`).
|
|
122
|
-
|
|
153
|
+
loadDaiEnv();
|
|
123
154
|
const adapter = getAdapter(process.env);
|
|
124
155
|
const us = await adapter.fetchUS(key);
|
|
125
|
-
if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.`, 2);
|
|
156
|
+
if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.dai.`, 2);
|
|
126
157
|
hash = us.ac_hash;
|
|
127
158
|
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);
|
|
128
159
|
title = opts.title || us.title;
|
|
@@ -178,9 +209,84 @@ function gitRemote() { try { return git(["remote", "get-url", "origin"]); } catc
|
|
|
178
209
|
function gitBranch() { try { return git(["rev-parse", "--abbrev-ref", "HEAD"]); } catch { return null; } }
|
|
179
210
|
function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return null; } }
|
|
180
211
|
|
|
212
|
+
// ── check --ci: el gate de governance/ci-rules.md, ejecutable ────────────────
|
|
213
|
+
//
|
|
214
|
+
// La brecha del issue #26: ci-rules.md prometía "sin implements.yaml el CI bloquea",
|
|
215
|
+
// pero no existía el comando que lo hiciera. Era una regla escrita que nadie aplicaba.
|
|
216
|
+
//
|
|
217
|
+
// Lo que NO hace: exigirle US a todo. Una `chore/` o una `fix/` sin ticket son trabajo
|
|
218
|
+
// legítimo (branch-naming.md), y un gate que las bloquea se desactiva a la semana.
|
|
219
|
+
// Quién decide es requiresLink(), leyendo el nombre de la branch.
|
|
220
|
+
//
|
|
221
|
+
// Salidas: 0 = pasa · 1 = falta el link · 2 = hay link pero el QUÉ cambió (atrasado)
|
|
222
|
+
async function cmdCheckCi(opts = {}) {
|
|
223
|
+
const branch = opts.branch || process.env.DAI_CI_BRANCH || ciBranch() || gitBranch();
|
|
224
|
+
const { required, reason } = requiresLink(branch);
|
|
225
|
+
const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
|
|
226
|
+
|
|
227
|
+
info(`branch '${branch || "(desconocida)"}' — ${reason}`);
|
|
228
|
+
if (!required) {
|
|
229
|
+
if (rows.length) info(`igual declara ${rows.length} US (${rows.map((r) => r.id).join(", ")}) — se chequea su cobertura.`);
|
|
230
|
+
else { ok("gate OK — esta branch no requiere US."); process.exit(0); }
|
|
231
|
+
}
|
|
232
|
+
if (required && rows.length === 0) {
|
|
233
|
+
const ids = trackerKeysIn(branch);
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
"✗ gate: falta el link QUÉ↔CÓMO — esta branch no tiene implements.yaml.\n" +
|
|
236
|
+
` Crealo: dai link-us ${ids[0] || "<ID-DE-LA-US>"}\n` +
|
|
237
|
+
" Si NO implementa una US (tooling, deps, docs), renombrá la branch con un\n" +
|
|
238
|
+
" prefijo exento — chore/, docs/, ci/ — según governance/branch-naming.md.\n");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Hay link: que además esté al día contra la US viva. Sin token/backend eso no se
|
|
243
|
+
// puede saber, y un gate que bloquea por falta de red es un gate que se apaga:
|
|
244
|
+
// con --no-network (o sin adaptador utilizable) valida el link y no más.
|
|
245
|
+
if (opts.noNetwork) {
|
|
246
|
+
ok(`gate OK — ${rows.length} US linkeada(s): ${rows.map((r) => r.id).join(", ")} (--no-network: no se comparó contra la US viva).`);
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
loadDaiEnv();
|
|
250
|
+
let adapter;
|
|
251
|
+
try { adapter = getAdapter(process.env); }
|
|
252
|
+
catch (e) {
|
|
253
|
+
warn(`no puedo comparar contra la US viva: ${e.message}`);
|
|
254
|
+
ok(`gate OK igual — el link existe (${rows.map((r) => r.id).join(", ")}). Configurá el backend para chequear también el atraso.`);
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
let worst = 0;
|
|
258
|
+
for (const r of rows) {
|
|
259
|
+
let live = null, netErr = null;
|
|
260
|
+
try { live = await adapter.fetchUS(r.id); } catch (e) { netErr = String(e.message).split("\n")[0]; }
|
|
261
|
+
if (netErr) { warn(`${r.id}: no pude leer la US (${netErr}) — no bloqueo por un problema de red/credencial.`); continue; }
|
|
262
|
+
const status = coverageStatus(r.ac_hash, live?.ac_hash);
|
|
263
|
+
if (status === "al-dia") ok(`${r.id} al día (${r.version})`);
|
|
264
|
+
else if (status === "atrasado") {
|
|
265
|
+
process.stderr.write(`✗ gate: ${r.id} ATRASADO — implementaste ${r.ac_hash}, la US viva es ${live.ac_hash}.\n` +
|
|
266
|
+
` El QUÉ cambió. Resincronizá y revisá que lo cubras: dai link-us ${r.id} --resync\n`);
|
|
267
|
+
worst = Math.max(worst, 2);
|
|
268
|
+
} else {
|
|
269
|
+
warn(`${r.id}: no encontré la US en ${adapter.kind} — el link apunta a un ID que el tracker no tiene.`);
|
|
270
|
+
worst = Math.max(worst, 2);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (worst === 0) ok(`gate OK — ${rows.length} US linkeada(s) y al día.`);
|
|
274
|
+
process.exit(worst);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// La branch real en CI: en una PR, HEAD es un merge commit detached, así que
|
|
278
|
+
// `git rev-parse --abbrev-ref HEAD` devuelve "HEAD" y no el nombre. Cada forge la
|
|
279
|
+
// expone en su propia variable.
|
|
280
|
+
function ciBranch() {
|
|
281
|
+
const e = process.env;
|
|
282
|
+
return e.GITHUB_HEAD_REF || e.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || e.CI_COMMIT_REF_NAME ||
|
|
283
|
+
e.BITBUCKET_BRANCH || e.BUILD_SOURCEBRANCHNAME ||
|
|
284
|
+
(e.GITHUB_REF_NAME && !/^\d+\/merge$/.test(e.GITHUB_REF_NAME) ? e.GITHUB_REF_NAME : null) || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
181
287
|
// ── check ──────────────────────────────────────────────────────────────────
|
|
182
288
|
async function cmdCheck() {
|
|
183
|
-
|
|
289
|
+
loadDaiEnv();
|
|
184
290
|
const adapter = getAdapter(process.env);
|
|
185
291
|
const found = discoverImplements(process.cwd(), { includeArchived: false });
|
|
186
292
|
let worst = 0, n = 0;
|
|
@@ -210,38 +316,106 @@ async function cmdCheck() {
|
|
|
210
316
|
}
|
|
211
317
|
|
|
212
318
|
// ── stamp ──────────────────────────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
|
|
319
|
+
// Estampa la cobertura de UNA US: la de esta branch. Antes recorría todo el repo
|
|
320
|
+
// —archivados incluidos— así que cerrar una US le dejaba un comentario a las cuatro
|
|
321
|
+
// del sprint (issue #22). Ahora decide con branch-scope.mjs y, si no puede saber cuál,
|
|
322
|
+
// PREGUNTA en vez de estampar de más: un comentario en el tracker no se deshace.
|
|
323
|
+
async function cmdStamp(ids = [], opts = {}) {
|
|
324
|
+
loadDaiEnv();
|
|
215
325
|
const adapter = getAdapter(process.env);
|
|
216
326
|
const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
327
|
+
const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
|
|
328
|
+
const allRows = flattenImplements(discoverImplements(process.cwd()));
|
|
329
|
+
const scope = stampScope({ branch, rows, allRows, ids, all: !!opts.all });
|
|
330
|
+
|
|
331
|
+
if (scope.mode === "none") { process.stdout.write("No hay implements.yaml para estampar.\n"); return; }
|
|
332
|
+
if (scope.mode === "explicit" && scope.missing?.length) {
|
|
333
|
+
fail(`no encontré implements.yaml para: ${scope.missing.join(", ")}.\n` +
|
|
334
|
+
` US en este repo: ${allRows.map((r) => r.id).join(", ") || "(ninguna)"}`, 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let targets = scope.targets;
|
|
338
|
+
if (scope.mode === "ambiguous") {
|
|
339
|
+
warn(`${scope.reason}.`);
|
|
340
|
+
const listed = scope.candidates.map((r, i) => ` ${i + 1}) ${r.id} ${C.dim(`(${r.change})`)}`).join("\n");
|
|
341
|
+
process.stdout.write(` US vivas en el repo:\n${listed}\n`);
|
|
342
|
+
if (opts.yes || !process.stdin.isTTY) {
|
|
343
|
+
fail("no sé cuál estampar. Decilo explícitamente: dai stamp <ID> (o `dai stamp --all` para todas).", 1);
|
|
344
|
+
}
|
|
345
|
+
const ans = await askOrCancel(" ¿Cuál estampo? (número, varios con coma, 'a'=todas, Enter=cancelar) ");
|
|
346
|
+
if (!ans) { info("Cancelado — no se estampó nada."); return; }
|
|
347
|
+
if (/^a(ll|)$/i.test(ans)) targets = scope.candidates;
|
|
348
|
+
else {
|
|
349
|
+
const picked = ans.split(/[,\s]+/).filter(Boolean).map((t) => Number(t));
|
|
350
|
+
if (picked.some((n) => !Number.isInteger(n) || n < 1 || n > scope.candidates.length)) {
|
|
351
|
+
fail(`respuesta inválida: '${ans}'. Se esperaba número(s) entre 1 y ${scope.candidates.length}, o 'a'.`, 1);
|
|
352
|
+
}
|
|
353
|
+
targets = picked.map((n) => scope.candidates[n - 1]);
|
|
354
|
+
}
|
|
355
|
+
} else if (scope.mode !== "all") {
|
|
356
|
+
info(`${scope.reason} → estampo ${targets.map((t) => t.id).join(", ")}.`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const r of targets) {
|
|
360
|
+
const live = await adapter.fetchUS(r.id);
|
|
361
|
+
const status = coverageStatus(r.ac_hash, live?.ac_hash);
|
|
224
362
|
const record = {
|
|
225
|
-
repo:
|
|
363
|
+
repo: r.repo, change: r.change, version: r.version, ac_hash: r.ac_hash, status,
|
|
226
364
|
branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
|
|
227
365
|
};
|
|
228
|
-
const where = await adapter.stamp(
|
|
229
|
-
process.stdout.write(`✓ ${
|
|
366
|
+
const where = await adapter.stamp(r.id, record);
|
|
367
|
+
process.stdout.write(`✓ ${r.id} → ${where} (${statusLabel(status)})\n`);
|
|
230
368
|
}
|
|
231
|
-
if (
|
|
369
|
+
if (targets.length === 0) process.stdout.write("No se estampó nada.\n");
|
|
232
370
|
}
|
|
233
371
|
|
|
234
372
|
// ── forge (review) ───────────────────────────────────────────────────────────
|
|
373
|
+
// El review.json lo escribe la SKILL, no el CLI — así que `dai init`/`dai sync` son los
|
|
374
|
+
// únicos que ponían `.dai/reviews/` en el .gitignore, y un repo inicializado con una dai
|
|
375
|
+
// vieja se comía borradores a medio editar en un commit (issue #25). Acá lo arreglamos
|
|
376
|
+
// donde duele: al consumir el archivo. Aditivo, y solo si el borrador está bajo
|
|
377
|
+
// `.dai/reviews/` — si el equipo decidió versionar sus reviews en otro lado, no opinamos.
|
|
378
|
+
function ensureReviewsIgnored(fromPath) {
|
|
379
|
+
const rel = relative(process.cwd(), fromPath).replace(/\\/g, "/");
|
|
380
|
+
if (!rel.startsWith(".dai/reviews/")) return;
|
|
381
|
+
const giPath = join(process.cwd(), ".gitignore");
|
|
382
|
+
if (!existsSync(giPath)) return; // sin .gitignore no inventamos uno
|
|
383
|
+
const cur = readFileSync(giPath, "utf8");
|
|
384
|
+
const gi = reconcileGitignore(cur, {}); // {} → solo el `ensure` base (.env.dai, .dai/reviews/)
|
|
385
|
+
if (!gi.changed) return;
|
|
386
|
+
writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n");
|
|
387
|
+
info(".gitignore: agregué `.dai/reviews/` — los borradores de review no viajan en un commit.");
|
|
388
|
+
info(" Si tu equipo los quiere versionar, sacá esa línea a mano.");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Una pregunta puntual por TTY. Ctrl+D (EOF) devuelve "" — se trata como "cancelar", no
|
|
392
|
+
// como un crash: en un prompt que precede a una acción irreversible, abortar es la
|
|
393
|
+
// respuesta más segura, y "Aborted with Ctrl+D" no le dice eso a nadie.
|
|
394
|
+
async function askOrCancel(q) {
|
|
395
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
396
|
+
try { return (await rl.question(q)).trim(); }
|
|
397
|
+
catch { process.stdout.write("\n"); return ""; }
|
|
398
|
+
finally { rl.close(); }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Corre una llamada al forge y, si falla, la traduce a una causa concreta (issue #24)
|
|
402
|
+
// en vez del genérico "¿token? ¿ref correcta?".
|
|
403
|
+
async function forgeCall(pr, fn) {
|
|
404
|
+
try { return await fn(); }
|
|
405
|
+
catch (e) { fail(describeForgeError(pr, { ...parseForgeError(e), env: process.env }), 1); }
|
|
406
|
+
}
|
|
407
|
+
|
|
235
408
|
async function cmdForge(sub, ref, opts) {
|
|
236
|
-
|
|
409
|
+
loadDaiEnv();
|
|
237
410
|
const pr = parsePrRef(ref, gitRemote());
|
|
238
411
|
if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
|
|
239
412
|
if (sub === "pr") {
|
|
240
|
-
|
|
413
|
+
const j = await forgeCall(pr, () => getPR(pr, process.env));
|
|
414
|
+
process.stdout.write(JSON.stringify(j, null, 2) + "\n");
|
|
241
415
|
} else if (sub === "comment") {
|
|
242
416
|
const body = opts.bodyFile ? readFileSync(opts.bodyFile, "utf8") : opts.body;
|
|
243
417
|
if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
|
|
244
|
-
const res = await postComment(pr, body, process.env);
|
|
418
|
+
const res = await forgeCall(pr, () => postComment(pr, body, process.env));
|
|
245
419
|
process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
|
|
246
420
|
} else if (sub === "review") {
|
|
247
421
|
await cmdForgeReview(pr, opts);
|
|
@@ -259,11 +433,11 @@ async function cmdForge(sub, ref, opts) {
|
|
|
259
433
|
async function cmdForgeReview(pr, opts) {
|
|
260
434
|
if (!opts.from) fail("falta --from <review.json>. Lo escribe la skill dai-review; revisalo antes de postear.", 1);
|
|
261
435
|
const review = parseFindings(readFileSync(opts.from, "utf8"));
|
|
436
|
+
ensureReviewsIgnored(opts.from);
|
|
262
437
|
|
|
263
438
|
// El diff sale de git (local, por SSH), no de la API: es la fuente de verdad de qué
|
|
264
439
|
// línea es comentable, y no gasta rate limit.
|
|
265
|
-
const remote = await
|
|
266
|
-
if (!remote) fail("no pude leer la PR/MR del forge (¿token? ¿ref correcta?).", 1);
|
|
440
|
+
const remote = await forgeCall(pr, () => getPR(pr, process.env));
|
|
267
441
|
const base = opts.base || remote.baseRef;
|
|
268
442
|
if (!base) fail("no pude saber la branch base de la PR. Pasala con --base <branch>.", 1);
|
|
269
443
|
let diff = "";
|
|
@@ -310,7 +484,7 @@ async function cmdForgeReview(pr, opts) {
|
|
|
310
484
|
}
|
|
311
485
|
if (!kept.length && !review.summary) fail("no hay nada que postear (0 comentarios y resumen vacío).", 1);
|
|
312
486
|
|
|
313
|
-
const res = await postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env);
|
|
487
|
+
const res = await forgeCall(pr, () => postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env));
|
|
314
488
|
ok(`review posteado${res.url ? `: ${res.url}` : ""} — ${res.posted} comentario(s) en línea.`);
|
|
315
489
|
if (res.failed.length) {
|
|
316
490
|
warn(`${res.failed.length} comentario(s) NO entraron (gitlab no es atómico: el resumen y el resto SÍ están posteados):`);
|
|
@@ -332,7 +506,7 @@ function loadJiraFieldsSpec() {
|
|
|
332
506
|
|
|
333
507
|
async function cmdPublish(file, opts = {}) {
|
|
334
508
|
if (!file) fail("uso: dai publish <archivo-us.md> [--parent KEY] [--issuetype T] [--field alias=valor]", 1);
|
|
335
|
-
|
|
509
|
+
loadDaiEnv();
|
|
336
510
|
const md = readFileSync(file, "utf8");
|
|
337
511
|
const title = extractTitle(md);
|
|
338
512
|
if (!title) fail("no pude extraer el título de la US (falta un '# Título').", 1);
|
|
@@ -362,6 +536,254 @@ async function cmdPublish(file, opts = {}) {
|
|
|
362
536
|
info(`Próximo paso (el dev abre el CÓMO): dai link-us ${r.id}`);
|
|
363
537
|
}
|
|
364
538
|
|
|
539
|
+
// ── edit-us / update-us: el QUÉ cambia y el tracker se entera ────────────────
|
|
540
|
+
//
|
|
541
|
+
// Dos puertas de entrada a UN camino. La diferencia es de dónde sale el markdown:
|
|
542
|
+
//
|
|
543
|
+
// dai edit-us <ID> trae la US del tracker → la abrís en tu editor → valida → empuja
|
|
544
|
+
// dai update-us <ID> ya tenés el .md escrito (lo refinaste implementando) → empuja
|
|
545
|
+
//
|
|
546
|
+
// El tramo compartido —validar, mostrar el diff, proponer el bump de spec_version,
|
|
547
|
+
// confirmar, escribir, re-estampar el ac_hash local— es `pushUS`. No es código duplicado
|
|
548
|
+
// con dos nombres: es un solo camino con dos entradas, y por eso las dos puertas dan
|
|
549
|
+
// exactamente el mismo preview y la misma confirmación.
|
|
550
|
+
|
|
551
|
+
// Los campos propios de Jira, si el proyecto los exige (compartido por las dos puertas).
|
|
552
|
+
function fieldsFor(adapter, opts) {
|
|
553
|
+
if (opts.field === undefined) return undefined;
|
|
554
|
+
if (adapter.kind !== "jira") fail(`--field es solo para jira (DAI_PM=${adapter.kind}).`, 2);
|
|
555
|
+
return resolveJiraFields({
|
|
556
|
+
spec: loadJiraFieldsSpec(),
|
|
557
|
+
issuetype: opts.issuetype || process.env.DAI_JIRA_ISSUETYPE || "Story",
|
|
558
|
+
overrides: parseFieldOverrides(asList(opts.field)),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// El backend md devuelve SINCRÓNICO (el contrato del adaptador lo permite), así que
|
|
563
|
+
// `adapter.fetchUS(id).catch(...)` explotaba con DAI_PM=md. Se normaliza a promesa.
|
|
564
|
+
const fetchLive = (adapter, id) => Promise.resolve().then(() => adapter.fetchUS(id)).catch(() => null);
|
|
565
|
+
|
|
566
|
+
// Valida el formato e imprime el veredicto. Devuelve el resultado; corta si hay errores.
|
|
567
|
+
function validateOrFail(md, source, { strict = false } = {}) {
|
|
568
|
+
const v = validateUS(md);
|
|
569
|
+
const lines = renderValidation(v);
|
|
570
|
+
process.stdout.write(`\n ── formato de la US (${source}) ────────────\n`);
|
|
571
|
+
for (const l of lines) process.stdout.write(l + "\n");
|
|
572
|
+
if (!v.ok) {
|
|
573
|
+
process.stdout.write("\n");
|
|
574
|
+
fail("la US no tiene el formato mínimo para viajar al tracker (ver arriba).\n" +
|
|
575
|
+
" El molde canónico está en .dai/templates/formato-us.md, y /grill-user-story te interroga hasta llegar a él.", 2);
|
|
576
|
+
}
|
|
577
|
+
if (strict && v.warnings.length) {
|
|
578
|
+
process.stdout.write("\n");
|
|
579
|
+
fail(`--strict: ${v.warnings.length} advertencia(s) y ninguna se puede ignorar en este modo.`, 2);
|
|
580
|
+
}
|
|
581
|
+
return v;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// El tramo compartido: preview → spec_version → confirmación → tracker → ac_hash local.
|
|
585
|
+
// `md` puede reescribirse acá (el bump de spec_version), por eso devuelve el markdown final.
|
|
586
|
+
async function pushUS(id, md, { adapter, file, opts, live }) {
|
|
587
|
+
const validation = validateOrFail(md, rel(file), { strict: !!opts.strict });
|
|
588
|
+
const title = opts.title || validation.title;
|
|
589
|
+
const newHash = acHash(md);
|
|
590
|
+
|
|
591
|
+
// ── spec_version: el número COMUNICA, el hash DETECTA (METODOLOGIA §4) ──────
|
|
592
|
+
// Si cambiaron los criterios se PROPONE subirlo, no se impone: dai mirando el hash no
|
|
593
|
+
// distingue un criterio nuevo de un typo corregido, y quien sabe la diferencia es el PO.
|
|
594
|
+
const curVer = parseSpecVersion(md);
|
|
595
|
+
const hashChanged = live?.ac_hash !== newHash;
|
|
596
|
+
let newVer = curVer;
|
|
597
|
+
if (hashChanged && !opts.noBump) {
|
|
598
|
+
const proposed = bumpSpecVersion(curVer);
|
|
599
|
+
if (opts.bump === true || opts.yes) newVer = proposed;
|
|
600
|
+
else if (typeof opts.bump === "string") newVer = opts.bump;
|
|
601
|
+
else if (process.stdin.isTTY) {
|
|
602
|
+
process.stdout.write(`\n Cambiaron los criterios (ac_hash ${C.dim(live?.ac_hash ?? "ninguno")} → ${C.b(newHash)}).\n`);
|
|
603
|
+
process.stdout.write(` s = cambio ${C.b("material")}: subo spec_version a ${C.b(proposed)} y los repos con ${curVer || "la versión vieja"} se marcan ATRASADOS\n`);
|
|
604
|
+
process.stdout.write(` n = cambio ${C.b("editorial")} (typo, redacción): se queda en ${curVer || "(sin versión)"}\n`);
|
|
605
|
+
const ans = await askOrCancel(` ¿Subo spec_version a ${proposed}? (S/n) `);
|
|
606
|
+
if (!/^n/i.test(ans)) newVer = proposed;
|
|
607
|
+
} else {
|
|
608
|
+
// Sin TTY y sin --bump/--no-bump no hay quién decida, y decidir por nuestra cuenta
|
|
609
|
+
// sería inventar la respuesta a la única pregunta que este comando NO puede
|
|
610
|
+
// responder solo. Se deja como está, pero se DICE — un no-op silencioso acá
|
|
611
|
+
// termina en un spec_version que dejó de comunicar nada.
|
|
612
|
+
info(`cambiaron los criterios y spec_version se queda en ${curVer || "(sin versión)"}: no hay TTY para preguntarlo.`);
|
|
613
|
+
info(` Si el cambio es material: --bump (${curVer || "v1"} → ${proposed}, marca atrasados a los repos)`);
|
|
614
|
+
info(" Si es editorial (typo): --no-bump");
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (newVer && newVer !== curVer) md = setSpecVersion(md, newVer);
|
|
618
|
+
|
|
619
|
+
// ── Preview: qué cambia ALLÁ ARRIBA. Se muestra siempre, antes de escribir nada ──
|
|
620
|
+
// "(ninguno)" también del lado del "sin cambios": imprimir el `null` crudo hace dudar
|
|
621
|
+
// de si el comando se rompió, justo en el preview que la persona lee antes de aprobar.
|
|
622
|
+
const show = (v) => v ?? "(ninguno)";
|
|
623
|
+
const chg = (from, to) => (from === to ? `${show(to)}${C.dim(" (sin cambios)")}` : `${C.dim(show(from))} → ${C.b(show(to))}`);
|
|
624
|
+
process.stdout.write(`\n ── ${id} · qué cambia en ${adapter.kind} ────────────\n`);
|
|
625
|
+
process.stdout.write(` título: ${chg(live?.title, title)}\n`);
|
|
626
|
+
process.stdout.write(` criterios: ${validation.criteria.length}\n`);
|
|
627
|
+
process.stdout.write(` version: ${chg(curVer, newVer)}\n`);
|
|
628
|
+
process.stdout.write(` ac_hash: ${chg(live?.ac_hash, newHash)}\n`);
|
|
629
|
+
process.stdout.write(` fuente: ${rel(file)}\n ──────────────────────────────────────\n`);
|
|
630
|
+
|
|
631
|
+
if (opts.dryRun) { info("[dry-run] no se tocó el tracker."); return md; }
|
|
632
|
+
if (!opts.yes && process.stdin.isTTY) {
|
|
633
|
+
const ans = await askOrCancel(` Esto PISA la US ${id} en ${adapter.kind}. ¿Guardo? (s/N) `);
|
|
634
|
+
if (!/^s|^y/i.test(ans)) { info("Cancelado — el tracker quedó como estaba."); return null; }
|
|
635
|
+
}
|
|
636
|
+
if (md !== readFileSync(file, "utf8")) writeFileSync(file, md); // el bump también queda local
|
|
637
|
+
|
|
638
|
+
const r = await adapter.updateUS(id, { title, descriptionMarkdown: md, fields: fieldsFor(adapter, opts) });
|
|
639
|
+
ok(`${id} actualizada en ${adapter.kind}${r.url ? ` → ${r.url}` : ""}`);
|
|
640
|
+
|
|
641
|
+
// Re-estampar el ac_hash local: si no, `dai check` marca atrasado por tu propia edición.
|
|
642
|
+
if (opts.noResync) { info("--no-resync: el implements.yaml quedó con el ac_hash viejo (dai check te lo va a marcar)."); return md; }
|
|
643
|
+
const target = discoverImplements(process.cwd()).find((f) => (f.implements || []).some((im) => im.id === id));
|
|
644
|
+
if (!target) { info(`sin implements.yaml para ${id} en este repo — no hay ac_hash local que resincronizar.`); return md; }
|
|
645
|
+
const prev = (target.implements.find((im) => im.id === id) || {}).ac_hash;
|
|
646
|
+
if (prev === newHash && !newVer) { ok(`ac_hash local ya estaba al día (${newHash}).`); return md; }
|
|
647
|
+
let txt = readFileSync(target.path, "utf8").replace(/^(\s*ac_hash:\s*).*$/m, `$1${newHash}`);
|
|
648
|
+
if (newVer) txt = txt.replace(/^(\s*version:\s*).*$/m, `$1${newVer}`);
|
|
649
|
+
writeFileSync(target.path, txt);
|
|
650
|
+
ok(`ac_hash re-estampado: ${prev} → ${newHash}${newVer && newVer !== curVer ? ` (${newVer})` : ""} en ${rel(target.path)}`);
|
|
651
|
+
if (prev !== newHash) warn("cambiaron los criterios: revisá que tu implementación los cubra, y corré tus tests.");
|
|
652
|
+
return md;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// El .md de trabajo de una US: explícito con --us, o el `us.md` del change que la implementa.
|
|
656
|
+
function usFileFor(id, opts, { quiet = false } = {}) {
|
|
657
|
+
if (typeof opts.us === "string") return opts.us;
|
|
658
|
+
const target = discoverImplements(process.cwd()).find((f) => (f.implements || []).some((im) => im.id === id));
|
|
659
|
+
const guess = target ? join(dirname(target.path), "us.md") : null;
|
|
660
|
+
if (guess && existsSync(guess)) {
|
|
661
|
+
if (!quiet) info(`sin --us: uso ${rel(guess)} (el change que implementa ${id}).`);
|
|
662
|
+
return guess;
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── edit-us: traer la US del tracker, editarla, validarla y guardarla ─────────
|
|
668
|
+
//
|
|
669
|
+
// Para el PO: la US vive en el tracker, no en un .md que alguien tiene que acordarse de
|
|
670
|
+
// sincronizar. Este comando la BAJA, te la abre en tu editor, valida el formato cuando
|
|
671
|
+
// guardás, te muestra qué cambia y recién ahí la sube. Si el formato no da, te deja
|
|
672
|
+
// volver al editor en vez de tirarte el trabajo.
|
|
673
|
+
async function cmdEditUs(id, opts = {}) {
|
|
674
|
+
if (!id) fail("uso: dai edit-us <ID> [--us <archivo.md>] [--strict] [--dry-run] [--yes]", 1);
|
|
675
|
+
if (!isValidKey(id)) fail(`key inválido: '${id}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
|
|
676
|
+
loadDaiEnv();
|
|
677
|
+
const adapter = getAdapter(process.env);
|
|
678
|
+
if (typeof adapter.updateUS !== "function") fail(`el backend '${adapter.kind}' no soporta actualizar US.`, 1);
|
|
679
|
+
|
|
680
|
+
const live = await fetchLive(adapter, id);
|
|
681
|
+
if (!live) {
|
|
682
|
+
fail(`no encontré la US ${id} en ${adapter.kind}. ¿Es el key correcto?\n` +
|
|
683
|
+
` Para CREARLA: dai publish <us.md> (edit-us edita una que ya existe)`, 2);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Dónde se edita: el us.md del change si existe (así el dev y el PO tocan el MISMO
|
|
687
|
+
// archivo), si no `.dai/us/<ID>.md`, que es la convención del backend md.
|
|
688
|
+
const file = usFileFor(id, opts, { quiet: true }) ||
|
|
689
|
+
join(process.env.DAI_MD_US_DIR || join(".dai", "us"), `${id}.md`);
|
|
690
|
+
|
|
691
|
+
// El cuerpo que baja del tracker. Si ya hay un .md local con el MISMO ac_hash, se
|
|
692
|
+
// respeta el local: puede tener secciones del molde (contexto, fuera de scope) que el
|
|
693
|
+
// tracker no devuelve, y pisarlas con la versión de arriba sería perder trabajo.
|
|
694
|
+
let md = live.raw || null;
|
|
695
|
+
const localExists = existsSync(file);
|
|
696
|
+
const local = localExists ? readFileSync(file, "utf8") : null;
|
|
697
|
+
if (local && acHash(local) === live.ac_hash) {
|
|
698
|
+
md = local;
|
|
699
|
+
info(`${rel(file)} ya está al día con ${adapter.kind} (ac_hash ${live.ac_hash}) — edito el local, que tiene el molde completo.`);
|
|
700
|
+
} else if (md == null) {
|
|
701
|
+
fail(`el backend '${adapter.kind}' no devuelve el cuerpo de la US, así que no puedo traerla para editar.\n` +
|
|
702
|
+
" Editá el .md a mano y empujalo con: dai update-us " + id + " --us <archivo.md>", 1);
|
|
703
|
+
} else if (localExists && typeof opts.us === "string") {
|
|
704
|
+
// Pediste ESE archivo: es la fuente, punto. Bajarle la versión del tracker encima
|
|
705
|
+
// borraría justo lo que viniste a subir — es el camino que usa /grill-user-story,
|
|
706
|
+
// que escribe la US refinada al .md y después la empuja.
|
|
707
|
+
md = local;
|
|
708
|
+
info(`${rel(file)} es tu fuente (local ${acHash(local) || "sin criterios"} vs vivo ${live.ac_hash}) — no lo piso con el tracker.`);
|
|
709
|
+
} else if (localExists) {
|
|
710
|
+
warn(`${rel(file)} existe pero DIFIERE del tracker (local ${acHash(local) || "sin criterios"} vs vivo ${live.ac_hash}).`);
|
|
711
|
+
// Ante la duda gana lo LOCAL: es trabajo que alguien escribió y que el tracker no
|
|
712
|
+
// tiene. Pisarlo es la única de las dos opciones que destruye algo.
|
|
713
|
+
if (!opts.yes && process.stdin.isTTY) {
|
|
714
|
+
const ans = await askOrCancel(" ¿Lo piso con la versión del tracker? (s/N — 'n' edita el local tal cual) ");
|
|
715
|
+
if (!/^s|^y/i.test(ans)) { md = local; info("Edito el local, sin pisarlo."); }
|
|
716
|
+
} else {
|
|
717
|
+
md = local;
|
|
718
|
+
info(`edito el local sin pisarlo (${opts.yes ? "--yes" : "no interactivo"}). Para partir del tracker, borrá ${rel(file)} o pasá otro --us.`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
723
|
+
if (md !== local) { writeFileSync(file, md); ok(`traje ${id} de ${adapter.kind} → ${rel(file)}`); }
|
|
724
|
+
|
|
725
|
+
// ── El ciclo editar → validar ──────────────────────────────────────────────
|
|
726
|
+
// Un formato inválido NO tira el trabajo: te devuelve al editor con los errores a la
|
|
727
|
+
// vista. Se sale con Ctrl+C, no perdiendo lo escrito.
|
|
728
|
+
for (;;) {
|
|
729
|
+
if (!opts.noEditor) await openEditor(file);
|
|
730
|
+
md = readFileSync(file, "utf8");
|
|
731
|
+
const v = validateUS(md);
|
|
732
|
+
if (v.ok || opts.noEditor || !process.stdin.isTTY) break;
|
|
733
|
+
process.stdout.write(`\n ── formato de la US (${rel(file)}) ────────────\n`);
|
|
734
|
+
for (const l of renderValidation(v)) process.stdout.write(l + "\n");
|
|
735
|
+
const ans = await askOrCancel("\n El formato no da. ¿Vuelvo a abrir el editor? (S/n — 'n' aborta sin tocar el tracker) ");
|
|
736
|
+
if (/^n/i.test(ans)) { info("Abortado — el tracker quedó como estaba; tu edición está en " + rel(file) + "."); return; }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
await pushUS(id, md, { adapter, file, opts, live });
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Abre $VISUAL/$EDITOR sobre el archivo. Sin editor configurado no se impone `vi`: se
|
|
743
|
+
// pide editar el archivo y volver — funciona igual en una terminal, en un IDE, o con el
|
|
744
|
+
// archivo abierto en otra ventana.
|
|
745
|
+
async function openEditor(file) {
|
|
746
|
+
const ed = process.env.VISUAL || process.env.EDITOR;
|
|
747
|
+
if (ed && process.stdin.isTTY) {
|
|
748
|
+
info(`abriendo ${rel(file)} en ${ed}…`);
|
|
749
|
+
try {
|
|
750
|
+
execFileSync(ed, [file], { stdio: "inherit", shell: process.platform === "win32" });
|
|
751
|
+
return;
|
|
752
|
+
} catch (e) {
|
|
753
|
+
warn(`no pude abrir '${ed}': ${String(e.message).split("\n")[0]}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (!process.stdin.isTTY) return;
|
|
757
|
+
if (!ed) info("no hay $EDITOR ni $VISUAL configurados.");
|
|
758
|
+
await askOrCancel(` Editá ${rel(file)} y presioná Enter cuando termines (Ctrl+C para abortar) `);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ── update-us: empuja al tracker una US que ya escribiste ─────────────────────
|
|
762
|
+
//
|
|
763
|
+
// El inverso de `dai publish`: la US ya existe con su key, la refinaste implementando (un
|
|
764
|
+
// criterio que apareció escribiendo el test) y el tracker quedó viejo. Sin esto había que
|
|
765
|
+
// copiar y pegar a mano, que es justo lo que el método no quiere (Art. 10).
|
|
766
|
+
async function cmdUpdateUs(id, opts = {}) {
|
|
767
|
+
if (!id) fail("uso: dai update-us <ID> [--us <archivo.md>] [--strict] [--no-resync] [--dry-run] [--yes]", 1);
|
|
768
|
+
if (!isValidKey(id)) fail(`key inválido: '${id}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
|
|
769
|
+
loadDaiEnv();
|
|
770
|
+
|
|
771
|
+
const file = usFileFor(id, opts);
|
|
772
|
+
if (!file) {
|
|
773
|
+
fail(`falta --us <archivo.md> con la US (no encontré un us.md junto al implements.yaml de ${id}).\n` +
|
|
774
|
+
` Si querés traerla del tracker y editarla ahí mismo: dai edit-us ${id}`, 1);
|
|
775
|
+
}
|
|
776
|
+
if (!existsSync(file)) fail(`no existe el archivo '${file}'.`, 1);
|
|
777
|
+
|
|
778
|
+
const adapter = getAdapter(process.env);
|
|
779
|
+
if (typeof adapter.updateUS !== "function") fail(`el backend '${adapter.kind}' no soporta actualizar US.`, 1);
|
|
780
|
+
const live = await fetchLive(adapter, id);
|
|
781
|
+
if (!live && adapter.kind !== "md") {
|
|
782
|
+
fail(`no encontré la US ${id} en ${adapter.kind}. ¿Es el key correcto? Para CREARLA: dai publish ${rel(file)}`, 2);
|
|
783
|
+
}
|
|
784
|
+
await pushUS(id, readFileSync(file, "utf8"), { adapter, file, opts, live });
|
|
785
|
+
}
|
|
786
|
+
|
|
365
787
|
// ── done: cierra una US — vuelve a la base, actualiza y borra la branch local ──
|
|
366
788
|
function cmdDone(opts) {
|
|
367
789
|
const base = opts.base || "main";
|
|
@@ -409,7 +831,7 @@ function cmdDone(opts) {
|
|
|
409
831
|
// ── pr: crea TU PROPIA PR/MR precargada desde el template + el link ────────────
|
|
410
832
|
// (Distinto de dai-review, que revisa la PR de OTRO. Tu PR la creas y revisas tú.)
|
|
411
833
|
async function cmdPr(opts) {
|
|
412
|
-
|
|
834
|
+
loadDaiEnv();
|
|
413
835
|
const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
|
|
414
836
|
if (!remote) fail("no hay remoto git 'origin'. Configúralo para crear la PR.", 1);
|
|
415
837
|
if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
|
|
@@ -447,7 +869,7 @@ async function cmdPr(opts) {
|
|
|
447
869
|
for (const f of found) for (const im of f.implements || []) {
|
|
448
870
|
if (!isPlaceholderId(im.id)) { entry = { f, im }; break; }
|
|
449
871
|
}
|
|
450
|
-
if (!entry) fail("no
|
|
872
|
+
if (!entry) fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
|
|
451
873
|
const { id, version, ac_hash } = entry.im;
|
|
452
874
|
|
|
453
875
|
// 2. Estado de trazabilidad (dai check) contra la US viva.
|
|
@@ -470,7 +892,7 @@ async function cmdPr(opts) {
|
|
|
470
892
|
const usUrl = usUrlFor(id, live?.url);
|
|
471
893
|
if (!usUrl) {
|
|
472
894
|
warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
|
|
473
|
-
warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env (p. ej. https://tu-tracker/browse/{id}).`);
|
|
895
|
+
warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
|
|
474
896
|
}
|
|
475
897
|
const body = composePrBody(readFileSync(tplPath, "utf8"), {
|
|
476
898
|
id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
|
|
@@ -632,7 +1054,7 @@ async function cmdInstall(opts) {
|
|
|
632
1054
|
// dai (colisión → warn + skip). `dai sync` NO las toca: es solo de dai.
|
|
633
1055
|
function cmdInstallFrom(opts) {
|
|
634
1056
|
if (typeof opts.from !== "string" || !opts.from.trim())
|
|
635
|
-
fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]) o un path local", 2);
|
|
1057
|
+
fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]), un paquete npm (npm:@scope/pkg) o un path local", 2);
|
|
636
1058
|
let want;
|
|
637
1059
|
try { want = parseAssistants(typeof opts.for === "string" ? opts.for : "all"); }
|
|
638
1060
|
catch (e) { fail(`--for ${e.message}`); }
|
|
@@ -651,6 +1073,33 @@ function cmdInstallFrom(opts) {
|
|
|
651
1073
|
try { git(args); }
|
|
652
1074
|
catch (e) { rmSync(tmp, { recursive: true, force: true }); fail(`no pude clonar la fuente: ${String(e.message).split("\n")[0]}`, 1); }
|
|
653
1075
|
root = tmp;
|
|
1076
|
+
} else if (src.type === "npm") {
|
|
1077
|
+
tmp = mkdtempSync(join(tmpdir(), "dai-skills-"));
|
|
1078
|
+
info(`bajando el paquete npm ${src.location} …`);
|
|
1079
|
+
try {
|
|
1080
|
+
// `npm install` (no `npm pack`) en el temp. Dos decisiones a propósito:
|
|
1081
|
+
// 1) install en vez de pack: los registries de GRUPO de GitLab devuelven una
|
|
1082
|
+
// `dist.tarball` malformada (el scope duplicado en el nombre) que `npm pack` sigue
|
|
1083
|
+
// literal y da 404; `npm install` —como `npx`— reconstruye la URL y resuelve.
|
|
1084
|
+
// 2) copiamos el `.npmrc` del repo al temp y corremos con cwd=temp: así npm ve el
|
|
1085
|
+
// registry/scope privado (con `--prefix` npm NO lee el `.npmrc` del cwd y se va al
|
|
1086
|
+
// registry público). `--ignore-scripts`: no corremos scripts de un paquete de 3ros.
|
|
1087
|
+
const repoNpmrc = join(process.cwd(), ".npmrc");
|
|
1088
|
+
if (existsSync(repoNpmrc)) cpSync(repoNpmrc, join(tmp, ".npmrc"));
|
|
1089
|
+
runNpmTool("npm", ["install", src.location,
|
|
1090
|
+
"--no-save", "--no-package-lock", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
1091
|
+
{ cwd: tmp, stdio: ["ignore", "ignore", "pipe"] });
|
|
1092
|
+
// El paquete queda en <tmp>/node_modules/<name>. name = spec sin la versión
|
|
1093
|
+
// (@scope/pkg@1.2.3 → @scope/pkg; el `@` inicial del scope no cuenta).
|
|
1094
|
+
let name = src.location; const at = name.lastIndexOf("@");
|
|
1095
|
+
if (at > 0) name = name.slice(0, at);
|
|
1096
|
+
root = join(tmp, "node_modules", name);
|
|
1097
|
+
if (!existsSync(root)) throw new Error(`npm install no dejó '${name}' en node_modules`);
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1100
|
+
const detail = String(e.stderr || e.stdout || e.message || "").split("\n").map((s) => s.trim()).filter(Boolean).slice(0, 3).join("\n ");
|
|
1101
|
+
fail(`no pude bajar el paquete npm '${src.location}':\n ${detail || "npm install falló — revisá el spec, el registry y el .npmrc del repo"}`, 1);
|
|
1102
|
+
}
|
|
654
1103
|
} else {
|
|
655
1104
|
root = src.location;
|
|
656
1105
|
if (!existsSync(root)) fail(`no existe la fuente: ${root}`, 2);
|
|
@@ -738,7 +1187,7 @@ async function cmdInit(repo, opts) {
|
|
|
738
1187
|
}
|
|
739
1188
|
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
740
1189
|
|
|
741
|
-
process.stdout.write(
|
|
1190
|
+
process.stdout.write(initBanner());
|
|
742
1191
|
|
|
743
1192
|
// Preguntas primero (después cerramos readline para liberar stdin a los instaladores).
|
|
744
1193
|
let forOpt = typeof opts.for === "string" ? opts.for.toLowerCase() : null;
|
|
@@ -788,29 +1237,34 @@ async function cmdInit(repo, opts) {
|
|
|
788
1237
|
}
|
|
789
1238
|
writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
|
|
790
1239
|
ok(".dai/ moldes (templates) + reglas (governance) del método");
|
|
1240
|
+
info(" gate de CI opcional: cp .dai/templates/ci-dai-gate.yml .github/workflows/");
|
|
1241
|
+
|
|
1242
|
+
// Config de dai en SUS PROPIOS archivos, sin tocar el `.env`/`.env.example` del equipo:
|
|
1243
|
+
// muchas orgs versionan el `.env` como política, así que dai lo deja en paz (solo lo lee,
|
|
1244
|
+
// por compat) y pone lo suyo en `.env.dai` (ver ADR-0017). `.env.dai.example` se versiona
|
|
1245
|
+
// como plantilla; `.env.dai` (gitignored) es donde cada dev completa token y datos propios.
|
|
1246
|
+
const envBlock = envFor(pm);
|
|
791
1247
|
|
|
792
|
-
// .env.example —
|
|
793
|
-
|
|
794
|
-
const exSrc = envFor(pm);
|
|
795
|
-
const exPath = join(repo, ".env.example");
|
|
1248
|
+
// .env.dai.example — plantilla VERSIONADA, mismas claves con valores VACÍOS (sin secretos).
|
|
1249
|
+
const exPath = join(repo, ".env.dai.example");
|
|
796
1250
|
if (existsSync(exPath)) {
|
|
797
|
-
const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur,
|
|
798
|
-
if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.example claves de dai agregadas (aditivo)"); }
|
|
799
|
-
else ok(".env.example ya tenía la config de dai");
|
|
800
|
-
} else { writeFileSync(exPath,
|
|
1251
|
+
const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur, envBlock);
|
|
1252
|
+
if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.dai.example claves de dai agregadas (aditivo)"); }
|
|
1253
|
+
else ok(".env.dai.example ya tenía la config de dai");
|
|
1254
|
+
} else { writeFileSync(exPath, envBlock); ok(".env.dai.example creado (plantilla versionada)"); }
|
|
801
1255
|
|
|
802
|
-
// .env —
|
|
803
|
-
const envPath = join(repo, ".env")
|
|
1256
|
+
// .env.dai — el real de cada dev (gitignored): aditivo; si no existe, lo crea.
|
|
1257
|
+
const envPath = join(repo, ".env.dai");
|
|
804
1258
|
if (existsSync(envPath)) {
|
|
805
1259
|
const cur = readFileSync(envPath, "utf8"), merged = mergeEnv(cur, envBlock);
|
|
806
|
-
if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env
|
|
807
|
-
else ok(".env
|
|
808
|
-
} else { writeFileSync(envPath, envBlock); ok(`.env
|
|
1260
|
+
if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env.dai claves de dai agregadas (aditivo, DAI_PM=${pm}${pm === "md" ? "" : " — completa el token"})`); }
|
|
1261
|
+
else ok(".env.dai ya tenía la config de dai");
|
|
1262
|
+
} else { writeFileSync(envPath, envBlock); ok(`.env.dai creado (no versionado), DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
|
|
809
1263
|
|
|
810
1264
|
// .gitignore — versiona los artefactos de dai (según --for), deja fuera solo lo personal.
|
|
811
1265
|
const giPath = join(repo, ".gitignore");
|
|
812
1266
|
const gi = reconcileGitignore(existsSync(giPath) ? readFileSync(giPath, "utf8") : "", want);
|
|
813
|
-
if (gi.changed) { writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"); ok(".gitignore ajustado (skills/constitución versionadas; .env y settings.local.json fuera)"); }
|
|
1267
|
+
if (gi.changed) { writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"); ok(".gitignore ajustado (skills/constitución versionadas; .env.dai y settings.local.json fuera)"); }
|
|
814
1268
|
|
|
815
1269
|
mkdirSync(join(repo, ".github"), { recursive: true });
|
|
816
1270
|
cpSync(join(ROOT, "templates", "pull-request.md"), join(repo, ".github", "pull_request_template.md"));
|
|
@@ -894,12 +1348,12 @@ async function cmdInit(repo, opts) {
|
|
|
894
1348
|
}
|
|
895
1349
|
|
|
896
1350
|
// ── Próximos pasos ─────────────────────────────────────────────────────────
|
|
897
|
-
process.stdout.write("\n ✔ Repo configurado. Próximos pasos:\n");
|
|
1351
|
+
process.stdout.write("\n " + C.y("✔") + " " + C.b("Repo configurado.") + " Próximos pasos:\n");
|
|
898
1352
|
process.stdout.write(pm === "md"
|
|
899
|
-
?
|
|
900
|
-
: ` 1.
|
|
901
|
-
process.stdout.write(
|
|
902
|
-
process.stdout.write(
|
|
1353
|
+
? ` 1. Crea tu primera US en ${C.cy(".dai/us/<ID>.md")} (criterios bajo '## Criterios de aceptación')\n`
|
|
1354
|
+
: ` 1. Copia ${C.cy(".env.dai.example")} → ${C.cy(".env.dai")} y completa el token de ${pm}; verifica con ${C.y("dai doctor")}\n`);
|
|
1355
|
+
process.stdout.write(` 2. ${C.y("dai link-us <ID>")} → crea la branch + el link a la US\n`);
|
|
1356
|
+
process.stdout.write(` 3. Implementa con test primero, después: ${C.y("dai check")}\n`);
|
|
903
1357
|
process.stdout.write(" Guía paso a paso: https://github.com/dforce2055/dai/blob/main/docs/PROBAR.md\n\n");
|
|
904
1358
|
}
|
|
905
1359
|
|
|
@@ -1031,7 +1485,7 @@ function cmdSync(repo, opts) {
|
|
|
1031
1485
|
() => writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"));
|
|
1032
1486
|
|
|
1033
1487
|
if (dry) info("dry-run: nada escrito. Quitá --dry-run para aplicar.");
|
|
1034
|
-
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"); }
|
|
1488
|
+
else { process.stdout.write("\n"); ok(`sync completo — .dai/ ahora en v${cliV}`); process.stdout.write(" (El .env.dai y OpenSpec no se tocan: OpenSpec se actualiza aparte con `openspec`.)\n"); }
|
|
1035
1489
|
}
|
|
1036
1490
|
|
|
1037
1491
|
// Imprime el estado de version-drift del scaffold (ADR-0010) con color + ícono.
|
|
@@ -1097,7 +1551,7 @@ function cmdUpgrade(opts) {
|
|
|
1097
1551
|
|
|
1098
1552
|
// ── doctor: diagnóstico ───────────────────────────────────────────────────────
|
|
1099
1553
|
function cmdDoctor() {
|
|
1100
|
-
|
|
1554
|
+
loadDaiEnv();
|
|
1101
1555
|
info(`dai doctor — versión v${readFileSync(join(ROOT, "VERSION"), "utf8").trim()}`);
|
|
1102
1556
|
|
|
1103
1557
|
// Una skill sirve si está en el repo actual (la puso `dai init`) o global (la puso
|
|
@@ -1151,11 +1605,11 @@ function cmdDoctor() {
|
|
|
1151
1605
|
const pm = process.env.DAI_PM || "md";
|
|
1152
1606
|
ok(`DAI_PM=${pm}`);
|
|
1153
1607
|
if (pm === "jira") {
|
|
1154
|
-
if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en
|
|
1155
|
-
if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en
|
|
1608
|
+
if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en .env.dai");
|
|
1609
|
+
if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en .env.dai");
|
|
1156
1610
|
// Ojo: solo miramos que el token ESTÉ, no que sirva — uno vencido pasa este chequeo
|
|
1157
1611
|
// y recién falla al publicar. Verificarlo de verdad es pegarle a la red.
|
|
1158
|
-
if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en
|
|
1612
|
+
if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en .env.dai"); else ok("token de Jira presente (no verificado: eso lo dice `dai publish`)");
|
|
1159
1613
|
if (!process.env.DAI_JIRA_PROJECT) warn("DAI_JIRA_PROJECT vacío — solo hace falta para `dai publish` (crear issues)");
|
|
1160
1614
|
else {
|
|
1161
1615
|
try { ok(`proyecto=${assertProjectKey(process.env.DAI_JIRA_PROJECT)} (para dai publish)`); }
|
|
@@ -1173,7 +1627,7 @@ function cmdDoctor() {
|
|
|
1173
1627
|
}
|
|
1174
1628
|
}
|
|
1175
1629
|
if (pm === "clickup") {
|
|
1176
|
-
if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en
|
|
1630
|
+
if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en .env.dai"); else ok("token de ClickUp presente");
|
|
1177
1631
|
process.env.DAI_CLICKUP_LIST_ID ? ok(`lista=${process.env.DAI_CLICKUP_LIST_ID} (para dai publish)`)
|
|
1178
1632
|
: warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
|
|
1179
1633
|
}
|
|
@@ -1196,8 +1650,10 @@ switch (cmd) {
|
|
|
1196
1650
|
case "ac-hash": cmdAcHash(pos[0]); break;
|
|
1197
1651
|
case "ls": cmdLs(opts); break;
|
|
1198
1652
|
case "link-us": cmdLinkUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1199
|
-
case "check": cmdCheck().catch((e) => fail(String(e.message))); break;
|
|
1200
|
-
case "stamp": cmdStamp().catch((e) => fail(String(e.message))); break;
|
|
1653
|
+
case "check": (opts.ci ? cmdCheckCi(opts) : cmdCheck()).catch((e) => fail(String(e.message))); break;
|
|
1654
|
+
case "stamp": cmdStamp(pos, opts).catch((e) => fail(String(e.message))); break;
|
|
1655
|
+
case "update-us": cmdUpdateUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1656
|
+
case "edit-us": cmdEditUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1201
1657
|
case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
|
|
1202
1658
|
case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1203
1659
|
case "pr":
|
|
@@ -1227,8 +1683,19 @@ switch (cmd) {
|
|
|
1227
1683
|
" [--field alias=valor] campos propios que exige tu Jira (.dai/jira-fields.json); repetible\n" +
|
|
1228
1684
|
" link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
|
|
1229
1685
|
" link-us <KEY> --resync re-estampa el ac_hash contra la US viva (tras un ⚠️ de check)\n" +
|
|
1686
|
+
" edit-us <KEY> trae la US del tracker, la abrís en tu editor, valida el formato,\n" +
|
|
1687
|
+
" muestra qué cambia y la guarda (para el PO)\n" +
|
|
1688
|
+
" [--no-editor] no abre $EDITOR (para skills/scripts que ya escribieron el .md)\n" +
|
|
1689
|
+
" [--bump | --no-bump] decide el spec_version sin preguntar (sin TTY no se toca y avisa)\n" +
|
|
1690
|
+
" update-us <KEY> [--us <md>] empuja al tracker un .md que ya escribiste + re-estampa el ac_hash\n" +
|
|
1691
|
+
" [--dry-run] [--yes] sin --yes muestra el diff y pide confirmación · [--no-resync]\n" +
|
|
1692
|
+
" [--strict] las advertencias de formato también frenan · [--no-bump] no toca spec_version\n" +
|
|
1230
1693
|
" check compara vs la US viva → atrasado (ADR-0003)\n" +
|
|
1231
|
-
"
|
|
1694
|
+
" check --ci gate de CI: exige el link según branch-naming (chore/ y docs/ exentas)\n" +
|
|
1695
|
+
" [--branch b] la branch a evaluar (en CI se detecta sola) · [--no-network]\n" +
|
|
1696
|
+
" salidas: 0 pasa · 1 falta el link · 2 el QUÉ cambió\n" +
|
|
1697
|
+
" stamp [<ID>…] [--all] estampa la cobertura en el tracker (ADR-0005)\n" +
|
|
1698
|
+
" sin ID: la US de esta branch; si hay varias, pregunta\n" +
|
|
1232
1699
|
" done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
|
|
1233
1700
|
" 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" +
|
|
1234
1701
|
" pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
|
|
@@ -1238,16 +1705,16 @@ switch (cmd) {
|
|
|
1238
1705
|
" Sin --yes no postea nada: muestra el preview y valida que cada hallazgo apunte al diff.\n\n" +
|
|
1239
1706
|
"Instalación:\n" +
|
|
1240
1707
|
" skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
|
|
1241
|
-
" skills install --from <git-url|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
|
|
1708
|
+
" skills install --from <git-url|npm:pkg|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
|
|
1242
1709
|
" init [<repo>] scaffolder interactivo del repo (asistente, gestor, OpenSpec)\n" +
|
|
1243
1710
|
" --for <asistentes> claude|copilot|cursor (combinables con coma) · o both|all (default all)\n" +
|
|
1244
1711
|
" ej: --for claude,cursor · --for copilot · --for all\n" +
|
|
1245
1712
|
" --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
|
|
1246
|
-
" sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env ni OpenSpec)\n" +
|
|
1713
|
+
" 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" +
|
|
1247
1714
|
" 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" +
|
|
1248
1715
|
" docs <destino> documentación conceptual → <destino>\n" +
|
|
1249
1716
|
" doctor diagnóstico del entorno\n\n" +
|
|
1250
|
-
" (config: .env — ver .env.example)\n"
|
|
1717
|
+
" (config: .env.dai — ver .env.dai.example)\n"
|
|
1251
1718
|
);
|
|
1252
1719
|
process.exit(cmd && cmd !== "help" ? 1 : 0);
|
|
1253
1720
|
}
|