@dforce2055/dai 0.7.0 → 0.8.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 +9 -3
- package/CHANGELOG.md +65 -0
- package/README.md +12 -6
- package/VERSION +1 -1
- package/cli/dai.mjs +127 -49
- package/cli/lib/args.mjs +13 -2
- package/cli/lib/bootstrap.mjs +69 -19
- package/cli/lib/http.mjs +61 -0
- package/cli/lib/jira-fields.mjs +143 -0
- package/cli/lib/pm-jira.mjs +52 -12
- package/docs/adr/0014-copilot-agent-skills.md +99 -0
- package/docs/adr/0015-jira-corporativo.md +104 -0
- package/docs/adr/README.md +4 -0
- package/package.json +1 -1
- package/skills/dai-review/SKILL.md +1 -1
- package/skills/doc-to-backlog/SKILL.md +1 -1
- package/skills/grill-epic/SKILL.md +13 -5
- package/skills/grill-intent/SKILL.md +1 -1
- package/skills/grill-user-story/SKILL.md +16 -3
- package/skills/link-us/SKILL.md +1 -1
- package/skills/tdd/SKILL.md +1 -1
- package/templates/jira-fields.example.json +49 -0
- package/templates/skill.md +8 -4
package/.env.example
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Las variables ya exportadas en la shell/CI ganan sobre el .env.
|
|
4
4
|
|
|
5
5
|
# ── Backend del tracker ──────────────────────────────────────────────────────
|
|
6
|
-
# md | jira | clickup
|
|
6
|
+
# md | jira | clickup
|
|
7
7
|
DAI_PM=md
|
|
8
8
|
|
|
9
9
|
# Plantilla del link al ticket (para `dai ls` / stamp). {id} se reemplaza.
|
|
@@ -17,8 +17,14 @@ DAI_MD_US_DIR=.dai/us
|
|
|
17
17
|
# DAI_JIRA_BASE_URL=https://miempresa.atlassian.net
|
|
18
18
|
# DAI_JIRA_EMAIL=usuario@miempresa.com
|
|
19
19
|
# DAI_JIRA_TOKEN= # ← SECRETO. api_token de id.atlassian.com. Nunca commitear.
|
|
20
|
-
# DAI_JIRA_PROJECT= # clave del
|
|
21
|
-
# DAI_JIRA_ISSUETYPE=Story # tipo de issue a crear (default Story)
|
|
20
|
+
# DAI_JIRA_PROJECT= # clave del PROYECTO (ej. PROJ), NO la de un ticket (PROJ-123) — para `dai publish`
|
|
21
|
+
# DAI_JIRA_ISSUETYPE=Story # tipo de issue a crear (default Story). Se pisa con `dai publish --issuetype Epic`
|
|
22
|
+
# DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json # campos propios que tu Jira exige al crear
|
|
23
|
+
# # (ADR-0015). Si el archivo no existe, se ignora. Molde en templates/.
|
|
24
|
+
#
|
|
25
|
+
# ¿El proxy de tu empresa intercepta TLS? Node no usa el trust store del sistema, así que
|
|
26
|
+
# falla donde el navegador anda. Declará la CA — NUNCA NODE_TLS_REJECT_UNAUTHORIZED=0:
|
|
27
|
+
# NODE_EXTRA_CA_CERTS=/ruta/ca-empresa.pem
|
|
22
28
|
|
|
23
29
|
# ── Backend clickup (REST v2) — activar con DAI_PM=clickup ───────────────────
|
|
24
30
|
# DAI_CLICKUP_TOKEN= # ← SECRETO. token personal scopeado.
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,70 @@
|
|
|
3
3
|
Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
|
|
4
4
|
(ver `VERSION`).
|
|
5
5
|
|
|
6
|
+
## [0.8.0] — 2026-07-15
|
|
7
|
+
|
|
8
|
+
**El primer analista funcional real usó dai contra un Jira corporativo, y encontró el
|
|
9
|
+
hueco entre "anda en mi Jira de juguete" y "anda en el de una empresa".**
|
|
10
|
+
|
|
11
|
+
### Agregado
|
|
12
|
+
- **Campos propios de Jira**, declarados por issuetype en `.dai/jira-fields.json` (o
|
|
13
|
+
`DAI_JIRA_FIELDS_FILE`) con nombre humano, forma, default y opciones válidas. dai valida
|
|
14
|
+
**antes** de llamar a la red: un typo da `'Mejraa' no es opción de 'clasificacion' —
|
|
15
|
+
válidas: Mejora | Corrección`, no un 400 críptico. El valor se elige por US con
|
|
16
|
+
`dai publish us.md --field clasificacion=Corrección` (repetible), porque la clasificación
|
|
17
|
+
cambia según la historia y un default fijo publicaría todas iguales ([ADR-0015](docs/adr/0015-jira-corporativo.md)).
|
|
18
|
+
- `dai publish --parent <KEY>` — cuelga la US de su épica (antes `createUS` nunca mandaba
|
|
19
|
+
`parent`, así que las US quedaban sueltas).
|
|
20
|
+
- `dai publish --issuetype <T>` — **`grill-epic` gana su fallback por CLI**: hasta ahora, sin
|
|
21
|
+
MCP, una épica quedaba en un `.md` para pegar a mano.
|
|
22
|
+
- **Diagnóstico de TLS**: un fallo de certificado ahora enseña `NODE_EXTRA_CA_CERTS` (el caso
|
|
23
|
+
típico es el proxy corporativo: Node no usa el trust store del sistema) y explica por qué
|
|
24
|
+
`NODE_TLS_REJECT_UNAUTHORIZED=0` no es una alternativa — apaga la verificación entera y por
|
|
25
|
+
ahí viaja tu token.
|
|
26
|
+
- **Constitución**: dos reglas nuevas para todos los asistentes — no bajar la seguridad para
|
|
27
|
+
avanzar, y parar y avisar si el CLI no llega en vez de improvisar la llamada por fuera.
|
|
28
|
+
|
|
29
|
+
### Cambiado
|
|
30
|
+
- **Copilot lee `SKILL.md` nativo** ([ADR-0014](docs/adr/0014-copilot-agent-skills.md)).
|
|
31
|
+
`dai init --for copilot` genera `.github/skills/` (copia cruda, **con los `templates/`**) en
|
|
32
|
+
vez de `.github/prompts/*.prompt.md`, y borra los prompts viejos de dai para que no dupliquen
|
|
33
|
+
cada `/comando`. Los prompt files propios del equipo no se tocan.
|
|
34
|
+
- **`dai skills install --for copilot` ya existe** → `~/.copilot/skills/`. Antes warneaba
|
|
35
|
+
*"Copilot no tiene skills instalables"*: era cierto hasta que GitHub adoptó Agent Skills, y
|
|
36
|
+
por eso una skill "instalada global" no le aparecía a nadie que usara la app o el CLI de
|
|
37
|
+
Copilot (`~/.claude/skills` solo lo mira VS Code).
|
|
38
|
+
- **`dai doctor` reporta solo los asistentes que el repo usa.** Antes listaba los tres siempre:
|
|
39
|
+
quien configuraba uno veía 14 warnings de los otros dos y leía "está todo roto". Ahora
|
|
40
|
+
también chequea Copilot, valida `DAI_JIRA_PROJECT` y que el archivo de campos parsee.
|
|
41
|
+
- **Un flag repetido acumula** (`--field a=1 --field b=2`) en vez de que gane el último en
|
|
42
|
+
silencio.
|
|
43
|
+
|
|
44
|
+
### Arreglado
|
|
45
|
+
- **`doc-to-backlog` y `grill-epic` no cargaban en Copilot.** Sus descripciones tenían un `: `
|
|
46
|
+
suelto (*"…épicas finales: extrae…"*), que un parser YAML lee como el arranque de un mapa y
|
|
47
|
+
descarta la skill entera — justo las dos que más necesita un analista funcional. El defecto
|
|
48
|
+
siempre estuvo en la fuente: lo tapaban nuestro `parseFrontmatter` (que es un regex, no YAML)
|
|
49
|
+
y la conversión a `.prompt.md` (que citaba el valor al serializarlo). Ahora las 7
|
|
50
|
+
descripciones van citadas, **`validateSkill` valida que `name` y `description` sean escalares
|
|
51
|
+
YAML válidos**, y el molde de `templates/skill.md` cita por defecto.
|
|
52
|
+
- `DAI_JIRA_PROJECT=PROJ-42` (la clave de un **ticket**, el error de config más común) daba
|
|
53
|
+
un 400 de Jira que no lo explicaba. Ahora falla **antes de la red**, con los dos caminos:
|
|
54
|
+
`DAI_JIRA_PROJECT=PROJ`, o `--parent PROJ-42` si querías colgarla de esa épica.
|
|
55
|
+
|
|
56
|
+
### Quitado
|
|
57
|
+
- `skillToPrompt()` y el adaptador `.github/prompts/` de Copilot. Cuando el formato de una
|
|
58
|
+
skill es un estándar abierto, el mejor adaptador es ninguno.
|
|
59
|
+
|
|
60
|
+
### Interno
|
|
61
|
+
- **158 tests** (+39 desde 0.7.0): `jira-fields` ×19, `http`/TLS ×7, contrato YAML del
|
|
62
|
+
frontmatter ×7, `assertProjectKey` y el payload de `createUS` ×6.
|
|
63
|
+
- **Primera verificación en Windows.** Hasta ahora dai no se había instalado nunca en Windows
|
|
64
|
+
(no hay CI de esa plataforma): se probó el ciclo completo — install, `dai init --for copilot`,
|
|
65
|
+
skills globales en `~/.copilot/skills`, y Copilot cargando las 7. Los campos propios de Jira
|
|
66
|
+
y el diagnóstico de TLS están cubiertos por tests contra un Jira simulado, **todavía no
|
|
67
|
+
contra un Jira corporativo real**.
|
|
68
|
+
- `lib/jira-fields.mjs` y `lib/http.mjs` nuevos, cero dependencias (como el resto del CLI).
|
|
69
|
+
|
|
6
70
|
## [0.7.0] — 2026-07-14
|
|
7
71
|
|
|
8
72
|
**dai es el distribuidor de skills de cualquier stack, sin opinar sobre su contenido.**
|
|
@@ -194,6 +258,7 @@ ClickUp y Jira Cloud.
|
|
|
194
258
|
- Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
|
|
195
259
|
`files` de npm sin tests ni secretos.
|
|
196
260
|
|
|
261
|
+
[0.8.0]: https://github.com/dforce2055/dai/releases/tag/v0.8.0
|
|
197
262
|
[0.7.0]: https://github.com/dforce2055/dai/releases/tag/v0.7.0
|
|
198
263
|
[0.6.0]: https://github.com/dforce2055/dai/releases/tag/v0.6.0
|
|
199
264
|
[0.5.0]: https://github.com/dforce2055/dai/releases/tag/v0.5.0
|
package/README.md
CHANGED
|
@@ -266,12 +266,18 @@ Las skills se invocan en el asistente — **no en todas sus superficies** (lími
|
|
|
266
266
|
| **Claude Desktop** | ✅ | `~/.claude/skills/` (global) — sin IDE ni consola |
|
|
267
267
|
| **Claude Code** | ✅ | `~/.claude/skills/` + `.claude/skills/` del repo |
|
|
268
268
|
| **Cursor Agent (IDE)** | ✅ | `.cursor/skills/*/SKILL.md` + `.cursor/rules/*.mdc` |
|
|
269
|
-
| **Copilot
|
|
270
|
-
| **Copilot
|
|
271
|
-
| **Copilot
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
269
|
+
| **Copilot app / CLI** | ✅ | `~/.copilot/skills/` (global) + `.github/skills/` del repo |
|
|
270
|
+
| **Copilot en VS Code / JetBrains** | ✅ | ídem (modo agente) |
|
|
271
|
+
| **Copilot code review · cloud agent** | ✅ | `.github/skills/` del repo |
|
|
272
|
+
| **Chat de github.com** | ❌ | no carga skills |
|
|
273
|
+
|
|
274
|
+
**El analista no necesita IDE**: cierra el ciclo con **Claude Desktop** o con la **app / CLI de
|
|
275
|
+
Copilot**. El CLI `dai` corre en cualquier terminal, con cualquier asistente o ninguno.
|
|
276
|
+
|
|
277
|
+
> Copilot lee el `SKILL.md` **tal cual**, sin conversión ([ADR-0014](docs/adr/0014-copilot-agent-skills.md)).
|
|
278
|
+
> Si vienes de dai ≤ 0.7, corre `dai init --for copilot` una vez: cambia los
|
|
279
|
+
> `.github/prompts/*.prompt.md` por `.github/skills/` (con los `templates/`, que antes se
|
|
280
|
+
> perdían) y limpia los viejos para que no te dupliquen cada `/comando`.
|
|
275
281
|
|
|
276
282
|
## Se adapta a cualquier escala
|
|
277
283
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.8.0
|
package/cli/dai.mjs
CHANGED
|
@@ -27,10 +27,12 @@ import { branchUrl, commitUrl, parseRemote, detectForge } from "./lib/forge-url.
|
|
|
27
27
|
import { parsePrRef, getPR, postComment } from "./lib/forge-api.mjs";
|
|
28
28
|
import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
|
|
29
29
|
import { dirsEqual } from "./lib/fsutil.mjs";
|
|
30
|
-
import { parseFlags, parseAssistants, isAssistantToken } from "./lib/args.mjs";
|
|
30
|
+
import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
|
|
31
31
|
import { versionDrift, planUpgrade } from "./lib/semver.mjs";
|
|
32
32
|
import { parseSource } from "./lib/skills-source.mjs";
|
|
33
|
-
import {
|
|
33
|
+
import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles } from "./lib/bootstrap.mjs";
|
|
34
|
+
import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/jira-fields.mjs";
|
|
35
|
+
import { assertProjectKey } from "./lib/pm-jira.mjs";
|
|
34
36
|
|
|
35
37
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
38
|
|
|
@@ -48,6 +50,9 @@ const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint
|
|
|
48
50
|
const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
|
|
49
51
|
const CLAUDE_SKILLS_DIR = process.env.CLAUDE_SKILLS_DIR || join(homedir(), ".claude", "skills");
|
|
50
52
|
const CURSOR_SKILLS_DIR = process.env.CURSOR_SKILLS_DIR || join(homedir(), ".cursor", "skills");
|
|
53
|
+
// Copilot lee las skills personales de ~/.copilot/skills (NO de ~/.claude/skills, que
|
|
54
|
+
// solo mira VS Code). Por eso una skill "instalada global" no le aparecía (ADR-0014).
|
|
55
|
+
const COPILOT_SKILLS_DIR = process.env.COPILOT_SKILLS_DIR || join(homedir(), ".copilot", "skills");
|
|
51
56
|
function git(args, opts = {}) {
|
|
52
57
|
// stderr en 'pipe' (no 'inherit') para no filtrar errores de git a la salida;
|
|
53
58
|
// quedan en e.stderr para quien quiera inspeccionarlos.
|
|
@@ -238,16 +243,45 @@ async function cmdForge(sub, ref, opts) {
|
|
|
238
243
|
}
|
|
239
244
|
|
|
240
245
|
// ── publish: crea la US en el tracker desde un .md (fallback del MCP) ──────────
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
|
|
247
|
+
// Los campos propios que exige el proyecto, declarados en .dai/jira-fields.json
|
|
248
|
+
// (o donde apunte DAI_JIRA_FIELDS_FILE). Sin archivo → {}: un Jira sin campos
|
|
249
|
+
// obligatorios publica igual que siempre.
|
|
250
|
+
function loadJiraFieldsSpec() {
|
|
251
|
+
const path = process.env.DAI_JIRA_FIELDS_FILE || join(".dai", "jira-fields.json");
|
|
252
|
+
if (!existsSync(path)) return {};
|
|
253
|
+
return parseFieldsFile(readFileSync(path, "utf8"), path);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function cmdPublish(file, opts = {}) {
|
|
257
|
+
if (!file) fail("uso: dai publish <archivo-us.md> [--parent KEY] [--issuetype T] [--field alias=valor]", 1);
|
|
243
258
|
loadEnv();
|
|
244
259
|
const md = readFileSync(file, "utf8");
|
|
245
260
|
const title = extractTitle(md);
|
|
246
261
|
if (!title) fail("no pude extraer el título de la US (falta un '# Título').", 1);
|
|
247
262
|
const adapter = getAdapter(process.env);
|
|
248
263
|
if (typeof adapter.createUS !== "function") fail(`el backend '${adapter.kind}' no soporta crear US.`, 1);
|
|
249
|
-
|
|
264
|
+
|
|
265
|
+
const parent = typeof opts.parent === "string" ? opts.parent : undefined;
|
|
266
|
+
const issuetype = typeof opts.issuetype === "string" ? opts.issuetype : undefined;
|
|
267
|
+
const project = typeof opts.project === "string" ? opts.project : undefined;
|
|
268
|
+
|
|
269
|
+
// Los campos propios son cosa de Jira; los otros backends no los usan.
|
|
270
|
+
let fields;
|
|
271
|
+
if (adapter.kind === "jira") {
|
|
272
|
+
const type = issuetype || process.env.DAI_JIRA_ISSUETYPE || "Story";
|
|
273
|
+
fields = resolveJiraFields({
|
|
274
|
+
spec: loadJiraFieldsSpec(),
|
|
275
|
+
issuetype: type,
|
|
276
|
+
overrides: parseFieldOverrides(asList(opts.field)),
|
|
277
|
+
});
|
|
278
|
+
} else if (opts.field !== undefined) {
|
|
279
|
+
fail(`--field es solo para jira (DAI_PM=${adapter.kind}).`, 2);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const r = await adapter.createUS({ title, descriptionMarkdown: md, parent, issuetype, project, fields });
|
|
250
283
|
ok(`US publicada en ${adapter.kind}: ${r.id}${r.url ? ` → ${r.url}` : ""}`);
|
|
284
|
+
if (parent) info(`colgada de ${parent}`);
|
|
251
285
|
info(`Próximo paso (el dev abre el CÓMO): dai link-us ${r.id}`);
|
|
252
286
|
}
|
|
253
287
|
|
|
@@ -423,12 +457,9 @@ async function cmdInstall(opts) {
|
|
|
423
457
|
let want;
|
|
424
458
|
try { want = parseAssistants(typeof opts.for === "string" ? opts.for : "all"); }
|
|
425
459
|
catch (e) { fail(`--for ${e.message}`); }
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const wantClaude = want.claude, wantCursor = want.cursor;
|
|
430
|
-
if (!wantClaude && !wantCursor) fail("nada para instalar: pasá --for claude|cursor|all.");
|
|
431
|
-
const installFor = [wantClaude && "claude", wantCursor && "cursor"].filter(Boolean).join("+");
|
|
460
|
+
const wantClaude = want.claude, wantCursor = want.cursor, wantCopilot = want.copilot;
|
|
461
|
+
if (!wantClaude && !wantCursor && !wantCopilot) fail("nada para instalar: pasá --for claude|copilot|cursor|all.");
|
|
462
|
+
const installFor = [wantClaude && "claude", wantCopilot && "copilot", wantCursor && "cursor"].filter(Boolean).join("+");
|
|
432
463
|
const interactive = process.stdin.isTTY && !opts.global && opts.local === undefined;
|
|
433
464
|
const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
434
465
|
|
|
@@ -439,17 +470,21 @@ async function cmdInstall(opts) {
|
|
|
439
470
|
return a === "l" ? { scope: "local", repo: process.cwd() } : a === "s" ? { scope: "skip" } : { scope: "global" };
|
|
440
471
|
};
|
|
441
472
|
|
|
473
|
+
// Cada asistente tiene SU dir: Claude ~/.claude/skills · Copilot ~/.copilot/skills
|
|
474
|
+
// (ADR-0014) · Cursor ~/.cursor/skills. Local, el de Copilot es .github/skills.
|
|
475
|
+
const LOCAL_DIR = { claude: [".claude", "skills"], copilot: [".github", "skills"], cursor: [".cursor", "skills"] };
|
|
476
|
+
const GLOBAL_DIR = { claude: CLAUDE_SKILLS_DIR, copilot: COPILOT_SKILLS_DIR, cursor: CURSOR_SKILLS_DIR };
|
|
477
|
+
|
|
442
478
|
const installOne = async ({ name, scope, repo, kind }) => {
|
|
443
479
|
const src = join(skillsSrc, name);
|
|
444
|
-
const targetDir = scope === "local"
|
|
445
|
-
? join(repo, kind === "cursor" ? ".cursor" : ".claude", "skills")
|
|
446
|
-
: kind === "cursor" ? CURSOR_SKILLS_DIR : CLAUDE_SKILLS_DIR;
|
|
480
|
+
const targetDir = scope === "local" ? join(repo, ...LOCAL_DIR[kind]) : GLOBAL_DIR[kind];
|
|
447
481
|
const target = join(targetDir, name);
|
|
448
|
-
const label =
|
|
482
|
+
const label = `${name} (${kind})`;
|
|
449
483
|
|
|
450
484
|
let sameAsSource = false;
|
|
451
485
|
if (existsSync(target)) {
|
|
452
|
-
if (kind
|
|
486
|
+
if (kind !== "cursor") {
|
|
487
|
+
// claude y copilot son copia cruda del SKILL.md: comparación directa.
|
|
453
488
|
sameAsSource = dirsEqual(src, target);
|
|
454
489
|
} else {
|
|
455
490
|
const tempRoot = mkdtempSync(join(tmpdir(), "dai-cursor-skill-"));
|
|
@@ -486,6 +521,7 @@ async function cmdInstall(opts) {
|
|
|
486
521
|
const { scope, repo } = await scopeFor(name);
|
|
487
522
|
if (scope === "skip") { warn(`salto ${name}`); continue; }
|
|
488
523
|
if (wantClaude) await installOne({ name, scope, repo, kind: "claude" });
|
|
524
|
+
if (wantCopilot) await installOne({ name, scope, repo, kind: "copilot" });
|
|
489
525
|
if (wantCursor) await installOne({ name, scope, repo, kind: "cursor" });
|
|
490
526
|
}
|
|
491
527
|
if (rl) rl.close();
|
|
@@ -692,12 +728,20 @@ async function cmdInit(repo, opts) {
|
|
|
692
728
|
ok(`Claude: .claude/skills/ (${skills.length}) + CLAUDE.md${cCur ? " (bloque dai, aditivo)" : ""} → conoce el método y las skills`);
|
|
693
729
|
}
|
|
694
730
|
if (wantCopilot) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
731
|
+
// Copilot lee SKILL.md nativo (Agent Skills, ADR-0014): copia cruda, sin convertir.
|
|
732
|
+
// Así viajan también los templates/ que la conversión a .prompt.md perdía.
|
|
733
|
+
const dir = join(repo, ".github", "skills");
|
|
734
|
+
mkdirSync(dir, { recursive: true });
|
|
735
|
+
for (const name of skills) cpSync(join(skillsSrc, name), join(dir, name), { recursive: true });
|
|
698
736
|
const ciPath = join(repo, ".github", "copilot-instructions.md"), ciCur = existsSync(ciPath) ? readFileSync(ciPath, "utf8") : "";
|
|
699
737
|
writeFileSync(ciPath, upsertBlock(ciCur, constitution("copilot")));
|
|
700
|
-
ok(`Copilot: .github/
|
|
738
|
+
ok(`Copilot: .github/skills/ (${skills.length}) + copilot-instructions.md${ciCur ? " (bloque dai, aditivo)" : ""}`);
|
|
739
|
+
// Los .prompt.md de versiones previas duplicarían cada /comando con una copia
|
|
740
|
+
// vieja y sin templates. Solo borramos los que generó dai, nunca los del equipo.
|
|
741
|
+
const pdir = join(repo, ".github", "prompts");
|
|
742
|
+
const stale = existsSync(pdir) ? stalePromptFiles(skills).filter((f) => existsSync(join(pdir, f))) : [];
|
|
743
|
+
for (const f of stale) rmSync(join(pdir, f), { force: true });
|
|
744
|
+
if (stale.length) info(` quité ${stale.length} .prompt.md viejo(s) de .github/prompts/ — ahora son skills`);
|
|
701
745
|
}
|
|
702
746
|
if (wantCursor) {
|
|
703
747
|
const dir = join(repo, ".cursor", "skills");
|
|
@@ -949,35 +993,52 @@ function cmdDoctor() {
|
|
|
949
993
|
loadEnv();
|
|
950
994
|
info(`dai doctor — versión v${readFileSync(join(ROOT, "VERSION"), "utf8").trim()}`);
|
|
951
995
|
|
|
952
|
-
// Una skill sirve si está en el repo actual (
|
|
953
|
-
//
|
|
996
|
+
// Una skill sirve si está en el repo actual (la puso `dai init`) o global (la puso
|
|
997
|
+
// `dai install`). Reportamos dónde — y SOLO de los asistentes que este repo usa: antes
|
|
998
|
+
// se listaban los tres siempre, así que quien configuró uno veía 14 warnings de los
|
|
999
|
+
// otros dos y leía "está todo roto" cuando estaba todo bien.
|
|
954
1000
|
const skillsRoot = join(ROOT, "skills");
|
|
955
1001
|
const skillNames = readdirSync(skillsRoot).filter((n) => statSync(join(skillsRoot, n)).isDirectory());
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1002
|
+
const cwd = process.cwd();
|
|
1003
|
+
const ASSISTANTS = [
|
|
1004
|
+
{ kind: "claude", label: "Claude", local: join(cwd, ".claude", "skills"), global: CLAUDE_SKILLS_DIR, home: "~/.claude/skills" },
|
|
1005
|
+
{ kind: "copilot", label: "Copilot", local: join(cwd, ".github", "skills"), global: COPILOT_SKILLS_DIR, home: "~/.copilot/skills" },
|
|
1006
|
+
{ kind: "cursor", label: "Cursor", local: join(cwd, ".cursor", "skills"), global: CURSOR_SKILLS_DIR, home: "~/.cursor/skills" },
|
|
1007
|
+
];
|
|
1008
|
+
const present = (a) => skillNames.some((n) => existsSync(join(a.local, n)) || existsSync(join(a.global, n)));
|
|
1009
|
+
const active = ASSISTANTS.filter(present);
|
|
1010
|
+
|
|
1011
|
+
if (active.length === 0) {
|
|
1012
|
+
warn("no encontré skills de dai para ningún asistente.");
|
|
1013
|
+
process.stdout.write(" En un repo: dai init --for claude|copilot|cursor|all\n");
|
|
1014
|
+
process.stdout.write(" O global: dai install --for all\n");
|
|
964
1015
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1016
|
+
for (const a of active) {
|
|
1017
|
+
info(`skills ${a.label} (repo local / global ${a.home}):`);
|
|
1018
|
+
for (const name of skillNames) {
|
|
1019
|
+
const local = existsSync(join(a.local, name)), global = existsSync(join(a.global, name));
|
|
1020
|
+
if (local && global) ok(`${name} (local + global)`);
|
|
1021
|
+
else if (local) ok(`${name} (local, este repo)`);
|
|
1022
|
+
else if (global) ok(`${name} (global)`);
|
|
1023
|
+
else warn(`${name} — falta para ${a.label} (dai init --for ${a.kind}, o dai install --for ${a.kind})`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (active.some((a) => a.kind === "copilot")) {
|
|
1027
|
+
const ci = join(cwd, ".github", "copilot-instructions.md");
|
|
1028
|
+
existsSync(ci) ? ok("constitución Copilot (.github/copilot-instructions.md)") : warn("falta la constitución de Copilot (dai init --for copilot)");
|
|
1029
|
+
// Los .prompt.md viejos duplican cada /comando con una copia sin templates.
|
|
1030
|
+
const pdir = join(cwd, ".github", "prompts");
|
|
1031
|
+
const stale = existsSync(pdir) ? stalePromptFiles(skillNames).filter((f) => existsSync(join(pdir, f))) : [];
|
|
1032
|
+
if (stale.length) warn(`${stale.length} .prompt.md viejo(s) en .github/prompts/ — duplican las skills. Corré \`dai init --for copilot\` para limpiarlos.`);
|
|
1033
|
+
}
|
|
1034
|
+
if (active.some((a) => a.kind === "cursor")) {
|
|
1035
|
+
const localRule = join(cwd, ".cursor", "rules", "dai-constitution.mdc");
|
|
1036
|
+
const globalRule = join(homedir(), ".cursor", "rules", "dai-constitution.mdc");
|
|
1037
|
+
if (existsSync(localRule) && existsSync(globalRule)) ok("constitución Cursor (local + global)");
|
|
1038
|
+
else if (existsSync(localRule)) ok("constitución Cursor (local)");
|
|
1039
|
+
else if (existsSync(globalRule)) ok("constitución Cursor (global)");
|
|
1040
|
+
else warn("falta constitución Cursor (dai-constitution.mdc)");
|
|
974
1041
|
}
|
|
975
|
-
const localRule = join(process.cwd(), ".cursor", "rules", "dai-constitution.mdc");
|
|
976
|
-
const globalRule = join(homedir(), ".cursor", "rules", "dai-constitution.mdc");
|
|
977
|
-
if (existsSync(localRule) && existsSync(globalRule)) ok("constitución Cursor (local + global)");
|
|
978
|
-
else if (existsSync(localRule)) ok("constitución Cursor (local)");
|
|
979
|
-
else if (existsSync(globalRule)) ok("constitución Cursor (global)");
|
|
980
|
-
else warn("falta constitución Cursor (dai-constitution.mdc)");
|
|
981
1042
|
|
|
982
1043
|
info("adaptador de PM:");
|
|
983
1044
|
const pm = process.env.DAI_PM || "md";
|
|
@@ -985,9 +1046,24 @@ function cmdDoctor() {
|
|
|
985
1046
|
if (pm === "jira") {
|
|
986
1047
|
if (!process.env.DAI_JIRA_BASE_URL) warn("falta DAI_JIRA_BASE_URL en el .env");
|
|
987
1048
|
if (!process.env.DAI_JIRA_EMAIL) warn("falta DAI_JIRA_EMAIL en el .env");
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1049
|
+
// Ojo: solo miramos que el token ESTÉ, no que sirva — uno vencido pasa este chequeo
|
|
1050
|
+
// y recién falla al publicar. Verificarlo de verdad es pegarle a la red.
|
|
1051
|
+
if (!process.env.DAI_JIRA_TOKEN) warn("falta DAI_JIRA_TOKEN en el .env"); else ok("token de Jira presente (no verificado: eso lo dice `dai publish`)");
|
|
1052
|
+
if (!process.env.DAI_JIRA_PROJECT) warn("DAI_JIRA_PROJECT vacío — solo hace falta para `dai publish` (crear issues)");
|
|
1053
|
+
else {
|
|
1054
|
+
try { ok(`proyecto=${assertProjectKey(process.env.DAI_JIRA_PROJECT)} (para dai publish)`); }
|
|
1055
|
+
catch (e) { warn(String(e.message)); }
|
|
1056
|
+
}
|
|
1057
|
+
// Campos propios del proyecto: que el archivo parsee ANTES de necesitarlo.
|
|
1058
|
+
const fpath = process.env.DAI_JIRA_FIELDS_FILE || join(".dai", "jira-fields.json");
|
|
1059
|
+
if (!existsSync(fpath)) info(`sin campos propios declarados (${fpath} no existe — normal si tu Jira no los exige)`);
|
|
1060
|
+
else {
|
|
1061
|
+
try {
|
|
1062
|
+
const spec = parseFieldsFile(readFileSync(fpath, "utf8"), fpath);
|
|
1063
|
+
const types = Object.keys(spec);
|
|
1064
|
+
ok(`campos propios: ${fpath} — issuetypes: ${types.length ? types.join(", ") : "(ninguno)"}`);
|
|
1065
|
+
} catch (e) { warn(String(e.message)); }
|
|
1066
|
+
}
|
|
991
1067
|
}
|
|
992
1068
|
if (pm === "clickup") {
|
|
993
1069
|
if (!process.env.DAI_CLICKUP_TOKEN) warn("falta DAI_CLICKUP_TOKEN en el .env"); else ok("token de ClickUp presente");
|
|
@@ -1016,7 +1092,7 @@ switch (cmd) {
|
|
|
1016
1092
|
case "check": cmdCheck().catch((e) => fail(String(e.message))); break;
|
|
1017
1093
|
case "stamp": cmdStamp().catch((e) => fail(String(e.message))); break;
|
|
1018
1094
|
case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
|
|
1019
|
-
case "publish": cmdPublish(pos[0]).catch((e) => fail(String(e.message))); break;
|
|
1095
|
+
case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1020
1096
|
case "pr": cmdPr(opts).catch((e) => fail(String(e.message))); break;
|
|
1021
1097
|
case "done": cmdDone(opts); break;
|
|
1022
1098
|
case "archive": cmdArchive(pos[0], opts); break;
|
|
@@ -1039,6 +1115,8 @@ switch (cmd) {
|
|
|
1039
1115
|
" ac-hash <us.md> calcula el ac_hash (ADR-0001)\n" +
|
|
1040
1116
|
" ls [--json] lista lo que implementa el repo (ADR-0005)\n" +
|
|
1041
1117
|
" publish <us.md> crea la US en el tracker (Jira/ClickUp/md) y devuelve el key\n" +
|
|
1118
|
+
" [--parent KEY] la cuelga de su épica · [--issuetype T] p. ej. Epic\n" +
|
|
1119
|
+
" [--field alias=valor] campos propios que exige tu Jira (.dai/jira-fields.json); repetible\n" +
|
|
1042
1120
|
" link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
|
|
1043
1121
|
" link-us <KEY> --resync re-estampa el ac_hash contra la US viva (tras un ⚠️ de check)\n" +
|
|
1044
1122
|
" check compara vs la US viva → atrasado (ADR-0003)\n" +
|
package/cli/lib/args.mjs
CHANGED
|
@@ -28,6 +28,17 @@ export function parseAssistants(str) {
|
|
|
28
28
|
return want;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Un flag REPETIDO acumula en lista en vez de pisarse (`--field a=1 --field b=2`).
|
|
32
|
+
// Antes ganaba el último en silencio, que para --field sería perder datos sin avisar.
|
|
33
|
+
const push = (opts, key, val) => {
|
|
34
|
+
if (!(key in opts)) { opts[key] = val; return; }
|
|
35
|
+
if (Array.isArray(opts[key])) opts[key].push(val);
|
|
36
|
+
else opts[key] = [opts[key], val];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Normaliza un flag que puede venir 0, 1 o N veces → siempre lista.
|
|
40
|
+
export const asList = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
|
|
41
|
+
|
|
31
42
|
export function parseFlags(argv) {
|
|
32
43
|
const opts = {};
|
|
33
44
|
const pos = [];
|
|
@@ -36,8 +47,8 @@ export function parseFlags(argv) {
|
|
|
36
47
|
if (a.startsWith("--")) {
|
|
37
48
|
const key = camel(a.slice(2));
|
|
38
49
|
const next = argv[i + 1];
|
|
39
|
-
if (next !== undefined && !next.startsWith("--")) { opts
|
|
40
|
-
else opts
|
|
50
|
+
if (next !== undefined && !next.startsWith("--")) { push(opts, key, next); i++; }
|
|
51
|
+
else push(opts, key, true); // flag booleano
|
|
41
52
|
} else {
|
|
42
53
|
pos.push(a);
|
|
43
54
|
}
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -4,40 +4,78 @@
|
|
|
4
4
|
|
|
5
5
|
const REPO_URL = "https://github.com/dforce2055/dai";
|
|
6
6
|
|
|
7
|
+
// Un escalar YAML "plano" (sin comillas) no puede contener `: ` ni ` #`, ni empezar con
|
|
8
|
+
// un indicador: YAML lo leería como un mapa, un comentario o una estructura.
|
|
9
|
+
//
|
|
10
|
+
// Esto importa porque el parser de acá es un regex, no YAML — se traga cualquier cosa.
|
|
11
|
+
// Mientras dai CONVERTÍA el SKILL.md para Copilot y Cursor, el `JSON.stringify` de la
|
|
12
|
+
// conversión citaba el valor y tapaba el problema sin querer. Al entregar el SKILL.md
|
|
13
|
+
// crudo (ADR-0014) lo lee un parser YAML de verdad, y ahí una descripción con un `: `
|
|
14
|
+
// suelto tira abajo la skill entera. Le pasó a `doc-to-backlog` y a `grill-epic`.
|
|
15
|
+
const YAML_INDICATORS = /^[-?:,[\]{}#&*!|>'"%@`]/;
|
|
16
|
+
export function yamlScalarIssue(raw) {
|
|
17
|
+
const v = String(raw ?? "").trim();
|
|
18
|
+
if (v === "") return "está vacío";
|
|
19
|
+
const quoted = (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"));
|
|
20
|
+
if (quoted && v.length >= 2) return null; // citado: YAML acepta lo que sea adentro
|
|
21
|
+
if (v.includes(": ")) return "contiene ': ' sin comillas (YAML lo lee como un mapa)";
|
|
22
|
+
if (v.includes(" #")) return "contiene ' #' sin comillas (YAML lo lee como un comentario)";
|
|
23
|
+
if (YAML_INDICATORS.test(v)) return `empieza con '${v[0]}', que YAML reserva`;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// El valor crudo de una clave del frontmatter, tal cual está escrito (con comillas si
|
|
28
|
+
// las tiene). Es lo que hay que validar: `parseFrontmatter` ya las saca.
|
|
29
|
+
export function rawFrontmatterValue(md, key) {
|
|
30
|
+
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
const line = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
33
|
+
return line ? line[1].trim() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Saca las comillas de un escalar YAML citado. Sin comillas, lo devuelve tal cual.
|
|
37
|
+
function unquoteScalar(s) {
|
|
38
|
+
if (s == null) return null;
|
|
39
|
+
const v = s.trim();
|
|
40
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
|
|
41
|
+
try { return JSON.parse(v); } catch { return v.slice(1, -1); }
|
|
42
|
+
}
|
|
43
|
+
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
|
|
7
47
|
// Parsea el frontmatter YAML de un SKILL.md → { name, description, body }.
|
|
48
|
+
// Devuelve los valores YA sin comillas, para que quien los reserialice (skillToCursor)
|
|
49
|
+
// no los cite dos veces.
|
|
8
50
|
export function parseFrontmatter(md) {
|
|
9
51
|
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
10
52
|
if (!m) return { name: null, description: null, body: md.trim() };
|
|
11
53
|
const fm = m[1];
|
|
12
|
-
const name = (fm.match(/^name:\s*(.+)$/m) || [])[1]
|
|
13
|
-
const description = (fm.match(/^description:\s*(.+)$/m) || [])[1]
|
|
54
|
+
const name = unquoteScalar((fm.match(/^name:\s*(.+)$/m) || [])[1]) || null;
|
|
55
|
+
const description = unquoteScalar((fm.match(/^description:\s*(.+)$/m) || [])[1]) || null;
|
|
14
56
|
return { name, description, body: m[2].trim() };
|
|
15
57
|
}
|
|
16
58
|
|
|
17
|
-
// Valida el contrato MÍNIMO de un SKILL.md para que dai lo ingiera y
|
|
18
|
-
//
|
|
19
|
-
//
|
|
59
|
+
// Valida el contrato MÍNIMO de un SKILL.md para que dai lo ingiera y los asistentes lo
|
|
60
|
+
// carguen: frontmatter con `name` y `description`, y que ambos sean YAML válido — si no,
|
|
61
|
+
// el asistente descarta la skill entera. Devuelve null si está OK, o el motivo.
|
|
20
62
|
// NO valida el contenido de la skill — eso es criterio del equipo (ADR-0013).
|
|
21
63
|
export function validateSkill(md) {
|
|
22
64
|
const { name, description } = parseFrontmatter(md);
|
|
23
65
|
if (!name && !description) return "sin frontmatter (falta name y description)";
|
|
24
66
|
if (!name) return "falta 'name' en el frontmatter";
|
|
25
67
|
if (!description) return "falta 'description' en el frontmatter";
|
|
68
|
+
for (const key of ["name", "description"]) {
|
|
69
|
+
const issue = yamlScalarIssue(rawFrontmatterValue(md, key));
|
|
70
|
+
if (issue) return `'${key}' no es YAML válido: ${issue} — citá el valor con comillas dobles`;
|
|
71
|
+
}
|
|
26
72
|
return null;
|
|
27
73
|
}
|
|
28
74
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const fm = [
|
|
34
|
-
"---",
|
|
35
|
-
"mode: agent",
|
|
36
|
-
description ? `description: ${JSON.stringify(description)}` : null,
|
|
37
|
-
"---",
|
|
38
|
-
].filter((x) => x !== null).join("\n");
|
|
39
|
-
return `${fm}\n\n${body}\n`;
|
|
40
|
-
}
|
|
75
|
+
// Los archivos de Copilot que dai generaba ANTES de que Copilot adoptara Agent Skills
|
|
76
|
+
// (ADR-0014). `dai init` los borra al reencontrarlos, para que no queden duplicando
|
|
77
|
+
// cada `/comando` con una copia vieja y sin templates.
|
|
78
|
+
export const stalePromptFiles = (skills) => skills.map((n) => `${n}.prompt.md`);
|
|
41
79
|
|
|
42
80
|
// Transforma un SKILL.md (Claude) en un SKILL.md de Cursor.
|
|
43
81
|
// Conserva name/description/body y ajusta solo el frontmatter.
|
|
@@ -60,7 +98,17 @@ export function envFor(pm) {
|
|
|
60
98
|
return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\nDAI_TRACKER_URL_TEMPLATE=https://app.clickup.com/t/{id}\n";
|
|
61
99
|
}
|
|
62
100
|
if (pm === "jira") {
|
|
63
|
-
return head +
|
|
101
|
+
return head +
|
|
102
|
+
"DAI_PM=jira\n" +
|
|
103
|
+
"DAI_JIRA_BASE_URL=\n" +
|
|
104
|
+
"DAI_JIRA_EMAIL=\n" +
|
|
105
|
+
"DAI_JIRA_TOKEN=\n" +
|
|
106
|
+
"# La clave del PROYECTO (p. ej. PROJ), no la de un ticket (PROJ-123).\n" +
|
|
107
|
+
"DAI_JIRA_PROJECT=\n" +
|
|
108
|
+
"DAI_JIRA_ISSUETYPE=Story\n" +
|
|
109
|
+
"# Campos propios que tu Jira exige al crear. Si el archivo no existe, se ignora.\n" +
|
|
110
|
+
"DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n" +
|
|
111
|
+
"DAI_TRACKER_URL_TEMPLATE=\n";
|
|
64
112
|
}
|
|
65
113
|
return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n";
|
|
66
114
|
}
|
|
@@ -124,7 +172,7 @@ export function reconcileGitignore(text, want) {
|
|
|
124
172
|
// copilot-instructions.md (Copilot). Mismo núcleo, distinto encabezado.
|
|
125
173
|
export function constitution(kind) {
|
|
126
174
|
const head = kind === "copilot"
|
|
127
|
-
? "# Instrucciones de Copilot para este repo\n\nEste repo sigue la metodología **dai**. Aplica estas reglas en todo lo que generes.\n\n> **Superficie:**
|
|
175
|
+
? "# Instrucciones de Copilot para este repo\n\nEste repo sigue la metodología **dai**. Aplica estas reglas en todo lo que generes.\n\n> **Superficie:** las skills de dai (`.github/skills/`) se invocan con `/nombre-skill`, o\n> cuando el agente las detecta por su descripción. Funcionan en el Copilot CLI, en la app,\n> y en modo agente de VS Code / JetBrains (ADR-0014). El CLI `dai` corre en cualquier\n> terminal."
|
|
128
176
|
: kind === "cursor"
|
|
129
177
|
? "# Constitución del proyecto (dai)\n\nEste repo sigue la metodología **dai**. Estas reglas gobiernan todo el trabajo.\n\n> **Superficie:** las skills de dai (`.cursor/skills/`) se invocan con `/nombre-skill`\n> o cuando el agente las detecta por descripción. El CLI `dai` corre en cualquier terminal."
|
|
130
178
|
: "# Constitución del proyecto (dai)\n\nEste repo sigue la metodología **dai**. Estas reglas gobiernan todo el trabajo.";
|
|
@@ -145,6 +193,8 @@ export function constitution(kind) {
|
|
|
145
193
|
- **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.
|
|
146
194
|
- **La IA confirma antes de construir:** el asistente declara que entendió esta constitución y la va a obedecer antes de generar código.
|
|
147
195
|
- **Secretos:** en \`.env\` (nunca commiteados). git por **SSH**, APIs por **token scopeado**.
|
|
196
|
+
- **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.
|
|
197
|
+
- **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.
|
|
148
198
|
- **Docs vivas:** una constitución o arquitectura desactualizada es un defecto, no documentación.
|
|
149
199
|
- Separa el QUÉ (funcional) del CÓMO (técnico); no mezcles.
|
|
150
200
|
|