jarvis-ai-framework 3.2.0 → 4.0.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.
Files changed (50) hide show
  1. package/agents/product/prod.discovery-interviewer.md +1 -1
  2. package/bin/README.md +14 -14
  3. package/bin/commands/docs-publish.js +50 -92
  4. package/bin/commands/docs-sync.js +30 -63
  5. package/bin/lib/cli/program.js +23 -25
  6. package/bin/lib/docs/fetch-file.js +29 -26
  7. package/bin/lib/docs/publish-file.js +89 -161
  8. package/bin/lib/docs/storage/build-index.js +87 -0
  9. package/bin/lib/docs/storage/filesystem.js +203 -0
  10. package/bin/lib/docs/storage/index.js +75 -0
  11. package/bin/lib/docs/validate-frontmatter.js +14 -4
  12. package/bin/lib/vcs/api.js +0 -125
  13. package/package.json +2 -2
  14. package/rules/AGENTS.md +12 -9
  15. package/rules/engineering/eng.integrations-rules.md +2 -1
  16. package/rules/product/prod-rules.md +4 -5
  17. package/rules-on-demand/product/prod.guided-rules.md +104 -0
  18. package/skills/jarvis-docs-central/README.md +57 -269
  19. package/skills/jarvis-docs-central/SKILL.md +126 -296
  20. package/skills/jarvis-init/SKILL.md +46 -7
  21. package/templates/ENV-template.md +20 -5
  22. package/templates/engineering/ARD-template.md +39 -6
  23. package/templates/engineering/architecture-template.md +1 -0
  24. package/templates/product/prod-breakdown-template.md +20 -2
  25. package/templates/product/prod-discovery-template.md +24 -1
  26. package/templates/product/prod-epic-template.md +32 -1
  27. package/templates/product/prod-frd-template.md +29 -2
  28. package/templates/product/prod-issue-template.md +36 -1
  29. package/templates/product/prod-prd-template.md +33 -2
  30. package/workflows/engineering/eng.build-tech-spec.md +2 -2
  31. package/workflows/engineering/eng.create-ard-from-code.md +9 -11
  32. package/workflows/engineering/eng.create-ard.md +15 -13
  33. package/workflows/engineering/eng.create-rfc.md +8 -10
  34. package/workflows/engineering/eng.pr.md +4 -4
  35. package/workflows/engineering/eng.pre-pr.md +4 -4
  36. package/workflows/engineering/eng.rpa.robot.md +4 -2
  37. package/workflows/engineering/eng.start.md +17 -16
  38. package/workflows/product/prod.roadmap.preview.md +4 -1
  39. package/workflows/product/prod.spec.ard.md +7 -3
  40. package/workflows/product/prod.spec.breakdown.md +7 -3
  41. package/workflows/product/prod.spec.clarify.md +5 -1
  42. package/workflows/product/prod.spec.discovery.md +7 -3
  43. package/workflows/product/prod.spec.epic.md +13 -11
  44. package/workflows/product/prod.spec.frd.md +15 -13
  45. package/workflows/product/prod.spec.issue.md +5 -1
  46. package/workflows/product/prod.spec.md +3 -1
  47. package/workflows/product/prod.spec.prd.md +15 -13
  48. package/workflows/warm-up.md +3 -3
  49. package/bin/lib/docs/redis-cache.js +0 -49
  50. package/bin/lib/vcs/fetch-raw.js +0 -42
@@ -1,194 +1,122 @@
1
1
  /**
2
- * @fileoverview Publica um documento no central-docs (branch + commit + MR/PR).
3
- * Substitui `publish-file.sh` (removido) — chama os adapters de `vcs/api.js`
4
- * direto, sem subprocess, sem arquivo temporário para passar JSON/body.
2
+ * @fileoverview Publica um documento no Central docs pelo adaptador configurado
3
+ * (`CENTRAL_DOCS_STORAGE`). O destino segue a estrutura única
4
+ * `{SQUAD}/{product|engineering}/{produto}/{arquivo}` e o índice do squad é
5
+ * regenerado a cada publicação (ver `storage/build-index.js`).
5
6
  * @module docs/publish-file
6
7
  */
7
8
 
8
- import { readFileSync } from "node:fs";
9
- import { validateFrontmatter, extractMetadata } from "./validate-frontmatter.js";
10
- import { commitFiles, createMergeRequest } from "../vcs/api.js";
11
- import { fetchFile } from "./fetch-file.js";
12
- import { redisDel } from "./redis-cache.js";
9
+ import { basename, extname } from "node:path";
10
+ import { validateFrontmatter } from "./validate-frontmatter.js";
13
11
  import { logger } from "../utils/logger.js";
14
12
 
15
- const VALID_TYPES = ["prd", "frd", "ard", "rfc", "swagger", "qa-report"];
16
-
17
- /**
18
- * @param {{ tipo: string, squad: string, workspace: string, feature: string, localFile: string }} p
19
- * @returns {{ destPath: string, area: "product"|"engineering" }}
20
- */
21
- export function resolveDestPath({ tipo, squad, workspace, feature, localFile }) {
22
- const base = `${squad}/${workspace}`;
23
-
24
- if (tipo === "prd" || tipo === "frd") {
25
- return { destPath: `${base}/product/${tipo}-${feature}.md`, area: "product" };
26
- }
27
- if (tipo === "ard") {
28
- return { destPath: `${base}/engineering/ARD/${tipo}-${feature}.md`, area: "engineering" };
29
- }
30
- if (tipo === "rfc") {
31
- return { destPath: `${base}/engineering/RFC/${tipo}-${feature}.md`, area: "engineering" };
32
- }
33
- if (tipo === "swagger") {
34
- const ext = localFile.split(".").pop().toLowerCase();
35
- const swaggerExt = ext === "yaml" || ext === "yml" ? ext : ext === "json" ? "json" : null;
36
- const destPath = swaggerExt
37
- ? `${base}/engineering/swagger/api-${feature}.${swaggerExt}`
38
- : `${base}/engineering/swagger/swagger-${feature}.md`; // default: .md (wrapper com metadados)
39
- return { destPath, area: "engineering" };
40
- }
41
- if (tipo === "qa-report") {
42
- return { destPath: `${base}/engineering/qa/qa-report-${feature}.md`, area: "engineering" };
13
+ /** @constant {Readonly<Record<string, "product"|"engineering">>} Área de cada tipo */
14
+ export const AREA_BY_TYPE = Object.freeze({
15
+ prd: "product",
16
+ frd: "product",
17
+ feat: "product",
18
+ epic: "product",
19
+ story: "product",
20
+ task: "product",
21
+ ard: "engineering",
22
+ rfc: "engineering",
23
+ swagger: "engineering",
24
+ "qa-report": "engineering",
25
+ });
26
+
27
+ /** @constant {ReadonlyArray<string>} Tipos aceitos pelo `jarvis docs publish` */
28
+ export const VALID_TYPES = Object.freeze(Object.keys(AREA_BY_TYPE));
29
+
30
+ const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
31
+
32
+ function assertSegment(label, value) {
33
+ if (!value || !SEGMENT.test(value)) {
34
+ throw new Error(
35
+ `${label} inválido: "${value ?? ""}" (use letras, números, "-" ou "_", sem "/" nem "..")`
36
+ );
43
37
  }
44
-
45
- throw new Error(`Tipo inválido: ${tipo}\nTipos válidos: ${VALID_TYPES.join(", ")}`);
46
38
  }
47
39
 
48
40
  /**
49
- * Insere `newLine` logo após o header `## {area}` — sem nunca descartar uma
50
- * linha existente. O `publish-file.sh` original usava `getline` do AWK para
51
- * "pular" a linha seguinte ao header antes de imprimir a nova entrada; essa
52
- * linha pulada nunca era reimpressa, o que apagava silenciosamente a entrada
53
- * anterior do índice na segunda publicação numa mesma seção (reproduzido e
54
- * documentado em `architecture.md` desta sessão, seção 3.4).
55
- * @param {string} content
56
- * @param {"product"|"engineering"} area
57
- * @param {string} newLine
58
- * @returns {{ content: string, found: boolean }}
41
+ * Calcula o destino do documento no Central docs.
42
+ * O nome é o do arquivo local quando ele já segue `{tipo}-…` (ex: `prd-001-checkout.md`);
43
+ * senão vem de `--feature` (`{tipo}-{feature}.{ext}`).
44
+ *
45
+ * @param {Object} p
46
+ * @param {string} p.tipo
47
+ * @param {string} p.squad
48
+ * @param {string} p.produto - Padrão: WORKSPACE (o caller resolve)
49
+ * @param {string} p.localFile
50
+ * @param {string} [p.feature]
51
+ * @returns {{ destPath: string, area: "product"|"engineering", fileName: string }}
59
52
  */
60
- export function insertIndexLine(content, area, newLine) {
61
- const lines = content.split("\n");
62
- const headerPattern = /^## (product|engineering)\b/;
63
-
64
- for (let i = 0; i < lines.length; i++) {
65
- const match = lines[i].match(headerPattern);
66
- if (match && match[1] === area) {
67
- lines.splice(i + 1, 0, newLine);
68
- return { content: lines.join("\n"), found: true };
69
- }
53
+ export function resolveDestPath({ tipo, squad, produto, localFile, feature }) {
54
+ const area = AREA_BY_TYPE[tipo];
55
+ if (!area) {
56
+ throw new Error(`Tipo inválido: ${tipo}\nTipos válidos: ${VALID_TYPES.join(", ")}`);
57
+ }
58
+ assertSegment("Squad", squad);
59
+ assertSegment("Produto", produto);
60
+
61
+ const local = basename(localFile);
62
+ const ext = extname(local).toLowerCase() || ".md";
63
+ const followsPattern = local.toLowerCase().startsWith(`${tipo}-`) && /\.(md|ya?ml|json)$/.test(ext);
64
+
65
+ let fileName;
66
+ if (followsPattern) {
67
+ fileName = local;
68
+ } else if (feature) {
69
+ assertSegment("Feature", feature);
70
+ fileName = `${tipo}-${feature}${ext}`;
71
+ } else {
72
+ throw new Error(
73
+ `O arquivo "${local}" não segue o padrão {tipo}-{id}-{nome}${ext}.\n` +
74
+ `Renomeie o arquivo ou informe --feature <slug>.`
75
+ );
70
76
  }
71
77
 
72
- const trimmed = content.replace(/\n+$/, "");
73
- return { content: `${trimmed}\n\n## ${area}\n${newLine}\n`, found: false };
78
+ return { destPath: `${squad}/${area}/${produto}/${fileName}`, area, fileName };
74
79
  }
75
80
 
76
81
  /**
77
82
  * @param {Object} opts
78
- * @param {string} opts.localFile - Caminho do arquivo local (ex: `./docs/engineering/ard-x.md`)
79
- * @param {string} opts.tipo - prd|frd|ard|rfc|swagger|qa-report
80
- * @param {string} opts.feature - Slug da feature (kebab-case)
81
- * @param {string} opts.squad - Squad (já resolvido pelo caller: override ou ENV.md)
82
- * @param {string} opts.workspace - Workspace (já resolvido pelo caller: override ou ENV.md)
83
- * @param {string} opts.repo - CENTRAL_DOCS_REPO
84
- * @param {string} [opts.targetBranch] - Branch base do MR/PR (padrão: "dev")
85
- * @param {string|number} [opts.cacheTtl] - TTL do cache do index.md em segundos (padrão: 3600)
86
- * @returns {Promise<{ sha: string, mr: { vendor: string, iid: number|string, url: string } }>}
83
+ * @param {Object} opts.storage - Adaptador (`getStorage`)
84
+ * @param {string} opts.localFile
85
+ * @param {string} opts.tipo
86
+ * @param {string} opts.squad
87
+ * @param {string} opts.produto
88
+ * @param {string} [opts.feature]
89
+ * @returns {Promise<{ destPath: string, indexPath: string, sha?: string, branch?: string, mr?: Object }>}
87
90
  */
88
- export async function publishFile(opts) {
89
- const {
90
- localFile,
91
- tipo,
92
- feature,
93
- squad,
94
- workspace,
95
- repo,
96
- targetBranch = "dev",
97
- cacheTtl = "3600",
98
- } = opts;
99
-
100
- if (!repo) {
91
+ export async function publishFile({ storage, localFile, tipo, squad, produto, feature }) {
92
+ if (!storage) {
101
93
  throw new Error(
102
- "CENTRAL_DOCS_REPO não configurado no ENV.md\n\nConfigure a URL do repositório central:\n CENTRAL_DOCS_REPO=https://gitlab.com/org/central-docs.git"
94
+ "Central docs desligado (CENTRAL_DOCS_STORAGE vazio). Configure-o no /jarvis-init."
103
95
  );
104
96
  }
105
97
 
106
- logger.info("Validando frontmatter...");
107
- validateFrontmatter(localFile, tipo);
108
- logger.info("✅ Frontmatter válido");
109
-
110
- const metadata = extractMetadata(localFile);
111
- const version = metadata.version || "1.0.0";
112
- const status = metadata.status || "draft";
113
- const jira = metadata.jira || "";
114
- const docName = metadata.name || "";
98
+ const { destPath } = resolveDestPath({ tipo, squad, produto, localFile, feature });
115
99
 
116
- const { destPath, area } = resolveDestPath({ tipo, squad, workspace, feature, localFile });
117
- const indexPath = `${squad}/${workspace}/index.md`;
118
- const branchName = `docs/${squad}/${workspace}/${tipo}-${feature}`;
100
+ const isStructuredSwagger = tipo === "swagger" && !localFile.toLowerCase().endsWith(".md");
101
+ if (!isStructuredSwagger) {
102
+ logger.info("Validando frontmatter...");
103
+ validateFrontmatter(localFile, tipo);
104
+ logger.info("✅ Frontmatter válido");
105
+ }
119
106
 
120
107
  logger.info("");
121
108
  logger.info("📋 Resumo da Publicação:");
122
- logger.info(` Squad: ${squad}`);
123
- logger.info(` Workspace: ${workspace}`);
124
- logger.info(` Tipo: ${tipo}`);
125
- logger.info(` Feature: ${feature}`);
109
+ logger.info(` Armazenamento: ${storage.kind}${storage.isGit ? " (git)" : ""}`);
126
110
  logger.info(` Destino: ${destPath}`);
127
- logger.info(` Branch: ${branchName} → ${targetBranch}`);
128
111
  logger.info("");
129
112
 
130
- const fileContentBase64 = readFileSync(localFile).toString("base64");
113
+ const result = await storage.publish({ localFile, destPath, squad, tipo });
131
114
 
132
- logger.info("Buscando index.md do workspace...");
133
- let indexContent = "";
134
- try {
135
- indexContent = await fetchFile(indexPath, targetBranch, { repo, cacheTtl });
136
- } catch {
137
- indexContent = "";
115
+ logger.info(`✅ Publicado em ${destPath}`);
116
+ logger.info(`🗂️ Índice regenerado: ${result.indexPath}`);
117
+ if (result.mr) {
118
+ logger.info(`🔀 Pull/Merge Request: ${result.mr.url}`);
138
119
  }
139
120
 
140
- let indexAction;
141
- if (!indexContent) {
142
- logger.info("⚠️ index.md não encontrado, será criado");
143
- indexAction = "create";
144
- indexContent = `# ${workspace}\n\n**Squad:** ${squad}\n\n## ${area}\n\n`;
145
- } else {
146
- indexAction = "update";
147
- }
148
-
149
- let newLine = `- [${tipo}-${feature}.md](${area}/${tipo}-${feature}.md) | v${version} | ${status}`;
150
- if (jira) newLine += ` | jira: ${jira}`;
151
- if (docName) newLine += ` | ${docName}`;
152
-
153
- const { content: updatedIndex } = insertIndexLine(indexContent, area, newLine);
154
- const updatedIndexBase64 = Buffer.from(updatedIndex, "utf-8").toString("base64");
155
-
156
- logger.info("Criando branch e commit...");
157
- const commitResult = await commitFiles(repo, {
158
- branch: branchName,
159
- startBranch: targetBranch,
160
- message: `docs(${squad}): add ${tipo} ${feature}`,
161
- files: [
162
- { path: destPath, contentBase64: fileContentBase64, action: "create" },
163
- { path: indexPath, contentBase64: updatedIndexBase64, action: indexAction },
164
- ],
165
- });
166
- logger.info(`✅ Commit criado: ${commitResult.sha}`);
167
-
168
- logger.info("Criando Merge/Pull Request...");
169
- let mrDescription = `## Documento\n\n- **Tipo**: ${tipo}\n- **Workspace**: ${workspace}\n- **Feature**: ${feature}`;
170
- if (jira) mrDescription += `\n- **Card**: ${jira}`;
171
- if (docName) mrDescription += `\n- **Nome**: ${docName}`;
172
-
173
- const mr = await createMergeRequest(repo, {
174
- source: branchName,
175
- target: targetBranch,
176
- title: `docs(${squad}): ${tipo} ${feature}`,
177
- description: mrDescription,
178
- });
179
-
180
- logger.info("");
181
- logger.info("✅ Merge/Pull Request criado com sucesso!");
182
- logger.info("");
183
- logger.info(` #${mr.iid}: ${branchName} → ${targetBranch}`);
184
- logger.info(` URL: ${mr.url}`);
185
- logger.info("");
186
-
187
- redisDel(`docs:${indexPath}:${targetBranch}`);
188
- redisDel(`docs:${indexPath}:main`);
189
- logger.info("🔄 Cache Redis invalidado");
190
- logger.info("");
191
- logger.info("✅ Publicação concluída!");
192
-
193
- return { sha: commitResult.sha, mr };
121
+ return result;
194
122
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @fileoverview Gera o `{SQUAD}/index.md` do Central docs a partir do frontmatter
3
+ * dos documentos. O índice é sempre regenerado pela listagem (nunca editado), para
4
+ * duas publicações seguidas não apagarem a entrada uma da outra.
5
+ * @module docs/storage/build-index
6
+ */
7
+
8
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
9
+ import { join, relative, sep } from "node:path";
10
+ import { parseFrontmatter } from "../../utils/frontmatter.js";
11
+
12
+ /** @constant {ReadonlyArray<string>} Áreas do Central docs, na ordem do índice */
13
+ export const AREAS = Object.freeze(["product", "engineering"]);
14
+
15
+ /**
16
+ * Lista arquivos (recursivo) de uma pasta, em ordem alfabética.
17
+ * @param {string} dir
18
+ * @returns {string[]} caminhos absolutos
19
+ */
20
+ export function walkFiles(dir) {
21
+ if (!existsSync(dir)) return [];
22
+ return readdirSync(dir)
23
+ .sort()
24
+ .flatMap((name) => {
25
+ const full = join(dir, name);
26
+ return statSync(full).isDirectory() ? walkFiles(full) : [full];
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Monta o conteúdo do índice de um squad.
32
+ * Formato: `# SQUAD` › `## área` › `### produto` › `- [arquivo](caminho) | vX | status | nome`.
33
+ * @param {string} root - Pasta do Central docs
34
+ * @param {string} squad
35
+ * @returns {string}
36
+ */
37
+ export function renderIndex(root, squad) {
38
+ const out = [`# ${squad}`, ""];
39
+
40
+ for (const area of AREAS) {
41
+ const areaDir = join(root, squad, area);
42
+ const files = walkFiles(areaDir).filter((f) => !f.endsWith(`${sep}index.md`));
43
+ out.push(`## ${area}`, "");
44
+ if (files.length === 0) {
45
+ out.push("_Nenhum documento._", "");
46
+ continue;
47
+ }
48
+
49
+ const byProduct = new Map();
50
+ for (const file of files) {
51
+ const [product = "", ...rest] = relative(areaDir, file).split(sep);
52
+ if (rest.length === 0) continue; // arquivo solto na área, sem pasta de produto
53
+ if (!byProduct.has(product)) byProduct.set(product, []);
54
+ byProduct.get(product).push(file);
55
+ }
56
+
57
+ for (const [product, productFiles] of byProduct) {
58
+ out.push(`### ${product}`);
59
+ for (const file of productFiles) {
60
+ const rel = relative(join(root, squad), file).split(sep).join("/");
61
+ const name = rel.split("/").pop();
62
+ const meta = file.endsWith(".md") ? parseFrontmatter(readFileSync(file, "utf-8")) : {};
63
+ let line = `- [${name}](${rel})`;
64
+ if (meta.version) line += ` | v${meta.version}`;
65
+ if (meta.status) line += ` | ${meta.status}`;
66
+ if (meta.name) line += ` | ${meta.name}`;
67
+ out.push(line);
68
+ }
69
+ out.push("");
70
+ }
71
+ }
72
+
73
+ return out.join("\n").replace(/\n+$/, "\n");
74
+ }
75
+
76
+ /**
77
+ * Regenera e grava `{root}/{squad}/index.md`.
78
+ * @param {string} root - Pasta do Central docs
79
+ * @param {string} squad
80
+ * @returns {{ path: string, content: string }}
81
+ */
82
+ export function buildIndex(root, squad) {
83
+ const content = renderIndex(root, squad);
84
+ const path = join(root, squad, "index.md");
85
+ writeFileSync(path, content);
86
+ return { path, content };
87
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @fileoverview Adaptador `filesystem` do Central docs: uma pasta do disco
3
+ * (dentro do workspace ou em qualquer caminho). Se a pasta tiver `.git` (clone
4
+ * de um repositório de documentação), liga a camada git: `pull` antes de ler e
5
+ * branch + commit + push + PR ao publicar.
6
+ *
7
+ * Todo comando externo usa `execFileSync` com argumentos (sem shell), e todo
8
+ * caminho recebido é resolvido dentro da pasta (sem `..`).
9
+ * @module docs/storage/filesystem
10
+ */
11
+
12
+ import { execFileSync } from "node:child_process";
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
14
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
+ import { createMergeRequest as defaultCreateMergeRequest } from "../../vcs/api.js";
16
+ import { logger } from "../../utils/logger.js";
17
+ import { buildIndex, walkFiles } from "./build-index.js";
18
+
19
+ /**
20
+ * Executa `git` na pasta informada, sem shell.
21
+ * @param {string[]} args
22
+ * @param {string} cwd
23
+ * @returns {string} stdout sem espaços nas pontas
24
+ */
25
+ export function runGit(args, cwd) {
26
+ return execFileSync("git", args, {
27
+ cwd,
28
+ encoding: "utf-8",
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ }).trim();
31
+ }
32
+
33
+ /**
34
+ * Resolve um caminho relativo dentro de `root`, rejeitando o que sair dela.
35
+ * @param {string} root
36
+ * @param {string} rel
37
+ * @returns {string} caminho absoluto
38
+ * @throws {Error} caminho absoluto, com `..` que escape da pasta, ou vazio
39
+ */
40
+ export function safePath(root, rel) {
41
+ if (!rel || isAbsolute(rel)) {
42
+ throw new Error(`Caminho inválido para o Central docs: "${rel}"`);
43
+ }
44
+ const full = resolve(root, rel);
45
+ const fromRoot = relative(root, full);
46
+ if (fromRoot === "" || fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
47
+ throw new Error(`Caminho fora da pasta do Central docs: "${rel}"`);
48
+ }
49
+ return full;
50
+ }
51
+
52
+ /**
53
+ * @param {Object} cfg
54
+ * @param {string} cfg.location - Pasta do Central docs (absoluta)
55
+ * @param {string} [cfg.ref] - Branch lida no `pull` (CENTRAL_DOCS_REF)
56
+ * @param {string} [cfg.targetBranch] - Branch base do PR (CENTRAL_DOCS_TARGET_BRANCH)
57
+ * @param {string} [cfg.reviewers] - Revisores (vão na descrição do PR)
58
+ * @param {Object} [cfg.deps] - `runGit` e `createMergeRequest` injetáveis (testes)
59
+ */
60
+ export function createFilesystemStorage({
61
+ location,
62
+ ref = "main",
63
+ targetBranch = "main",
64
+ reviewers = "",
65
+ deps = {},
66
+ }) {
67
+ const git = deps.runGit ?? runGit;
68
+ const createMergeRequest = deps.createMergeRequest ?? defaultCreateMergeRequest;
69
+ const isGit = existsSync(join(location, ".git"));
70
+ let pulled = false;
71
+
72
+ /** `git pull --ff-only` uma vez por instância; falha vira aviso e a leitura segue local. */
73
+ function pullOnce() {
74
+ if (!isGit || pulled) return;
75
+ pulled = true;
76
+ try {
77
+ git(["pull", "--ff-only", "origin", ref], location);
78
+ } catch (error) {
79
+ logger.warn(`⚠️ Não foi possível atualizar o Central docs (git pull): ${error.message.split("\n")[0]}`);
80
+ logger.warn(" Usando a cópia local.");
81
+ }
82
+ }
83
+
84
+ function ensureLocation() {
85
+ if (!existsSync(location)) {
86
+ throw new Error(
87
+ `Pasta do Central docs não encontrada: ${location}\nCrie a pasta ou ajuste CENTRAL_DOCS_LOCATION no ENV.md (/jarvis-init).`
88
+ );
89
+ }
90
+ }
91
+
92
+ return {
93
+ kind: "filesystem",
94
+ location,
95
+ isGit,
96
+
97
+ /**
98
+ * Lê um documento.
99
+ * @param {string} path - Caminho dentro do Central docs (ex: `CORE/index.md`)
100
+ * @returns {Promise<string>}
101
+ * @throws {Error} com `code = 404` se o arquivo não existir
102
+ */
103
+ async fetch(path) {
104
+ ensureLocation();
105
+ pullOnce();
106
+ const full = safePath(location, path);
107
+ if (!existsSync(full)) {
108
+ const err = new Error(`Arquivo não encontrado no Central docs: ${path}`);
109
+ err.code = 404;
110
+ throw err;
111
+ }
112
+ return readFileSync(full, "utf-8");
113
+ },
114
+
115
+ /**
116
+ * Lista os `.md` sob um prefixo.
117
+ * @param {string} prefix
118
+ * @returns {Promise<string[]>} caminhos relativos à pasta, com `/`
119
+ */
120
+ async list(prefix) {
121
+ ensureLocation();
122
+ pullOnce();
123
+ return walkFiles(safePath(location, prefix))
124
+ .filter((f) => f.endsWith(".md"))
125
+ .map((f) => relative(location, f).split(sep).join("/"));
126
+ },
127
+
128
+ /**
129
+ * Regenera `{squad}/index.md`. Com `.git` e `scripts/build-index.sh` no repositório,
130
+ * usa o script dele (o CI do repositório confere o índice com ele).
131
+ * @param {string} squad
132
+ * @returns {string} caminho relativo do índice
133
+ */
134
+ buildIndex(squad) {
135
+ const script = join(location, "scripts", "build-index.sh");
136
+ if (isGit && existsSync(script)) {
137
+ execFileSync("bash", [script], { cwd: location, stdio: ["ignore", "pipe", "pipe"] });
138
+ } else {
139
+ buildIndex(location, squad);
140
+ }
141
+ return `${squad}/index.md`;
142
+ },
143
+
144
+ /**
145
+ * Grava um documento e regenera o índice do squad.
146
+ * Sem `.git`: copia e pronto. Com `.git`: branch → cópia → índice → commit → push → PR.
147
+ * @param {Object} p
148
+ * @param {string} p.localFile - Arquivo de origem
149
+ * @param {string} p.destPath - Destino dentro do Central docs (`{SQUAD}/{área}/{produto}/{arquivo}`)
150
+ * @param {string} p.squad
151
+ * @param {string} p.tipo
152
+ * @returns {Promise<{ destPath: string, indexPath: string, sha?: string, branch?: string, mr?: Object }>}
153
+ */
154
+ async publish({ localFile, destPath, squad, tipo }) {
155
+ ensureLocation();
156
+ const dest = safePath(location, destPath);
157
+
158
+ if (!isGit) {
159
+ mkdirSync(dirname(dest), { recursive: true });
160
+ copyFileSync(localFile, dest);
161
+ const indexPath = this.buildIndex(squad);
162
+ return { destPath, indexPath };
163
+ }
164
+
165
+ const branch = `docs/${destPath.replace(extname(destPath), "")}`;
166
+ git(["checkout", targetBranch], location);
167
+ try {
168
+ git(["pull", "--ff-only", "origin", targetBranch], location);
169
+ } catch (error) {
170
+ logger.warn(`⚠️ git pull falhou (${error.message.split("\n")[0]}); seguindo com a cópia local.`);
171
+ }
172
+ git(["checkout", "-b", branch], location);
173
+
174
+ try {
175
+ mkdirSync(dirname(dest), { recursive: true });
176
+ copyFileSync(localFile, dest);
177
+ const indexPath = this.buildIndex(squad);
178
+ git(["add", "--", destPath, indexPath], location);
179
+ git(["commit", "-m", `docs(${squad}): add ${tipo} ${basename(destPath)}`], location);
180
+ const sha = git(["rev-parse", "HEAD"], location);
181
+
182
+ git(["push", "-u", "origin", branch], location);
183
+ const remoteUrl = git(["remote", "get-url", "origin"], location);
184
+ let description = `## Documento\n\n- **Tipo**: ${tipo}\n- **Arquivo**: \`${destPath}\``;
185
+ if (reviewers) description += `\n- **Revisores**: ${reviewers}`;
186
+ const mr = await createMergeRequest(remoteUrl, {
187
+ source: branch,
188
+ target: targetBranch,
189
+ title: `docs(${squad}): ${tipo} ${basename(destPath)}`,
190
+ description,
191
+ });
192
+
193
+ return { destPath, indexPath, sha, branch, mr };
194
+ } finally {
195
+ try {
196
+ git(["checkout", targetBranch], location);
197
+ } catch {
198
+ // a branch de trabalho fica; o erro original (se houver) é o que importa
199
+ }
200
+ }
201
+ },
202
+ };
203
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @fileoverview Fábrica dos adaptadores de armazenamento do Central docs.
3
+ * O valor de `CENTRAL_DOCS_STORAGE` escolhe o adaptador; `CENTRAL_DOCS_LOCATION`
4
+ * diz onde ele guarda. Hoje só existe o `filesystem` (com camada git opcional);
5
+ * S3 e Google Drive são as issues #144 e #145.
6
+ * @module docs/storage
7
+ */
8
+
9
+ import { isAbsolute, resolve } from "node:path";
10
+ import { createFilesystemStorage } from "./filesystem.js";
11
+
12
+ /** @constant {ReadonlyArray<string>} Adaptadores disponíveis */
13
+ export const SUPPORTED_STORAGES = Object.freeze(["filesystem"]);
14
+
15
+ /**
16
+ * Erro de configuração do Central docs (variável ausente, valor não suportado).
17
+ */
18
+ export class StorageConfigError extends Error {}
19
+
20
+ /**
21
+ * Resolve `CENTRAL_DOCS_LOCATION` a partir da pasta do workspace (a que contém `$IDE/`).
22
+ * @param {string} location
23
+ * @param {string} root - Pasta do workspace
24
+ * @returns {string} caminho absoluto
25
+ */
26
+ export function resolveLocation(location, root) {
27
+ return isAbsolute(location) ? location : resolve(root, location);
28
+ }
29
+
30
+ /**
31
+ * Devolve o adaptador configurado no ENV.md, ou `null` se o Central docs estiver desligado.
32
+ *
33
+ * @param {Record<string, string>} env - Variáveis do ENV.md (`loadEnv`)
34
+ * @param {Object} opts
35
+ * @param {string} opts.root - Pasta do workspace (`resolveEnvPath(...).root`)
36
+ * @param {Object} [opts.deps] - Dependências injetáveis (testes): `runGit`, `createMergeRequest`
37
+ * @returns {ReturnType<typeof createFilesystemStorage> | null}
38
+ * @throws {StorageConfigError} valor de STORAGE não suportado, LOCATION ausente ou variável antiga
39
+ */
40
+ export function getStorage(env, { root, deps } = {}) {
41
+ const storage = (env.CENTRAL_DOCS_STORAGE || "").trim().toLowerCase();
42
+
43
+ if (!storage) {
44
+ if (env.CENTRAL_DOCS_REPO) {
45
+ throw new StorageConfigError(
46
+ "CENTRAL_DOCS_REPO não é mais usado. Rode o /jarvis-init e escolha Upgrade (C) para migrar " +
47
+ "para CENTRAL_DOCS_STORAGE + CENTRAL_DOCS_LOCATION."
48
+ );
49
+ }
50
+ return null;
51
+ }
52
+
53
+ if (!SUPPORTED_STORAGES.includes(storage)) {
54
+ throw new StorageConfigError(
55
+ `Armazenamento "${storage}" ainda não suportado. Use: ${SUPPORTED_STORAGES.join(", ")}.`
56
+ );
57
+ }
58
+
59
+ if (!env.CENTRAL_DOCS_LOCATION) {
60
+ throw new StorageConfigError(
61
+ "CENTRAL_DOCS_LOCATION não definido no ENV.md (pasta do Central docs)."
62
+ );
63
+ }
64
+ if (!root) {
65
+ throw new StorageConfigError("getStorage: pasta do workspace (root) não informada.");
66
+ }
67
+
68
+ return createFilesystemStorage({
69
+ location: resolveLocation(env.CENTRAL_DOCS_LOCATION, root),
70
+ ref: env.CENTRAL_DOCS_REF || "main",
71
+ targetBranch: env.CENTRAL_DOCS_TARGET_BRANCH || "main",
72
+ reviewers: env.CENTRAL_DOCS_REVIEWERS || "",
73
+ deps,
74
+ });
75
+ }