@dforce2055/dai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +30 -0
- package/CHANGELOG.md +46 -0
- package/CODE_OF_CONDUCT.md +37 -0
- package/CONTRIBUTING.md +66 -0
- package/LICENSE +674 -0
- package/README.md +288 -0
- package/SECURITY.md +37 -0
- package/VERSION +1 -0
- package/cli/dai.mjs +692 -0
- package/cli/lib/ac-hash.mjs +74 -0
- package/cli/lib/args.mjs +23 -0
- package/cli/lib/bootstrap.mjs +74 -0
- package/cli/lib/env.mjs +23 -0
- package/cli/lib/forge-api.mjs +96 -0
- package/cli/lib/forge-url.mjs +61 -0
- package/cli/lib/fsutil.mjs +24 -0
- package/cli/lib/implements.mjs +94 -0
- package/cli/lib/link-us.mjs +59 -0
- package/cli/lib/pm-adapter.mjs +59 -0
- package/cli/lib/pm-clickup.mjs +54 -0
- package/cli/lib/pm-jira.mjs +123 -0
- package/cli/lib/pr.mjs +53 -0
- package/cli/lib/us.mjs +36 -0
- package/docs/EJEMPLO-END-TO-END.md +330 -0
- package/docs/MANIFIESTO.md +114 -0
- package/docs/METODOLOGIA.md +254 -0
- package/docs/PROBAR.md +91 -0
- package/docs/SCRUM-CON-IA.md +190 -0
- package/docs/adr/0001-contrato-ac-hash.md +86 -0
- package/docs/adr/0002-agnostico-del-asistente.md +87 -0
- package/docs/adr/0003-deteccion-y-estampado-son-comandos.md +73 -0
- package/docs/adr/0004-ubicacion-y-schema-implements.md +94 -0
- package/docs/adr/0005-superficie-comandos-y-stamp.md +65 -0
- package/docs/adr/0006-distribucion-y-licencia.md +59 -0
- package/docs/adr/0007-modelo-de-autenticacion.md +63 -0
- package/docs/adr/README.md +19 -0
- package/docs/detalle/01-refinamiento.md +33 -0
- package/docs/detalle/02-planning.md +27 -0
- package/docs/detalle/03-ramas.md +32 -0
- package/docs/detalle/04-tdd.md +35 -0
- package/docs/detalle/05-smoke.md +32 -0
- package/docs/detalle/06-code-review.md +34 -0
- package/docs/detalle/07-merge-trazabilidad.md +33 -0
- package/docs/detalle/08-daily.md +29 -0
- package/docs/detalle/09-review.md +25 -0
- package/docs/detalle/10-retro.md +27 -0
- package/docs/detalle/README.md +20 -0
- package/docs/glosario.md +79 -0
- package/docs/guias/dev.md +66 -0
- package/docs/guias/lead.md +53 -0
- package/docs/guias/po.md +50 -0
- package/governance/branch-naming.md +36 -0
- package/governance/ci-rules.md +57 -0
- package/governance/commit-convention.md +76 -0
- package/index.html +479 -0
- package/install.sh +19 -0
- package/manifest.yaml +76 -0
- package/package.json +55 -0
- package/skills/dai-review/SKILL.md +78 -0
- package/skills/doc-to-backlog/SKILL.md +70 -0
- package/skills/doc-to-backlog/templates/backlog-candidato.md +49 -0
- package/skills/grill-epic/SKILL.md +76 -0
- package/skills/grill-intent/SKILL.md +43 -0
- package/skills/grill-intent/templates/intent.md +36 -0
- package/skills/grill-user-story/SKILL.md +76 -0
- package/skills/grill-user-story/templates/user-story.md +61 -0
- package/skills/link-us/SKILL.md +42 -0
- package/skills/link-us/templates/implements.yaml +16 -0
- package/skills/tdd/SKILL.md +109 -0
- package/skills/tdd/deep-modules.md +33 -0
- package/skills/tdd/interface-design.md +31 -0
- package/skills/tdd/mocking.md +59 -0
- package/skills/tdd/refactoring.md +10 -0
- package/skills/tdd/tests.md +61 -0
- package/templates/adr.md +43 -0
- package/templates/commit-msg +48 -0
- package/templates/definition-of-done.md +50 -0
- package/templates/definition-of-ready.md +51 -0
- package/templates/epica.md +62 -0
- package/templates/formato-us.md +129 -0
- package/templates/pull-request.md +62 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// dai · cálculo del ac_hash — contrato en docs/adr/0001-contrato-ac-hash.md
|
|
2
|
+
//
|
|
3
|
+
// El ac_hash es lo que hace que un cambio del QUÉ marque solo a los CÓMO
|
|
4
|
+
// atrasados (Art. 11). DEBE calcularse idéntico acá y en el CI.
|
|
5
|
+
//
|
|
6
|
+
// Algoritmo (ADR-0001):
|
|
7
|
+
// 1. Extraer el bloque "Criterios de aceptación" de la US.
|
|
8
|
+
// 2. Normalizar: quitar marcado editorial (checkboxes, viñetas, énfasis,
|
|
9
|
+
// numeración AC-N, headings) y colapsar todo el whitespace.
|
|
10
|
+
// 3. SHA-256 del texto normalizado, truncado a 8 hex.
|
|
11
|
+
//
|
|
12
|
+
// El ORDEN de los criterios es significativo: reordenar cambia el hash
|
|
13
|
+
// (por eso el formato de US pide orden estable). No se ordena acá.
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
// Encuentra el heading de criterios de aceptación y devuelve el texto hasta
|
|
18
|
+
// el próximo heading de igual o menor nivel. Tolera el emoji 🔗 y variantes.
|
|
19
|
+
export function extractAcBlock(markdown) {
|
|
20
|
+
const lines = markdown.split(/\r?\n/);
|
|
21
|
+
const headingRe = /^(#{1,6})\s+(.*)$/;
|
|
22
|
+
const isAcHeading = (t) =>
|
|
23
|
+
/criterios\s+de\s+aceptaci[oó]n/i.test(t.replace(/[🔗*_`#]/g, "").trim());
|
|
24
|
+
|
|
25
|
+
let start = -1;
|
|
26
|
+
let level = 0;
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
const m = lines[i].match(headingRe);
|
|
29
|
+
if (m && isAcHeading(m[2])) {
|
|
30
|
+
start = i + 1;
|
|
31
|
+
level = m[1].length;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (start === -1) return null; // sin bloque de criterios
|
|
36
|
+
|
|
37
|
+
let end = lines.length;
|
|
38
|
+
for (let i = start; i < lines.length; i++) {
|
|
39
|
+
const m = lines[i].match(headingRe);
|
|
40
|
+
if (m && m[1].length <= level) {
|
|
41
|
+
end = i;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return lines.slice(start, end).join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Normaliza el bloque a texto canónico según el contrato.
|
|
49
|
+
export function normalizeAcBlock(block) {
|
|
50
|
+
return block
|
|
51
|
+
.split(/\r?\n/)
|
|
52
|
+
.map((line) =>
|
|
53
|
+
line
|
|
54
|
+
.replace(/^\s*>\s?/, "") // blockquotes
|
|
55
|
+
.replace(/^\s*[-*+]\s+\[[ xX]\]\s*/, "") // checkbox list items
|
|
56
|
+
.replace(/^\s*[-*+]\s+/, "") // viñetas
|
|
57
|
+
.replace(/^\s*\d+[.)]\s+/, "") // numeración de lista
|
|
58
|
+
.replace(/[*_`]+/g, "") // énfasis / código inline (antes de AC-N)
|
|
59
|
+
.replace(/\bAC[-\s]?\d+\b\s*[—:\-]?\s*/gi, "") // etiquetas AC-N + separador
|
|
60
|
+
.replace(/^#{1,6}\s+/, "") // headings sueltos
|
|
61
|
+
)
|
|
62
|
+
.join(" ")
|
|
63
|
+
.replace(/\s+/g, " ") // colapsar todo el whitespace
|
|
64
|
+
.trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Devuelve el ac_hash (8 hex) de una US en markdown, o null si no hay criterios.
|
|
68
|
+
export function acHash(markdown) {
|
|
69
|
+
const block = extractAcBlock(markdown);
|
|
70
|
+
if (block == null) return null;
|
|
71
|
+
const normalized = normalizeAcBlock(block);
|
|
72
|
+
if (normalized === "") return null;
|
|
73
|
+
return createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 8);
|
|
74
|
+
}
|
package/cli/lib/args.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// dai · parser de argumentos del CLI (cero dependencias).
|
|
2
|
+
// Distingue flags BOOLEANOS (`--force`, `--global`, `--dry-run`) de flags con
|
|
3
|
+
// VALOR (`--us x`, `--local repo`): un flag toma valor solo si lo que sigue no es
|
|
4
|
+
// otro flag. Así `--global --dry-run` NO consume mal el segundo.
|
|
5
|
+
|
|
6
|
+
export const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
7
|
+
|
|
8
|
+
export function parseFlags(argv) {
|
|
9
|
+
const opts = {};
|
|
10
|
+
const pos = [];
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
const a = argv[i];
|
|
13
|
+
if (a.startsWith("--")) {
|
|
14
|
+
const key = camel(a.slice(2));
|
|
15
|
+
const next = argv[i + 1];
|
|
16
|
+
if (next !== undefined && !next.startsWith("--")) { opts[key] = next; i++; }
|
|
17
|
+
else opts[key] = true; // flag booleano
|
|
18
|
+
} else {
|
|
19
|
+
pos.push(a);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { opts, pos };
|
|
23
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// dai · bootstrap de un repo: genera los adaptadores de asistente (ADR-0002, capa 3).
|
|
2
|
+
// Una sola fuente (los SKILL.md) alimenta a Claude y a Copilot. Los archivos de
|
|
3
|
+
// Copilot se GENERAN, no se mantienen a mano (si no, driftean).
|
|
4
|
+
|
|
5
|
+
const REPO_URL = "https://github.com/dforce2055/dai";
|
|
6
|
+
|
|
7
|
+
// Parsea el frontmatter YAML de un SKILL.md → { name, description, body }.
|
|
8
|
+
export function parseFrontmatter(md) {
|
|
9
|
+
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
10
|
+
if (!m) return { name: null, description: null, body: md.trim() };
|
|
11
|
+
const fm = m[1];
|
|
12
|
+
const name = (fm.match(/^name:\s*(.+)$/m) || [])[1]?.trim() || null;
|
|
13
|
+
const description = (fm.match(/^description:\s*(.+)$/m) || [])[1]?.trim() || null;
|
|
14
|
+
return { name, description, body: m[2].trim() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Transforma un SKILL.md (Claude) en un prompt file de Copilot (.prompt.md).
|
|
18
|
+
// Cambia el frontmatter; el cuerpo (la lógica) es el mismo.
|
|
19
|
+
export function skillToPrompt(md) {
|
|
20
|
+
const { description, body } = parseFrontmatter(md);
|
|
21
|
+
const fm = [
|
|
22
|
+
"---",
|
|
23
|
+
"mode: agent",
|
|
24
|
+
description ? `description: ${JSON.stringify(description)}` : null,
|
|
25
|
+
"---",
|
|
26
|
+
].filter((x) => x !== null).join("\n");
|
|
27
|
+
return `${fm}\n\n${body}\n`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Contenido del .env según el backend de PM elegido (tokens vacíos, a completar).
|
|
31
|
+
export function envFor(pm) {
|
|
32
|
+
const head = "# Config de dai — completá lo que falte. NUNCA commitees tokens (.env está gitignored).\n";
|
|
33
|
+
if (pm === "clickup") {
|
|
34
|
+
return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\nDAI_TRACKER_URL_TEMPLATE=https://app.clickup.com/t/{id}\n";
|
|
35
|
+
}
|
|
36
|
+
if (pm === "jira") {
|
|
37
|
+
return head + "DAI_PM=jira\nDAI_JIRA_BASE_URL=\nDAI_JIRA_EMAIL=\nDAI_JIRA_TOKEN=\nDAI_JIRA_PROJECT=\nDAI_JIRA_ISSUETYPE=Story\nDAI_TRACKER_URL_TEMPLATE=\n";
|
|
38
|
+
}
|
|
39
|
+
return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// La constitución del repo: se inyecta como CLAUDE.md (Claude) o
|
|
43
|
+
// copilot-instructions.md (Copilot). Mismo núcleo, distinto encabezado.
|
|
44
|
+
export function constitution(kind) {
|
|
45
|
+
const head = kind === "copilot"
|
|
46
|
+
? "# Instrucciones de Copilot para este repo\n\nEste repo sigue la metodología **dai**. Aplicá estas reglas en todo lo que generes.\n\n> **Superficie:** los prompts de dai (`.github/prompts/`) se invocan solo en VS Code /\n> JetBrains, o como custom agents en el Copilot CLI — no en la app standalone ni en\n> github.com. El CLI `dai` corre en cualquier terminal."
|
|
47
|
+
: "# Constitución del proyecto (dai)\n\nEste repo sigue la metodología **dai**. Estas reglas gobiernan todo el trabajo.";
|
|
48
|
+
return `${head}
|
|
49
|
+
|
|
50
|
+
## Valores
|
|
51
|
+
|
|
52
|
+
1. El QUÉ y el CÓMO son cosas distintas, con dueños distintos.
|
|
53
|
+
2. La IA asiste; la persona decide y firma.
|
|
54
|
+
3. Nada existe si no se puede testear ni trazar.
|
|
55
|
+
4. La ceremonia se agrega cuando duele, no antes.
|
|
56
|
+
|
|
57
|
+
## Reglas
|
|
58
|
+
|
|
59
|
+
- **No vibe coding:** toda implementación arranca de una US con criterios testeables.
|
|
60
|
+
- **TDD:** test primero, por la interfaz pública; sobrevive a un refactor.
|
|
61
|
+
- **El link se autora una vez** (\`implements.yaml\`); la cobertura se **deriva** (nunca a mano).
|
|
62
|
+
- **Secretos:** en \`.env\` (nunca commiteados). git por **SSH**, APIs por **token scopeado**.
|
|
63
|
+
- Separá el QUÉ (funcional) del CÓMO (técnico); no mezcles.
|
|
64
|
+
|
|
65
|
+
## Herramientas
|
|
66
|
+
|
|
67
|
+
- **Skills (el QUÉ):** \`doc-to-backlog\` (un doc → backlog) · \`grill-intent\` (Gate 0) · \`grill-epic\` (épicas) · \`grill-user-story\` (la US)
|
|
68
|
+
- **Skills (el CÓMO):** \`link-us\`, \`tdd\`, \`dai-review\`
|
|
69
|
+
- **CLI:** \`dai link-us <ID>\` · \`dai check\` · \`dai stamp\` · \`dai pr\` · \`dai ls\`
|
|
70
|
+
- **Formatos:** \`.dai/templates/\` · **Governance:** \`.dai/governance/\`
|
|
71
|
+
|
|
72
|
+
Detalle completo de la metodología: ${REPO_URL}
|
|
73
|
+
`;
|
|
74
|
+
}
|
package/cli/lib/env.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// dai · loader mínimo de .env (cero dependencias).
|
|
2
|
+
// Carga variables desde un archivo .env SIN pisar las que ya estén en el entorno
|
|
3
|
+
// (para que un valor exportado en la shell/CI gane sobre el archivo).
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
export function loadEnv(path = ".env", env = process.env) {
|
|
8
|
+
let text;
|
|
9
|
+
try { text = readFileSync(path, "utf8"); } catch { return env; } // sin .env: no pasa nada
|
|
10
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
13
|
+
const m = line.match(/^(?:export\s+)?([\w.-]+)\s*=\s*(.*)$/);
|
|
14
|
+
if (!m) continue;
|
|
15
|
+
let [, key, val] = m;
|
|
16
|
+
val = val.trim();
|
|
17
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
18
|
+
val = val.slice(1, -1);
|
|
19
|
+
}
|
|
20
|
+
if (!(key in env)) env[key] = val;
|
|
21
|
+
}
|
|
22
|
+
return env;
|
|
23
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// dai · API del forge para review de PR/MR (GitHub + GitLab).
|
|
2
|
+
// Cara CLI del "forge adapter": lee la PR y postea el comentario vía REST + token.
|
|
3
|
+
// La cara skill (MCP del forge) produce el MISMO comentario estándar.
|
|
4
|
+
// Auth: token SCOPEADO (GITHUB_TOKEN / GITLAB_TOKEN), nunca contraseñas. git usa SSH.
|
|
5
|
+
|
|
6
|
+
import { parseRemote, detectForge } from "./forge-url.mjs";
|
|
7
|
+
|
|
8
|
+
// Resuelve una referencia de PR/MR: URL completa, o solo un número + el remoto git.
|
|
9
|
+
// → { forge, host, projectPath, owner, repo, number }
|
|
10
|
+
export function parsePrRef(input, remote) {
|
|
11
|
+
const s = String(input ?? "").trim();
|
|
12
|
+
const url = s.match(/^https?:\/\/([^/]+)\/(.+?)\/(?:-\/)?(?:pull|merge_requests)\/(\d+)/i);
|
|
13
|
+
if (url) return build(url[1], url[2].replace(/\/+$/, ""), Number(url[3]));
|
|
14
|
+
if (/^\d+$/.test(s)) {
|
|
15
|
+
const r = parseRemote(remote);
|
|
16
|
+
return r ? build(r.host, r.path, Number(s)) : null;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function build(host, projectPath, number) {
|
|
22
|
+
const forge = detectForge(host);
|
|
23
|
+
const parts = projectPath.split("/");
|
|
24
|
+
return { forge, host, projectPath, owner: parts[0], repo: parts.slice(1).join("/"), number };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function apiBase(ref) {
|
|
28
|
+
if (ref.forge === "github") return ref.host === "github.com" ? "https://api.github.com" : `https://${ref.host}/api/v3`;
|
|
29
|
+
if (ref.forge === "gitlab") return `https://${ref.host}/api/v4`;
|
|
30
|
+
throw new Error(`forge no soportado para review: ${ref.forge} (solo github/gitlab)`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function prApiUrl(ref) {
|
|
34
|
+
if (ref.forge === "github") return `${apiBase(ref)}/repos/${ref.owner}/${ref.repo}/pulls/${ref.number}`;
|
|
35
|
+
return `${apiBase(ref)}/projects/${encodeURIComponent(ref.projectPath)}/merge_requests/${ref.number}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function commentApiUrl(ref) {
|
|
39
|
+
if (ref.forge === "github") return `${apiBase(ref)}/repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments`;
|
|
40
|
+
return `${apiBase(ref)}/projects/${encodeURIComponent(ref.projectPath)}/merge_requests/${ref.number}/notes`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function authHeaders(ref, env = process.env) {
|
|
44
|
+
if (ref.forge === "github") {
|
|
45
|
+
return { Authorization: `Bearer ${env.GITHUB_TOKEN || ""}`, Accept: "application/vnd.github+json", "User-Agent": "dai" };
|
|
46
|
+
}
|
|
47
|
+
return { "PRIVATE-TOKEN": env.GITLAB_TOKEN || "" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Comentario estándar de dai-review (mismo formato lo emita el CLI o la skill).
|
|
51
|
+
export function renderReviewComment(r) {
|
|
52
|
+
const { us, version, checkStatus, dod, errors = [], improvements = [], good = [] } = r;
|
|
53
|
+
const bul = (arr) => (arr.length ? arr.map((x) => `- ${x}`).join("\n") : "- — ninguno —");
|
|
54
|
+
const head = us
|
|
55
|
+
? `**US:** \`${us}\`${version ? ` @ ${version}` : ""} · \`dai check\`: ${checkStatus || "?"}`
|
|
56
|
+
: "**US:** _(este PR no declara implementar una US)_";
|
|
57
|
+
return [
|
|
58
|
+
"## 🤖 dai-review",
|
|
59
|
+
"",
|
|
60
|
+
head,
|
|
61
|
+
dod ? `**Definition of Done:** ${dod}` : null,
|
|
62
|
+
"",
|
|
63
|
+
"### 🔴 Errores (correctitud)",
|
|
64
|
+
bul(errors),
|
|
65
|
+
"",
|
|
66
|
+
"### 🟡 Mejoras (calidad, reuso, simplicidad)",
|
|
67
|
+
bul(improvements),
|
|
68
|
+
"",
|
|
69
|
+
"### ✅ Lo que está bien",
|
|
70
|
+
bul(good),
|
|
71
|
+
"",
|
|
72
|
+
"---",
|
|
73
|
+
"_Revisión asistida por dai. La aprobación la firma un humano (Art. 5 del manifiesto)._",
|
|
74
|
+
].filter((l) => l !== null).join("\n") + "\n";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Efectos de red (testeados con fetch mockeado en cli/test) ────────────────
|
|
78
|
+
export async function getPR(ref, env = process.env) {
|
|
79
|
+
const res = await fetch(prApiUrl(ref), { headers: authHeaders(ref, env) });
|
|
80
|
+
if (!res.ok) throw new Error(`forge ${res.status}: ${await res.text()}`);
|
|
81
|
+
const j = await res.json();
|
|
82
|
+
return ref.forge === "github"
|
|
83
|
+
? { title: j.title, state: j.state, body: j.body, branch: j.head?.ref, url: j.html_url }
|
|
84
|
+
: { title: j.title, state: j.state, body: j.description, branch: j.source_branch, url: j.web_url };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function postComment(ref, body, env = process.env) {
|
|
88
|
+
const res = await fetch(commentApiUrl(ref), {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { ...authHeaders(ref, env), "Content-Type": "application/json" },
|
|
91
|
+
body: JSON.stringify({ body }),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) throw new Error(`forge ${res.status}: ${await res.text()}`);
|
|
94
|
+
const j = await res.json();
|
|
95
|
+
return { url: j.html_url || j.web_url || null };
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// dai · construcción de URLs web desde el remoto de git (ADR-0005).
|
|
2
|
+
// Deriva repo/branch/commit web URLs sin API del forge. La forma de la URL es
|
|
3
|
+
// específica del forge (GitHub / GitLab / Bitbucket), detectada por el host.
|
|
4
|
+
|
|
5
|
+
// Normaliza un remoto (SSH o HTTPS) a { host, path }.
|
|
6
|
+
// git@github.com:org/repo.git → { host: github.com, path: org/repo }
|
|
7
|
+
// https://github.com/org/repo.git → { host: github.com, path: org/repo }
|
|
8
|
+
// ssh://git@host:22/org/repo.git → { host, path: org/repo }
|
|
9
|
+
export function parseRemote(remote) {
|
|
10
|
+
if (!remote) return null;
|
|
11
|
+
let r = remote.trim();
|
|
12
|
+
|
|
13
|
+
// scp-like: git@host:org/repo(.git)
|
|
14
|
+
const scp = r.match(/^[\w.-]+@([^:]+):(.+)$/);
|
|
15
|
+
if (scp) return { host: scp[1], path: stripGit(scp[2]) };
|
|
16
|
+
|
|
17
|
+
// url-like: (ssh|https|http|git)://[user@]host[:port]/org/repo(.git)
|
|
18
|
+
const url = r.match(/^[a-z]+:\/\/(?:[^@/]+@)?([^:/]+)(?::\d+)?\/(.+)$/i);
|
|
19
|
+
if (url) return { host: url[1], path: stripGit(url[2]) };
|
|
20
|
+
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripGit(p) {
|
|
25
|
+
return p.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Detecta el forge por el host. Heurística + default GitHub-style.
|
|
29
|
+
export function detectForge(host) {
|
|
30
|
+
const h = (host || "").toLowerCase();
|
|
31
|
+
if (h.includes("gitlab")) return "gitlab";
|
|
32
|
+
if (h.includes("bitbucket")) return "bitbucket";
|
|
33
|
+
if (h.includes("github")) return "github";
|
|
34
|
+
return "github"; // default: el esquema más común
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function repoWebUrl(remote) {
|
|
38
|
+
const p = parseRemote(remote);
|
|
39
|
+
if (!p) return null;
|
|
40
|
+
return `https://${p.host}/${p.path}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SCHEMES = {
|
|
44
|
+
github: { tree: "/tree/", commit: "/commit/" },
|
|
45
|
+
gitlab: { tree: "/-/tree/", commit: "/-/commit/" },
|
|
46
|
+
bitbucket: { tree: "/src/", commit: "/commits/" },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function branchUrl(remote, branch) {
|
|
50
|
+
const base = repoWebUrl(remote);
|
|
51
|
+
if (!base || !branch) return null;
|
|
52
|
+
const s = SCHEMES[detectForge(parseRemote(remote).host)];
|
|
53
|
+
return base + s.tree + encodeURIComponent(branch).replace(/%2F/g, "/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function commitUrl(remote, sha) {
|
|
57
|
+
const base = repoWebUrl(remote);
|
|
58
|
+
if (!base || !sha) return null;
|
|
59
|
+
const s = SCHEMES[detectForge(parseRemote(remote).host)];
|
|
60
|
+
return base + s.commit + sha;
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// dai · utilidades de filesystem para el instalador (cero dependencias).
|
|
2
|
+
|
|
3
|
+
import { readdirSync, statSync, readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Mapa { pathRelativo → contenido } de todos los archivos bajo dir.
|
|
7
|
+
export function fileMap(dir, base = dir, map = {}) {
|
|
8
|
+
for (const name of readdirSync(dir)) {
|
|
9
|
+
const p = join(dir, name);
|
|
10
|
+
const st = statSync(p);
|
|
11
|
+
if (st.isDirectory()) fileMap(p, base, map);
|
|
12
|
+
else map[relative(base, p)] = readFileSync(p, "utf8");
|
|
13
|
+
}
|
|
14
|
+
return map;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ¿Dos directorios tienen exactamente los mismos archivos con el mismo contenido?
|
|
18
|
+
export function dirsEqual(a, b) {
|
|
19
|
+
if (!existsSync(a) || !existsSync(b)) return false;
|
|
20
|
+
const ma = fileMap(a), mb = fileMap(b);
|
|
21
|
+
const ka = Object.keys(ma).sort(), kb = Object.keys(mb).sort();
|
|
22
|
+
if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
|
|
23
|
+
return ka.every((k) => ma[k] === mb[k]);
|
|
24
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// dai · parseo y descubrimiento de implements.yaml (ADR-0004).
|
|
2
|
+
// Parser YAML mínimo para NUESTRO schema (cero dependencias). No es un parser
|
|
3
|
+
// general de YAML: cubre exactamente la forma del implements.yaml.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const SKIP_DIRS = new Set([
|
|
9
|
+
"node_modules", ".git", "dist", "build", "vendor", "coverage", ".next", ".cache",
|
|
10
|
+
// scaffolding del propio dai: contienen implements.yaml de PLANTILLA, no trabajo real
|
|
11
|
+
".claude", ".github", ".dai",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// ¿El id es todavía un placeholder de plantilla (ABC-###, <change-id>)? No es una US real.
|
|
15
|
+
export function isPlaceholderId(id) {
|
|
16
|
+
return !id || /[#<>]/.test(id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function unquote(v) {
|
|
20
|
+
const t = v.trim();
|
|
21
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
22
|
+
return t.slice(1, -1);
|
|
23
|
+
}
|
|
24
|
+
return t;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stripComment(line) {
|
|
28
|
+
// quita comentarios: `#` al inicio o precedido por espacio (nuestros valores no llevan #)
|
|
29
|
+
return line.replace(/(^|\s)#.*$/, "").replace(/\s+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Parsea el texto de un implements.yaml a un objeto estructurado.
|
|
33
|
+
export function parseImplements(text) {
|
|
34
|
+
const out = { change: null, repo: null, implements: [], introduces: [], autor: null };
|
|
35
|
+
let listKey = null; // key top-level cuyo valor es una lista
|
|
36
|
+
let curObj = null; // objeto actual dentro de una lista de objetos
|
|
37
|
+
|
|
38
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
39
|
+
const line = stripComment(raw);
|
|
40
|
+
if (line.trim() === "") continue;
|
|
41
|
+
const indent = line.length - line.trimStart().length;
|
|
42
|
+
const body = line.trim();
|
|
43
|
+
|
|
44
|
+
if (indent === 0) {
|
|
45
|
+
const m = body.match(/^([\w-]+):\s*(.*)$/);
|
|
46
|
+
if (!m) continue;
|
|
47
|
+
const [, key, val] = m;
|
|
48
|
+
if (val === "") { out[key] = out[key] ?? []; listKey = key; curObj = null; }
|
|
49
|
+
else { out[key] = unquote(val); listKey = null; curObj = null; }
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// línea indentada: pertenece a la lista listKey
|
|
54
|
+
if (!listKey) continue;
|
|
55
|
+
if (body.startsWith("- ")) {
|
|
56
|
+
const rest = body.slice(2).trim();
|
|
57
|
+
const kv = rest.match(/^([\w-]+):\s*(.*)$/);
|
|
58
|
+
if (kv && kv[2] !== "") { // item objeto: "- id: X"
|
|
59
|
+
curObj = { [kv[1]]: unquote(kv[2]) };
|
|
60
|
+
out[listKey].push(curObj);
|
|
61
|
+
} else { // item escalar: "- valor"
|
|
62
|
+
out[listKey].push(unquote(rest));
|
|
63
|
+
curObj = null;
|
|
64
|
+
}
|
|
65
|
+
} else if (curObj) { // atributo del objeto actual: "version: v1"
|
|
66
|
+
const kv = body.match(/^([\w-]+):\s*(.*)$/);
|
|
67
|
+
if (kv) curObj[kv[1]] = unquote(kv[2]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Camina el árbol desde root y devuelve { path, ...parsed } por cada implements.yaml.
|
|
74
|
+
export function discoverImplements(root) {
|
|
75
|
+
const found = [];
|
|
76
|
+
const walk = (dir) => {
|
|
77
|
+
let entries;
|
|
78
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
79
|
+
for (const name of entries) {
|
|
80
|
+
const full = join(dir, name);
|
|
81
|
+
let st;
|
|
82
|
+
try { st = statSync(full); } catch { continue; }
|
|
83
|
+
if (st.isDirectory()) {
|
|
84
|
+
if (!SKIP_DIRS.has(name)) walk(full);
|
|
85
|
+
} else if (name === "implements.yaml") {
|
|
86
|
+
try {
|
|
87
|
+
found.push({ path: full, ...parseImplements(readFileSync(full, "utf8")) });
|
|
88
|
+
} catch { /* archivo inválido: se ignora en el listado */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
walk(root);
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// dai · helpers puros de link-us (crear el link QUÉ↔CÓMO por construcción).
|
|
2
|
+
// La parte con efectos (git, escribir archivos) vive en dai.mjs; acá solo la
|
|
3
|
+
// lógica pura y testeable: slug, nombre de branch, título, render del yaml.
|
|
4
|
+
|
|
5
|
+
// Key del tracker, agnóstico de herramienta: Jira (`ABC-482`), ClickUp (`86cxyz`),
|
|
6
|
+
// custom, etc. Solo exige un identificador sano para una branch/URL: sin espacios,
|
|
7
|
+
// sin barras, no vacío.
|
|
8
|
+
export const US_KEY_RE = /^[\w.-]+$/;
|
|
9
|
+
|
|
10
|
+
export function isValidKey(key) {
|
|
11
|
+
return US_KEY_RE.test((key || "").trim());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// slug: minúsculas, sin acentos/ñ, no-alfanumérico → guion, recortado.
|
|
15
|
+
export function slugify(s, max = 42) {
|
|
16
|
+
let out = (s || "")
|
|
17
|
+
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "");
|
|
21
|
+
if (out.length > max) { out = out.slice(0, max); const cut = out.lastIndexOf("-"); if (cut > 0) out = out.slice(0, cut); }
|
|
22
|
+
return out.replace(/-+$/g, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function branchName(key, title) {
|
|
26
|
+
return `feature/${key}-${slugify(title)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Título de la US: primer H1 que NO sea la cabecera de metadata.
|
|
30
|
+
export function extractTitle(md) {
|
|
31
|
+
for (const line of (md || "").split(/\r?\n/)) {
|
|
32
|
+
const m = line.match(/^#\s+(.+?)\s*$/);
|
|
33
|
+
if (!m) continue;
|
|
34
|
+
const text = m[1].replace(/[🔗*_`#]/g, "").trim();
|
|
35
|
+
if (/metadata/i.test(text)) continue; // saltea "# 🔗 Metadata de trazabilidad"
|
|
36
|
+
if (text === "") continue;
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Render del implements.yaml (schema ADR-0004).
|
|
43
|
+
export function renderImplementsYaml({ change, repo, id, version = "v1", ac_hash, autor }) {
|
|
44
|
+
return `# Link QUÉ↔CÓMO · scaffoldeado por dai link-us. El ÚNICO link autorado a mano.
|
|
45
|
+
# Schema: docs/adr/0004-ubicacion-y-schema-implements.md
|
|
46
|
+
change: ${change}
|
|
47
|
+
repo: ${repo}
|
|
48
|
+
|
|
49
|
+
implements:
|
|
50
|
+
- id: ${id}
|
|
51
|
+
version: ${version}
|
|
52
|
+
ac_hash: ${ac_hash}
|
|
53
|
+
|
|
54
|
+
introduces:
|
|
55
|
+
- <capacidad-tecnica> # completar: specs técnicas nuevas de este change
|
|
56
|
+
|
|
57
|
+
autor: ${autor}
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// dai · adaptador de PM (decisión abierta #3). Un contrato, varios backends.
|
|
2
|
+
//
|
|
3
|
+
// El adaptador tiene DOS caras (ADR-0002/0003): las skills publican la US por el
|
|
4
|
+
// MCP del asistente; el CLI (esto) lee/escribe por su cuenta. Backends CLI:
|
|
5
|
+
// md — local, offline (cero deps)
|
|
6
|
+
// jira — REST v2 (pm-jira.mjs)
|
|
7
|
+
// clickup — REST v2 (pm-clickup.mjs)
|
|
8
|
+
//
|
|
9
|
+
// Interfaz (fetchUS/stamp pueden ser sync o async — el CLI siempre await-ea):
|
|
10
|
+
// fetchUS(id) → { id, title, spec_version, ac_hash } | null
|
|
11
|
+
// stamp(id, record) → destino donde quedó la cobertura
|
|
12
|
+
// kind → nombre del backend
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { parseUS, renderCoverage } from "./us.mjs";
|
|
17
|
+
import { slugify } from "./link-us.mjs";
|
|
18
|
+
import { jiraAdapter } from "./pm-jira.mjs";
|
|
19
|
+
import { clickupAdapter } from "./pm-clickup.mjs";
|
|
20
|
+
|
|
21
|
+
// Re-export para compatibilidad (tests y CLI importan estos desde acá).
|
|
22
|
+
export { parseUS, coverageStatus, statusLabel, renderCoverage } from "./us.mjs";
|
|
23
|
+
|
|
24
|
+
// ── backend md (local, offline) ───────────────────────────────────────────────
|
|
25
|
+
function mdAdapter(env) {
|
|
26
|
+
const dir = env.DAI_MD_US_DIR || ".dai/us";
|
|
27
|
+
return {
|
|
28
|
+
kind: "md",
|
|
29
|
+
fetchUS(id) {
|
|
30
|
+
const p = join(dir, `${id}.md`);
|
|
31
|
+
if (!existsSync(p)) return null;
|
|
32
|
+
return { id, ...parseUS(readFileSync(p, "utf8")) };
|
|
33
|
+
},
|
|
34
|
+
stamp(id, record) {
|
|
35
|
+
const p = join(dir, `${id}.coverage.md`);
|
|
36
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
37
|
+
writeFileSync(p, renderCoverage(id, record));
|
|
38
|
+
return p;
|
|
39
|
+
},
|
|
40
|
+
createUS({ title, descriptionMarkdown }) {
|
|
41
|
+
const id = slugify(title); // sin tracker, el "key" es el slug del título
|
|
42
|
+
const p = join(dir, `${id}.md`);
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
writeFileSync(p, descriptionMarkdown.startsWith("# ") ? descriptionMarkdown : `# ${title}\n\n${descriptionMarkdown}`);
|
|
45
|
+
return { id, url: p };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getAdapter(env = process.env) {
|
|
51
|
+
const kind = (env.DAI_PM || "md").toLowerCase();
|
|
52
|
+
switch (kind) {
|
|
53
|
+
case "md": return mdAdapter(env);
|
|
54
|
+
case "jira": return jiraAdapter(env);
|
|
55
|
+
case "clickup": return clickupAdapter(env);
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`DAI_PM desconocido: '${kind}'. Valores: md | jira | clickup.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// dai · backend ClickUp del adaptador de PM (REST v2, cara CLI).
|
|
2
|
+
// Auth: token personal en el header Authorization (ADR-0007).
|
|
3
|
+
// Config: DAI_CLICKUP_TOKEN.
|
|
4
|
+
|
|
5
|
+
import { parseUS, renderCoverage } from "./us.mjs";
|
|
6
|
+
|
|
7
|
+
const API = "https://api.clickup.com/api/v2";
|
|
8
|
+
|
|
9
|
+
// include_markdown_description=true → la API devuelve el markdown real (con los
|
|
10
|
+
// headings), necesario para encontrar el bloque "Criterios de aceptación".
|
|
11
|
+
export function clickupTaskUrl(id) {
|
|
12
|
+
return `${API}/task/${encodeURIComponent(id)}?include_markdown_description=true`;
|
|
13
|
+
}
|
|
14
|
+
export function clickupCommentUrl(id) { return `${API}/task/${encodeURIComponent(id)}/comment`; }
|
|
15
|
+
export function clickupAuthHeaders(env) {
|
|
16
|
+
return { Authorization: env.DAI_CLICKUP_TOKEN || "", "Content-Type": "application/json" };
|
|
17
|
+
}
|
|
18
|
+
// Arma el texto de la US desde la respuesta de ClickUp (name + description).
|
|
19
|
+
export function clickupTaskToText(json) {
|
|
20
|
+
const desc = json.markdown_description || json.description || json.text_content || "";
|
|
21
|
+
return `# ${json.name || json.id || ""}\n\n${desc}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clickupAdapter(env) {
|
|
25
|
+
if (!env.DAI_CLICKUP_TOKEN) throw new Error("falta DAI_CLICKUP_TOKEN en el .env (backend clickup).");
|
|
26
|
+
return {
|
|
27
|
+
kind: "clickup",
|
|
28
|
+
async fetchUS(id) {
|
|
29
|
+
const res = await fetch(clickupTaskUrl(id), { headers: clickupAuthHeaders(env) });
|
|
30
|
+
if (res.status === 404) return null;
|
|
31
|
+
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
32
|
+
return { id, ...parseUS(clickupTaskToText(await res.json())) };
|
|
33
|
+
},
|
|
34
|
+
async stamp(id, record) {
|
|
35
|
+
const res = await fetch(clickupCommentUrl(id), {
|
|
36
|
+
method: "POST", headers: clickupAuthHeaders(env),
|
|
37
|
+
body: JSON.stringify({ comment_text: renderCoverage(id, record) }),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
40
|
+
return `task ${id} (comentario)`;
|
|
41
|
+
},
|
|
42
|
+
async createUS({ title, descriptionMarkdown }) {
|
|
43
|
+
const list = env.DAI_CLICKUP_LIST_ID;
|
|
44
|
+
if (!list) throw new Error("falta DAI_CLICKUP_LIST_ID en el .env (la lista donde crear la tarea).");
|
|
45
|
+
const res = await fetch(`${API}/list/${encodeURIComponent(list)}/task`, {
|
|
46
|
+
method: "POST", headers: clickupAuthHeaders(env),
|
|
47
|
+
body: JSON.stringify({ name: title, markdown_content: descriptionMarkdown }),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
|
|
50
|
+
const j = await res.json();
|
|
51
|
+
return { id: j.id, url: j.url || null };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|