jarvis-ai-framework 8.2.0 → 8.3.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/AGENTS.md +2 -0
- package/README.md +11 -0
- package/bin/README.md +3 -3
- package/bin/commands/fork.js +562 -0
- package/bin/commands/init.js +62 -1
- package/bin/lib/cli/program.js +27 -0
- package/bin/lib/core/company-profile.js +92 -0
- package/bin/lib/core/sync-engine.js +164 -1
- package/package.json +2 -2
- package/rules/AGENTS.md +1 -0
- package/rules/company-overlay-rules.md +15 -0
- package/skills/AGENTS.md +3 -0
- package/skills/SKILLS-ROADMAP.md +5 -0
- package/skills/jarvis-fork/SKILL.md +184 -0
- package/skills/jarvis-init/SKILL.md +11 -2
- package/templates/ENV-template.md +11 -0
package/AGENTS.md
CHANGED
|
@@ -63,6 +63,7 @@ prompts/
|
|
|
63
63
|
│ │ ├── eng-qa/ # Base neutra de QA (+ especializações em QA_SPECIALIZATIONS)
|
|
64
64
|
│ │ ├── eng-qa-planner/ # Planejamento de testes e análise de gaps de cobertura
|
|
65
65
|
│ │ ├── jarvis-report-issue/ # Auto-report de bugs no repo do Jarvis (CLI do host: gh)
|
|
66
|
+
│ │ ├── jarvis-fork/ # Fork guiado de um artefato do core para o overlay da empresa (COMPANY_PROFILE; CLI jarvis fork)
|
|
66
67
|
│ │ ├── eng-global-task-comment/ # Comentário no card (Jira/GitHub/GitLab, pela CLI do vendor)
|
|
67
68
|
│ │ ├── jarvis-list-specializations/ # Lista especializações de stack instaladas e registradas
|
|
68
69
|
│ │ ├── jarvis-create-specialization/ # Cria uma especialização de stack a partir do código do projeto e a registra
|
|
@@ -199,6 +200,7 @@ jarvis list # Listar agents, skills e wo
|
|
|
199
200
|
jarvis install-rtk # Instalar RTK (token killer — economia 60-90% em tokens de shell)
|
|
200
201
|
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
202
|
jarvis map <nome> [--depth N] [--reverse] # Mapa de chamadas de um workflow, skill ou agente (lê só as tabelas padronizadas)
|
|
203
|
+
jarvis fork [nome] [--list] [--update [--diff]] [--delete] [--candidate] # Overlay de empresa (#199): copia um artefato do core para $IDE/overlays/{COMPANY_PROFILE}/ com carimbo; lista; recarimba; remove
|
|
202
204
|
jarvis daily-report [--days N] [--json] # Junta os daily.md das sessões por dia (usado pelo /eng.daily-report)
|
|
203
205
|
```
|
|
204
206
|
|
package/README.md
CHANGED
|
@@ -205,6 +205,17 @@ Guia completo: [`docs/especializacoes-de-stack.md`](docs/especializacoes-de-stac
|
|
|
205
205
|
|
|
206
206
|
---
|
|
207
207
|
|
|
208
|
+
## Funciona com qualquer empresa
|
|
209
|
+
|
|
210
|
+
A stack vem do código; o **processo** vem da empresa, e muda de cliente para cliente: de onde nasce a branch, como se entrega, quais gates valem, campos do MR. `COMPANY_PROFILE` no `ENV.md` (nasce como `stark-industries`, o overlay padrão) aponta para `.{ide}/overlays/{perfil}/`, uma pasta que espelha o core e guarda só o que a empresa substituiu. O `jarvis init --upgrade` atualiza o core e nunca grava arquivo no overlay.
|
|
211
|
+
|
|
212
|
+
- **`/jarvis-fork {artefato}`**: uma conversa até achar a seção do core que não serve, o menor ajuste, a simulação e o fork no overlay, só com o Sim; `--list`, `--update`, `--delete`
|
|
213
|
+
- **Dois resolvers**: uma rule universal confere o overlay na chamada (workflows, skills, agentes, templates); o `jarvis init` e o `/jarvis-init` copiam por cima as `rules/` do overlay na instalação
|
|
214
|
+
|
|
215
|
+
Guia completo: [`docs/overlay-de-empresa.md`](docs/overlay-de-empresa.md).
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
208
219
|
## Camadas
|
|
209
220
|
|
|
210
221
|
O Jarvis e organizado em 5 camadas.
|
package/bin/README.md
CHANGED
|
@@ -11,7 +11,7 @@ bin/
|
|
|
11
11
|
├── commands/ # Um arquivo por comando. Lógica pura, zero commander.
|
|
12
12
|
│ ├── init.js, list.js, info.js, whoami.js, ...
|
|
13
13
|
│ ├── docs-sync.js, docs-publish.js
|
|
14
|
-
│ └── install-rtk.js, tokens.js, map.js
|
|
14
|
+
│ └── install-rtk.js, tokens.js, map.js, fork.js
|
|
15
15
|
└── lib/
|
|
16
16
|
├── cli/
|
|
17
17
|
│ ├── program.js # Único lugar que monta o commander — ver "Arquitetura"
|
|
@@ -25,7 +25,7 @@ bin/
|
|
|
25
25
|
├── tasks/
|
|
26
26
|
│ └── comment.js # Comentário no card (jira/github/gitlab) pelo cli-runner — mesmo padrão
|
|
27
27
|
├── config/ # ide-config.js (fonte de verdade de IDEs), constants.js
|
|
28
|
-
├── core/ # scanner.js, sync-engine.js, profile-filter.js, token-report.js
|
|
28
|
+
├── core/ # scanner.js, sync-engine.js (inclui o RuleResolver do overlay), profile-filter.js, token-report.js, company-profile.js (COMPANY_PROFILE e o default stark-industries)
|
|
29
29
|
├── docs/ # Central docs: fetch/publish pelo adaptador + validação de frontmatter
|
|
30
30
|
│ ├── validate-frontmatter.js # Valida/extrai frontmatter (feature, delivery e documentos de trabalho: PRD, FRD, épico, história, tarefa, ARD, RFC, QA)
|
|
31
31
|
│ ├── fetch-file.js # openStorageFromEnv() + fetchFile() — `docs sync`/`docs fetch`/`docs publish`
|
|
@@ -49,7 +49,7 @@ bin/commands/*.js → lógica de negócio pura: async (flags) => {...}
|
|
|
49
49
|
|
|
50
50
|
**Por que separar assim?** `bin/commands/*.js` nunca importa `commander` nem sabe o que é um `Command`. Cada arquivo só recebe um objeto `flags` e faz o que precisa. Isso significa:
|
|
51
51
|
- Testar a lógica de um comando não exige simular argv/commander — chama a função direto com um objeto.
|
|
52
|
-
- Trocar de biblioteca de CLI um dia (se um dia fizer sentido) significa reescrever só `program.js`, não os
|
|
52
|
+
- Trocar de biblioteca de CLI um dia (se um dia fizer sentido) significa reescrever só `program.js`, não os arquivos de `bin/commands/`.
|
|
53
53
|
- `program.js` é o **mapa completo** do CLI: para saber todo comando que existe, todas as flags que cada um aceita, é o único arquivo que precisa abrir.
|
|
54
54
|
|
|
55
55
|
## O adapter: commander → formato antigo de `flags`
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `jarvis fork {nome} [--list|--update|--delete] [--candidate]` (#199): a mecânica do
|
|
3
|
+
* overlay de empresa. Copia um artefato do core instalado para `$IDE/overlays/{COMPANY_PROFILE}/`,
|
|
4
|
+
* no mesmo caminho e com o mesmo nome, carimbado no frontmatter (`forked_from`, `forked_at`,
|
|
5
|
+
* `core_candidate`); lista o que está forkado; mostra o diff fork × core atual e recarimba
|
|
6
|
+
* (`--update`); remove o fork (`--delete`). A conversa (o que ajustar, simular, confirmar) é da
|
|
7
|
+
* skill de fork: este comando grava direto no overlay, e é o único que grava lá.
|
|
8
|
+
* @module commands/fork
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
lstatSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
cpSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { join, dirname, relative, sep, isAbsolute } from "node:path";
|
|
22
|
+
import { resolveEnvPath } from "../lib/env-loader.js";
|
|
23
|
+
import { getFrameworkRoot, getPackageInfo } from "../lib/utils/paths.js";
|
|
24
|
+
import { buildIndex } from "../lib/flow-map/index.js";
|
|
25
|
+
import { extractFrontmatterBlock, parseFrontmatter } from "../lib/utils/frontmatter.js";
|
|
26
|
+
import { readCompanyProfile } from "../lib/core/company-profile.js";
|
|
27
|
+
import { applyRuleOverlay } from "../lib/core/sync-engine.js";
|
|
28
|
+
import { logger, configureFromFlags } from "../lib/utils/logger.js";
|
|
29
|
+
import { RED, GREEN, YELLOW, CYAN, DIM, NC } from "../lib/utils/ui.js";
|
|
30
|
+
|
|
31
|
+
/** Tipos que o overlay conhece: as pastas do core que ele espelha. */
|
|
32
|
+
export const OVERLAY_TYPES = Object.freeze([
|
|
33
|
+
"workflows",
|
|
34
|
+
"skills",
|
|
35
|
+
"agents",
|
|
36
|
+
"rules",
|
|
37
|
+
"rules-on-demand",
|
|
38
|
+
"templates",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Tipos aceitos por caminho relativo (os demais vêm por nome, pelo índice do `jarvis map`). */
|
|
42
|
+
const PATH_TYPES = Object.freeze(["rules", "rules-on-demand", "templates"]);
|
|
43
|
+
|
|
44
|
+
/** Tipos que a IDE carrega sozinha: o fork recopia a cópia da IDE na hora (RuleResolver). */
|
|
45
|
+
const RULE_TYPES = Object.freeze(["rules", "rules-on-demand"]);
|
|
46
|
+
|
|
47
|
+
const STAMP_KEYS = /^(forked_from|forked_at|core_candidate):/;
|
|
48
|
+
|
|
49
|
+
/** `lstat` sem lançar: enxerga link simbólico (inclusive quebrado), que o `existsSync` não vê. */
|
|
50
|
+
function lstatOrNull(path) {
|
|
51
|
+
try {
|
|
52
|
+
return lstatSync(path);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* R5 do architecture.md: nenhum nível do caminho do alvo, do próprio alvo até o overlay, pode ser
|
|
60
|
+
* link simbólico (quebrado ou não), e o primeiro nível que existe precisa resolver, por `realpath`,
|
|
61
|
+
* para dentro do overlay. Uma subpasta linkada para fora (`overlays/acme/skills` → `.claude/skills`)
|
|
62
|
+
* ou um link quebrado no lugar do destino (`eng.start.md` → `/fora.md`) não podem levar o fork a
|
|
63
|
+
* gravar ou apagar lá. O próprio `overlays/{perfil}` pode ser um link (overlay num repositório da
|
|
64
|
+
* empresa): a raiz é o `realpath` dele, e a subida para nele sem o `lstat`.
|
|
65
|
+
* @param {string} overlayDir - precisa existir
|
|
66
|
+
* @param {string} target
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isInsideOverlay(overlayDir, target) {
|
|
70
|
+
const root = realpathSync(overlayDir);
|
|
71
|
+
let probe = target;
|
|
72
|
+
for (;;) {
|
|
73
|
+
if (probe === overlayDir) return true;
|
|
74
|
+
const stat = lstatOrNull(probe);
|
|
75
|
+
if (stat) {
|
|
76
|
+
if (stat.isSymbolicLink()) return false;
|
|
77
|
+
const real = realpathSync(probe);
|
|
78
|
+
return real === root || real.startsWith(root + sep);
|
|
79
|
+
}
|
|
80
|
+
const parent = dirname(probe);
|
|
81
|
+
if (parent === probe) return false;
|
|
82
|
+
probe = parent;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve `{nome}` para um artefato do core: workflow, skill ou agente pelo nome (índice do
|
|
88
|
+
* `jarvis map`), ou `rules/…`, `rules-on-demand/…`, `templates/…` por caminho relativo.
|
|
89
|
+
* @param {string} root - Raiz do core instalado
|
|
90
|
+
* @param {string} name
|
|
91
|
+
* @returns {{ type: string, relPath: string, isDir: boolean } | null} `relPath` com `/`
|
|
92
|
+
*/
|
|
93
|
+
export function resolveArtifact(root, name) {
|
|
94
|
+
if (!name || isAbsolute(name) || name.split(/[\\/]/).includes("..")) return null;
|
|
95
|
+
const { items } = buildIndex(root);
|
|
96
|
+
const item = items.get(name);
|
|
97
|
+
if (item) {
|
|
98
|
+
if (item.type === "skill") return { type: "skills", relPath: dirname(item.path), isDir: true };
|
|
99
|
+
return {
|
|
100
|
+
type: item.type === "workflow" ? "workflows" : "agents",
|
|
101
|
+
relPath: item.path,
|
|
102
|
+
isDir: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const norm = name.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
106
|
+
const first = norm.split("/")[0];
|
|
107
|
+
if (PATH_TYPES.includes(first) && norm.endsWith(".md") && existsSync(join(root, norm))) {
|
|
108
|
+
return { type: first, relPath: norm, isDir: false };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Carimba o frontmatter com a origem do fork. Sem frontmatter (ex.: template), cria um só com o
|
|
115
|
+
* carimbo. Carimbo anterior é substituído.
|
|
116
|
+
* @param {string} content
|
|
117
|
+
* @param {{ from: string, at: string, candidate?: boolean }} stamp
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
export function stampFrontmatter(rawContent, { from, at, candidate = false }) {
|
|
121
|
+
const content = lf(rawContent);
|
|
122
|
+
const lines = [`forked_from: ${from}`, `forked_at: ${at}`];
|
|
123
|
+
if (candidate) lines.push("core_candidate: true");
|
|
124
|
+
const block = extractFrontmatterBlock(content);
|
|
125
|
+
if (block === null) return `---\n${lines.join("\n")}\n---\n\n${content}`;
|
|
126
|
+
const kept = block
|
|
127
|
+
.split("\n")
|
|
128
|
+
.filter((l) => !STAMP_KEYS.test(l))
|
|
129
|
+
.join("\n");
|
|
130
|
+
const fenceEnd = content.indexOf("\n---", 3);
|
|
131
|
+
return `---\n${kept}\n${lines.join("\n")}${content.slice(fenceEnd)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Lista o que está no overlay, e só isso (não compara com o core).
|
|
136
|
+
* @param {string} overlayDir
|
|
137
|
+
* @returns {Array<{ path: string, type: string, forkedFrom: string | null, forkedAt: string | null, candidate: boolean, unknownType: boolean }>}
|
|
138
|
+
*/
|
|
139
|
+
export function listOverlay(overlayDir) {
|
|
140
|
+
if (!existsSync(overlayDir)) return [];
|
|
141
|
+
const out = [];
|
|
142
|
+
const walk = (dir) => {
|
|
143
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
144
|
+
const full = join(dir, entry.name);
|
|
145
|
+
if (entry.isDirectory()) {
|
|
146
|
+
walk(full);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
150
|
+
const rel = relative(overlayDir, full).split(sep).join("/");
|
|
151
|
+
const type = rel.split("/")[0];
|
|
152
|
+
// Skill: uma linha por pasta (o SKILL.md), não por arquivo de references/
|
|
153
|
+
if (type === "skills" && !rel.endsWith("/SKILL.md")) continue;
|
|
154
|
+
const fm = parseFrontmatter(lf(readFileSync(full, "utf-8")));
|
|
155
|
+
out.push({
|
|
156
|
+
path: type === "skills" ? dirname(rel) : rel,
|
|
157
|
+
type,
|
|
158
|
+
forkedFrom: fm.forked_from ?? null,
|
|
159
|
+
forkedAt: fm.forked_at ?? null,
|
|
160
|
+
candidate: fm.core_candidate === "true",
|
|
161
|
+
unknownType: !OVERLAY_TYPES.includes(type),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
walk(overlayDir);
|
|
166
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Linhas que, mudando no core, costumam quebrar o contrato de um fork: arquivos de sessão, chaves
|
|
171
|
+
* do ENV.md, templates lidos por outros comandos e chamadas do CLI. O `--update` só marca; quem decide é a pessoa.
|
|
172
|
+
*/
|
|
173
|
+
const CONTRACT_PATTERN =
|
|
174
|
+
/(\.jarvis\/sessions|\$SESSIONS_DIR|architecture\.md|plan\.md|gate-checklist|pull-request\.md|ENV\.md|[A-Z][A-Z_]{3,}=|templates\/|\bjarvis [a-z][a-z-]+)/;
|
|
175
|
+
|
|
176
|
+
/** Quebras de linha do Windows viram `\n`: fork editado em CRLF não pode marcar tudo como diferente. */
|
|
177
|
+
const lf = (text) => text.replace(/\r\n?/g, "\n");
|
|
178
|
+
|
|
179
|
+
/** Acima disto (linhas do fork × linhas do core, depois do corte do prefixo e sufixo iguais) o LCS não roda. */
|
|
180
|
+
export const DIFF_CELL_LIMIT = 25_000_000;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Diff linha a linha (LCS) entre o fork e o core atual, sem dependência nova. O prefixo e o sufixo
|
|
184
|
+
* iguais são cortados antes do LCS; linhas iguais e as do carimbo não entram; `contract` marca
|
|
185
|
+
* mudança de contrato provável. Acima de `DIFF_CELL_LIMIT` células, não compara (`tooLarge`).
|
|
186
|
+
* @param {string} forkText
|
|
187
|
+
* @param {string} coreText
|
|
188
|
+
* @returns {{ changes: Array<{ op: "-" | "+", line: string, index: number }>, contract: boolean, tooLarge: boolean, removed?: number, added?: number }}
|
|
189
|
+
* `-` = só no fork (`index` na linha do fork) · `+` = só no core atual (`index` na linha do core);
|
|
190
|
+
* com `tooLarge`, `removed`/`added` são as contagens do miolo diferente e `changes` vem vazio
|
|
191
|
+
*/
|
|
192
|
+
export function diffAgainstCore(forkText, coreText) {
|
|
193
|
+
const a = lf(forkText).split("\n");
|
|
194
|
+
const b = lf(coreText).split("\n");
|
|
195
|
+
// Corta o prefixo e o sufixo iguais: o LCS só vê o miolo que mudou.
|
|
196
|
+
let start = 0;
|
|
197
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
198
|
+
let endA = a.length;
|
|
199
|
+
let endB = b.length;
|
|
200
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
201
|
+
endA--;
|
|
202
|
+
endB--;
|
|
203
|
+
}
|
|
204
|
+
const midA = a.slice(start, endA);
|
|
205
|
+
const midB = b.slice(start, endB);
|
|
206
|
+
const n = midA.length;
|
|
207
|
+
const m = midB.length;
|
|
208
|
+
if ((n + 1) * (m + 1) > DIFF_CELL_LIMIT) {
|
|
209
|
+
return { changes: [], contract: false, tooLarge: true, removed: n, added: m };
|
|
210
|
+
}
|
|
211
|
+
const diff = diffLcs(midA, midB);
|
|
212
|
+
// Os índices do miolo voltam a ser índices do arquivo inteiro.
|
|
213
|
+
return { ...diff, changes: diff.changes.map((c) => ({ ...c, index: c.index + start })) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Título `#…` mais próximo acima de `index` (para o `--diff` dizer em que seção a mudança está). */
|
|
217
|
+
function sectionBefore(lines, index) {
|
|
218
|
+
for (let k = Math.min(index, lines.length - 1); k >= 0; k--) {
|
|
219
|
+
if (/^#{1,6} /.test(lines[k])) return lines[k];
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function diffLcs(a, b) {
|
|
225
|
+
const n = a.length;
|
|
226
|
+
const m = b.length;
|
|
227
|
+
const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
228
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
229
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
230
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const changes = [];
|
|
234
|
+
let i = 0;
|
|
235
|
+
let j = 0;
|
|
236
|
+
while (i < n && j < m) {
|
|
237
|
+
if (a[i] === b[j]) {
|
|
238
|
+
i++;
|
|
239
|
+
j++;
|
|
240
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
241
|
+
changes.push({ op: "-", line: a[i], index: i });
|
|
242
|
+
i++;
|
|
243
|
+
} else {
|
|
244
|
+
changes.push({ op: "+", line: b[j], index: j });
|
|
245
|
+
j++;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
while (i < n) {
|
|
249
|
+
changes.push({ op: "-", line: a[i], index: i });
|
|
250
|
+
i++;
|
|
251
|
+
}
|
|
252
|
+
while (j < m) {
|
|
253
|
+
changes.push({ op: "+", line: b[j], index: j });
|
|
254
|
+
j++;
|
|
255
|
+
}
|
|
256
|
+
const relevant = changes.filter((c) => !STAMP_KEYS.test(c.line));
|
|
257
|
+
return {
|
|
258
|
+
changes: relevant,
|
|
259
|
+
contract: relevant.some((c) => CONTRACT_PATTERN.test(c.line)),
|
|
260
|
+
tooLarge: false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Tabela do `--list`: Caminho | Tipo | forked_from | forked_at | Candidato. Só lista; não compara.
|
|
266
|
+
* @param {ReturnType<typeof listOverlay>} entries
|
|
267
|
+
* @returns {string[]} linhas (cabeçalho, separador e uma por fork)
|
|
268
|
+
*/
|
|
269
|
+
export function formatOverlayTable(entries) {
|
|
270
|
+
const rows = entries.map((e) => [
|
|
271
|
+
e.path,
|
|
272
|
+
e.unknownType ? `⚠️ tipo não resolvido (${e.type})` : e.type,
|
|
273
|
+
e.forkedFrom ?? "(sem carimbo)",
|
|
274
|
+
e.forkedAt ?? "—",
|
|
275
|
+
e.candidate ? "candidato a core" : "—",
|
|
276
|
+
]);
|
|
277
|
+
const header = ["Caminho", "Tipo", "forked_from", "forked_at", "Candidato"];
|
|
278
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
279
|
+
const line = (cells) =>
|
|
280
|
+
cells
|
|
281
|
+
.map((c, i) => c.padEnd(widths[i]))
|
|
282
|
+
.join(" ")
|
|
283
|
+
.trimEnd();
|
|
284
|
+
return [
|
|
285
|
+
` ${line(header)}`,
|
|
286
|
+
` ${widths.map((w) => "-".repeat(w)).join(" ")}`,
|
|
287
|
+
...rows.map((r) => ` ${line(r)}`),
|
|
288
|
+
];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Lê o perfil ativo a partir do ENV.md do workspace.
|
|
293
|
+
* @returns {{ ideDir: string, profile: string, overlayDir: string, warning: string | null } | { error: string }}
|
|
294
|
+
*/
|
|
295
|
+
function readContext(flags) {
|
|
296
|
+
let resolved;
|
|
297
|
+
try {
|
|
298
|
+
resolved = resolveEnvPath(process.cwd(), flags);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return { error: err.message.split("\n")[0] };
|
|
301
|
+
}
|
|
302
|
+
const ideDir = dirname(resolved.path);
|
|
303
|
+
const envText = existsSync(resolved.path) ? readFileSync(resolved.path, "utf-8") : undefined;
|
|
304
|
+
const { profile, warning } = readCompanyProfile(envText);
|
|
305
|
+
return { ideDir, profile, warning, overlayDir: join(ideDir, "overlays", profile) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `jarvis fork {nome}` / `jarvis fork --list`.
|
|
310
|
+
* @param {Record<string, unknown> & { _?: string[] }} flags
|
|
311
|
+
* @returns {Promise<number>} código de saída (também gravado em `process.exitCode`)
|
|
312
|
+
*/
|
|
313
|
+
export async function fork(flags = {}) {
|
|
314
|
+
configureFromFlags(flags);
|
|
315
|
+
const exit = (code) => {
|
|
316
|
+
process.exitCode = code;
|
|
317
|
+
return code;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const ctx = readContext(flags);
|
|
321
|
+
if (ctx.error) {
|
|
322
|
+
logger.error(`${RED}🚫 ${ctx.error}${NC}`);
|
|
323
|
+
logger.info(`${DIM}Rode jarvis init e depois /jarvis-init na IDE.${NC}`);
|
|
324
|
+
return exit(1);
|
|
325
|
+
}
|
|
326
|
+
if (ctx.warning) logger.info(`${YELLOW}${ctx.warning}${NC}`);
|
|
327
|
+
|
|
328
|
+
if (flags.list) {
|
|
329
|
+
const entries = listOverlay(ctx.overlayDir);
|
|
330
|
+
if (entries.length === 0) {
|
|
331
|
+
logger.info(
|
|
332
|
+
`ℹ️ Overlay "${ctx.profile}" sem forks. Use /jarvis-fork {artefato} para começar.`
|
|
333
|
+
);
|
|
334
|
+
return exit(0);
|
|
335
|
+
}
|
|
336
|
+
logger.info(`${CYAN}Overlay ${ctx.profile}: ${entries.length} fork(s)${NC}`);
|
|
337
|
+
for (const line of formatOverlayTable(entries)) logger.info(line);
|
|
338
|
+
return exit(0);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const name = flags._?.[0];
|
|
342
|
+
if (!name) {
|
|
343
|
+
logger.error(
|
|
344
|
+
`${RED}Uso: jarvis fork {nome} [--candidate] | jarvis fork {nome} --update | jarvis fork {nome} --delete | jarvis fork --list${NC}`
|
|
345
|
+
);
|
|
346
|
+
return exit(1);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const root = getFrameworkRoot();
|
|
350
|
+
const artifact = resolveArtifact(root, name) ?? resolveInOverlay(ctx.overlayDir, name);
|
|
351
|
+
if (!artifact) {
|
|
352
|
+
logger.error(
|
|
353
|
+
`${YELLOW}⚠️ "${name}" não é um artefato do core. Use /jarvis-fork --list para ver o que já está no overlay ou jarvis map para os nomes do core.${NC}`
|
|
354
|
+
);
|
|
355
|
+
return exit(1);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const src = join(root, artifact.relPath);
|
|
359
|
+
const dest = join(ctx.overlayDir, artifact.relPath);
|
|
360
|
+
|
|
361
|
+
if (flags.delete) return exit(deleteFork({ ctx, artifact, name, src, dest }));
|
|
362
|
+
if (flags.update) {
|
|
363
|
+
return exit(
|
|
364
|
+
updateFork({
|
|
365
|
+
ctx,
|
|
366
|
+
artifact,
|
|
367
|
+
name,
|
|
368
|
+
src,
|
|
369
|
+
dest,
|
|
370
|
+
candidate: flags.candidate === true,
|
|
371
|
+
showDiff: flags.diff === true,
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// lstat, não existsSync: um link quebrado no lugar do destino também ocupa o caminho.
|
|
377
|
+
if (lstatOrNull(dest)) {
|
|
378
|
+
logger.error(
|
|
379
|
+
`${YELLOW}⚠️ ${artifact.relPath} já está no overlay "${ctx.profile}" (ou o caminho está ocupado por um link). Use /jarvis-fork ${name} --update para ajustar.${NC}`
|
|
380
|
+
);
|
|
381
|
+
return exit(1);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// O fork é quem cria a pasta do overlay quando ela ainda não existe (o sync também a cria).
|
|
385
|
+
mkdirSync(ctx.overlayDir, { recursive: true });
|
|
386
|
+
if (!isInsideOverlay(ctx.overlayDir, dest)) {
|
|
387
|
+
logger.error(
|
|
388
|
+
`${RED}⚠️ ${artifact.relPath} resolve para fora do overlay "${ctx.profile}" (link simbólico?); nada gravado.${NC}`
|
|
389
|
+
);
|
|
390
|
+
return exit(1);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const stamp = {
|
|
394
|
+
from: `${name}@${getPackageInfo().version}`,
|
|
395
|
+
at: new Date().toISOString().slice(0, 10),
|
|
396
|
+
candidate: Boolean(flags.candidate),
|
|
397
|
+
};
|
|
398
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
399
|
+
if (artifact.isDir) {
|
|
400
|
+
cpSync(src, dest, { recursive: true });
|
|
401
|
+
const skillMd = join(dest, "SKILL.md");
|
|
402
|
+
writeFileSync(skillMd, stampFrontmatter(readFileSync(skillMd, "utf-8"), stamp), "utf-8");
|
|
403
|
+
} else {
|
|
404
|
+
writeFileSync(dest, stampFrontmatter(readFileSync(src, "utf-8"), stamp), "utf-8");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const shownDest = relative(dirname(ctx.ideDir), dest).split(sep).join("/");
|
|
408
|
+
logger.info(`${GREEN}✓ Fork gravado:${NC} ${shownDest}`);
|
|
409
|
+
logger.info(
|
|
410
|
+
`${DIM} forked_from: ${stamp.from} · forked_at: ${stamp.at}${stamp.candidate ? " · core_candidate: true" : ""}${NC}`
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
if (RULE_TYPES.includes(artifact.type)) {
|
|
414
|
+
// A IDE carrega rules/ sozinha: recopia agora, sem esperar o próximo jarvis init --upgrade
|
|
415
|
+
const result = { copied: [], skipped: [], errors: [] };
|
|
416
|
+
applyRuleOverlay(ctx.ideDir, result);
|
|
417
|
+
if (result.errors.length > 0) {
|
|
418
|
+
for (const e of result.errors) logger.error(`${RED} ✗ ${e}${NC}`);
|
|
419
|
+
logger.error(
|
|
420
|
+
`${RED} O fork está no overlay, mas a cópia na IDE falhou: corrija e rode jarvis init --upgrade.${NC}`
|
|
421
|
+
);
|
|
422
|
+
return exit(1);
|
|
423
|
+
}
|
|
424
|
+
logger.info(
|
|
425
|
+
`${DIM} ${artifact.type}/ recopiada na IDE com a marcação "> Overlay: ${ctx.profile}"${NC}`
|
|
426
|
+
);
|
|
427
|
+
} else {
|
|
428
|
+
logger.info(
|
|
429
|
+
`${DIM} Vale na próxima chamada (rule de precedência). Edite o arquivo do overlay, não o da IDE.${NC}`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
logger.info(
|
|
433
|
+
`${DIM} A conversa e a confirmação ficam na skill de fork; este comando grava direto.${NC}`
|
|
434
|
+
);
|
|
435
|
+
return exit(0);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Para `--update`/`--delete` de um artefato que o core deixou de ter: acha o fork pelo mesmo
|
|
440
|
+
* critério de nome, só que dentro do overlay.
|
|
441
|
+
*/
|
|
442
|
+
function resolveInOverlay(overlayDir, name) {
|
|
443
|
+
if (!existsSync(overlayDir)) return null;
|
|
444
|
+
const found = resolveArtifact(overlayDir, name);
|
|
445
|
+
return found ? { ...found, onlyInOverlay: true } : null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function mainFile(artifact, base) {
|
|
449
|
+
return artifact.isDir ? join(base, "SKILL.md") : base;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* `--update`: a conversa é da skill; aqui, o recarimbo (sem `--diff`) ou, com `--diff`, só o diff
|
|
454
|
+
* fork × core atual, agrupado pela seção `#…` mais próxima, **sem gravar nada**: ver o diff não
|
|
455
|
+
* muda o carimbo; a skill chama o `--update` sem `--diff` depois do Sim. O recarimbo não altera o
|
|
456
|
+
* conteúdo do fork, mas regrava o arquivo em LF.
|
|
457
|
+
*/
|
|
458
|
+
function updateFork({ ctx, artifact, name, src, dest, candidate = false, showDiff = false }) {
|
|
459
|
+
if (!existsSync(dest)) {
|
|
460
|
+
logger.error(
|
|
461
|
+
`${YELLOW}⚠️ ${name} não está no overlay "${ctx.profile}". Use /jarvis-fork ${name} para forkar.${NC}`
|
|
462
|
+
);
|
|
463
|
+
return 1;
|
|
464
|
+
}
|
|
465
|
+
// Confere o destino e, numa skill, também o SKILL.md: a pasta pode ser real e o arquivo um link
|
|
466
|
+
// para fora (o writeFileSync seguiria o link).
|
|
467
|
+
const forkPath = mainFile(artifact, dest);
|
|
468
|
+
if (!isInsideOverlay(ctx.overlayDir, dest) || !isInsideOverlay(ctx.overlayDir, forkPath)) {
|
|
469
|
+
logger.error(
|
|
470
|
+
`${RED}⚠️ ${artifact.relPath} resolve para fora do overlay "${ctx.profile}" (link simbólico?); nada alterado.${NC}`
|
|
471
|
+
);
|
|
472
|
+
return 1;
|
|
473
|
+
}
|
|
474
|
+
if (artifact.onlyInOverlay || !existsSync(src)) {
|
|
475
|
+
logger.info(
|
|
476
|
+
`${YELLOW}⚠️ O core atual não tem mais ${artifact.relPath}. O fork continua valendo; se não for mais necessário, use /jarvis-fork ${name} --delete.${NC}`
|
|
477
|
+
);
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
const forkText = lf(readFileSync(forkPath, "utf-8"));
|
|
481
|
+
const version = getPackageInfo().version;
|
|
482
|
+
|
|
483
|
+
if (showDiff) {
|
|
484
|
+
const coreText = lf(readFileSync(mainFile(artifact, src), "utf-8"));
|
|
485
|
+
const diff = diffAgainstCore(forkText, coreText);
|
|
486
|
+
if (diff.tooLarge) {
|
|
487
|
+
logger.info(
|
|
488
|
+
`${YELLOW}Diff ${name}: arquivos grandes demais para comparar linha a linha (${diff.removed} linhas só do fork, ${diff.added} só do core atual, fora o trecho igual). Compare com uma ferramenta de diff.${NC}`
|
|
489
|
+
);
|
|
490
|
+
} else if (diff.changes.length === 0) {
|
|
491
|
+
logger.info(`${GREEN}✅ ${name}: o fork é igual ao core atual (v${version}).${NC}`);
|
|
492
|
+
} else {
|
|
493
|
+
logger.info(
|
|
494
|
+
`${CYAN}Diff ${name}: fork (-) × core atual v${version} (+)${diff.contract ? ` ${RED}🔴 possível mudança de contrato${NC}` : ""}${NC}`
|
|
495
|
+
);
|
|
496
|
+
const forkLines = forkText.split("\n");
|
|
497
|
+
const coreLines = coreText.split("\n");
|
|
498
|
+
let lastSection;
|
|
499
|
+
for (const c of diff.changes) {
|
|
500
|
+
const section = sectionBefore(c.op === "-" ? forkLines : coreLines, c.index);
|
|
501
|
+
if (section !== lastSection) {
|
|
502
|
+
logger.info(`${CYAN}── ${section ?? "(início do arquivo)"}${NC}`);
|
|
503
|
+
lastSection = section;
|
|
504
|
+
}
|
|
505
|
+
const color = c.op === "-" ? YELLOW : GREEN;
|
|
506
|
+
logger.info(`${color}${c.op} ${c.line}${NC}`);
|
|
507
|
+
}
|
|
508
|
+
logger.info(
|
|
509
|
+
`${DIM} Linhas "-" são só do fork (seus ajustes ou texto antigo do core); "+" são só do core atual. O que trazer é decisão sua, na skill de fork.${NC}`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
logger.info(
|
|
513
|
+
`${DIM} Nada gravado: ver o diff não muda o carimbo. Para recarimbar, rode --update sem --diff.${NC}`
|
|
514
|
+
);
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
logger.info(
|
|
519
|
+
`${DIM} Sem comparação com o core (use --diff para ver o que o core atual tem de diferente).${NC}`
|
|
520
|
+
);
|
|
521
|
+
const current = parseFrontmatter(forkText);
|
|
522
|
+
const stamped = stampFrontmatter(forkText, {
|
|
523
|
+
from: `${name}@${version}`,
|
|
524
|
+
at: current.forked_at ?? new Date().toISOString().slice(0, 10),
|
|
525
|
+
candidate: candidate || current.core_candidate === "true",
|
|
526
|
+
});
|
|
527
|
+
// Regrava mesmo sem mudança de carimbo quando o arquivo estava em CRLF: o fork fica em LF.
|
|
528
|
+
if (stamped !== readFileSync(forkPath, "utf-8")) writeFileSync(forkPath, stamped, "utf-8");
|
|
529
|
+
logger.info(`${DIM} forked_from: ${name}@${version} (arquivo regravado em LF)${NC}`);
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* `--delete`: remove o fork do overlay; rule e rule-on-demand voltam ao core na IDE
|
|
535
|
+
* (sem marcação). A pergunta "Remover o fork e voltar ao core?" é da skill.
|
|
536
|
+
*/
|
|
537
|
+
function deleteFork({ ctx, artifact, name, src, dest }) {
|
|
538
|
+
if (!existsSync(dest)) {
|
|
539
|
+
logger.error(`${YELLOW}⚠️ ${name} não está no overlay "${ctx.profile}". Nada a remover.${NC}`);
|
|
540
|
+
return 1;
|
|
541
|
+
}
|
|
542
|
+
if (!isInsideOverlay(ctx.overlayDir, dest)) {
|
|
543
|
+
logger.error(
|
|
544
|
+
`${RED}⚠️ ${artifact.relPath} resolve para fora do overlay "${ctx.profile}" (link simbólico?); nada removido.${NC}`
|
|
545
|
+
);
|
|
546
|
+
return 1;
|
|
547
|
+
}
|
|
548
|
+
rmSync(dest, { recursive: true, force: true });
|
|
549
|
+
logger.info(`${GREEN}✓ Fork removido:${NC} overlays/${ctx.profile}/${artifact.relPath}`);
|
|
550
|
+
if (RULE_TYPES.includes(artifact.type)) {
|
|
551
|
+
const ideCopy = join(ctx.ideDir, artifact.relPath);
|
|
552
|
+
if (existsSync(src)) {
|
|
553
|
+
mkdirSync(dirname(ideCopy), { recursive: true });
|
|
554
|
+
cpSync(src, ideCopy);
|
|
555
|
+
logger.info(`${DIM} ${artifact.relPath} voltou à versão do core na IDE${NC}`);
|
|
556
|
+
} else if (existsSync(ideCopy)) {
|
|
557
|
+
rmSync(ideCopy, { force: true });
|
|
558
|
+
logger.info(`${DIM} ${artifact.relPath} removida da IDE: o core atual não a tem${NC}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return 0;
|
|
562
|
+
}
|