jarvis-ai-framework 5.5.0 → 5.7.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 (35) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -1
  3. package/bin/commands/daily-report.js +90 -0
  4. package/bin/commands/init.js +42 -2
  5. package/bin/lib/cli/program.js +14 -1
  6. package/bin/lib/config/constants.js +5 -0
  7. package/bin/lib/core/env-upgrade.js +14 -1
  8. package/bin/lib/core/session-scan.js +266 -0
  9. package/bin/lib/core/stale-skills.js +63 -0
  10. package/bin/lib/core/workspace-mode.js +57 -0
  11. package/package.json +2 -2
  12. package/rules/artifact-style-rules.md +5 -3
  13. package/rules/engineering/eng-rules.md +12 -1
  14. package/rules/engineering/eng.integrations-rules.md +1 -1
  15. package/rules/product/prod-rules.md +2 -1
  16. package/rules-on-demand/engineering/eng.plan-rules.md +1 -1
  17. package/rules-on-demand/engineering/eng.pr-rules.md +3 -3
  18. package/rules-on-demand/engineering/eng.pre-pr-rules.md +2 -2
  19. package/rules-on-demand/engineering/eng.transitions-rules.md +16 -7
  20. package/rules-on-demand/engineering/eng.work-rules.md +1 -1
  21. package/skills/jarvis-create-specialization/SKILL.md +1 -1
  22. package/skills/jarvis-init/SKILL.md +10 -6
  23. package/skills/jarvis-init/assets/passo-adicionar-stacks.md +1 -1
  24. package/skills/jarvis-init/assets/setup-guide.md +3 -3
  25. package/templates/ENV-template.md +7 -3
  26. package/templates/engineering/architecture-template.md +9 -8
  27. package/templates/engineering/daily-template.md +2 -2
  28. package/workflows/engineering/eng.daily-report.md +131 -0
  29. package/workflows/engineering/eng.plan.md +2 -2
  30. package/workflows/engineering/eng.pr.md +28 -17
  31. package/workflows/engineering/eng.pre-pr.md +20 -8
  32. package/workflows/engineering/eng.spec-consolidate.md +4 -0
  33. package/workflows/engineering/eng.start.md +4 -2
  34. package/workflows/engineering/eng.work.md +4 -4
  35. package/workflows/warm-up.md +2 -2
package/AGENTS.md CHANGED
@@ -194,11 +194,12 @@ 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
+ jarvis init --upgrade [--ide X] [--yes] [--remove-stale] # Atualizar projeto existente: assets + variáveis novas do ENV (inclui WORKSPACE_MODE) + documentos nas pastas novas + lista/remove skills antigos
198
198
  jarvis list # Listar agents, skills e workflows
199
199
  jarvis install-rtk # Instalar RTK (token killer — economia 60-90% em tokens de shell)
200
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)
201
201
  jarvis map <nome> [--depth N] [--reverse] # Mapa de chamadas de um workflow, skill ou agente (lê só as tabelas padronizadas)
202
+ jarvis daily-report [--days N] [--json] # Junta os daily.md das sessões por dia (usado pelo /eng.daily-report)
202
203
  ```
203
204
 
204
205
  Quando atuar em um projeto alvo, detectar comandos em:
package/README.md CHANGED
@@ -119,7 +119,7 @@ npm install -g jarvis-ai-framework@latest
119
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).
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). Também lista os skills instalados que saíram do framework e pergunta antes de removê-los (`--remove-stale` remove sem perguntar só os listados; skills registrados em `*_SPECIALIZATIONS` nunca são listados).
123
123
 
124
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:
125
125
 
@@ -0,0 +1,90 @@
1
+ import { isAbsolute, join, relative } from "node:path";
2
+ import { scanSessions, parseDays } from "../lib/core/session-scan.js";
3
+ import { resolveEnvPath, loadEnv } from "../lib/env-loader.js";
4
+ import { logger } from "../lib/utils/logger.js";
5
+ import { GREEN, YELLOW, CYAN, DIM, NC } from "../lib/utils/ui.js";
6
+
7
+ const USAGE = "Uso: jarvis daily-report [--days 1|3|7|15|30|60] [--json]";
8
+ const DEFAULTS = {
9
+ DOCS_FOLDER: "docs",
10
+ JARVIS_DOCS: "$DOCS_FOLDER/jarvis",
11
+ ENG_DOCS: "$JARVIS_DOCS/engineering",
12
+ SESSIONS_DIR: ".jarvis/sessions",
13
+ };
14
+
15
+ /** Expande `$VAR` com os valores do ENV (e os padrões acima), em até 5 passadas. */
16
+ export function expandVars(value, env) {
17
+ let out = value;
18
+ for (let i = 0; i < 5 && /\$[A-Z_]+/.test(out); i++) {
19
+ out = out.replace(/\$([A-Z_][A-Z0-9_]*)/g, (m, name) => env[name] ?? DEFAULTS[name] ?? m);
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function resolveDir(root, value) {
25
+ return isAbsolute(value) ? value : join(root, value);
26
+ }
27
+
28
+ /**
29
+ * Lê as sessões do workspace e devolve o JSON do relatório. Separado de `dailyReport` para ser testável.
30
+ * @param {Record<string, unknown>} flags - `days`, `ide`, `env-file`
31
+ * @param {string} [cwd]
32
+ * @param {Date} [now]
33
+ * @returns {{ code: number, json?: object, lines?: string[] }}
34
+ */
35
+ export function runDailyReport(flags = {}, cwd = process.cwd(), now = new Date()) {
36
+ const parsed = parseDays(flags.days);
37
+ if (parsed.error) return { code: 1, lines: [`❌ ${parsed.error}`, USAGE] };
38
+
39
+ let envPath;
40
+ let root;
41
+ try {
42
+ ({ path: envPath, root } = resolveEnvPath(cwd, flags));
43
+ } catch (err) {
44
+ return { code: 1, lines: [`❌ ${err.message}`, USAGE] };
45
+ }
46
+ root = root || cwd;
47
+ const env = { ...DEFAULTS, ...loadEnv(cwd, envPath) };
48
+
49
+ const sessionsDir = join(resolveDir(root, expandVars(env.SESSIONS_DIR, env)), "eng");
50
+ const reportsDir = join(resolveDir(root, expandVars(env.ENG_DOCS, env)), "daily-reports");
51
+
52
+ const result = scanSessions(sessionsDir, { days: parsed.days, now });
53
+ const reportPathAbs = join(reportsDir, result.fileName);
54
+ return {
55
+ code: 0,
56
+ json: {
57
+ ...result,
58
+ workspace: root,
59
+ sessionsDir: relative(root, sessionsDir) || ".",
60
+ reportPath: relative(root, reportPathAbs),
61
+ reportPathAbs,
62
+ },
63
+ };
64
+ }
65
+
66
+ export async function dailyReport(flags) {
67
+ const { code, json, lines } = runDailyReport(flags);
68
+ if (code !== 0) {
69
+ for (const line of lines) logger.error(line);
70
+ process.exit(code);
71
+ }
72
+
73
+ if (flags.json) {
74
+ process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
75
+ return;
76
+ }
77
+
78
+ logger.info(`${CYAN}🗓️ Registros dos últimos ${json.days} dia(s)${NC}`);
79
+ logger.info(`${DIM}Janela: ${json.window.from} a ${json.window.to} (fuso local)${NC}`);
80
+ logger.info(
81
+ `Sessões lidas: ${json.sessionsScanned} · na janela: ${json.sessionsInWindow} · dias com registro: ${json.byDate.length}`
82
+ );
83
+ logger.info(
84
+ json.alerts.length
85
+ ? `${YELLOW}⚠️ ${json.alerts.length} sessão(ões) sem daily.md: ${json.alerts.map((a) => a.key).join(", ")}${NC}`
86
+ : `${GREEN}✅ Todas as sessões da janela têm daily.md${NC}`
87
+ );
88
+ for (const w of json.warnings) logger.info(`${DIM}aviso: ${w}${NC}`);
89
+ logger.info(`${DIM}Relatório (escrito pelo /eng.daily-report): ${json.reportPath}${NC}`);
90
+ }
@@ -16,6 +16,11 @@ import { getPackageInfo, getFrameworkRoot } from "../lib/utils/paths.js";
16
16
  import { logger, configureFromFlags } from "../lib/utils/logger.js";
17
17
  import { resolveEnvPath } from "../lib/env-loader.js";
18
18
  import { LOCK_FILE } from "../lib/config/constants.js";
19
+ import {
20
+ findStaleSkills,
21
+ registeredSpecializations,
22
+ removeStaleSkills,
23
+ } from "../lib/core/stale-skills.js";
19
24
  import { parseEnvEntries, planEnvUpgrade, applyEnvUpgrade } from "../lib/core/env-upgrade.js";
20
25
  import {
21
26
  expandEnv,
@@ -283,7 +288,7 @@ async function runUpgrade(cwd, flags) {
283
288
  join(getFrameworkRoot(), "templates", "ENV-template.md"),
284
289
  "utf-8"
285
290
  );
286
- const envPlan = planEnvUpgrade(envText, templateText);
291
+ const envPlan = planEnvUpgrade(envText, templateText, { workspaceDir });
287
292
  const addedKeys = envPlan.added.map((a) => a.key);
288
293
 
289
294
  // 3. Documentos: o plano usa o ENV como ficará depois do upgrade (com as chaves novas)
@@ -313,6 +318,34 @@ async function runUpgrade(cwd, flags) {
313
318
  const today = new Date().toISOString().slice(0, 10);
314
319
  applyEnvUpgrade(envPath, envPlan, today, overrides);
315
320
 
321
+ // 3.1 Skills instalados que saíram do framework: só lista; remove com confirmação ou --remove-stale
322
+ const ideSkillsDir = join(workspaceDir, `.${getIDEFolder(ide)}`, "skills");
323
+ const stale = findStaleSkills({
324
+ ideSkillsDir,
325
+ frameworkSkillsDir: join(getFrameworkRoot(), "skills"),
326
+ registered: registeredSpecializations(parseEnvEntries(envText)),
327
+ });
328
+ let staleRemoved = [];
329
+ let staleErrors = [];
330
+ if (stale.length > 0) {
331
+ logger.info(`${YELLOW}🧹 Skills instalados que saíram do framework:${NC}`);
332
+ for (const name of stale) logger.info(` .${getIDEFolder(ide)}/skills/${name}/`);
333
+ let confirmed = Boolean(flags.removeStale);
334
+ if (!confirmed && !flags.yes && process.stdin.isTTY) {
335
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
336
+ const answer = await ask(rl, `Remover estes skills antigos? (s/N): `);
337
+ rl.close();
338
+ confirmed = answer.trim().toLowerCase() === "s";
339
+ }
340
+ if (confirmed) {
341
+ ({ removed: staleRemoved, errors: staleErrors } = removeStaleSkills(ideSkillsDir, stale));
342
+ } else {
343
+ logger.info(
344
+ `${DIM} Nada removido. Apague as pastas à mão ou rode de novo com ${BOLD}--remove-stale${NC}${DIM}.${NC}`
345
+ );
346
+ }
347
+ }
348
+
316
349
  let moved = [];
317
350
  let rewritten = [];
318
351
  let moveErrors = [];
@@ -342,6 +375,8 @@ async function runUpgrade(cwd, flags) {
342
375
  for (const s of docsPlan.skipped)
343
376
  logger.info(`${YELLOW} ⚠️ Pulado (${s.reason}): ${s.from}${NC}`);
344
377
  for (const e of moveErrors) logger.error(`${RED} ✗ ${e}${NC}`);
378
+ if (staleRemoved.length) logger.info(` • Skills antigos removidos: ${staleRemoved.join(", ")}`);
379
+ for (const e of staleErrors) logger.error(`${RED} ✗ ${e}${NC}`);
345
380
  for (const k of envPlan.pending) {
346
381
  logger.info(`${YELLOW} ⚠️ ${k} está vazio: responda no /jarvis-init upgrade${NC}`);
347
382
  }
@@ -350,7 +385,12 @@ async function runUpgrade(cwd, flags) {
350
385
  `${YELLOW} ⚠️ Central docs no formato antigo (CENTRAL_DOCS_REPO): a migração é feita pelo /jarvis-init upgrade${NC}`
351
386
  );
352
387
  }
388
+ if (envPlan.warnings.includes("WORKSPACE_MODE")) {
389
+ logger.info(
390
+ `${YELLOW} ⚠️ WORKSPACE_MODE: a raiz é um repositório e há subpastas com .git (submódulos?): gravado como project; confira no ENV.md${NC}`
391
+ );
392
+ }
353
393
  logger.info("");
354
394
  logger.info(`${CYAN}➡️ Agora rode ${BOLD}/jarvis-init upgrade${NC}${CYAN} na IDE.${NC}\n`);
355
- return sync.errors.length || moveErrors.length ? 1 : 0;
395
+ return sync.errors.length || moveErrors.length || staleErrors.length ? 1 : 0;
356
396
  }
@@ -26,6 +26,7 @@ import { qaSignoff } from "../../commands/qa-signoff.js";
26
26
  import { installRtk } from "../../commands/install-rtk.js";
27
27
  import { tokens } from "../../commands/tokens.js";
28
28
  import { map } from "../../commands/map.js";
29
+ import { dailyReport } from "../../commands/daily-report.js";
29
30
  import { runCreateMerge } from "../vcs/create-merge.js";
30
31
  import { runCreateIssue } from "../vcs/create-issue.js";
31
32
  import { runComment } from "../tasks/comment.js";
@@ -92,7 +93,11 @@ export function buildProgram() {
92
93
  "--upgrade",
93
94
  "Atualiza um projeto já configurado: arquivos do framework, variáveis novas do ENV e pastas de documentos"
94
95
  )
95
- .option("--yes", "Com --upgrade, move os documentos para as pastas novas sem perguntar");
96
+ .option("--yes", "Com --upgrade, move os documentos para as pastas novas sem perguntar")
97
+ .option(
98
+ "--remove-stale",
99
+ "Com --upgrade, remove sem perguntar os skills instalados que saíram do framework (só os listados)"
100
+ );
96
101
  withLogOptions(initCmd).action((options) => init(toLegacyFlags(options)));
97
102
 
98
103
  cmd(program, "list")
@@ -183,6 +188,14 @@ export function buildProgram() {
183
188
  .option("--reverse", "Mostra quem chama, em vez do que é chamado")
184
189
  .action((name, options) => map(toLegacyFlags(options, { _: [name].filter(Boolean) })));
185
190
 
191
+ cmd(program, "daily-report")
192
+ .description("Junta os daily.md das sessões por dia (base do /eng.daily-report)")
193
+ .option("--days <n>", "Janela em dias: 1, 3, 7, 15, 30 ou 60 (padrão: 7)")
194
+ .option("--json", "Saída em JSON")
195
+ .option("--ide <ide>")
196
+ .option("--env-file <path>")
197
+ .action((options) => dailyReport(toLegacyFlags(options)));
198
+
186
199
  const vcs = cmd(program, "vcs").description("Adapters de Git host (GitLab/GitHub/Bitbucket)");
187
200
 
188
201
  cmd(vcs, "create-merge")
@@ -46,6 +46,11 @@ export const OBSOLETE_PATHS = Object.freeze([
46
46
  "skills/lovable-prompt-generator",
47
47
  "steering/skills/skill-churn-audit.md",
48
48
  "steering/skills/skill-lovable-prompt-generator.md",
49
+ // eng-scraper e eng-scraper-robot-builder (renomeados na #81 para eng-automation): ficavam sobrando na pasta da IDE
50
+ "skills/eng-scraper",
51
+ "skills/eng-scraper-robot-builder",
52
+ "steering/skills/skill-eng-scraper.md",
53
+ "steering/skills/skill-eng-scraper-robot-builder.md",
49
54
  "skills/eng-nestjs",
50
55
  "skills/eng-rabbitmq",
51
56
  "skills/eng-arch-c4",
@@ -2,6 +2,7 @@
2
2
  // Regra: só acrescenta. Nenhuma chave existente muda, preenchida, vazia ou com lista de opções.
3
3
  // Mesmas regras do Upgrade (C) do skill jarvis-init (itens 4, 5, 9, 10 e 11).
4
4
  import { readFileSync, writeFileSync } from "node:fs";
5
+ import { detectWorkspaceMode } from "./workspace-mode.js";
5
6
 
6
7
  const KEY_LINE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
7
8
 
@@ -35,10 +36,13 @@ function isDataOnlyKey(key) {
35
36
 
36
37
  /**
37
38
  * Calcula o que o upgrade acrescenta, sem gravar nada.
39
+ * @param {string} envText
40
+ * @param {string} templateText
41
+ * @param {{ workspaceDir?: string }} [options] - `workspaceDir`: pasta que contém o `$IDE/`, para detectar o `WORKSPACE_MODE`
38
42
  * @returns {{ added: {key: string, value: string}[], pending: string[], warnings: string[] }}
39
43
  * `pending` e `warnings` trazem só nomes de chave, nunca valores do ENV.
40
44
  */
41
- export function planEnvUpgrade(envText, templateText) {
45
+ export function planEnvUpgrade(envText, templateText, { workspaceDir } = {}) {
42
46
  const env = parseEnvEntries(envText);
43
47
  const template = parseEnvEntries(templateText);
44
48
  const hub = (env.get("HUB") || "").toUpperCase();
@@ -57,6 +61,15 @@ export function planEnvUpgrade(envText, templateText) {
57
61
  value = PROD_MODE_BY_AREA[area] || "";
58
62
  if (!value) pending.push("PROD_MODE");
59
63
  }
64
+ if (key === "WORKSPACE_MODE" && workspaceDir) {
65
+ const repos = (env.get("WORKSPACE_REPOS") || "")
66
+ .split(",")
67
+ .map((r) => r.trim())
68
+ .filter(Boolean);
69
+ const detected = detectWorkspaceMode(workspaceDir, { repos });
70
+ value = detected.mode;
71
+ if (detected.warning) warnings.push("WORKSPACE_MODE");
72
+ }
60
73
  added.push({ key, value });
61
74
  }
62
75
 
@@ -0,0 +1,266 @@
1
+ // Varredura das sessões de engenharia para o /eng.daily-report: junta as seções `## AAAA-MM-DD`
2
+ // dos `daily.md` por dia e aponta as sessões que deveriam ter `daily.md` e não têm.
3
+ // Só lê: não escreve nas sessões nem cria o relatório (quem escreve é o workflow).
4
+ // As datas são sempre do calendário LOCAL (o `.timestamp_start` é gravado em UTC).
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ export const ALLOWED_DAYS = [1, 3, 7, 15, 30, 60];
9
+ export const DEFAULT_DAYS = 7;
10
+
11
+ // Dia em que o `daily.md` passou a existir (gerado pelo eng.pr). Sessões sem `daily.md` que
12
+ // começaram antes disso só geram alerta se chegaram ao eng.pr (têm `pull-request.md`).
13
+ export const DAILY_SINCE = "2026-10-15";
14
+
15
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
16
+ const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
17
+
18
+ /** Valida a janela. Sem valor: o padrão (7). */
19
+ export function parseDays(value) {
20
+ if (value === undefined || value === null || value === "" || value === true)
21
+ return { days: DEFAULT_DAYS };
22
+ const n = Number(String(value).trim());
23
+ if (!ALLOWED_DAYS.includes(n)) {
24
+ return {
25
+ error: `Janela inválida: ${value}. Valores aceitos: ${ALLOWED_DAYS.join(", ")}`,
26
+ };
27
+ }
28
+ return { days: n };
29
+ }
30
+
31
+ /** `AAAA-MM-DD` no fuso local (sem `toISOString`, que é UTC). */
32
+ export function localDate(date) {
33
+ const y = date.getFullYear();
34
+ const m = String(date.getMonth() + 1).padStart(2, "0");
35
+ const d = String(date.getDate()).padStart(2, "0");
36
+ return `${y}-${m}-${d}`;
37
+ }
38
+
39
+ /** Hoje e os `days - 1` dias anteriores, do mais recente para o mais antigo. */
40
+ export function buildWindow(days, now = new Date()) {
41
+ const dates = [];
42
+ for (let i = 0; i < days; i++) {
43
+ dates.push(localDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - i)));
44
+ }
45
+ return { from: dates[dates.length - 1], to: dates[0], dates };
46
+ }
47
+
48
+ export function reportFileName(today, days) {
49
+ return `daily-report-${today}-${days}d.md`;
50
+ }
51
+
52
+ export function redactEmails(text) {
53
+ return typeof text === "string" ? text.replace(EMAIL_RE, "[e-mail removido]") : text;
54
+ }
55
+
56
+ function readText(path) {
57
+ try {
58
+ return readFileSync(path, "utf8");
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function unquote(value) {
65
+ const v = value.trim();
66
+ return /^(["']).*\1$/.test(v) ? v.slice(1, -1) : v;
67
+ }
68
+
69
+ /** Frontmatter `chave: valor` simples (ignora comentários). */
70
+ function parseFrontmatter(text) {
71
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
72
+ const data = {};
73
+ if (!m) return { data, body: text };
74
+ for (const line of m[1].split(/\r?\n/)) {
75
+ if (/^\s*#/.test(line)) continue;
76
+ const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
77
+ if (kv) data[kv[1]] = unquote(kv[2]);
78
+ }
79
+ return { data, body: text.slice(m[0].length) };
80
+ }
81
+
82
+ /** Primeira linha não vazia depois de `### Timesheet`, sem crases nem cercas de código. */
83
+ function extractTimesheet(section) {
84
+ const m = section.match(/^###\s+Timesheet\s*$([\s\S]*?)(?=^###?\s|(?![\s\S]))/m);
85
+ if (!m) return null;
86
+ const line = m[1]
87
+ .split(/\r?\n/)
88
+ .map((l) => l.trim())
89
+ .find((l) => l && !/^```/.test(l));
90
+ return line ? line.replace(/^`+|`+$/g, "").trim() : null;
91
+ }
92
+
93
+ function extractDescription(section) {
94
+ const m = section.match(/^###\s+Descrição detalhada\s*$([\s\S]*?)(?=^###\s|(?![\s\S]))/m);
95
+ return m ? m[1].trim() : "";
96
+ }
97
+
98
+ /**
99
+ * Lê um `daily.md`: frontmatter (`task_key`, `card_url`, `pr`, `branch`) e uma entrada por
100
+ * seção `## AAAA-MM-DD`, com a descrição detalhada e a linha de timesheet.
101
+ */
102
+ export function parseDaily(text) {
103
+ const { data, body } = parseFrontmatter(text);
104
+ const sections = [];
105
+ const parts = body.split(/^##\s+(\d{4}-\d{2}-\d{2})\s*$/m);
106
+ for (let i = 1; i < parts.length; i += 2) {
107
+ sections.push({
108
+ date: parts[i],
109
+ description: redactEmails(extractDescription(parts[i + 1])),
110
+ timesheet: redactEmails(extractTimesheet(parts[i + 1])),
111
+ });
112
+ }
113
+ return {
114
+ taskKey: data.task_key || null,
115
+ cardUrl: data.card_url || null,
116
+ pr: data.pr || null,
117
+ branch: data.branch || null,
118
+ sections,
119
+ };
120
+ }
121
+
122
+ function firstHeading(text) {
123
+ const m = text && text.match(/^#\s+(.+)$/m);
124
+ return m ? m[1].trim() : null;
125
+ }
126
+
127
+ /** Linha `Issue:` do cabeçalho do architecture.md (com ou sem negrito e `>`). */
128
+ function issueLine(text) {
129
+ const m = text && text.match(/^[>\s]*\**Issue:\**\s*(.+?)\s*$/m);
130
+ return m ? m[1].trim() : null;
131
+ }
132
+
133
+ function prNumberFrom(text) {
134
+ if (!text) return null;
135
+ const m = text.match(/\/(?:pull|merge_requests)\/(\d+)/) || text.match(/\b(?:PR|MR)\s*#(\d+)/);
136
+ return m ? `#${m[1]}` : null;
137
+ }
138
+
139
+ /**
140
+ * Data e atividade de uma sessão sem `daily.md`, só com fontes locais (o vendor fica com o
141
+ * workflow). Ordem da data: `Criado em:` > `.timestamp_start` (UTC → local) > modificação da pasta.
142
+ */
143
+ export function sessionInfo(dir) {
144
+ const arch = readText(join(dir, "architecture.md"));
145
+ const plan = readText(join(dir, "plan.md"));
146
+ const pr = readText(join(dir, "pull-request.md"));
147
+
148
+ let date = null;
149
+ let dateSource = null;
150
+ const created = arch && arch.match(/Criado em:\**\s*(\d{4}-\d{2}-\d{2})/);
151
+ if (created) {
152
+ date = created[1];
153
+ dateSource = "architecture";
154
+ }
155
+ if (!date) {
156
+ const ts = (readText(join(dir, ".timestamp_start")) || "").trim();
157
+ const parsed = ts ? new Date(ts) : null;
158
+ if (parsed && !Number.isNaN(parsed.getTime())) {
159
+ date = localDate(parsed);
160
+ dateSource = "timestamp_start";
161
+ }
162
+ }
163
+ if (!date) {
164
+ date = localDate(statSync(dir).mtime);
165
+ dateSource = "mtime";
166
+ }
167
+
168
+ const issue = issueLine(arch);
169
+ const issueKey = issue ? (issue.match(/#\d+|\b[A-Z][A-Z0-9]+-\d+\b/) || [null])[0] : null;
170
+ const issueTitle = issue ? (issue.match(/\s[—–-]\s+(.+)$/) || [null, null])[1] : null;
171
+ const archTitle = firstHeading(arch);
172
+ const usefulArchTitle =
173
+ archTitle && !/^architecture(\.md)?\s*[:—-]?\s*[#\w-]*$/i.test(archTitle) ? archTitle : null;
174
+
175
+ const activity = issueTitle || firstHeading(plan) || firstHeading(pr) || usefulArchTitle || null;
176
+
177
+ return {
178
+ date,
179
+ dateSource,
180
+ activity: redactEmails(activity),
181
+ issueKey,
182
+ prNumber: prNumberFrom(pr),
183
+ hasPullRequest: pr !== null,
184
+ };
185
+ }
186
+
187
+ /** Sessão sem `daily.md` gera alerta se chegou ao eng.pr ou começou depois do `daily.md` existir. */
188
+ export function needsAlert({ hasDaily, hasPullRequest, date }) {
189
+ if (hasDaily) return false;
190
+ return hasPullRequest || date >= DAILY_SINCE;
191
+ }
192
+
193
+ const byKey = (a, b) => a.key.localeCompare(b.key, "pt-BR", { numeric: true });
194
+
195
+ /**
196
+ * Varre `sessionsDir` (o `$SESSIONS_DIR/eng`) e devolve o que entra na janela.
197
+ * @param {string} sessionsDir
198
+ * @param {{ days?: number, now?: Date }} [options]
199
+ */
200
+ export function scanSessions(sessionsDir, { days = DEFAULT_DAYS, now = new Date() } = {}) {
201
+ const window = buildWindow(days, now);
202
+ const inWindow = new Set(window.dates);
203
+ const warnings = [];
204
+ const perDate = new Map();
205
+ const alerts = [];
206
+ const touched = new Set();
207
+
208
+ let keys = [];
209
+ if (existsSync(sessionsDir)) {
210
+ keys = readdirSync(sessionsDir, { withFileTypes: true })
211
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
212
+ .map((e) => e.name);
213
+ } else {
214
+ warnings.push(`pasta de sessões não encontrada: ${sessionsDir}`);
215
+ }
216
+
217
+ for (const key of keys) {
218
+ const dir = join(sessionsDir, key);
219
+ const dailyText = readText(join(dir, "daily.md"));
220
+
221
+ if (dailyText !== null) {
222
+ const daily = parseDaily(dailyText);
223
+ for (const s of daily.sections) {
224
+ if (!DATE_RE.test(s.date) || !inWindow.has(s.date)) continue;
225
+ if (!perDate.has(s.date)) perDate.set(s.date, []);
226
+ perDate.get(s.date).push({
227
+ key,
228
+ taskKey: daily.taskKey,
229
+ cardUrl: daily.cardUrl,
230
+ pr: daily.pr,
231
+ branch: daily.branch,
232
+ description: s.description,
233
+ timesheet: s.timesheet,
234
+ });
235
+ touched.add(key);
236
+ }
237
+ continue;
238
+ }
239
+
240
+ const info = sessionInfo(dir);
241
+ if (!inWindow.has(info.date)) continue;
242
+ if (!needsAlert({ hasDaily: false, hasPullRequest: info.hasPullRequest, date: info.date }))
243
+ continue;
244
+ if (info.dateSource === "mtime")
245
+ warnings.push(`sessão ${key}: data tirada da modificação da pasta (sem outra fonte)`);
246
+ alerts.push({ key, ...info });
247
+ touched.add(key);
248
+ }
249
+
250
+ const byDate = [...perDate.entries()]
251
+ .sort(([a], [b]) => (a < b ? 1 : -1))
252
+ .map(([date, tasks]) => ({ date, tasks: tasks.sort(byKey) }));
253
+ alerts.sort((a, b) => (a.date === b.date ? byKey(a, b) : a.date < b.date ? 1 : -1));
254
+
255
+ return {
256
+ days,
257
+ today: window.to,
258
+ window: { from: window.from, to: window.to },
259
+ fileName: reportFileName(window.to, days),
260
+ sessionsScanned: keys.length,
261
+ sessionsInWindow: touched.size,
262
+ byDate,
263
+ alerts,
264
+ warnings,
265
+ };
266
+ }
@@ -0,0 +1,63 @@
1
+ // Skills obsoletos (`jarvis init --upgrade`): skills instalados na pasta da IDE que não existem
2
+ // mais no framework (renomeados ou removidos). O upgrade só LISTA e, com confirmação, remove.
3
+ // Nunca marca como obsoleto um skill registrado em alguma lista `*_SPECIALIZATIONS` do ENV.md
4
+ // (especializações criadas pelo usuário com o jarvis-create-specialization).
5
+ // Só lê nomes de pasta: nunca lê nem imprime o conteúdo dos skills.
6
+ import { existsSync, readdirSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/;
10
+
11
+ /**
12
+ * Nomes registrados nas listas `*_SPECIALIZATIONS` do ENV (aceita vírgulas, colchetes, aspas e espaços).
13
+ * @param {Map<string, string>} entries - `parseEnvEntries(envText)`
14
+ * @returns {Set<string>}
15
+ */
16
+ export function registeredSpecializations(entries) {
17
+ const names = new Set();
18
+ for (const [key, value] of entries) {
19
+ if (!key.endsWith("_SPECIALIZATIONS")) continue;
20
+ for (const item of value.replace(/[[\]"']/g, "").split(",")) {
21
+ const name = item.trim();
22
+ if (name) names.add(name);
23
+ }
24
+ }
25
+ return names;
26
+ }
27
+
28
+ /**
29
+ * @param {{ ideSkillsDir: string, frameworkSkillsDir: string, registered?: Set<string> }} p
30
+ * @returns {string[]} nomes (pastas) de skills instalados que o framework não tem mais
31
+ */
32
+ export function findStaleSkills({ ideSkillsDir, frameworkSkillsDir, registered = new Set() }) {
33
+ if (!existsSync(ideSkillsDir)) return [];
34
+ return readdirSync(ideSkillsDir, { withFileTypes: true })
35
+ .filter((e) => e.isDirectory() && SKILL_NAME.test(e.name))
36
+ .map((e) => e.name)
37
+ .filter((name) => existsSync(join(ideSkillsDir, name, "SKILL.md")))
38
+ .filter((name) => !existsSync(join(frameworkSkillsDir, name)))
39
+ .filter((name) => !registered.has(name))
40
+ .sort();
41
+ }
42
+
43
+ /**
44
+ * Remove as pastas listadas, uma a uma (só nomes já validados por `findStaleSkills`).
45
+ * @returns {{ removed: string[], errors: string[] }}
46
+ */
47
+ export function removeStaleSkills(ideSkillsDir, names) {
48
+ const removed = [];
49
+ const errors = [];
50
+ for (const name of names) {
51
+ if (!SKILL_NAME.test(name)) {
52
+ errors.push(`${name}: nome inválido`);
53
+ continue;
54
+ }
55
+ try {
56
+ rmSync(join(ideSkillsDir, name), { recursive: true, force: true });
57
+ removed.push(name);
58
+ } catch (err) {
59
+ errors.push(`${name}: ${err.message}`);
60
+ }
61
+ }
62
+ return { removed, errors };
63
+ }
@@ -0,0 +1,57 @@
1
+ // Modo de trabalho do workspace (WORKSPACE_MODE): decide se a pasta que contém o `$IDE/` é
2
+ // um projeto único ou um workspace. O que define o modo é a RAIZ ser ou não um repositório,
3
+ // e não quantos repositórios existem dentro dela:
4
+ // - raiz com `.git` → "project" (o git já delimita o código)
5
+ // - raiz sem `.git` e 1+ subpastas com `.git` → "workspace"
6
+ // - raiz sem `.git` e nenhuma subpasta com `.git` → "project" (padrão)
7
+ // Caso ambíguo (raiz com `.git` e subpastas também com `.git`, como submódulos): "project" + aviso.
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ export const WORKSPACE_MODES = ["workspace", "project"];
12
+
13
+ /** Subpastas diretas com `.git` (arquivo ou pasta). Ignora ocultas e `node_modules`. */
14
+ function listGitSubdirs(dir) {
15
+ let entries;
16
+ try {
17
+ entries = readdirSync(dir, { withFileTypes: true });
18
+ } catch {
19
+ return [];
20
+ }
21
+ return entries
22
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
23
+ .filter((e) => existsSync(join(dir, e.name, ".git")))
24
+ .map((e) => e.name)
25
+ .sort();
26
+ }
27
+
28
+ /**
29
+ * @param {string} workspaceDir - pasta que contém o `$IDE/`
30
+ * @param {{ repos?: string[] }} [options] - `repos`: allowlist (WORKSPACE_REPOS); vazia = todas
31
+ * @returns {{ mode: "workspace"|"project", repos: string[], warning: string|null }}
32
+ */
33
+ export function detectWorkspaceMode(workspaceDir, { repos = [] } = {}) {
34
+ const rootIsRepo = existsSync(join(workspaceDir, ".git"));
35
+ const allow = new Set(repos);
36
+ const found = listGitSubdirs(workspaceDir).filter((n) => allow.size === 0 || allow.has(n));
37
+
38
+ if (rootIsRepo) {
39
+ return {
40
+ mode: "project",
41
+ repos: [],
42
+ warning: found.length
43
+ ? "a raiz é um repositório e há subpastas com .git (submódulos ou repositórios aninhados): tratada como projeto único"
44
+ : null,
45
+ };
46
+ }
47
+ if (found.length >= 1) return { mode: "workspace", repos: found, warning: null };
48
+ return { mode: "project", repos: [], warning: null };
49
+ }
50
+
51
+ /** Lê o valor do ENV: só `workspace` ou `project` (sem diferenciar maiúsculas); outro valor = null. */
52
+ export function normalizeWorkspaceMode(value) {
53
+ const v = String(value || "")
54
+ .trim()
55
+ .toLowerCase();
56
+ return WORKSPACE_MODES.includes(v) ? v : null;
57
+ }