@dforce2055/dai 0.10.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/CHANGELOG.md +107 -0
- package/README.md +11 -1
- package/VERSION +1 -1
- package/cli/dai.mjs +431 -20
- package/cli/lib/bootstrap.mjs +5 -2
- package/cli/lib/branch-scope.mjs +144 -0
- package/cli/lib/forge-api.mjs +57 -0
- package/cli/lib/pm-adapter.mjs +16 -2
- package/cli/lib/pm-clickup.mjs +16 -1
- package/cli/lib/pm-jira.mjs +21 -1
- package/cli/lib/us-format.mjs +147 -0
- package/docs/EJEMPLO-END-TO-END.md +26 -3
- package/docs/METODOLOGIA.md +5 -0
- package/docs/PROBAR.md +12 -1
- package/docs/SCRUM-CON-IA.md +2 -2
- package/docs/adr/0018-alcance-de-stamp-y-gate-de-ci.md +183 -0
- package/docs/adr/README.md +1 -0
- package/docs/detalle/01-refinamiento.md +14 -0
- package/docs/detalle/07-merge-trazabilidad.md +13 -2
- package/docs/glosario.md +1 -1
- package/docs/guias/dev.md +21 -0
- package/docs/guias/po.md +40 -1
- package/governance/ci-rules.md +47 -8
- package/package.json +1 -1
- 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
|
@@ -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
|
|
|
@@ -50,6 +53,9 @@ const _color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
|
50
53
|
const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
|
|
51
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; };
|
|
53
59
|
|
|
54
60
|
// Banner de bienvenida de `dai init`: el Sol de Mayo en bloques (cuerpo y rayos rectos en
|
|
55
61
|
// oro; rayos ondulados en celeste) al lado del título, más el preview de lo que se configura.
|
|
@@ -203,6 +209,81 @@ function gitRemote() { try { return git(["remote", "get-url", "origin"]); } catc
|
|
|
203
209
|
function gitBranch() { try { return git(["rev-parse", "--abbrev-ref", "HEAD"]); } catch { return null; } }
|
|
204
210
|
function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return null; } }
|
|
205
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
|
+
|
|
206
287
|
// ── check ──────────────────────────────────────────────────────────────────
|
|
207
288
|
async function cmdCheck() {
|
|
208
289
|
loadDaiEnv();
|
|
@@ -235,38 +316,106 @@ async function cmdCheck() {
|
|
|
235
316
|
}
|
|
236
317
|
|
|
237
318
|
// ── stamp ──────────────────────────────────────────────────────────────────
|
|
238
|
-
|
|
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 = {}) {
|
|
239
324
|
loadDaiEnv();
|
|
240
325
|
const adapter = getAdapter(process.env);
|
|
241
326
|
const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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);
|
|
249
362
|
const record = {
|
|
250
|
-
repo:
|
|
363
|
+
repo: r.repo, change: r.change, version: r.version, ac_hash: r.ac_hash, status,
|
|
251
364
|
branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
|
|
252
365
|
};
|
|
253
|
-
const where = await adapter.stamp(
|
|
254
|
-
process.stdout.write(`✓ ${
|
|
366
|
+
const where = await adapter.stamp(r.id, record);
|
|
367
|
+
process.stdout.write(`✓ ${r.id} → ${where} (${statusLabel(status)})\n`);
|
|
255
368
|
}
|
|
256
|
-
if (
|
|
369
|
+
if (targets.length === 0) process.stdout.write("No se estampó nada.\n");
|
|
257
370
|
}
|
|
258
371
|
|
|
259
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
|
+
|
|
260
408
|
async function cmdForge(sub, ref, opts) {
|
|
261
409
|
loadDaiEnv();
|
|
262
410
|
const pr = parsePrRef(ref, gitRemote());
|
|
263
411
|
if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
|
|
264
412
|
if (sub === "pr") {
|
|
265
|
-
|
|
413
|
+
const j = await forgeCall(pr, () => getPR(pr, process.env));
|
|
414
|
+
process.stdout.write(JSON.stringify(j, null, 2) + "\n");
|
|
266
415
|
} else if (sub === "comment") {
|
|
267
416
|
const body = opts.bodyFile ? readFileSync(opts.bodyFile, "utf8") : opts.body;
|
|
268
417
|
if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
|
|
269
|
-
const res = await postComment(pr, body, process.env);
|
|
418
|
+
const res = await forgeCall(pr, () => postComment(pr, body, process.env));
|
|
270
419
|
process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
|
|
271
420
|
} else if (sub === "review") {
|
|
272
421
|
await cmdForgeReview(pr, opts);
|
|
@@ -284,11 +433,11 @@ async function cmdForge(sub, ref, opts) {
|
|
|
284
433
|
async function cmdForgeReview(pr, opts) {
|
|
285
434
|
if (!opts.from) fail("falta --from <review.json>. Lo escribe la skill dai-review; revisalo antes de postear.", 1);
|
|
286
435
|
const review = parseFindings(readFileSync(opts.from, "utf8"));
|
|
436
|
+
ensureReviewsIgnored(opts.from);
|
|
287
437
|
|
|
288
438
|
// El diff sale de git (local, por SSH), no de la API: es la fuente de verdad de qué
|
|
289
439
|
// línea es comentable, y no gasta rate limit.
|
|
290
|
-
const remote = await
|
|
291
|
-
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));
|
|
292
441
|
const base = opts.base || remote.baseRef;
|
|
293
442
|
if (!base) fail("no pude saber la branch base de la PR. Pasala con --base <branch>.", 1);
|
|
294
443
|
let diff = "";
|
|
@@ -335,7 +484,7 @@ async function cmdForgeReview(pr, opts) {
|
|
|
335
484
|
}
|
|
336
485
|
if (!kept.length && !review.summary) fail("no hay nada que postear (0 comentarios y resumen vacío).", 1);
|
|
337
486
|
|
|
338
|
-
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));
|
|
339
488
|
ok(`review posteado${res.url ? `: ${res.url}` : ""} — ${res.posted} comentario(s) en línea.`);
|
|
340
489
|
if (res.failed.length) {
|
|
341
490
|
warn(`${res.failed.length} comentario(s) NO entraron (gitlab no es atómico: el resumen y el resto SÍ están posteados):`);
|
|
@@ -387,6 +536,254 @@ async function cmdPublish(file, opts = {}) {
|
|
|
387
536
|
info(`Próximo paso (el dev abre el CÓMO): dai link-us ${r.id}`);
|
|
388
537
|
}
|
|
389
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
|
+
|
|
390
787
|
// ── done: cierra una US — vuelve a la base, actualiza y borra la branch local ──
|
|
391
788
|
function cmdDone(opts) {
|
|
392
789
|
const base = opts.base || "main";
|
|
@@ -840,6 +1237,7 @@ async function cmdInit(repo, opts) {
|
|
|
840
1237
|
}
|
|
841
1238
|
writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
|
|
842
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/");
|
|
843
1241
|
|
|
844
1242
|
// Config de dai en SUS PROPIOS archivos, sin tocar el `.env`/`.env.example` del equipo:
|
|
845
1243
|
// muchas orgs versionan el `.env` como política, así que dai lo deja en paz (solo lo lee,
|
|
@@ -1252,8 +1650,10 @@ switch (cmd) {
|
|
|
1252
1650
|
case "ac-hash": cmdAcHash(pos[0]); break;
|
|
1253
1651
|
case "ls": cmdLs(opts); break;
|
|
1254
1652
|
case "link-us": cmdLinkUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1255
|
-
case "check": cmdCheck().catch((e) => fail(String(e.message))); break;
|
|
1256
|
-
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;
|
|
1257
1657
|
case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
|
|
1258
1658
|
case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1259
1659
|
case "pr":
|
|
@@ -1283,8 +1683,19 @@ switch (cmd) {
|
|
|
1283
1683
|
" [--field alias=valor] campos propios que exige tu Jira (.dai/jira-fields.json); repetible\n" +
|
|
1284
1684
|
" link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
|
|
1285
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" +
|
|
1286
1693
|
" check compara vs la US viva → atrasado (ADR-0003)\n" +
|
|
1287
|
-
"
|
|
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" +
|
|
1288
1699
|
" done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
|
|
1289
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" +
|
|
1290
1701
|
" pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -197,8 +197,11 @@ export function reconcileGitignore(text, want) {
|
|
|
197
197
|
if (broad.has(norm(line))) { changed = true; return false; }
|
|
198
198
|
return true;
|
|
199
199
|
});
|
|
200
|
-
|
|
201
|
-
|
|
200
|
+
// Se compara NORMALIZADO: `.dai/reviews`, `/.dai/reviews/` y `.dai/reviews/` son la
|
|
201
|
+
// misma regla para git. Comparando el texto crudo agregábamos un duplicado al repo
|
|
202
|
+
// de quien ya la había puesto a mano.
|
|
203
|
+
const have = new Set(lines.filter((l) => !l.trim().startsWith("#")).map((l) => norm(l)));
|
|
204
|
+
const add = ensure.filter((e) => !have.has(norm(e)));
|
|
202
205
|
if (add.length) {
|
|
203
206
|
changed = true;
|
|
204
207
|
if (lines.length && lines[lines.length - 1].trim() !== "") lines.push("");
|