trackfw 2.15.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ 'use strict'
2
+
3
+ // knownAgentIds retorna a lista canônica de ids de agentes conhecidos pelo
4
+ // trackfw, em ordem estável e determinística. Espelha
5
+ // internal/identity/preset.go:KnownAgentIDs.
6
+ function knownAgentIds() {
7
+ return [
8
+ 'architect',
9
+ 'backend',
10
+ 'frontend',
11
+ 'qa',
12
+ 'infra',
13
+ 'security',
14
+ 'dba',
15
+ 'ux',
16
+ 'code-quality',
17
+ 'data',
18
+ ]
19
+ }
20
+
21
+ // presets contém o catálogo de presets temáticos de identidade. display_name
22
+ // e slug são HARDCODED aqui — não derivados chamando slugify em tempo de
23
+ // execução — porque essa é uma decisão explícita do ADR: hardcoding evita
24
+ // depender do comportamento de normalização Unicode sendo idêntico entre os
25
+ // CLIs Go, Node.js e Python. Espelha internal/identity/preset.go:presets.
26
+ const PRESETS = {
27
+ greek: {
28
+ architect: { display_name: 'Zeus', slug: 'zeus' },
29
+ backend: { display_name: 'Apolo', slug: 'apolo' },
30
+ frontend: { display_name: 'Afrodite', slug: 'afrodite' },
31
+ qa: { display_name: 'Ártemis', slug: 'artemis' },
32
+ infra: { display_name: 'Ares', slug: 'ares' },
33
+ security: { display_name: 'Hades', slug: 'hades' },
34
+ dba: { display_name: 'Poseidon', slug: 'poseidon' },
35
+ ux: { display_name: 'Atena', slug: 'atena' },
36
+ 'code-quality': { display_name: 'Hefesto', slug: 'hefesto' },
37
+ data: { display_name: 'Métis', slug: 'metis' },
38
+ },
39
+ norse: {
40
+ architect: { display_name: 'Odin', slug: 'odin' },
41
+ backend: { display_name: 'Thor', slug: 'thor' },
42
+ frontend: { display_name: 'Freya', slug: 'freya' },
43
+ qa: { display_name: 'Heimdall', slug: 'heimdall' },
44
+ infra: { display_name: 'Tyr', slug: 'tyr' },
45
+ security: { display_name: 'Vidar', slug: 'vidar' },
46
+ dba: { display_name: 'Njord', slug: 'njord' },
47
+ ux: { display_name: 'Idun', slug: 'idun' },
48
+ 'code-quality': { display_name: 'Bragi', slug: 'bragi' },
49
+ data: { display_name: 'Mimir', slug: 'mimir' },
50
+ },
51
+ potter: {
52
+ architect: { display_name: 'Dumbledore', slug: 'dumbledore' },
53
+ backend: { display_name: 'Snape', slug: 'snape' },
54
+ frontend: { display_name: 'Luna', slug: 'luna' },
55
+ qa: { display_name: 'Moody', slug: 'moody' },
56
+ infra: { display_name: 'Hagrid', slug: 'hagrid' },
57
+ security: { display_name: 'Kingsley', slug: 'kingsley' },
58
+ dba: { display_name: 'Flitwick', slug: 'flitwick' },
59
+ ux: { display_name: 'Tonks', slug: 'tonks' },
60
+ 'code-quality': { display_name: 'Hermione', slug: 'hermione' },
61
+ data: { display_name: 'Trelawney', slug: 'trelawney' },
62
+ },
63
+ thrones: {
64
+ architect: { display_name: 'Tyrion', slug: 'tyrion' },
65
+ backend: { display_name: 'Jon', slug: 'jon' },
66
+ frontend: { display_name: 'Sansa', slug: 'sansa' },
67
+ qa: { display_name: 'Arya', slug: 'arya' },
68
+ infra: { display_name: 'Brienne', slug: 'brienne' },
69
+ security: { display_name: 'Varys', slug: 'varys' },
70
+ dba: { display_name: 'Samwell', slug: 'samwell' },
71
+ ux: { display_name: 'Margaery', slug: 'margaery' },
72
+ 'code-quality': { display_name: 'Stannis', slug: 'stannis' },
73
+ data: { display_name: 'Bran', slug: 'bran' },
74
+ },
75
+ chaves: {
76
+ architect: { display_name: 'Girafales', slug: 'girafales' },
77
+ backend: { display_name: 'Madruga', slug: 'madruga' },
78
+ frontend: { display_name: 'Chiquinha', slug: 'chiquinha' },
79
+ qa: { display_name: 'Florinda', slug: 'florinda' },
80
+ infra: { display_name: 'Barriga', slug: 'barriga' },
81
+ security: { display_name: 'Quico', slug: 'quico' },
82
+ dba: { display_name: 'Clotilde', slug: 'clotilde' },
83
+ ux: { display_name: 'Popis', slug: 'popis' },
84
+ 'code-quality': { display_name: 'Nhonho', slug: 'nhonho' },
85
+ data: { display_name: 'Godinez', slug: 'godinez' },
86
+ },
87
+ pioneers: {
88
+ architect: { display_name: 'Turing', slug: 'turing' },
89
+ backend: { display_name: 'Ritchie', slug: 'ritchie' },
90
+ frontend: { display_name: 'Berners-Lee', slug: 'berners-lee' },
91
+ qa: { display_name: 'Hamilton', slug: 'hamilton' },
92
+ infra: { display_name: 'Torvalds', slug: 'torvalds' },
93
+ security: { display_name: 'Diffie', slug: 'diffie' },
94
+ dba: { display_name: 'Codd', slug: 'codd' },
95
+ ux: { display_name: 'Norman', slug: 'norman' },
96
+ 'code-quality': { display_name: 'Knuth', slug: 'knuth' },
97
+ data: { display_name: 'Hopper', slug: 'hopper' },
98
+ },
99
+ starwars: {
100
+ architect: { display_name: 'Yoda', slug: 'yoda' },
101
+ backend: { display_name: 'Han', slug: 'han' },
102
+ frontend: { display_name: 'Leia', slug: 'leia' },
103
+ qa: { display_name: 'Ahsoka', slug: 'ahsoka' },
104
+ infra: { display_name: 'Chewbacca', slug: 'chewbacca' },
105
+ security: { display_name: 'Vader', slug: 'vader' },
106
+ dba: { display_name: 'R2-D2', slug: 'r2-d2' },
107
+ ux: { display_name: 'Padmé', slug: 'padme' },
108
+ 'code-quality': { display_name: 'Obi-Wan', slug: 'obi-wan' },
109
+ data: { display_name: 'C-3PO', slug: 'c-3po' },
110
+ },
111
+ tolkien: {
112
+ architect: { display_name: 'Gandalf', slug: 'gandalf' },
113
+ backend: { display_name: 'Aragorn', slug: 'aragorn' },
114
+ frontend: { display_name: 'Arwen', slug: 'arwen' },
115
+ qa: { display_name: 'Legolas', slug: 'legolas' },
116
+ infra: { display_name: 'Gimli', slug: 'gimli' },
117
+ security: { display_name: 'Boromir', slug: 'boromir' },
118
+ dba: { display_name: 'Elrond', slug: 'elrond' },
119
+ ux: { display_name: 'Galadriel', slug: 'galadriel' },
120
+ 'code-quality': { display_name: 'Faramir', slug: 'faramir' },
121
+ data: { display_name: 'Bilbo', slug: 'bilbo' },
122
+ },
123
+ turma: {
124
+ architect: { display_name: 'Franjinha', slug: 'franjinha' },
125
+ backend: { display_name: 'Cebolinha', slug: 'cebolinha' },
126
+ frontend: { display_name: 'Magali', slug: 'magali' },
127
+ qa: { display_name: 'Mônica', slug: 'monica' },
128
+ infra: { display_name: 'Cascão', slug: 'cascao' },
129
+ security: { display_name: 'Bidu', slug: 'bidu' },
130
+ dba: { display_name: 'Marocas', slug: 'marocas' },
131
+ ux: { display_name: 'Anjinho', slug: 'anjinho' },
132
+ 'code-quality': { display_name: 'Titi', slug: 'titi' },
133
+ data: { display_name: 'Chico', slug: 'chico' },
134
+ },
135
+ egyptian: {
136
+ architect: { display_name: 'Thoth', slug: 'thoth' },
137
+ backend: { display_name: 'Rá', slug: 'ra' },
138
+ frontend: { display_name: 'Ísis', slug: 'isis' },
139
+ qa: { display_name: 'Hórus', slug: 'horus' },
140
+ infra: { display_name: 'Ptah', slug: 'ptah' },
141
+ security: { display_name: 'Anúbis', slug: 'anubis' },
142
+ dba: { display_name: 'Seshat', slug: 'seshat' },
143
+ ux: { display_name: 'Bastet', slug: 'bastet' },
144
+ 'code-quality': { display_name: 'Maat', slug: 'maat' },
145
+ data: { display_name: 'Osíris', slug: 'osiris' },
146
+ },
147
+ }
148
+
149
+ // presetOrder é a ordem canônica e estável em que os nomes de presets são
150
+ // listados (ex.: por presetNames e em mensagens de erro).
151
+ const PRESET_ORDER = ['greek', 'norse', 'potter', 'thrones', 'chaves', 'pioneers', 'starwars', 'tolkien', 'turma', 'egyptian']
152
+
153
+ // preset retorna a Config de identidade para o nome de preset temático dado.
154
+ // Se name não for um preset conhecido, lança erro listando os nomes válidos.
155
+ //
156
+ // A Config retornada é sempre uma cópia: mutá-la não afeta a tabela de
157
+ // presets no nível do módulo nem qualquer outra Config retornada por uma
158
+ // chamada anterior a preset().
159
+ function preset(name) {
160
+ const agents = PRESETS[name]
161
+ if (!agents) {
162
+ throw new Error(`identity: preset desconhecido "${name}" (validos: ${presetNames().join(', ')})`)
163
+ }
164
+ const copied = {}
165
+ for (const [id, agent] of Object.entries(agents)) {
166
+ copied[id] = { display_name: agent.display_name, slug: agent.slug }
167
+ }
168
+ return { schema_version: 1, agents: copied }
169
+ }
170
+
171
+ // presetNames retorna os nomes de todos os presets conhecidos, na ordem
172
+ // estável: greek, norse, potter, thrones, chaves, pioneers, starwars,
173
+ // tolkien, turma, egyptian.
174
+ function presetNames() {
175
+ return [...PRESET_ORDER]
176
+ }
177
+
178
+ // Verificação em tempo de carga do módulo: presetOrder e PRESETS devem estar
179
+ // em sincronia — guarda defensiva contra drift silencioso entre o mapa e a
180
+ // ordem documentada (espelha o init() de internal/identity/preset.go).
181
+ ;(function assertPresetOrderInSync() {
182
+ if (PRESET_ORDER.length !== Object.keys(PRESETS).length) {
183
+ throw new Error('identity: presetOrder e PRESETS fora de sincronia')
184
+ }
185
+ const seen = new Set()
186
+ for (const name of PRESET_ORDER) {
187
+ if (!PRESETS[name]) {
188
+ throw new Error(`identity: preset "${name}" listado em presetOrder mas ausente de PRESETS`)
189
+ }
190
+ seen.add(name)
191
+ }
192
+ if (seen.size !== PRESET_ORDER.length) {
193
+ throw new Error('identity: presetOrder contem nomes duplicados')
194
+ }
195
+ })()
196
+
197
+ module.exports = { knownAgentIds, preset, presetNames }
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+
3
+ // maxSlugLength é o tamanho máximo aceito para um slug gerado.
4
+ const MAX_SLUG_LENGTH = 40
5
+
6
+ // collapseDashes colapsa sequências de '-' em um único '-'.
7
+ function collapseDashes(value) {
8
+ let out = ''
9
+ let previousDash = false
10
+ for (const char of value) {
11
+ if (char === '-') {
12
+ if (previousDash) continue
13
+ previousDash = true
14
+ } else {
15
+ previousDash = false
16
+ }
17
+ out += char
18
+ }
19
+ return out
20
+ }
21
+
22
+ // slugify converte input em um slug normalizado que casa com
23
+ // ^[a-z0-9]+(-[a-z0-9]+)*$.
24
+ //
25
+ // Passos, na ordem exata (espelham internal/identity/slug.go):
26
+ // 1. Normalização Unicode NFD + remoção de marcas diacríticas (\p{Mn}).
27
+ // 2. Lowercase.
28
+ // 3. Espaços (incluindo sequências) e underscores viram '-'.
29
+ // 4. Qualquer caractere fora de [a-z0-9-] é descartado.
30
+ // 5. Sequências de '-' colapsam em um único '-'.
31
+ // 6. '-' nas bordas (início/fim) são removidos.
32
+ // 7. Resultado vazio é erro.
33
+ // 8. Resultado com mais de 40 caracteres é erro.
34
+ //
35
+ // slugify nunca "corrige" silenciosamente uma entrada degenerada — sempre
36
+ // lança erro em vez de adivinhar.
37
+ function slugify(input) {
38
+ const folded = String(input).normalize('NFD').replace(/\p{Mn}/gu, '')
39
+ const lowered = folded.toLowerCase()
40
+ const replaced = lowered.replace(/[ _]/g, '-')
41
+
42
+ let filtered = ''
43
+ for (const char of replaced) {
44
+ if ((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char === '-') {
45
+ filtered += char
46
+ }
47
+ }
48
+
49
+ const collapsed = collapseDashes(filtered)
50
+ const trimmed = collapsed.replace(/^-+/, '').replace(/-+$/, '')
51
+
52
+ if (trimmed === '') {
53
+ throw new Error(`identity: slug vazio para "${input}"`)
54
+ }
55
+ if (trimmed.length > MAX_SLUG_LENGTH) {
56
+ throw new Error(`identity: slug "${trimmed}" excede o tamanho maximo de ${MAX_SLUG_LENGTH} caracteres (tem ${trimmed.length})`)
57
+ }
58
+
59
+ return trimmed
60
+ }
61
+
62
+ module.exports = { slugify, MAX_SLUG_LENGTH }
@@ -1,9 +1,12 @@
1
1
  'use strict'
2
2
 
3
+ const os = require('node:os')
4
+
3
5
  const { catalog, items, target, surfaceFor, readAsset } = require('./catalog')
4
6
  const { render } = require('./render')
5
7
  const { IntegrationManager } = require('./manager')
6
8
  const { legacyHashes } = require('./legacy')
9
+ const identityStore = require('../identity')
7
10
 
8
11
  function parseSurfaces(values = []) {
9
12
  const result = {}
@@ -40,6 +43,7 @@ function selections(kind, options = {}) {
40
43
 
41
44
  function buildPlans(kind, options = {}) {
42
45
  const selected = selections(kind, options)
46
+ const identityConfig = options.identity || { agents: {} }
43
47
  const plans = []
44
48
  for (const { target: targetEntry, surface } of selected.targets) {
45
49
  const capability = surface.capabilities[kind]
@@ -50,7 +54,7 @@ function buildPlans(kind, options = {}) {
50
54
  for (const item of selected.itemEntries) {
51
55
  for (const installPath of paths) {
52
56
  const destination = installPath.path.replace('{{id}}', item.id)
53
- const content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination })
57
+ const content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination, identity: identityConfig })
54
58
  const claim = { target: targetEntry.id, surface: surface.id, scope, kind, item: item.id }
55
59
  plans.push({
56
60
  claim,
@@ -89,7 +93,13 @@ function result(kind, plans, statuses) {
89
93
  }
90
94
 
91
95
  function execute(kind, operation, options = {}, roots = {}) {
92
- const plans = buildPlans(kind, options)
96
+ // A identidade é resolvida do disco antes de buildPlans — pular esta etapa
97
+ // reverteria silenciosamente os nomes customizados dos agentes para os
98
+ // defaults neutros (espelha internal/commands/integrations_flags.go:
99
+ // executeIntegrationMutation/executeIntegrationList).
100
+ const homeRoot = roots.homeRoot || os.homedir()
101
+ const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
102
+ const plans = buildPlans(kind, { ...options, identity: identityConfig })
93
103
  const manager = new IntegrationManager(roots)
94
104
  let statuses
95
105
  if (operation === 'list') statuses = manager.inspect(plans)
@@ -5,6 +5,8 @@ const fs = require('node:fs')
5
5
  const os = require('node:os')
6
6
  const path = require('node:path')
7
7
 
8
+ const { frontmatterName } = require('./render')
9
+
8
10
  const SCHEMA_VERSION = 1
9
11
  const sha256 = content => crypto.createHash('sha256').update(content).digest('hex')
10
12
  const claimKey = claim => [claim.target, claim.surface, claim.scope, claim.kind, claim.item].join('\u0000')
@@ -145,6 +147,7 @@ class IntegrationManager {
145
147
  }
146
148
 
147
149
  preflight(operation, { plan, file }, manifest, force) {
150
+ if (operation !== 'uninstall') this.detectNameCollision(plan, file, force)
148
151
  const status = this.inspectResolved(plan, file, manifest)
149
152
  const record = manifest.artifacts[file]
150
153
  const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === claimKey(plan.claim)))
@@ -159,6 +162,53 @@ class IntegrationManager {
159
162
  }
160
163
  }
161
164
 
165
+ // detectNameCollision protege contra dois artefatos de agente gerenciados
166
+ // distintos declarando o mesmo "name" de frontmatter dentro do mesmo
167
+ // diretório de destino (ADR ADR-2026-07-25-identidade-personalizavel-de-
168
+ // agentes, seção D4). Isso importa porque, com identidades customizáveis,
169
+ // dois item.id diferentes poderiam resolver para o mesmo nome renderizado
170
+ // (ex.: dois agentes ambos apontados para o slug "zeus"), e algumas
171
+ // surfaces usam esse campo name para descoberta de agentes.
172
+ //
173
+ // Limitação: a varredura só inspeciona irmãos ".md", porque é o único
174
+ // formato onde temos uma forma barata e sem dependências (frontmatterName)
175
+ // de ler de volta o name declarado a partir de bytes já renderizados.
176
+ // Artefatos JSON (cli-agent-json/agent-json) e TOML (custom-agent-toml)
177
+ // não são varridos para colisões. Espelha
178
+ // internal/integrations/manager.go:detectNameCollision.
179
+ detectNameCollision(plan, file, force) {
180
+ if (plan.claim.kind !== 'agents') return
181
+ if (path.extname(file) !== '.md') return
182
+ const desiredName = frontmatterName(plan.content)
183
+ if (!desiredName) return
184
+ const directory = path.dirname(file)
185
+ let entries
186
+ try {
187
+ entries = fs.readdirSync(directory, { withFileTypes: true })
188
+ } catch (err) {
189
+ if (err.code === 'ENOENT') return
190
+ throw new Error(`scan ${directory} for name collisions: ${err.message}`)
191
+ }
192
+ for (const entry of entries) {
193
+ if (entry.isDirectory() || path.extname(entry.name) !== '.md') continue
194
+ const candidate = path.join(directory, entry.name)
195
+ if (candidate === file) continue
196
+ let data
197
+ try {
198
+ data = fs.readFileSync(candidate)
199
+ } catch {
200
+ continue
201
+ }
202
+ const candidateName = frontmatterName(data)
203
+ if (!candidateName || candidateName !== desiredName) continue
204
+ if (force) {
205
+ process.stderr.write(`aviso: ${candidate} declara o mesmo name ${desiredName} que ${file}; prosseguindo por --force\n`)
206
+ continue
207
+ }
208
+ throw new Error(`artifact ${file} declares name ${desiredName} which collides with existing file ${candidate}`)
209
+ }
210
+ }
211
+
162
212
  inspectResolved(plan, file, manifest) {
163
213
  const record = manifest.artifacts[file]
164
214
  const managed = Boolean(record && record.claims.some(claim => claimKey(claim) === claimKey(plan.claim)))
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { lookup: lookupIdentity, agentName } = require('../identity')
4
+
3
5
  // Mapa de model: nomes canônicos do catálogo → tier aceito pelo Antigravity CLI (agy)
4
6
  const MODEL_MAP = { opus: 'pro', sonnet: 'flash' }
5
7
  // Valores que já são tiers válidos do agy e devem passar sem transformação
@@ -65,38 +67,171 @@ function resolveModel(model) {
65
67
  return MODEL_MAP[model] || ''
66
68
  }
67
69
 
68
- // toolsFor retorna SET_ARCH para agentes cujo nome termina em "architect", SET_IMPL para os demais.
69
- // IDs proibidos (edit_file, read_file, find, view_code_item, view_file_outline, call_mcp_tool)
70
- // nunca fazem parte de nenhum dos conjuntos.
71
- function toolsFor(name) {
72
- return name.endsWith('architect') ? SET_ARCH : SET_IMPL
70
+ // toolsFor retorna SET_ARCH para o agente canônico "architect" (item.id do
71
+ // catálogo, não o nome renderizado — que pode ser customizado pela
72
+ // identidade), SET_IMPL para os demais. IDs proibidos (edit_file, read_file,
73
+ // find, view_code_item, view_file_outline, call_mcp_tool) nunca fazem parte
74
+ // de nenhum dos conjuntos.
75
+ function toolsFor(itemId) {
76
+ return itemId === 'architect' ? SET_ARCH : SET_IMPL
77
+ }
78
+
79
+ // greetingLine monta a primeira linha injetada no corpo do agente quando há
80
+ // identidade configurada. Sem apelido configurado, apenas o display name do
81
+ // agente é mencionado. Espelha internal/integrations/render.go:greetingLine.
82
+ function greetingLine(displayName, nickname) {
83
+ if (!nickname) return `Você é ${displayName}.`
84
+ return `Você é ${displayName}. Trate o usuário como ${nickname}.`
85
+ }
86
+
87
+ // insertBodyPrefix insere prefix como a nova primeira linha da seção de
88
+ // corpo de um markdown cru (frontmatter + corpo), seguida de linha em
89
+ // branco. Se source não tiver frontmatter reconhecível, prefix é inserido no
90
+ // topo. Reusa a mesma detecção de fronteira do frontmatter usada por
91
+ // markdownParts, para que Rota A e Rota B concordem sobre onde o corpo
92
+ // começa. Espelha internal/integrations/render.go:insertBodyPrefix.
93
+ function insertBodyPrefix(source, prefix) {
94
+ const trimmed = String(source).trim()
95
+ if (!prefix) return trimmed
96
+ if (!trimmed.startsWith('---\n')) return `${prefix}\n\n${trimmed}`
97
+ const end = trimmed.indexOf('\n---', 4)
98
+ if (end < 0) return `${prefix}\n\n${trimmed}`
99
+ const insertAt = end + 4
100
+ const head = trimmed.slice(0, insertAt)
101
+ const rest = trimmed.slice(insertAt).replace(/^\n+/, '')
102
+ if (rest === '') return `${head}\n\n${prefix}`
103
+ return `${head}\n\n${prefix}\n\n${rest}`
104
+ }
105
+
106
+ // rewriteFrontmatterFields substitui as linhas "name:" e "description:" do
107
+ // frontmatter de um markdown cru por name e description, preservando toda
108
+ // outra linha do frontmatter byte a byte (ordem, espaçamento, estilo de
109
+ // aspas) e deixando o corpo intocado. Usado pela Rota B (o branch default de
110
+ // render) para que representações que consomem o frontmatter cru —
111
+ // principalmente "subagent" (claude, gemini, cursor, copilot, kiro-ide,
112
+ // windsurf) — recebam a identidade customizada. Se o frontmatter não tiver
113
+ // "name:" ou "description:", a chave é simplesmente deixada ausente — esta
114
+ // função nunca inventa uma chave que não existia. Se source não tiver
115
+ // frontmatter reconhecível, source é retornado sem alteração (trimado).
116
+ // Espelha internal/integrations/render.go:rewriteFrontmatterFields.
117
+ function rewriteFrontmatterFields(source, name, description) {
118
+ const trimmed = String(source).trim()
119
+ if (!trimmed.startsWith('---\n')) return trimmed
120
+ const end = trimmed.indexOf('\n---', 4)
121
+ if (end < 0) return trimmed
122
+ const frontmatter = trimmed.slice(4, end)
123
+ const rest = trimmed.slice(end) // começa com "\n---", seguido do corpo
124
+
125
+ const lines = frontmatter.split('\n').map(line => {
126
+ const separator = line.indexOf(':')
127
+ if (separator < 0) return line
128
+ const key = line.slice(0, separator).trim()
129
+ let replacement
130
+ if (key === 'name') replacement = name
131
+ else if (key === 'description') replacement = description
132
+ else return line
133
+ const value = line.slice(separator + 1).trim()
134
+ const quoted = value.length >= 2 && value.startsWith('"') && value.endsWith('"')
135
+ return quoted ? `${key}: "${replacement}"` : `${key}: ${replacement}`
136
+ })
137
+
138
+ return `---\n${lines.join('\n')}${rest}`
139
+ }
140
+
141
+ // frontmatterName extrai apenas o campo "name" de um frontmatter YAML
142
+ // delimitado por ---, sem os valores default aplicados por markdownParts.
143
+ // Retorna undefined quando o arquivo não tem frontmatter reconhecível ou não
144
+ // declara "name". Usado pela detecção de colisão em manager.js. Espelha
145
+ // internal/integrations/render.go:frontmatterName.
146
+ function frontmatterName(content) {
147
+ const text = String(content).trim()
148
+ if (!text.startsWith('---\n')) return undefined
149
+ const end = text.indexOf('\n---', 4)
150
+ if (end < 0) return undefined
151
+ const frontmatter = text.slice(4, end)
152
+ for (const line of frontmatter.split('\n')) {
153
+ const separator = line.indexOf(':')
154
+ if (separator < 0) continue
155
+ const key = line.slice(0, separator).trim()
156
+ if (key !== 'name') continue
157
+ let value = line.slice(separator + 1).trim()
158
+ value = value.replace(/^"+/, '').replace(/"+$/, '')
159
+ if (value === '') return undefined
160
+ return value
161
+ }
162
+ return undefined
73
163
  }
74
164
 
75
- function render({ kind, content, capability }) {
165
+ // render converte um item canônico do catálogo para a representação nativa
166
+ // declarada por uma surface alvo. Quando identity carrega uma identidade
167
+ // customizada para item.id, o name/description/body renderizados são
168
+ // personalizados (ADR ADR-2026-07-25-identidade-personalizavel-de-agentes,
169
+ // seções D1/D2/D6).
170
+ //
171
+ // render tem duas rotas de saída:
172
+ // - Rota A: "custom-agent-toml", "cli-agent-json", "agent-json" e
173
+ // "agent-directory" trabalham a partir de name/description/body já
174
+ // separados do frontmatter por markdownParts.
175
+ // - Rota B: o branch default, usado pela representação "subagent" (claude,
176
+ // gemini, cursor, copilot, kiro-ide, windsurf), retorna o source cru
177
+ // normalizado com o frontmatter ainda anexado. Quando há identidade
178
+ // configurada, suas linhas "name:"/"description:" são reescritas no
179
+ // lugar (ver rewriteFrontmatterFields) — necessário porque a seleção de
180
+ // subagent do Claude Code lê apenas o frontmatter, nunca o corpo.
181
+ //
182
+ // Ambas as rotas devem receber a injeção de identidade. Quando não há
183
+ // identidade configurada para item.id, name/description/body ficam
184
+ // exatamente como markdownParts produziu e o branch default retorna
185
+ // normalize(content) — a mesma expressão usada antes de existir suporte a
186
+ // identidade — então a saída sem identidade é garantida byte a byte
187
+ // inalterada por construção, não por coincidência.
188
+ function render({ kind, content, capability, item, identity: cfg }) {
76
189
  if (kind === 'skills') return normalize(content)
190
+
191
+ const id = item && cfg ? lookupIdentity(cfg, item.id) : undefined
77
192
  const parts = markdownParts(content)
193
+ let { name, description, body } = parts
194
+ let greeting = ''
195
+ if (id) {
196
+ greeting = greetingLine(id.display_name, (cfg && cfg.user_nickname) || '')
197
+ name = agentName(id.slug)
198
+ description = `${id.display_name} — ${description}`
199
+ body = `${greeting}\n\n${body}`
200
+ }
201
+
78
202
  if (capability.representation === 'custom-agent-toml') {
79
- return `name = ${JSON.stringify(parts.name.replaceAll('-', '_'))}\ndescription = ${JSON.stringify(parts.description)}\ndeveloper_instructions = ${JSON.stringify(parts.body)}\n`
203
+ return `name = ${JSON.stringify(name.replaceAll('-', '_'))}\ndescription = ${JSON.stringify(description)}\ndeveloper_instructions = ${JSON.stringify(body)}\n`
80
204
  }
81
205
  if (capability.representation === 'cli-agent-json' || capability.representation === 'agent-json') {
82
- return `${JSON.stringify({ name: parts.name, description: parts.description, prompt: parts.body }, null, 2)}\n`
206
+ // Ordem alfabética das chaves (description, name, prompt) é obrigatória:
207
+ // Go serializa via json.MarshalIndent(map[string]string{...}), cujo encoder
208
+ // ordena as chaves alfabeticamente, e Python usa sort_keys. JSON.stringify
209
+ // preserva a ordem de inserção, então a ordem é fixada aqui à mão. Sem
210
+ // isso, instalar por um CLI e listar por outro reporta falso `modified`
211
+ // (o manifest indexa artefatos por sha256 do conteúdo).
212
+ return `${JSON.stringify({ description, name, prompt: body }, null, 2)}\n`
83
213
  }
84
214
  if (capability.representation === 'agent-directory') {
85
215
  // Reconstrói o frontmatter para o Antigravity CLI (agy):
86
216
  // - mapeia model canônico para o tier aceito (opus→pro, sonnet→flash)
87
- // - injeta tools: SET_IMPL ou SET_ARCH dependendo do nome do agente
217
+ // - injeta tools: SET_IMPL ou SET_ARCH dependendo do item.id (não do
218
+ // nome renderizado, que pode ser customizado pela identidade)
88
219
  // - omite campos não suportados pelo agy
89
220
  const mappedModel = resolveModel(parts.model)
90
- const tools = toolsFor(parts.name)
91
- let out = `---\nname: ${parts.name}\ndescription: ${parts.description}\n`
221
+ const tools = toolsFor(item && item.id)
222
+ let out = `---\nname: ${name}\ndescription: ${description}\n`
92
223
  if (mappedModel) out += `model: ${mappedModel}\n`
93
224
  out += 'tools:\n'
94
225
  for (const tool of tools) out += ` - ${tool}\n`
95
226
  out += '---\n'
96
- if (parts.body) out += `${parts.body}\n`
227
+ if (body) out += `${body}\n`
97
228
  return out
98
229
  }
99
- return normalize(content)
230
+
231
+ if (!id) return normalize(content)
232
+ const withBody = insertBodyPrefix(content, greeting)
233
+ const withFrontmatter = rewriteFrontmatterFields(withBody, name, description)
234
+ return normalize(withFrontmatter)
100
235
  }
101
236
 
102
- module.exports = { render, markdownParts }
237
+ module.exports = { render, markdownParts, frontmatterName, greetingLine, insertBodyPrefix, rewriteFrontmatterFields }