jarvis-ai-framework 5.2.0 → 5.5.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 (42) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +14 -1
  3. package/bin/commands/init.js +149 -2
  4. package/bin/commands/tokens.js +9 -3
  5. package/bin/lib/cli/program.js +14 -2
  6. package/bin/lib/core/docs-migrate.js +259 -0
  7. package/bin/lib/core/env-upgrade.js +84 -0
  8. package/bin/lib/core/profile-filter.js +15 -3
  9. package/bin/lib/core/sync-engine.js +5 -3
  10. package/bin/lib/core/token-report.js +1 -0
  11. package/bin/lib/docs/storage/build-index.js +2 -1
  12. package/bin/lib/vcs/api.js +13 -1
  13. package/bin/lib/vcs/create-issue.js +17 -3
  14. package/package.json +2 -2
  15. package/rules/AGENTS.md +4 -1
  16. package/rules/artifact-style-rules.md +53 -0
  17. package/rules/product/prod-rules.md +4 -2
  18. package/rules/product/prod.spec-rules.md +2 -2
  19. package/rules-on-demand/engineering/eng.plan-rules.md +1 -1
  20. package/rules-on-demand/engineering/eng.pr-rules.md +4 -3
  21. package/rules-on-demand/engineering/eng.start-rules.md +2 -2
  22. package/rules-on-demand/engineering/eng.transitions-rules.md +58 -0
  23. package/rules-on-demand/engineering/eng.work-rules.md +9 -3
  24. package/skills/eng-global-pr/SKILL.md +3 -1
  25. package/skills/eng-global-task-comment/SKILL.md +2 -0
  26. package/skills/jarvis-docs-central/SKILL.md +1 -1
  27. package/skills/jarvis-init/SKILL.md +54 -17
  28. package/skills/jarvis-report-issue/SKILL.md +64 -9
  29. package/templates/ENV-template.md +10 -0
  30. package/templates/engineering/architecture-template.md +2 -1
  31. package/templates/engineering/plan-template.md +2 -0
  32. package/templates/product/prod-epic-template.md +1 -1
  33. package/templates/product/prod-frd-template.md +1 -1
  34. package/templates/product/prod-issue-template.md +1 -1
  35. package/templates/product/prod-prd-template.md +1 -1
  36. package/workflows/engineering/eng.plan.md +2 -1
  37. package/workflows/engineering/eng.pr.md +39 -6
  38. package/workflows/engineering/eng.pre-pr.md +2 -1
  39. package/workflows/engineering/eng.spec-consolidate.md +3 -1
  40. package/workflows/engineering/eng.start.md +8 -2
  41. package/workflows/engineering/eng.work.md +22 -5
  42. package/workflows/product/prod.spec.discovery.md +2 -2
package/AGENTS.md CHANGED
@@ -194,6 +194,7 @@ Comandos disponíveis:
194
194
  jarvis whoami [--logout] # Exibir identidade (ENV.md / git / SO)
195
195
  jarvis logout # Remover auth.json legado (opcional)
196
196
  jarvis init # Bootstrap do framework no projeto
197
+ jarvis init --upgrade [--ide X] [--yes] # Atualizar projeto existente: assets + variáveis novas do ENV + documentos nas pastas novas
197
198
  jarvis list # Listar agents, skills e workflows
198
199
  jarvis install-rtk # Instalar RTK (token killer — economia 60-90% em tokens de shell)
199
200
  jarvis tokens [--hub X --position Y --area Z --squad W] [--rtk] [--json] # Estimar o que carrega no início da sessão (perfil do ENV.md se sem flags)
package/README.md CHANGED
@@ -116,9 +116,11 @@ Mesmos passos 2 e 3 do [Get Started](#get-started) acima — `jarvis init --ide
116
116
 
117
117
  ```bash
118
118
  npm install -g jarvis-ai-framework@latest
119
- jarvis init --ide {ide} # re-sincroniza os assets (agents/skills/workflows/rules)
119
+ jarvis init --upgrade --ide {ide} # assets + variáveis novas do ENV.md + documentos para as pastas novas
120
120
  ```
121
121
 
122
+ Depois, na IDE, rode `/jarvis-init upgrade`: ele vai direto ao Upgrade (C) e cuida do que precisa de conversa (Central docs antigo e o passo das stacks). O `--upgrade` só **acrescenta** chaves ao `ENV.md`: nenhuma chave existente muda. Ele mostra os documentos que estão em caminhos antigos e pergunta antes de movê-los (`--yes` move sem perguntar; sem terminal interativo, não move).
123
+
122
124
  **Instalou via clone local** (fluxo de [Desenvolvendo o Jarvis localmente](#desenvolvendo-o-jarvis-localmente)) — quando o repo no GitHub ganhar commits novos, sincronize assim:
123
125
 
124
126
  ### 1. Atualizar o clone
@@ -203,6 +205,17 @@ Escolha **Upgrade (C)** — adiciona chaves novas do template sem apagar os valo
203
205
 
204
206
  ---
205
207
 
208
+ ## Estilo dos artefatos
209
+
210
+ O `/jarvis-init` pergunta o `ARTIFACT_STYLE` (variável do `ENV.md`):
211
+
212
+ | Valor | O que acontece |
213
+ |---|---|
214
+ | `ai-generated` (padrão) | Como sempre: metadados, links de rastreabilidade e padrões da ferramenta |
215
+ | `human-generated` | Nada do que o Jarvis publica (commit, PR/MR, comentário no card, issue) leva marca de IA ou da IDE: o `/eng.pr` confere e limpa antes do push. Todo documento gerado pergunta, no fim, se você quer a versão humanizada, gravada ao lado como `{nome}.public.md` para repassar a clientes ou stakeholders |
216
+
217
+ Com `human-generated`, o `/jarvis-init` copia a rule `rules/artifact-style-rules.md` (eixo `ARTIFACT_STYLE` do `Applies to`); com `ai-generated`, ela nem existe na sessão. Trocou o valor à mão? Rode `/jarvis-init upgrade`.
218
+
206
219
  ## Funciona com qualquer stack
207
220
 
208
221
  O Jarvis não presume Go, PHP, NestJS ou React. Cada área (backend e frontend) tem uma **skill base neutra**, e cada projeto registra no `ENV.md` as **especializações** da sua stack (`BACKEND_SPECIALIZATIONS` e `FRONTEND_SPECIALIZATIONS`). Os comandos `eng.*` carregam a base mais o que o projeto registrou.
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, dirname, basename } from "node:path";
3
3
  import { createInterface } from "node:readline";
4
4
  import { syncAssets } from "../lib/core/sync-engine.js";
5
5
  import { discoverAll } from "../lib/core/scanner.js";
@@ -7,12 +7,23 @@ import {
7
7
  IDES,
8
8
  getIDEConfig,
9
9
  getIDEFolder,
10
+ getIDEValues,
10
11
  getWorkflowsFolder,
11
12
  isIDESupported,
12
13
  } from "../lib/config/ide-config.js";
13
14
  import { showBanner, RED, GREEN, BLUE, YELLOW, CYAN, DIM, BOLD, NC } from "../lib/utils/ui.js";
14
- import { getPackageInfo } from "../lib/utils/paths.js";
15
+ import { getPackageInfo, getFrameworkRoot } from "../lib/utils/paths.js";
15
16
  import { logger, configureFromFlags } from "../lib/utils/logger.js";
17
+ import { resolveEnvPath } from "../lib/env-loader.js";
18
+ import { LOCK_FILE } from "../lib/config/constants.js";
19
+ import { parseEnvEntries, planEnvUpgrade, applyEnvUpgrade } from "../lib/core/env-upgrade.js";
20
+ import {
21
+ expandEnv,
22
+ planDocsMigration,
23
+ applyDocsMigration,
24
+ rewriteRelatedDiscovery,
25
+ declineEnvValues,
26
+ } from "../lib/core/docs-migrate.js";
16
27
 
17
28
  function ask(rl, question) {
18
29
  return new Promise((resolve) => rl.question(question, resolve));
@@ -25,6 +36,11 @@ export async function init(flags) {
25
36
 
26
37
  showBanner();
27
38
 
39
+ if (flags.upgrade) {
40
+ process.exitCode = await runUpgrade(cwd, flags);
41
+ return;
42
+ }
43
+
28
44
  let ide;
29
45
  if (flags.ide) {
30
46
  if (!isIDESupported(flags.ide)) {
@@ -207,3 +223,134 @@ export async function init(flags) {
207
223
  logger.info(` ${YELLOW}jarvis list${NC}`);
208
224
  logger.info("");
209
225
  }
226
+
227
+ /** IDE de um ENV.md encontrado (`.claude/ENV.md` → `claude`). */
228
+ function ideFromEnvPath(envPath) {
229
+ const folder = basename(dirname(envPath));
230
+ return getIDEValues().find((v) => `.${getIDEFolder(v)}` === folder);
231
+ }
232
+
233
+ function readLockVersion(workspaceDir) {
234
+ try {
235
+ return (
236
+ JSON.parse(readFileSync(join(workspaceDir, LOCK_FILE), "utf-8")).version || "desconhecida"
237
+ );
238
+ } catch {
239
+ return "desconhecida";
240
+ }
241
+ }
242
+
243
+ /**
244
+ * `jarvis init --upgrade`: atualiza um projeto já configurado em uma passada.
245
+ * 1. arquivos do framework (como o --force, sem perguntar); 2. só as chaves novas do ENV;
246
+ * 3. documentos dos caminhos antigos → docs/jarvis/… (com confirmação ou --yes).
247
+ * Nunca imprime valores do ENV, só nomes de chave e caminhos.
248
+ * @returns {Promise<number>} código de saída
249
+ */
250
+ async function runUpgrade(cwd, flags) {
251
+ let resolved;
252
+ try {
253
+ resolved = resolveEnvPath(cwd, flags.ide ? { ide: flags.ide } : {});
254
+ } catch (err) {
255
+ resolved = { error: err.message.split("\n")[0] };
256
+ }
257
+ const ide = flags.ide || (resolved.path && ideFromEnvPath(resolved.path));
258
+ if (resolved.error || !ide || !existsSync(resolved.path)) {
259
+ logger.error(
260
+ `${RED}🚫 Nenhuma instalação do Jarvis encontrada (ENV.md ausente)${resolved.error ? `: ${resolved.error}` : ""}.${NC}`
261
+ );
262
+ logger.error(
263
+ `${YELLOW}Rode ${BOLD}jarvis init --ide ${flags.ide || "{ide}"}${NC}${YELLOW} e depois ${BOLD}/jarvis-init${NC}${YELLOW} na IDE.${NC}\n`
264
+ );
265
+ return 1;
266
+ }
267
+ const workspaceDir = resolved.root;
268
+ const envPath = resolved.path;
269
+ const previousVersion = readLockVersion(workspaceDir);
270
+
271
+ // 1. Arquivos do framework (o ENV.md não está entre os sincronizados)
272
+ logger.info(`${BLUE}⬆️ Upgrade do Jarvis em .${getIDEFolder(ide)}/${NC}\n`);
273
+ const sync = syncAssets(workspaceDir, ide, {
274
+ force: true,
275
+ workflowsFolder: getWorkflowsFolder(ide),
276
+ keepEnvValues: true,
277
+ });
278
+ for (const item of sync.errors) logger.error(`${RED} ✗ ${item}${NC}`);
279
+
280
+ // 2. Variáveis novas (só acrescenta)
281
+ const envText = readFileSync(envPath, "utf-8");
282
+ const templateText = readFileSync(
283
+ join(getFrameworkRoot(), "templates", "ENV-template.md"),
284
+ "utf-8"
285
+ );
286
+ const envPlan = planEnvUpgrade(envText, templateText);
287
+ const addedKeys = envPlan.added.map((a) => a.key);
288
+
289
+ // 3. Documentos: o plano usa o ENV como ficará depois do upgrade (com as chaves novas)
290
+ const futureEntries = parseEnvEntries(
291
+ `${envText}\n${envPlan.added.map(({ key, value }) => `${key}=${value}`).join("\n")}\n`
292
+ );
293
+ const env = expandEnv(futureEntries);
294
+ const docsPlan = planDocsMigration(workspaceDir, env);
295
+
296
+ let accepted = false;
297
+ if (docsPlan.moves.length > 0) {
298
+ logger.info(`${YELLOW}📁 Documentos em caminhos antigos:${NC}`);
299
+ for (const m of docsPlan.moves) logger.info(` ${m.from} → ${m.to}`);
300
+ if (flags.yes) accepted = true;
301
+ else if (process.stdin.isTTY) {
302
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
303
+ const answer = await ask(rl, `Mover para docs/jarvis/…? (s/N): `);
304
+ rl.close();
305
+ accepted = answer.trim().toLowerCase() === "s";
306
+ } else {
307
+ logger.info(`${DIM} Sem terminal interativo e sem --yes: nada será movido.${NC}`);
308
+ }
309
+ }
310
+
311
+ const overrides =
312
+ docsPlan.moves.length > 0 && !accepted ? declineEnvValues(docsPlan, addedKeys, env) : {};
313
+ const today = new Date().toISOString().slice(0, 10);
314
+ applyEnvUpgrade(envPath, envPlan, today, overrides);
315
+
316
+ let moved = [];
317
+ let rewritten = [];
318
+ let moveErrors = [];
319
+ if (accepted) {
320
+ const result = applyDocsMigration(workspaceDir, docsPlan, env);
321
+ moved = result.moved;
322
+ moveErrors = result.errors;
323
+ rewritten = rewriteRelatedDiscovery(workspaceDir, env, moved);
324
+ }
325
+
326
+ // 4. Resumo (só nomes de chave e caminhos)
327
+ const pkg = getPackageInfo();
328
+ logger.info("");
329
+ logger.info(`${GREEN}✅ Upgrade concluído: ${previousVersion} → ${pkg.version}${NC}`);
330
+ logger.info(` • Arquivos do framework: ${sync.copied.length} item(ns) sincronizado(s)`);
331
+ logger.info(
332
+ ` • ENV.md: ${addedKeys.length ? `+ ${addedKeys.join(", ")}` : "nenhuma variável nova"}`
333
+ );
334
+ if (Object.keys(overrides).length) {
335
+ logger.info(` • Mantidos no caminho antigo: ${Object.keys(overrides).join(", ")}`);
336
+ }
337
+ if (docsPlan.moves.length && !accepted) {
338
+ logger.info(` • Documentos: nada movido (${docsPlan.moves.length} no caminho antigo)`);
339
+ }
340
+ if (moved.length) logger.info(` • Documentos movidos: ${moved.length}`);
341
+ if (rewritten.length) logger.info(` • related_discovery atualizado: ${rewritten.join(", ")}`);
342
+ for (const s of docsPlan.skipped)
343
+ logger.info(`${YELLOW} ⚠️ Pulado (${s.reason}): ${s.from}${NC}`);
344
+ for (const e of moveErrors) logger.error(`${RED} ✗ ${e}${NC}`);
345
+ for (const k of envPlan.pending) {
346
+ logger.info(`${YELLOW} ⚠️ ${k} está vazio: responda no /jarvis-init upgrade${NC}`);
347
+ }
348
+ if (envPlan.warnings.includes("CENTRAL_DOCS_REPO")) {
349
+ logger.info(
350
+ `${YELLOW} ⚠️ Central docs no formato antigo (CENTRAL_DOCS_REPO): a migração é feita pelo /jarvis-init upgrade${NC}`
351
+ );
352
+ }
353
+ logger.info("");
354
+ logger.info(`${CYAN}➡️ Agora rode ${BOLD}/jarvis-init upgrade${NC}${CYAN} na IDE.${NC}\n`);
355
+ return sync.errors.length || moveErrors.length ? 1 : 0;
356
+ }
@@ -6,16 +6,20 @@ import { logger } from "../lib/utils/logger.js";
6
6
 
7
7
  const AXES = ["HUB", "POSITION", "AREA", "SQUAD"];
8
8
 
9
- /** Perfil: as flags (`--hub` etc.) mandam; o que faltar vem do ENV.md. */
9
+ /**
10
+ * Perfil: as flags (`--hub` etc.) mandam; o que faltar vem do ENV.md.
11
+ * ARTIFACT_STYLE é opcional (vazio = ai-generated) e não entra em `missing`.
12
+ */
10
13
  function resolveProfile(flags) {
11
14
  const profile = {};
12
15
  for (const axis of AXES) {
13
16
  const value = flags[axis.toLowerCase()];
14
17
  if (typeof value === "string") profile[axis] = value;
15
18
  }
19
+ if (typeof flags.artifactStyle === "string") profile.ARTIFACT_STYLE = flags.artifactStyle;
16
20
 
17
21
  let rtkEnabled = flags.rtk === true || flags.rtk === "true";
18
- // O ENV.md só entra quando falta algum eixo; com os 4 eixos nas flags, o RTK vem só da flag.
22
+ // O ENV.md só entra quando falta algum eixo; com os 4 eixos nas flags, o RTK e o estilo vêm só das flags.
19
23
  if (AXES.some((axis) => !profile[axis])) {
20
24
  try {
21
25
  const { path } = resolveEnvPath(process.cwd(), flags);
@@ -23,6 +27,8 @@ function resolveProfile(flags) {
23
27
  for (const axis of AXES) {
24
28
  if (!profile[axis] && env[axis]) profile[axis] = env[axis];
25
29
  }
30
+ if (!profile.ARTIFACT_STYLE && env.ARTIFACT_STYLE)
31
+ profile.ARTIFACT_STYLE = env.ARTIFACT_STYLE;
26
32
  if (flags.rtk === undefined) rtkEnabled = env.RTK_ENABLED === "true";
27
33
  } catch {
28
34
  // sem ENV.md: valem só as flags
@@ -59,7 +65,7 @@ export async function tokens(flags) {
59
65
  showBanner();
60
66
  logger.info(`${CYAN}📏 Custo estimado no início da sessão${NC}`);
61
67
  logger.info(
62
- `${DIM}Perfil: HUB=${profile.HUB} | POSITION=${profile.POSITION} | AREA=${profile.AREA} | SQUAD=${profile.SQUAD}` +
68
+ `${DIM}Perfil: HUB=${profile.HUB} | POSITION=${profile.POSITION} | AREA=${profile.AREA} | SQUAD=${profile.SQUAD} | ARTIFACT_STYLE=${profile.ARTIFACT_STYLE || "ai-generated"}` +
63
69
  ` | RTK=${rtkEnabled ? "ligado" : "desligado"}${NC}`
64
70
  );
65
71
  logger.info(`${DIM}${report.estimate}${NC}`);
@@ -87,7 +87,12 @@ export function buildProgram() {
87
87
  const initCmd = cmd(program, "init")
88
88
  .description("Bootstrap do framework no workspace")
89
89
  .option("--ide <ide>", "IDE a configurar (pula o menu interativo)")
90
- .option("--force", "Sobrescreve a instalação existente sem perguntar");
90
+ .option("--force", "Sobrescreve a instalação existente sem perguntar")
91
+ .option(
92
+ "--upgrade",
93
+ "Atualiza um projeto já configurado: arquivos do framework, variáveis novas do ENV e pastas de documentos"
94
+ )
95
+ .option("--yes", "Com --upgrade, move os documentos para as pastas novas sem perguntar");
91
96
  withLogOptions(initCmd).action((options) => init(toLegacyFlags(options)));
92
97
 
93
98
  cmd(program, "list")
@@ -164,6 +169,7 @@ export function buildProgram() {
164
169
  .option("--position <position>")
165
170
  .option("--area <area>")
166
171
  .option("--squad <squad>")
172
+ .option("--artifact-style <estilo>", "ai-generated ou human-generated (padrão: lido do ENV.md)")
167
173
  .option("--rtk", "Força RTK ligado (padrão: lido do ENV.md)")
168
174
  .option("--json", "Saída em JSON")
169
175
  .option("--ide <ide>")
@@ -201,8 +207,14 @@ export function buildProgram() {
201
207
  .option("--repo <url-ou-path>")
202
208
  .option("--title <titulo>")
203
209
  .option("--body-file <path>")
210
+ .option("--label <labels>", "Labels separadas por vírgula (padrão: bug)")
204
211
  .action((options) =>
205
- runCreateIssue({ repo: options.repo, title: options.title, bodyFile: options.bodyFile })
212
+ runCreateIssue({
213
+ repo: options.repo,
214
+ title: options.title,
215
+ bodyFile: options.bodyFile,
216
+ labels: options.label,
217
+ })
206
218
  );
207
219
 
208
220
  const tasks = cmd(program, "tasks").description("Adapters de task manager");
@@ -0,0 +1,259 @@
1
+ // Documentos de trabalho nos caminhos antigos → docs/jarvis/… (`jarvis init --upgrade`).
2
+ // Mesmas regras do item 8 do Upgrade (C) do skill jarvis-init: conflito no destino pula,
3
+ // pasta antiga removida só se ficar vazia, `git mv` quando versionado, `related_discovery`
4
+ // reescrito pelo nome do arquivo e, na recusa, só chaves novas recebem o caminho antigo.
5
+ import {
6
+ existsSync,
7
+ readdirSync,
8
+ statSync,
9
+ mkdirSync,
10
+ renameSync,
11
+ rmdirSync,
12
+ readFileSync,
13
+ writeFileSync,
14
+ appendFileSync,
15
+ } from "node:fs";
16
+ import { join, resolve, relative, dirname, basename, isAbsolute, sep } from "node:path";
17
+ import { execFileSync } from "node:child_process";
18
+
19
+ const ENG_SUBDIRS = ["ARD", "RFC", "qa", "swagger", "openapi", "robots", "c4"];
20
+ const LEGACY_ENG_SUBDIRS = ["robots", "swagger", "openapi", "c4"];
21
+
22
+ /**
23
+ * Resolve `$NOME` com as outras chaves do ENV, **em qualquer ordem**: upgrades antigos
24
+ * acrescentaram `JARVIS_DOCS` no fim do arquivo, depois do `PROD_DOCS` que a usa.
25
+ * Desconhecida (ex.: `$IDE`) ou ciclo fica literal.
26
+ * @param {Map<string, string>} entries
27
+ * @returns {Map<string, string>}
28
+ */
29
+ export function expandEnv(entries) {
30
+ const out = new Map();
31
+ const resolving = new Set();
32
+ const resolveKey = (key) => {
33
+ if (out.has(key)) return out.get(key);
34
+ if (!entries.has(key) || resolving.has(key)) return null;
35
+ resolving.add(key);
36
+ const value = entries
37
+ .get(key)
38
+ .replace(/\$([A-Z][A-Z0-9_]*)/g, (m, name) => resolveKey(name) ?? m);
39
+ resolving.delete(key);
40
+ out.set(key, value);
41
+ return value;
42
+ };
43
+ for (const key of entries.keys()) resolveKey(key);
44
+ return out;
45
+ }
46
+
47
+ const clean = (p) => p.replace(/^\.\//, "").replace(/\/+$/, "");
48
+
49
+ /** Pastas de documentos resolvidas, com os padrões do template quando faltam. */
50
+ export function docsFolders(env) {
51
+ const docs = clean(env.get("DOCS_FOLDER") || "docs");
52
+ const jarvis = clean(env.get("JARVIS_DOCS") || `${docs}/jarvis`);
53
+ const prod = clean(env.get("PROD_DOCS") || `${jarvis}/product`);
54
+ const eng = clean(env.get("ENG_DOCS") || `${jarvis}/engineering`);
55
+ const discoveries = clean(env.get("DISCOVERIES_DOCS") || `${jarvis}/discoveries`);
56
+ return { docs, jarvis, prod, eng, discoveries };
57
+ }
58
+
59
+ function inside(workspaceDir, rel) {
60
+ if (isAbsolute(rel)) return false;
61
+ const r = relative(workspaceDir, resolve(workspaceDir, rel));
62
+ return r !== "" && !r.startsWith("..") && !isAbsolute(r);
63
+ }
64
+
65
+ function listFiles(dir) {
66
+ const files = [];
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ const p = join(dir, entry.name);
69
+ if (entry.isDirectory()) files.push(...listFiles(p));
70
+ else if (entry.isFile()) files.push(p);
71
+ }
72
+ return files;
73
+ }
74
+
75
+ /** Pares pasta antiga → pasta nova, na ordem do item 8 + discoveries. */
76
+ function sourcePairs(f) {
77
+ const pairs = [
78
+ { from: `${f.docs}/product`, to: f.prod, discoveriesSub: true },
79
+ ...ENG_SUBDIRS.map((s) => ({ from: `${f.docs}/engineering/${s}`, to: `${f.eng}/${s}` })),
80
+ ...LEGACY_ENG_SUBDIRS.map((s) => ({ from: `docs/engineering/${s}`, to: `${f.eng}/${s}` })),
81
+ { from: `${f.prod}/discoveries`, to: f.discoveries, discovery: true },
82
+ ];
83
+ const seen = new Set();
84
+ return pairs.filter((p) => {
85
+ if (p.from === p.to || seen.has(p.from)) return false;
86
+ seen.add(p.from);
87
+ return true;
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Planeja a mudança de pasta (sem mexer em nada).
93
+ * @param {string} workspaceDir — pasta que contém `.{ide}/`
94
+ * @param {Map<string, string>} env — ENV já expandido (`expandEnv`)
95
+ * @returns {{ moves: {from: string, to: string, discovery: boolean}[],
96
+ * skipped: {from: string, to: string, reason: string}[], sources: string[] }}
97
+ * Caminhos relativos ao workspace.
98
+ */
99
+ export function planDocsMigration(workspaceDir, env) {
100
+ const f = docsFolders(env);
101
+ const moves = [];
102
+ const skipped = [];
103
+ const sources = [];
104
+
105
+ for (const pair of sourcePairs(f)) {
106
+ if (!inside(workspaceDir, pair.from) || !inside(workspaceDir, pair.to)) {
107
+ skipped.push({ from: pair.from, to: pair.to, reason: "fora do workspace" });
108
+ continue;
109
+ }
110
+ const fromAbs = join(workspaceDir, pair.from);
111
+ if (!existsSync(fromAbs) || !statSync(fromAbs).isDirectory()) continue;
112
+
113
+ let added = false;
114
+ for (const fileAbs of listFiles(fromAbs)) {
115
+ const rel = relative(fromAbs, fileAbs).split(sep).join("/");
116
+ // docs/product/discoveries/* (layout antigo) vai direto para a pasta dos discoveries
117
+ const toDiscoveries =
118
+ pair.discovery || (pair.discoveriesSub && rel.startsWith("discoveries/"));
119
+ const to = toDiscoveries
120
+ ? `${f.discoveries}/${pair.discovery ? rel : rel.slice("discoveries/".length)}`
121
+ : `${pair.to}/${rel}`;
122
+ const from = `${pair.from}/${rel}`;
123
+ if (from === to) continue;
124
+ if (existsSync(join(workspaceDir, to)) || moves.some((m) => m.to === to)) {
125
+ skipped.push({ from, to, reason: "conflito" });
126
+ continue;
127
+ }
128
+ moves.push({ from, to, discovery: toDiscoveries });
129
+ added = true;
130
+ }
131
+ if (added) sources.push(pair.from);
132
+ }
133
+ return { moves, skipped, sources };
134
+ }
135
+
136
+ function isTracked(fileAbs) {
137
+ try {
138
+ execFileSync("git", ["ls-files", "--error-unmatch", basename(fileAbs)], {
139
+ cwd: dirname(fileAbs),
140
+ stdio: "ignore",
141
+ });
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ function removeEmptyDirs(dirAbs, stopAbs) {
149
+ let dir = dirAbs;
150
+ while (dir.startsWith(stopAbs) && dir !== stopAbs && existsSync(dir)) {
151
+ if (readdirSync(dir).length > 0) break;
152
+ rmdirSync(dir);
153
+ dir = dirname(dir);
154
+ }
155
+ }
156
+
157
+ function emptyDirsUnder(dirAbs) {
158
+ if (!existsSync(dirAbs)) return;
159
+ for (const entry of readdirSync(dirAbs, { withFileTypes: true })) {
160
+ if (entry.isDirectory()) emptyDirsUnder(join(dirAbs, entry.name));
161
+ }
162
+ if (readdirSync(dirAbs).length === 0) rmdirSync(dirAbs);
163
+ }
164
+
165
+ /**
166
+ * Aplica o plano: `git mv` se o arquivo é versionado, senão `rename`. Remove as pastas
167
+ * antigas só se ficarem vazias e garante `{JARVIS_DOCS}/` no `.gitignore` do workspace.
168
+ * @returns {{ moved: {from: string, to: string, discovery: boolean}[], errors: string[] }}
169
+ */
170
+ export function applyDocsMigration(workspaceDir, plan, env) {
171
+ const moved = [];
172
+ const errors = [];
173
+ for (const m of plan.moves) {
174
+ const fromAbs = join(workspaceDir, m.from);
175
+ const toAbs = join(workspaceDir, m.to);
176
+ try {
177
+ mkdirSync(dirname(toAbs), { recursive: true });
178
+ if (isTracked(fromAbs)) {
179
+ execFileSync("git", ["mv", fromAbs, toAbs], { cwd: dirname(fromAbs), stdio: "ignore" });
180
+ } else {
181
+ renameSync(fromAbs, toAbs);
182
+ }
183
+ moved.push(m);
184
+ } catch (err) {
185
+ errors.push(`${m.from}: ${err.message.split("\n")[0]}`);
186
+ }
187
+ }
188
+
189
+ // Pastas antigas vazias saem; a pasta raiz dos documentos ($DOCS_FOLDER) sempre fica.
190
+ const docsAbs = join(workspaceDir, docsFolders(env).docs);
191
+ for (const src of plan.sources) {
192
+ const srcAbs = join(workspaceDir, src);
193
+ emptyDirsUnder(srcAbs);
194
+ removeEmptyDirs(dirname(srcAbs), docsAbs);
195
+ }
196
+
197
+ if (moved.length > 0) ensureGitignore(workspaceDir, `${docsFolders(env).jarvis}/`);
198
+ return { moved, errors };
199
+ }
200
+
201
+ function ensureGitignore(workspaceDir, line) {
202
+ const p = join(workspaceDir, ".gitignore");
203
+ const text = existsSync(p) ? readFileSync(p, "utf-8") : "";
204
+ const bare = line.replace(/\/$/, "");
205
+ if (text.split("\n").some((l) => [line, bare, `/${line}`, `/${bare}`].includes(l.trim()))) return;
206
+ appendFileSync(p, `${text && !text.endsWith("\n") ? "\n" : ""}${line}\n`);
207
+ }
208
+
209
+ /**
210
+ * Nos `.md` de `PROD_DOCS`, todo `related_discovery:` cujo nome de arquivo seja o de um
211
+ * discovery movido passa ao caminho novo — inclusive valores que já estavam quebrados.
212
+ * @returns {string[]} arquivos alterados (relativos ao workspace)
213
+ */
214
+ export function rewriteRelatedDiscovery(workspaceDir, env, moved) {
215
+ const byName = new Map(moved.filter((m) => m.discovery).map((m) => [basename(m.to), m.to]));
216
+ if (byName.size === 0) return [];
217
+ const prodAbs = join(workspaceDir, docsFolders(env).prod);
218
+ if (!existsSync(prodAbs)) return [];
219
+
220
+ const changed = [];
221
+ for (const fileAbs of listFiles(prodAbs).filter((p) => p.endsWith(".md"))) {
222
+ const text = readFileSync(fileAbs, "utf-8");
223
+ const next = text.replace(
224
+ /^(related_discovery:[ \t]*)(\S+)[ \t]*$/gm,
225
+ (line, prefix, value) => {
226
+ const to = byName.get(basename(value));
227
+ return to && value !== to ? `${prefix}${to}` : line;
228
+ }
229
+ );
230
+ if (next !== text) {
231
+ writeFileSync(fileAbs, next);
232
+ changed.push(relative(workspaceDir, fileAbs).split(sep).join("/"));
233
+ }
234
+ }
235
+ return changed;
236
+ }
237
+
238
+ /**
239
+ * Recusa em mover: o valor que as chaves **novas** recebem para continuar no caminho antigo.
240
+ * Chave que já existe no ENV não muda (o upgrade só acrescenta).
241
+ * @param {{moves: {from: string, discovery: boolean}[]}} plan
242
+ * @param {string[]} addedKeys — chaves que o upgrade vai acrescentar
243
+ * @returns {Record<string, string>}
244
+ */
245
+ export function declineEnvValues(plan, addedKeys, env) {
246
+ const added = new Set(addedKeys);
247
+ const f = docsFolders(env);
248
+ const out = {};
249
+ if (added.has("DISCOVERIES_DOCS") && plan.moves.some((m) => m.discovery)) {
250
+ out.DISCOVERIES_DOCS = "$PROD_DOCS/discoveries";
251
+ }
252
+ if (added.has("PROD_DOCS") && plan.moves.some((m) => m.from.startsWith(`${f.docs}/product/`))) {
253
+ out.PROD_DOCS = "$DOCS_FOLDER/product";
254
+ }
255
+ if (added.has("ENG_DOCS") && plan.moves.some((m) => m.from.includes("/engineering/"))) {
256
+ out.ENG_DOCS = "$DOCS_FOLDER/engineering";
257
+ }
258
+ return out;
259
+ }
@@ -0,0 +1,84 @@
1
+ // Upgrade do ENV.md (`jarvis init --upgrade`): acrescenta as chaves do template que faltam.
2
+ // Regra: só acrescenta. Nenhuma chave existente muda, preenchida, vazia ou com lista de opções.
3
+ // Mesmas regras do Upgrade (C) do skill jarvis-init (itens 4, 5, 9, 10 e 11).
4
+ import { readFileSync, writeFileSync } from "node:fs";
5
+
6
+ const KEY_LINE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
7
+
8
+ // PROD_MODE nova: o valor vem da AREA (só no upgrade; a criação do ENV continua perguntando).
9
+ const PROD_MODE_BY_AREA = { ENGINEERING: "developer-guided", PRODUCT: "product-led" };
10
+
11
+ /**
12
+ * Lê as chaves do ENV na ordem do arquivo, **incluindo as vazias** (o `loadEnv`
13
+ * descarta as vazias, o que esconderia uma chave existente do diff).
14
+ * Linhas comentadas não contam.
15
+ * @returns {Map<string, string>}
16
+ */
17
+ export function parseEnvEntries(text) {
18
+ const entries = new Map();
19
+ for (const line of text.split("\n")) {
20
+ const m = line.trim().match(KEY_LINE);
21
+ if (m && !entries.has(m[1])) entries.set(m[1], m[2].trim());
22
+ }
23
+ return entries;
24
+ }
25
+
26
+ function isOptionList(value) {
27
+ return /^\[.*\]$/.test(value);
28
+ }
29
+
30
+ // Bloco "Data Engineering": DATA_* só entram com HUB=DATA. DATA_SPECIALIZATIONS é
31
+ // obrigatória em qualquer HUB (eng.specializations-rules.md) e nunca é excluída.
32
+ function isDataOnlyKey(key) {
33
+ return key.startsWith("DATA_") && key !== "DATA_SPECIALIZATIONS";
34
+ }
35
+
36
+ /**
37
+ * Calcula o que o upgrade acrescenta, sem gravar nada.
38
+ * @returns {{ added: {key: string, value: string}[], pending: string[], warnings: string[] }}
39
+ * `pending` e `warnings` trazem só nomes de chave, nunca valores do ENV.
40
+ */
41
+ export function planEnvUpgrade(envText, templateText) {
42
+ const env = parseEnvEntries(envText);
43
+ const template = parseEnvEntries(templateText);
44
+ const hub = (env.get("HUB") || "").toUpperCase();
45
+ const area = (env.get("AREA") || "").toUpperCase();
46
+
47
+ const added = [];
48
+ const pending = [];
49
+ const warnings = [];
50
+
51
+ for (const [key, templateValue] of template) {
52
+ if (env.has(key)) continue;
53
+ if (hub !== "DATA" && isDataOnlyKey(key)) continue;
54
+
55
+ let value = isOptionList(templateValue) ? "" : templateValue;
56
+ if (key === "PROD_MODE") {
57
+ value = PROD_MODE_BY_AREA[area] || "";
58
+ if (!value) pending.push("PROD_MODE");
59
+ }
60
+ added.push({ key, value });
61
+ }
62
+
63
+ if (env.has("PROD_MODE") && !env.get("PROD_MODE")) pending.push("PROD_MODE");
64
+
65
+ if (env.get("CENTRAL_DOCS_REPO") && !env.has("CENTRAL_DOCS_STORAGE")) {
66
+ warnings.push("CENTRAL_DOCS_REPO");
67
+ }
68
+
69
+ return { added, pending, warnings };
70
+ }
71
+
72
+ /**
73
+ * Grava as chaves novas no fim do ENV, num bloco datado. Nenhuma linha existente muda.
74
+ * `overrides` troca o valor de chaves novas (ex.: recusa em mover os discoveries).
75
+ * @returns {boolean} true se gravou
76
+ */
77
+ export function applyEnvUpgrade(envPath, plan, today, overrides = {}) {
78
+ if (plan.added.length === 0) return false;
79
+ const text = readFileSync(envPath, "utf-8");
80
+ const lines = plan.added.map(({ key, value }) => `${key}=${overrides[key] ?? value}`);
81
+ const sep = text.endsWith("\n") ? "" : "\n";
82
+ writeFileSync(envPath, `${text}${sep}\n# --- Upgrade ${today} ---\n${lines.join("\n")}\n`);
83
+ return true;
84
+ }