@dforce2055/dai 0.8.2 → 0.10.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 +81 -0
- package/README.md +41 -29
- package/VERSION +1 -1
- package/cli/dai.mjs +175 -44
- package/cli/lib/bootstrap.mjs +42 -7
- package/cli/lib/env.mjs +12 -0
- package/cli/lib/forge-api.mjs +89 -2
- package/cli/lib/pm-clickup.mjs +2 -2
- package/cli/lib/pm-jira.mjs +2 -2
- package/cli/lib/review-findings.mjs +195 -0
- package/cli/lib/skills-source.mjs +8 -0
- package/docs/EJEMPLO-END-TO-END.md +52 -43
- package/docs/MANIFIESTO.md +2 -2
- package/docs/METODOLOGIA.md +20 -15
- package/docs/PROBAR.md +13 -14
- package/docs/SCRUM-CON-IA.md +10 -10
- 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/0016-review-inline.md +128 -0
- package/docs/adr/0017-env-dai.md +64 -0
- package/docs/adr/README.md +2 -0
- package/docs/detalle/01-refinamiento.md +6 -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/08-daily.md +1 -1
- package/docs/detalle/README.md +1 -1
- package/docs/glosario.md +2 -2
- package/docs/guias/dev.md +10 -7
- package/docs/guias/index.md +12 -0
- package/docs/guias/lead.md +1 -1
- package/docs/guias/po.md +13 -7
- 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/package.json +9 -3
- package/skills/dai-review/SKILL.md +96 -42
package/cli/dai.mjs
CHANGED
|
@@ -21,11 +21,12 @@ 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
|
-
import { parsePrRef, getPR, postComment } from "./lib/forge-api.mjs";
|
|
27
|
+
import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs";
|
|
28
28
|
import { trackerUrl } from "./lib/tracker-url.mjs";
|
|
29
|
+
import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
|
|
29
30
|
import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
|
|
30
31
|
import { dirsEqual } from "./lib/fsutil.mjs";
|
|
31
32
|
import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
|
|
@@ -47,8 +48,33 @@ const warn = (m) => process.stdout.write(`⚠ ${m}\n`);
|
|
|
47
48
|
// Color ANSI mínimo — solo si es TTY y no está NO_COLOR (así no ensucia pipes/CI).
|
|
48
49
|
const _color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
49
50
|
const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
|
|
50
|
-
const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m) };
|
|
51
|
+
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) };
|
|
51
52
|
const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
|
|
53
|
+
|
|
54
|
+
// Banner de bienvenida de `dai init`: el Sol de Mayo en bloques (cuerpo y rayos rectos en
|
|
55
|
+
// oro; rayos ondulados en celeste) al lado del título, más el preview de lo que se configura.
|
|
56
|
+
// Todo con caracteres — cero-dep. Degrada a ASCII sin color si no hay TTY o con NO_COLOR.
|
|
57
|
+
const initBanner = () => {
|
|
58
|
+
const sun = [
|
|
59
|
+
" █ ",
|
|
60
|
+
" ▒ ▄███▄ ▒ ",
|
|
61
|
+
"██ █████ ██",
|
|
62
|
+
" ▒ ▀███▀ ▒ ",
|
|
63
|
+
" █ ",
|
|
64
|
+
];
|
|
65
|
+
const paintSun = (line) => [...line].map((ch) => (ch === "▒" ? C.cy(ch) : ch === " " ? " " : C.y(ch))).join("");
|
|
66
|
+
const aside = ["", paint("1;33", "dai") + " · Desarrollo Asistido por IA", C.dim("La IA asiste; la persona firma."), "", ""];
|
|
67
|
+
let out = "\n";
|
|
68
|
+
for (let i = 0; i < sun.length; i++) out += " " + paintSun(sun[i]) + (aside[i] ? " " + aside[i] : "") + "\n";
|
|
69
|
+
out += "\n " + C.b("Esto va a configurar el repo:") + "\n";
|
|
70
|
+
for (const b of [
|
|
71
|
+
"Skills del método (grill · link-us · tdd · dai-review) en tu asistente",
|
|
72
|
+
"La constitución del proyecto — las reglas del trabajo",
|
|
73
|
+
"OpenSpec para el CÓMO (design / tasks) — opcional",
|
|
74
|
+
"Plantilla de PR + .env.dai para el tracker",
|
|
75
|
+
]) out += " " + C.cy("▸") + " " + b + "\n";
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
52
78
|
const CLAUDE_SKILLS_DIR = process.env.CLAUDE_SKILLS_DIR || join(homedir(), ".claude", "skills");
|
|
53
79
|
const CURSOR_SKILLS_DIR = process.env.CURSOR_SKILLS_DIR || join(homedir(), ".cursor", "skills");
|
|
54
80
|
// Copilot lee las skills personales de ~/.copilot/skills (NO de ~/.claude/skills, que
|
|
@@ -118,10 +144,10 @@ async function cmdLinkUs(key, opts) {
|
|
|
118
144
|
title = opts.title || extractTitle(md);
|
|
119
145
|
} else {
|
|
120
146
|
// Fuente tracker: traer la US del adaptador (mismo hash que usará `dai check`).
|
|
121
|
-
|
|
147
|
+
loadDaiEnv();
|
|
122
148
|
const adapter = getAdapter(process.env);
|
|
123
149
|
const us = await adapter.fetchUS(key);
|
|
124
|
-
if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.`, 2);
|
|
150
|
+
if (!us) fail(`no encontré la US ${key} en el backend ${adapter.kind}. Pasa --us <md> o revisa el .env.dai.`, 2);
|
|
125
151
|
hash = us.ac_hash;
|
|
126
152
|
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);
|
|
127
153
|
title = opts.title || us.title;
|
|
@@ -179,7 +205,7 @@ function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return
|
|
|
179
205
|
|
|
180
206
|
// ── check ──────────────────────────────────────────────────────────────────
|
|
181
207
|
async function cmdCheck() {
|
|
182
|
-
|
|
208
|
+
loadDaiEnv();
|
|
183
209
|
const adapter = getAdapter(process.env);
|
|
184
210
|
const found = discoverImplements(process.cwd(), { includeArchived: false });
|
|
185
211
|
let worst = 0, n = 0;
|
|
@@ -210,7 +236,7 @@ async function cmdCheck() {
|
|
|
210
236
|
|
|
211
237
|
// ── stamp ──────────────────────────────────────────────────────────────────
|
|
212
238
|
async function cmdStamp() {
|
|
213
|
-
|
|
239
|
+
loadDaiEnv();
|
|
214
240
|
const adapter = getAdapter(process.env);
|
|
215
241
|
const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
|
|
216
242
|
const found = discoverImplements(process.cwd());
|
|
@@ -232,7 +258,7 @@ async function cmdStamp() {
|
|
|
232
258
|
|
|
233
259
|
// ── forge (review) ───────────────────────────────────────────────────────────
|
|
234
260
|
async function cmdForge(sub, ref, opts) {
|
|
235
|
-
|
|
261
|
+
loadDaiEnv();
|
|
236
262
|
const pr = parsePrRef(ref, gitRemote());
|
|
237
263
|
if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
|
|
238
264
|
if (sub === "pr") {
|
|
@@ -242,11 +268,82 @@ async function cmdForge(sub, ref, opts) {
|
|
|
242
268
|
if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
|
|
243
269
|
const res = await postComment(pr, body, process.env);
|
|
244
270
|
process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
|
|
271
|
+
} else if (sub === "review") {
|
|
272
|
+
await cmdForgeReview(pr, opts);
|
|
245
273
|
} else {
|
|
246
|
-
fail("uso: dai forge <pr|comment> <ref> [--body-file f | --body t]", 1);
|
|
274
|
+
fail("uso: dai forge <pr|comment|review> <ref> [--body-file f | --body t | --from review.json]", 1);
|
|
247
275
|
}
|
|
248
276
|
}
|
|
249
277
|
|
|
278
|
+
// dai forge review <ref> --from review.json [--dry-run | --yes]
|
|
279
|
+
//
|
|
280
|
+
// El reparto del ADR-0002 en una función: la skill trajo el CRITERIO (el review.json),
|
|
281
|
+
// el CLI hace lo MECÁNICO — validar que cada hallazgo apunte al diff de verdad, filtrar,
|
|
282
|
+
// y postear. Lo que más valor tiene acá no es postear: es RECHAZAR lo que un LLM inventó
|
|
283
|
+
// antes de que el forge conteste 422 sin decir cuál falló.
|
|
284
|
+
async function cmdForgeReview(pr, opts) {
|
|
285
|
+
if (!opts.from) fail("falta --from <review.json>. Lo escribe la skill dai-review; revisalo antes de postear.", 1);
|
|
286
|
+
const review = parseFindings(readFileSync(opts.from, "utf8"));
|
|
287
|
+
|
|
288
|
+
// El diff sale de git (local, por SSH), no de la API: es la fuente de verdad de qué
|
|
289
|
+
// línea es comentable, y no gasta rate limit.
|
|
290
|
+
const remote = await Promise.resolve(getPR(pr, process.env)).catch(() => null);
|
|
291
|
+
if (!remote) fail("no pude leer la PR/MR del forge (¿token? ¿ref correcta?).", 1);
|
|
292
|
+
const base = opts.base || remote.baseRef;
|
|
293
|
+
if (!base) fail("no pude saber la branch base de la PR. Pasala con --base <branch>.", 1);
|
|
294
|
+
let diff = "";
|
|
295
|
+
try {
|
|
296
|
+
git(["fetch", "origin", base, remote.branch], { stdio: ["inherit", "pipe", "pipe"] });
|
|
297
|
+
diff = git(["diff", `origin/${base}...origin/${remote.branch}`]);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
const err = String(e.stderr || e.message).trim();
|
|
300
|
+
if (/couldn't find remote ref|no such ref/i.test(err)) {
|
|
301
|
+
fail(`la branch '${remote.branch}' ya no está en origin (¿la PR se mergeó y se borró la branch?). ` +
|
|
302
|
+
`Un review inline necesita el diff vivo; sobre una PR cerrada no hay dónde anclar.`, 1);
|
|
303
|
+
}
|
|
304
|
+
fail(`no pude traer el diff de ${base}...${remote.branch}: ${err}`, 1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 1. Validar contra el diff. 2. Filtrar. Nada se cae en silencio: todo se reporta.
|
|
308
|
+
const { valid, rejected } = validateFindings(review.findings, diffPositions(diff));
|
|
309
|
+
const { kept, suppressed } = filterFindings(valid, {
|
|
310
|
+
minSeverity: opts.minSeverity || "low",
|
|
311
|
+
minConfidence: opts.minConfidence ? Number(opts.minConfidence) : 0,
|
|
312
|
+
maxComments: opts.maxComments ? Number(opts.maxComments) : Infinity,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const body = renderReviewSummary(review, { kept, suppressed, rejected });
|
|
316
|
+
const comments = kept.map((f) => ({ path: f.path, line: f.line, side: f.side, body: renderFindingBody(f) }));
|
|
317
|
+
|
|
318
|
+
// ── Preview (acción hacia afuera: se muestra SIEMPRE, se postea solo con --yes) ──
|
|
319
|
+
process.stdout.write(`\n ── Review a postear en ${remote.url || `#${pr.number}`} ──────────\n`);
|
|
320
|
+
process.stdout.write(` forge: ${pr.forge}${pr.forge === "gitlab" ? " (no atómico: son N llamadas)" : " (atómico: 1 llamada)"}\n`);
|
|
321
|
+
process.stdout.write(` diff: ${base}...${remote.branch}\n`);
|
|
322
|
+
process.stdout.write(` hallazgos: ${review.findings.length} en el archivo · ${kept.length} a postear · ${suppressed.length} filtrados · ${rejected.length} descartados\n\n`);
|
|
323
|
+
for (const f of kept) process.stdout.write(` ✓ ${f.path}:${f.line} [${f.severity}] ${f.body.split("\n")[0].slice(0, 60)}\n`);
|
|
324
|
+
for (const { finding: f, reason } of suppressed) process.stdout.write(` ~ ${f.path}:${f.line} [${f.severity}] filtrado — ${reason}\n`);
|
|
325
|
+
for (const { finding: f, reason } of rejected) process.stdout.write(` ✗ ${f.path}:${f.line} [${f.severity}] DESCARTADO — ${reason}\n`);
|
|
326
|
+
process.stdout.write(`\n ───────────────────────────────────────────────\n${body}\n ───────────────────────────────────────────────\n`);
|
|
327
|
+
|
|
328
|
+
if (rejected.length) {
|
|
329
|
+
warn(`${rejected.length} hallazgo(s) NO apuntan al diff y no se postean. Corregí el 'path'/'line' en ${opts.from} o borralos.`);
|
|
330
|
+
}
|
|
331
|
+
if (opts.dryRun) { info("[dry-run] no se posteó nada."); return; }
|
|
332
|
+
if (!opts.yes) {
|
|
333
|
+
info(`Nada posteado. Revisá el preview y, si está bien: dai forge review ${pr.number} --from ${opts.from} --yes`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (!kept.length && !review.summary) fail("no hay nada que postear (0 comentarios y resumen vacío).", 1);
|
|
337
|
+
|
|
338
|
+
const res = await postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env);
|
|
339
|
+
ok(`review posteado${res.url ? `: ${res.url}` : ""} — ${res.posted} comentario(s) en línea.`);
|
|
340
|
+
if (res.failed.length) {
|
|
341
|
+
warn(`${res.failed.length} comentario(s) NO entraron (gitlab no es atómico: el resumen y el resto SÍ están posteados):`);
|
|
342
|
+
for (const f of res.failed) process.stdout.write(` ✗ ${f.path}:${f.line} — ${f.error}\n`);
|
|
343
|
+
}
|
|
344
|
+
info("La aprobación la firma un humano: dai comentó, no aprobó (Art. 5).");
|
|
345
|
+
}
|
|
346
|
+
|
|
250
347
|
// ── publish: crea la US en el tracker desde un .md (fallback del MCP) ──────────
|
|
251
348
|
|
|
252
349
|
// Los campos propios que exige el proyecto, declarados en .dai/jira-fields.json
|
|
@@ -260,7 +357,7 @@ function loadJiraFieldsSpec() {
|
|
|
260
357
|
|
|
261
358
|
async function cmdPublish(file, opts = {}) {
|
|
262
359
|
if (!file) fail("uso: dai publish <archivo-us.md> [--parent KEY] [--issuetype T] [--field alias=valor]", 1);
|
|
263
|
-
|
|
360
|
+
loadDaiEnv();
|
|
264
361
|
const md = readFileSync(file, "utf8");
|
|
265
362
|
const title = extractTitle(md);
|
|
266
363
|
if (!title) fail("no pude extraer el título de la US (falta un '# Título').", 1);
|
|
@@ -337,7 +434,7 @@ function cmdDone(opts) {
|
|
|
337
434
|
// ── pr: crea TU PROPIA PR/MR precargada desde el template + el link ────────────
|
|
338
435
|
// (Distinto de dai-review, que revisa la PR de OTRO. Tu PR la creas y revisas tú.)
|
|
339
436
|
async function cmdPr(opts) {
|
|
340
|
-
|
|
437
|
+
loadDaiEnv();
|
|
341
438
|
const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
|
|
342
439
|
if (!remote) fail("no hay remoto git 'origin'. Configúralo para crear la PR.", 1);
|
|
343
440
|
if (!branch || branch === "HEAD") fail("no estás en una branch.", 1);
|
|
@@ -375,7 +472,7 @@ async function cmdPr(opts) {
|
|
|
375
472
|
for (const f of found) for (const im of f.implements || []) {
|
|
376
473
|
if (!isPlaceholderId(im.id)) { entry = { f, im }; break; }
|
|
377
474
|
}
|
|
378
|
-
if (!entry) fail("no
|
|
475
|
+
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);
|
|
379
476
|
const { id, version, ac_hash } = entry.im;
|
|
380
477
|
|
|
381
478
|
// 2. Estado de trazabilidad (dai check) contra la US viva.
|
|
@@ -398,7 +495,7 @@ async function cmdPr(opts) {
|
|
|
398
495
|
const usUrl = usUrlFor(id, live?.url);
|
|
399
496
|
if (!usUrl) {
|
|
400
497
|
warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
|
|
401
|
-
warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env (p. ej. https://tu-tracker/browse/{id}).`);
|
|
498
|
+
warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
|
|
402
499
|
}
|
|
403
500
|
const body = composePrBody(readFileSync(tplPath, "utf8"), {
|
|
404
501
|
id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
|
|
@@ -560,7 +657,7 @@ async function cmdInstall(opts) {
|
|
|
560
657
|
// dai (colisión → warn + skip). `dai sync` NO las toca: es solo de dai.
|
|
561
658
|
function cmdInstallFrom(opts) {
|
|
562
659
|
if (typeof opts.from !== "string" || !opts.from.trim())
|
|
563
|
-
fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]) o un path local", 2);
|
|
660
|
+
fail("--from necesita una fuente: un git URL (github.com/org/skills[#ref]), un paquete npm (npm:@scope/pkg) o un path local", 2);
|
|
564
661
|
let want;
|
|
565
662
|
try { want = parseAssistants(typeof opts.for === "string" ? opts.for : "all"); }
|
|
566
663
|
catch (e) { fail(`--for ${e.message}`); }
|
|
@@ -579,6 +676,33 @@ function cmdInstallFrom(opts) {
|
|
|
579
676
|
try { git(args); }
|
|
580
677
|
catch (e) { rmSync(tmp, { recursive: true, force: true }); fail(`no pude clonar la fuente: ${String(e.message).split("\n")[0]}`, 1); }
|
|
581
678
|
root = tmp;
|
|
679
|
+
} else if (src.type === "npm") {
|
|
680
|
+
tmp = mkdtempSync(join(tmpdir(), "dai-skills-"));
|
|
681
|
+
info(`bajando el paquete npm ${src.location} …`);
|
|
682
|
+
try {
|
|
683
|
+
// `npm install` (no `npm pack`) en el temp. Dos decisiones a propósito:
|
|
684
|
+
// 1) install en vez de pack: los registries de GRUPO de GitLab devuelven una
|
|
685
|
+
// `dist.tarball` malformada (el scope duplicado en el nombre) que `npm pack` sigue
|
|
686
|
+
// literal y da 404; `npm install` —como `npx`— reconstruye la URL y resuelve.
|
|
687
|
+
// 2) copiamos el `.npmrc` del repo al temp y corremos con cwd=temp: así npm ve el
|
|
688
|
+
// registry/scope privado (con `--prefix` npm NO lee el `.npmrc` del cwd y se va al
|
|
689
|
+
// registry público). `--ignore-scripts`: no corremos scripts de un paquete de 3ros.
|
|
690
|
+
const repoNpmrc = join(process.cwd(), ".npmrc");
|
|
691
|
+
if (existsSync(repoNpmrc)) cpSync(repoNpmrc, join(tmp, ".npmrc"));
|
|
692
|
+
runNpmTool("npm", ["install", src.location,
|
|
693
|
+
"--no-save", "--no-package-lock", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
694
|
+
{ cwd: tmp, stdio: ["ignore", "ignore", "pipe"] });
|
|
695
|
+
// El paquete queda en <tmp>/node_modules/<name>. name = spec sin la versión
|
|
696
|
+
// (@scope/pkg@1.2.3 → @scope/pkg; el `@` inicial del scope no cuenta).
|
|
697
|
+
let name = src.location; const at = name.lastIndexOf("@");
|
|
698
|
+
if (at > 0) name = name.slice(0, at);
|
|
699
|
+
root = join(tmp, "node_modules", name);
|
|
700
|
+
if (!existsSync(root)) throw new Error(`npm install no dejó '${name}' en node_modules`);
|
|
701
|
+
} catch (e) {
|
|
702
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
703
|
+
const detail = String(e.stderr || e.stdout || e.message || "").split("\n").map((s) => s.trim()).filter(Boolean).slice(0, 3).join("\n ");
|
|
704
|
+
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);
|
|
705
|
+
}
|
|
582
706
|
} else {
|
|
583
707
|
root = src.location;
|
|
584
708
|
if (!existsSync(root)) fail(`no existe la fuente: ${root}`, 2);
|
|
@@ -666,7 +790,7 @@ async function cmdInit(repo, opts) {
|
|
|
666
790
|
}
|
|
667
791
|
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
668
792
|
|
|
669
|
-
process.stdout.write(
|
|
793
|
+
process.stdout.write(initBanner());
|
|
670
794
|
|
|
671
795
|
// Preguntas primero (después cerramos readline para liberar stdin a los instaladores).
|
|
672
796
|
let forOpt = typeof opts.for === "string" ? opts.for.toLowerCase() : null;
|
|
@@ -717,28 +841,32 @@ async function cmdInit(repo, opts) {
|
|
|
717
841
|
writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
|
|
718
842
|
ok(".dai/ moldes (templates) + reglas (governance) del método");
|
|
719
843
|
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
844
|
+
// Config de dai en SUS PROPIOS archivos, sin tocar el `.env`/`.env.example` del equipo:
|
|
845
|
+
// muchas orgs versionan el `.env` como política, así que dai lo deja en paz (solo lo lee,
|
|
846
|
+
// por compat) y pone lo suyo en `.env.dai` (ver ADR-0017). `.env.dai.example` se versiona
|
|
847
|
+
// como plantilla; `.env.dai` (gitignored) es donde cada dev completa token y datos propios.
|
|
848
|
+
const envBlock = envFor(pm);
|
|
849
|
+
|
|
850
|
+
// .env.dai.example — plantilla VERSIONADA, mismas claves con valores VACÍOS (sin secretos).
|
|
851
|
+
const exPath = join(repo, ".env.dai.example");
|
|
724
852
|
if (existsSync(exPath)) {
|
|
725
|
-
const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur,
|
|
726
|
-
if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.example claves de dai agregadas (aditivo)"); }
|
|
727
|
-
else ok(".env.example ya tenía la config de dai");
|
|
728
|
-
} else { writeFileSync(exPath,
|
|
853
|
+
const cur = readFileSync(exPath, "utf8"), merged = mergeEnv(cur, envBlock);
|
|
854
|
+
if (merged !== cur) { writeFileSync(exPath, merged); ok(".env.dai.example claves de dai agregadas (aditivo)"); }
|
|
855
|
+
else ok(".env.dai.example ya tenía la config de dai");
|
|
856
|
+
} else { writeFileSync(exPath, envBlock); ok(".env.dai.example creado (plantilla versionada)"); }
|
|
729
857
|
|
|
730
|
-
// .env —
|
|
731
|
-
const envPath = join(repo, ".env")
|
|
858
|
+
// .env.dai — el real de cada dev (gitignored): aditivo; si no existe, lo crea.
|
|
859
|
+
const envPath = join(repo, ".env.dai");
|
|
732
860
|
if (existsSync(envPath)) {
|
|
733
861
|
const cur = readFileSync(envPath, "utf8"), merged = mergeEnv(cur, envBlock);
|
|
734
|
-
if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env
|
|
735
|
-
else ok(".env
|
|
736
|
-
} else { writeFileSync(envPath, envBlock); ok(`.env
|
|
862
|
+
if (merged !== cur) { writeFileSync(envPath, merged); ok(`.env.dai claves de dai agregadas (aditivo, DAI_PM=${pm}${pm === "md" ? "" : " — completa el token"})`); }
|
|
863
|
+
else ok(".env.dai ya tenía la config de dai");
|
|
864
|
+
} else { writeFileSync(envPath, envBlock); ok(`.env.dai creado (no versionado), DAI_PM=${pm}${pm === "md" ? "" : " (completa el token)"}`); }
|
|
737
865
|
|
|
738
866
|
// .gitignore — versiona los artefactos de dai (según --for), deja fuera solo lo personal.
|
|
739
867
|
const giPath = join(repo, ".gitignore");
|
|
740
868
|
const gi = reconcileGitignore(existsSync(giPath) ? readFileSync(giPath, "utf8") : "", want);
|
|
741
|
-
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)"); }
|
|
869
|
+
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)"); }
|
|
742
870
|
|
|
743
871
|
mkdirSync(join(repo, ".github"), { recursive: true });
|
|
744
872
|
cpSync(join(ROOT, "templates", "pull-request.md"), join(repo, ".github", "pull_request_template.md"));
|
|
@@ -822,12 +950,12 @@ async function cmdInit(repo, opts) {
|
|
|
822
950
|
}
|
|
823
951
|
|
|
824
952
|
// ── Próximos pasos ─────────────────────────────────────────────────────────
|
|
825
|
-
process.stdout.write("\n ✔ Repo configurado. Próximos pasos:\n");
|
|
953
|
+
process.stdout.write("\n " + C.y("✔") + " " + C.b("Repo configurado.") + " Próximos pasos:\n");
|
|
826
954
|
process.stdout.write(pm === "md"
|
|
827
|
-
?
|
|
828
|
-
: ` 1.
|
|
829
|
-
process.stdout.write(
|
|
830
|
-
process.stdout.write(
|
|
955
|
+
? ` 1. Crea tu primera US en ${C.cy(".dai/us/<ID>.md")} (criterios bajo '## Criterios de aceptación')\n`
|
|
956
|
+
: ` 1. Copia ${C.cy(".env.dai.example")} → ${C.cy(".env.dai")} y completa el token de ${pm}; verifica con ${C.y("dai doctor")}\n`);
|
|
957
|
+
process.stdout.write(` 2. ${C.y("dai link-us <ID>")} → crea la branch + el link a la US\n`);
|
|
958
|
+
process.stdout.write(` 3. Implementa con test primero, después: ${C.y("dai check")}\n`);
|
|
831
959
|
process.stdout.write(" Guía paso a paso: https://github.com/dforce2055/dai/blob/main/docs/PROBAR.md\n\n");
|
|
832
960
|
}
|
|
833
961
|
|
|
@@ -959,7 +1087,7 @@ function cmdSync(repo, opts) {
|
|
|
959
1087
|
() => writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n"));
|
|
960
1088
|
|
|
961
1089
|
if (dry) info("dry-run: nada escrito. Quitá --dry-run para aplicar.");
|
|
962
|
-
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"); }
|
|
1090
|
+
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"); }
|
|
963
1091
|
}
|
|
964
1092
|
|
|
965
1093
|
// Imprime el estado de version-drift del scaffold (ADR-0010) con color + ícono.
|
|
@@ -1025,7 +1153,7 @@ function cmdUpgrade(opts) {
|
|
|
1025
1153
|
|
|
1026
1154
|
// ── doctor: diagnóstico ───────────────────────────────────────────────────────
|
|
1027
1155
|
function cmdDoctor() {
|
|
1028
|
-
|
|
1156
|
+
loadDaiEnv();
|
|
1029
1157
|
info(`dai doctor — versión v${readFileSync(join(ROOT, "VERSION"), "utf8").trim()}`);
|
|
1030
1158
|
|
|
1031
1159
|
// Una skill sirve si está en el repo actual (la puso `dai init`) o global (la puso
|
|
@@ -1079,11 +1207,11 @@ function cmdDoctor() {
|
|
|
1079
1207
|
const pm = process.env.DAI_PM || "md";
|
|
1080
1208
|
ok(`DAI_PM=${pm}`);
|
|
1081
1209
|
if (pm === "jira") {
|
|
1082
|
-
if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en
|
|
1083
|
-
if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en
|
|
1210
|
+
if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en .env.dai");
|
|
1211
|
+
if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en .env.dai");
|
|
1084
1212
|
// Ojo: solo miramos que el token ESTÉ, no que sirva — uno vencido pasa este chequeo
|
|
1085
1213
|
// y recién falla al publicar. Verificarlo de verdad es pegarle a la red.
|
|
1086
|
-
if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en
|
|
1214
|
+
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`)");
|
|
1087
1215
|
if (!process.env.DAI_JIRA_PROJECT) warn("DAI_JIRA_PROJECT vacío — solo hace falta para `dai publish` (crear issues)");
|
|
1088
1216
|
else {
|
|
1089
1217
|
try { ok(`proyecto=${assertProjectKey(process.env.DAI_JIRA_PROJECT)} (para dai publish)`); }
|
|
@@ -1101,7 +1229,7 @@ function cmdDoctor() {
|
|
|
1101
1229
|
}
|
|
1102
1230
|
}
|
|
1103
1231
|
if (pm === "clickup") {
|
|
1104
|
-
if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en
|
|
1232
|
+
if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en .env.dai"); else ok("token de ClickUp presente");
|
|
1105
1233
|
process.env.DAI_CLICKUP_LIST_ID ? ok(`lista=${process.env.DAI_CLICKUP_LIST_ID} (para dai publish)`)
|
|
1106
1234
|
: warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
|
|
1107
1235
|
}
|
|
@@ -1160,19 +1288,22 @@ switch (cmd) {
|
|
|
1160
1288
|
" done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
|
|
1161
1289
|
" 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" +
|
|
1162
1290
|
" pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
|
|
1163
|
-
" forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n
|
|
1291
|
+
" forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
|
|
1292
|
+
" forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
|
|
1293
|
+
" --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
|
|
1294
|
+
" Sin --yes no postea nada: muestra el preview y valida que cada hallazgo apunte al diff.\n\n" +
|
|
1164
1295
|
"Instalación:\n" +
|
|
1165
1296
|
" skills install [--global | --local <repo>] [--force] [--dry-run] [--for <asistentes>] instala las skills de dai (alias: `install`)\n" +
|
|
1166
|
-
" skills install --from <git-url|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
|
|
1297
|
+
" skills install --from <git-url|npm:pkg|path>[#ref] [--for <asistentes>] instala skills EXTERNAS (por-stack), convertidas para los 3 asistentes (ADR-0013)\n" +
|
|
1167
1298
|
" init [<repo>] scaffolder interactivo del repo (asistente, gestor, OpenSpec)\n" +
|
|
1168
1299
|
" --for <asistentes> claude|copilot|cursor (combinables con coma) · o both|all (default all)\n" +
|
|
1169
1300
|
" ej: --for claude,cursor · --for copilot · --for all\n" +
|
|
1170
1301
|
" --pm md|jira|clickup · --openspec (con flags salteas las preguntas)\n" +
|
|
1171
|
-
" sync [<repo>] [--dry-run] [--for <asistentes>] refresca skills/constitución/templates a la versión del CLI (aditivo; no toca .env ni OpenSpec)\n" +
|
|
1302
|
+
" 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" +
|
|
1172
1303
|
" 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" +
|
|
1173
1304
|
" docs <destino> documentación conceptual → <destino>\n" +
|
|
1174
1305
|
" doctor diagnóstico del entorno\n\n" +
|
|
1175
|
-
" (config: .env — ver .env.example)\n"
|
|
1306
|
+
" (config: .env.dai — ver .env.dai.example)\n"
|
|
1176
1307
|
);
|
|
1177
1308
|
process.exit(cmd && cmd !== "help" ? 1 : 0);
|
|
1178
1309
|
}
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -47,13 +47,38 @@ function unquoteScalar(s) {
|
|
|
47
47
|
// Parsea el frontmatter YAML de un SKILL.md → { name, description, body }.
|
|
48
48
|
// Devuelve los valores YA sin comillas, para que quien los reserialice (skillToCursor)
|
|
49
49
|
// no los cite dos veces.
|
|
50
|
+
// Lee un campo del frontmatter. Soporta escalar de una línea (con/sin comillas) y BLOQUE
|
|
51
|
+
// YAML (`|` literal, `>` plegado, con chomp `-`/`+`): junta las líneas indentadas siguientes
|
|
52
|
+
// hasta la próxima clave (columna 0). El parser de dai es regex, no un YAML completo — esto
|
|
53
|
+
// cubre lo común: descripciones multilínea, que es como se escriben las skills reales.
|
|
54
|
+
function readFmField(fm, key) {
|
|
55
|
+
const lines = fm.split(/\r?\n/);
|
|
56
|
+
const keyRe = new RegExp(`^${key}:\\s*(.*)$`);
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
const m = lines[i].match(keyRe);
|
|
59
|
+
if (!m) continue;
|
|
60
|
+
const inline = m[1].trim();
|
|
61
|
+
const blk = inline.match(/^([|>])[-+]?\s*$/); // |, >, |-, |+, >-, >+
|
|
62
|
+
if (!blk) return unquoteScalar(inline) || null; // escalar de una línea
|
|
63
|
+
const buf = [];
|
|
64
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
65
|
+
if (lines[j].trim() === "") { buf.push(""); continue; }
|
|
66
|
+
if (!/^\s/.test(lines[j])) break; // sin indentar → empezó otra clave
|
|
67
|
+
buf.push(lines[j]);
|
|
68
|
+
}
|
|
69
|
+
const indent = ((buf.find((l) => l.trim() !== "") || "").match(/^\s*/) || [""])[0].length;
|
|
70
|
+
const text = buf.map((l) => l.slice(indent)).join("\n").replace(/\s+$/, "");
|
|
71
|
+
// `>` plegado: un salto simple → espacio; los dobles (párrafo) se conservan.
|
|
72
|
+
return (blk[1] === ">" ? text.replace(/([^\n])\n(?!\n)/g, "$1 ") : text) || null;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
export function parseFrontmatter(md) {
|
|
51
78
|
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
52
79
|
if (!m) return { name: null, description: null, body: md.trim() };
|
|
53
80
|
const fm = m[1];
|
|
54
|
-
|
|
55
|
-
const description = unquoteScalar((fm.match(/^description:\s*(.+)$/m) || [])[1]) || null;
|
|
56
|
-
return { name, description, body: m[2].trim() };
|
|
81
|
+
return { name: readFmField(fm, "name"), description: readFmField(fm, "description"), body: m[2].trim() };
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
// Valida el contrato MÍNIMO de un SKILL.md para que dai lo ingiera y los asistentes lo
|
|
@@ -66,7 +91,11 @@ export function validateSkill(md) {
|
|
|
66
91
|
if (!name) return "falta 'name' en el frontmatter";
|
|
67
92
|
if (!description) return "falta 'description' en el frontmatter";
|
|
68
93
|
for (const key of ["name", "description"]) {
|
|
69
|
-
const
|
|
94
|
+
const raw = rawFrontmatterValue(md, key);
|
|
95
|
+
// Un bloque YAML (`|`, `>`, con chomp) es literal → siempre válido; su contenido va en las
|
|
96
|
+
// líneas indentadas, no en el valor de la clave. No lo pasamos por el scalar-check.
|
|
97
|
+
if (/^[|>][-+]?\s*$/.test(String(raw ?? "").trim())) continue;
|
|
98
|
+
const issue = yamlScalarIssue(raw);
|
|
70
99
|
if (issue) return `'${key}' no es YAML válido: ${issue} — citá el valor con comillas dobles`;
|
|
71
100
|
}
|
|
72
101
|
return null;
|
|
@@ -98,7 +127,7 @@ export function skillToCursor(md) {
|
|
|
98
127
|
// la URL canónica que devuelve el tracker, así que el `/t/{id}` que escribíamos acá
|
|
99
128
|
// tapaba la de ClickUp con team_id. Queda como override manual para trackers raros.
|
|
100
129
|
export function envFor(pm) {
|
|
101
|
-
const head = "# Config de dai —
|
|
130
|
+
const head = "# Config de dai — va en .env.dai (NO versionado), no en el .env del equipo.\n# Completá lo que falte. NUNCA commitees tokens.\n";
|
|
102
131
|
if (pm === "clickup") {
|
|
103
132
|
return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n";
|
|
104
133
|
}
|
|
@@ -153,7 +182,13 @@ export function upsertBlock(existing, block, marker = "dai") {
|
|
|
153
182
|
export function reconcileGitignore(text, want) {
|
|
154
183
|
const norm = (s) => { let t = s.trim(); while (t.startsWith("/")) { t = t.slice(1); } while (t.endsWith("/")) { t = t.slice(0, -1); } return t; };
|
|
155
184
|
const broad = new Set();
|
|
156
|
-
|
|
185
|
+
// `.dai/` NO va acá: ahí vive config que SÍ se versiona (jira-fields.json). Solo se
|
|
186
|
+
// ignora `.dai/reviews/`, que son borradores de `dai forge review` — efímeros, con
|
|
187
|
+
// hallazgos a medio editar, y no tienen por qué viajar en un commit.
|
|
188
|
+
// `.env.dai` (config y secretos de dai) SÍ se ignora; el `.env` del equipo NO lo tocamos:
|
|
189
|
+
// es suyo y muchas orgs lo versionan como política (ADR-0017). La plantilla
|
|
190
|
+
// `.env.dai.example` sí se versiona (no matchea `.env.dai` exacto, así que no se ignora).
|
|
191
|
+
const ensure = [".env.dai", ".dai/reviews/"];
|
|
157
192
|
if (want.claude) { broad.add("CLAUDE.md"); broad.add(".claude"); ensure.push(".claude/settings.local.json"); }
|
|
158
193
|
if (want.cursor) { broad.add(".cursor"); }
|
|
159
194
|
let changed = false;
|
|
@@ -196,7 +231,7 @@ export function constitution(kind) {
|
|
|
196
231
|
- **El link se autora una vez** (\`implements.yaml\`); la cobertura se **deriva** (nunca a mano).
|
|
197
232
|
- **Verifica el comportamiento, no solo que compile:** que pase el chequeo estático o el build no prueba que funcione; ejercita el flujo real antes de darlo por hecho.
|
|
198
233
|
- **La IA confirma antes de construir:** el asistente declara que entendió esta constitución y la va a obedecer antes de generar código.
|
|
199
|
-
- **Secretos:** en \`.env\` (
|
|
234
|
+
- **Secretos:** en \`.env.dai\` (NO versionado; el \`.env\` del equipo no se toca). git por **SSH**, APIs por **token scopeado**.
|
|
200
235
|
- **No bajes la seguridad para avanzar:** si una llamada falla por el certificado, declara la CA (\`NODE_EXTRA_CA_CERTS\`). **Nunca** \`NODE_TLS_REJECT_UNAUTHORIZED=0\`, \`verify=False\`, \`-k\` ni equivalentes: apagan la verificación de toda la conexión, y por ahí viajan los tokens.
|
|
201
236
|
- **Si el CLI no llega, para y dilo:** cuando \`dai\` no cubre un caso, repórtalo — no improvises una llamada a la API por fuera. El atajo publica igual, pero rompe el link QUÉ↔CÓMO en silencio y nadie se entera hasta que la trazabilidad ya está mal.
|
|
202
237
|
- **Docs vivas:** una constitución o arquitectura desactualizada es un defecto, no documentación.
|
package/cli/lib/env.mjs
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
|
|
7
|
+
// Config de dai: `.env.dai` (propio, nunca versionado) tiene prioridad sobre `.env`.
|
|
8
|
+
// Pensado para equipos que versionan el `.env` (política de empresa): dai deja ese
|
|
9
|
+
// archivo en paz y pone sus claves y secretos en `.env.dai` (gitignored). Se carga
|
|
10
|
+
// `.env.dai` PRIMERO porque el loader es "primero-gana" (ver más abajo), así la
|
|
11
|
+
// precedencia queda: entorno (shell/CI) > .env.dai > .env. Leer también `.env`
|
|
12
|
+
// mantiene compatibilidad con repos previos que tienen los DAI_* ahí.
|
|
13
|
+
export function loadDaiEnv(env = process.env) {
|
|
14
|
+
loadEnv(".env.dai", env);
|
|
15
|
+
loadEnv(".env", env);
|
|
16
|
+
return env;
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
export function loadEnv(path = ".env", env = process.env) {
|
|
8
20
|
let text;
|
|
9
21
|
try { text = readFileSync(path, "utf8"); } catch { return env; } // sin .env: no pasa nada
|
package/cli/lib/forge-api.mjs
CHANGED
|
@@ -40,6 +40,38 @@ export function commentApiUrl(ref) {
|
|
|
40
40
|
return `${apiBase(ref)}/projects/${encodeURIComponent(ref.projectPath)}/merge_requests/${ref.number}/notes`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// El endpoint del review INLINE (resumen + comentarios anclados a archivo:línea).
|
|
44
|
+
// Distinto de commentApiUrl, que postea al hilo de la PR: por eso el comentario de dai
|
|
45
|
+
// caía al final en vez de dentro del archivo.
|
|
46
|
+
// github → 1 POST con todo (atómico)
|
|
47
|
+
// gitlab → 1 nota (resumen) + 1 discussion por comentario (NO atómico)
|
|
48
|
+
export function reviewApiUrl(ref) {
|
|
49
|
+
if (ref.forge === "github") return `${apiBase(ref)}/repos/${ref.owner}/${ref.repo}/pulls/${ref.number}/reviews`;
|
|
50
|
+
if (ref.forge === "gitlab") return `${apiBase(ref)}/projects/${encodeURIComponent(ref.projectPath)}/merge_requests/${ref.number}/discussions`;
|
|
51
|
+
throw new Error(`forge no soportado para review: ${ref.forge} (solo github/gitlab)`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// La posición de un comentario inline según el forge.
|
|
55
|
+
// github → { path, line, side, body }
|
|
56
|
+
// gitlab → position con los TRES shas de diff_refs + new_line/old_line según el lado
|
|
57
|
+
export function inlinePosition(ref, c, { diffRefs } = {}) {
|
|
58
|
+
if (ref.forge === "github") return { path: c.path, line: c.line, side: c.side || "RIGHT", body: c.body };
|
|
59
|
+
if (!diffRefs?.base_sha || !diffRefs?.head_sha) {
|
|
60
|
+
throw new Error("gitlab: faltan los diff_refs de la MR (base_sha/head_sha). Sin eso no se puede anclar un comentario.");
|
|
61
|
+
}
|
|
62
|
+
const position = {
|
|
63
|
+
base_sha: diffRefs.base_sha,
|
|
64
|
+
start_sha: diffRefs.start_sha || diffRefs.base_sha,
|
|
65
|
+
head_sha: diffRefs.head_sha,
|
|
66
|
+
position_type: "text",
|
|
67
|
+
new_path: c.path,
|
|
68
|
+
old_path: c.path,
|
|
69
|
+
};
|
|
70
|
+
if ((c.side || "RIGHT") === "LEFT") position.old_line = c.line;
|
|
71
|
+
else position.new_line = c.line;
|
|
72
|
+
return { body: c.body, position };
|
|
73
|
+
}
|
|
74
|
+
|
|
43
75
|
export function authHeaders(ref, env = process.env) {
|
|
44
76
|
if (ref.forge === "github") {
|
|
45
77
|
return { Authorization: `Bearer ${env.GITHUB_TOKEN || ""}`, Accept: "application/vnd.github+json", "User-Agent": "dai" };
|
|
@@ -75,13 +107,18 @@ export function renderReviewComment(r) {
|
|
|
75
107
|
}
|
|
76
108
|
|
|
77
109
|
// ── Efectos de red (testeados con fetch mockeado en cli/test) ────────────────
|
|
110
|
+
// `baseRef`/`headSha`/`diffRefs` son lo que hace falta para ANCLAR un comentario inline:
|
|
111
|
+
// GitHub necesita el sha del head; GitLab exige los tres shas de `diff_refs` en cada
|
|
112
|
+
// discussion. Sin esto no se puede postear un review inline.
|
|
78
113
|
export async function getPR(ref, env = process.env) {
|
|
79
114
|
const res = await fetch(prApiUrl(ref), { headers: authHeaders(ref, env) });
|
|
80
115
|
if (!res.ok) throw new Error(`forge ${res.status}: ${await res.text()}`);
|
|
81
116
|
const j = await res.json();
|
|
82
117
|
return ref.forge === "github"
|
|
83
|
-
? { title: j.title, state: j.state, body: j.body, branch: j.head?.ref, url: j.html_url
|
|
84
|
-
|
|
118
|
+
? { title: j.title, state: j.state, body: j.body, branch: j.head?.ref, url: j.html_url,
|
|
119
|
+
baseRef: j.base?.ref ?? null, headSha: j.head?.sha ?? null, diffRefs: null }
|
|
120
|
+
: { title: j.title, state: j.state, body: j.description, branch: j.source_branch, url: j.web_url,
|
|
121
|
+
baseRef: j.target_branch ?? null, headSha: j.diff_refs?.head_sha ?? null, diffRefs: j.diff_refs ?? null };
|
|
85
122
|
}
|
|
86
123
|
|
|
87
124
|
export async function postComment(ref, body, env = process.env) {
|
|
@@ -94,3 +131,53 @@ export async function postComment(ref, body, env = process.env) {
|
|
|
94
131
|
const j = await res.json();
|
|
95
132
|
return { url: j.html_url || j.web_url || null };
|
|
96
133
|
}
|
|
134
|
+
|
|
135
|
+
// ── Review inline ────────────────────────────────────────────────────────────
|
|
136
|
+
// `comments` viene con el body YA renderizado (el render vive en review-findings.mjs;
|
|
137
|
+
// acá solo se arma el payload y se postea).
|
|
138
|
+
//
|
|
139
|
+
// event: "COMMENT", NUNCA "APPROVE" — dai comenta, el humano firma (Art. 5). No es un
|
|
140
|
+
// default configurable: es un corte duro.
|
|
141
|
+
export async function postReview(ref, { body, comments = [], headSha = null, diffRefs = null }, env = process.env) {
|
|
142
|
+
if (ref.forge === "github") return postReviewGithub(ref, { body, comments, headSha }, env);
|
|
143
|
+
if (ref.forge === "gitlab") return postReviewGitlab(ref, { body, comments, diffRefs }, env);
|
|
144
|
+
throw new Error(`forge no soportado para review: ${ref.forge} (solo github/gitlab)`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// GitHub: TODO en un POST. O entra el review entero o no entra nada.
|
|
148
|
+
async function postReviewGithub(ref, { body, comments, headSha }, env) {
|
|
149
|
+
const payload = { event: "COMMENT", body, comments: comments.map((c) => inlinePosition(ref, c)) };
|
|
150
|
+
if (headSha) payload.commit_id = headSha;
|
|
151
|
+
const res = await fetch(reviewApiUrl(ref), {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { ...authHeaders(ref, env), "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify(payload),
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok) throw new Error(`forge ${res.status}: ${await res.text()}`);
|
|
157
|
+
const j = await res.json();
|
|
158
|
+
return { url: j.html_url || null, posted: comments.length, failed: [], atomic: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// GitLab: NO hay review atómico. Son 1 nota (el resumen) + N discussions sueltas, así que
|
|
162
|
+
// si la tercera falla, las dos primeras YA están publicadas. Se mitiga validando todo
|
|
163
|
+
// antes de la primera llamada (ver review-findings.mjs), pero no se puede prometer
|
|
164
|
+
// atomicidad — así que se reporta qué entró y qué no, en vez de fingirla.
|
|
165
|
+
async function postReviewGitlab(ref, { body, comments, diffRefs }, env) {
|
|
166
|
+
const summary = await postComment(ref, body, env);
|
|
167
|
+
const failed = [];
|
|
168
|
+
let posted = 0;
|
|
169
|
+
for (const c of comments) {
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(reviewApiUrl(ref), {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { ...authHeaders(ref, env), "Content-Type": "application/json" },
|
|
174
|
+
body: JSON.stringify(inlinePosition(ref, c, { diffRefs })),
|
|
175
|
+
});
|
|
176
|
+
if (!res.ok) throw new Error(`forge ${res.status}: ${await res.text()}`);
|
|
177
|
+
posted++;
|
|
178
|
+
} catch (e) {
|
|
179
|
+
failed.push({ path: c.path, line: c.line, error: String(e.message) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { url: summary.url, posted, failed, atomic: false };
|
|
183
|
+
}
|
package/cli/lib/pm-clickup.mjs
CHANGED
|
@@ -22,7 +22,7 @@ export function clickupTaskToText(json) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function clickupAdapter(env) {
|
|
25
|
-
if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env (backend clickup).");
|
|
25
|
+
if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env.dai (backend clickup).");
|
|
26
26
|
return {
|
|
27
27
|
kind: "clickup",
|
|
28
28
|
async fetchUS(id) {
|
|
@@ -43,7 +43,7 @@ export function clickupAdapter(env) {
|
|
|
43
43
|
},
|
|
44
44
|
async createUS({ title, descriptionMarkdown }) {
|
|
45
45
|
const list = env.DAI_CLICKUP_LIST_ID;
|
|
46
|
-
if (!list) throw new Error("falta DAI_CLICKUP_LIST_ID en el .env (la lista donde crear la tarea).");
|
|
46
|
+
if (!list) throw new Error("falta DAI_CLICKUP_LIST_ID en el .env.dai (la lista donde crear la tarea).");
|
|
47
47
|
const res = await fetch(`${API}/list/${encodeURIComponent(list)}/task`, {
|
|
48
48
|
method: "POST", headers: clickupAuthHeaders(env),
|
|
49
49
|
body: JSON.stringify({ name: title, markdown_content: descriptionMarkdown }),
|