trackfw 1.1.0 → 2.1.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.
@@ -1,40 +1,80 @@
1
1
  'use strict'
2
2
  const fs = require('fs')
3
3
  const path = require('path')
4
+ const config = require('../config')
4
5
 
5
- const VALID_STATES = {
6
- backlog: 'docs/roadmaps/backlog',
7
- wip: 'docs/roadmaps/wip',
8
- blocked: 'docs/roadmaps/blocked',
9
- done: 'docs/roadmaps/done',
10
- abandoned: 'docs/roadmaps/abandoned',
6
+ const STATE_ORDER = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
7
+
8
+ // stateDir retorna o caminho do diretório para um estado válido no modo flat, ou null se inválido.
9
+ function stateDir(state) {
10
+ const cfg = config.load()
11
+ const valid = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
12
+ if (!valid.includes(state)) return null
13
+ return cfg.roadmapDir + '/' + state
11
14
  }
12
15
 
13
- const STATE_ORDER = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
16
+ // agentStateDir retorna o diretório para um agente+estado em modo by_agent.
17
+ // agent=null usa o primeiro agente configurado (ou "default" se lista vazia).
18
+ function agentStateDir(agent, state) {
19
+ const cfg = config.load()
20
+ const valid = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
21
+ if (!valid.includes(state)) return null
22
+ if (!agent) {
23
+ agent = cfg.agents && cfg.agents.length > 0 ? cfg.agents[0] : 'default'
24
+ }
25
+ return cfg.roadmapDir + '/' + agent + '/' + state
26
+ }
14
27
 
15
- const TRANSITION_LOG_PATH = 'docs/roadmaps/.trackfw-log'
28
+ // logPath retorna o caminho do arquivo de log de transições.
29
+ function logPath() {
30
+ return config.load().roadmapDir + '/.trackfw-log'
31
+ }
16
32
 
17
33
  /**
18
- * listRoadmaps — lista roadmaps agrupados por estado (wip, backlog, blocked, done, abandoned).
34
+ * listRoadmaps — lista roadmaps agrupados por estado (e por agente em modo by_agent).
19
35
  * Se nenhum encontrado imprime mensagem orientando o usuário.
20
36
  */
21
37
  function listRoadmaps() {
38
+ const cfg = config.load()
22
39
  let found = false
23
40
 
24
- for (const state of STATE_ORDER) {
25
- const dir = VALID_STATES[state]
26
- let files = []
27
- try {
28
- files = fs.readdirSync(dir).filter(f => !fs.statSync(path.join(dir, f)).isDirectory() && f.endsWith('.md'))
29
- } catch (_) {
30
- continue
41
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
42
+ let agents = cfg.agents || []
43
+ if (agents.length === 0) {
44
+ try {
45
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
46
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
47
+ })
48
+ } catch (_) { agents = [] }
31
49
  }
32
- if (files.length === 0) continue
33
-
34
- found = true
35
- console.log(`[${state}]`)
36
- for (const f of files) {
37
- console.log(` ${f}`)
50
+ for (const agent of agents) {
51
+ for (const state of STATE_ORDER) {
52
+ const dir = cfg.roadmapDir + '/' + agent + '/' + state
53
+ let files = []
54
+ try {
55
+ files = fs.readdirSync(dir).filter(f => {
56
+ try { return !fs.statSync(path.join(dir, f)).isDirectory() && f.endsWith('.md') } catch (_) { return false }
57
+ })
58
+ } catch (_) { continue }
59
+ if (files.length === 0) continue
60
+ found = true
61
+ console.log(`[${agent}/${state}]`)
62
+ for (const f of files) console.log(` ${f}`)
63
+ }
64
+ }
65
+ } else {
66
+ for (const state of STATE_ORDER) {
67
+ const dir = cfg.roadmapDir + '/' + state
68
+ let files = []
69
+ try {
70
+ files = fs.readdirSync(dir).filter(f => {
71
+ try { return !fs.statSync(path.join(dir, f)).isDirectory() && f.endsWith('.md') } catch (_) { return false }
72
+ })
73
+ } catch (_) { continue }
74
+ if (files.length === 0) continue
75
+ found = true
76
+ console.log(`[${state}]`)
77
+ for (const f of files) console.log(` ${f}`)
38
78
  }
39
79
  }
40
80
 
@@ -44,8 +84,8 @@ function listRoadmaps() {
44
84
  }
45
85
 
46
86
  /**
47
- * showRoadmap — busca docs/roadmaps/ESTADO/NOME*.md (partial match), imprime cabeçalho + conteúdo.
48
- * 0 matches: erro. múltiplos: lista + erro. 1 match: imprime cabeçalho e conteúdo.
87
+ * showRoadmap — busca <roadmapDir>/ESTADO/NOME*.md (partial match, flat) ou
88
+ * <roadmapDir>/AGENTE/ESTADO/NOME*.md (by_agent), imprime cabeçalho + conteúdo.
49
89
  */
50
90
  function showRoadmap(name) {
51
91
  const matches = findRoadmapMatches(name)
@@ -58,9 +98,7 @@ function showRoadmap(name) {
58
98
 
59
99
  if (matches.length > 1) {
60
100
  console.log('Multiple roadmaps found — be more specific:')
61
- for (const m of matches) {
62
- console.log(` ${m}`)
63
- }
101
+ for (const m of matches) console.log(` ${m}`)
64
102
  console.error(`ambiguous match for "${name}"`)
65
103
  process.exitCode = 1
66
104
  return
@@ -78,12 +116,12 @@ function showRoadmap(name) {
78
116
 
79
117
  /**
80
118
  * moveRoadmap — move arquivo para diretório do estado alvo.
81
- * Valida estado, procura arquivo em qualquer estado (case-insensitive partial match),
82
- * move com fs.renameSync, chama appendTransitionLog, imprime confirmação.
119
+ * Em modo by_agent, mantém o agente na hierarquia.
83
120
  */
84
121
  function moveRoadmap(name, state) {
85
- const targetDir = VALID_STATES[state]
86
- if (!targetDir) {
122
+ const cfg = config.load()
123
+ const valid = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
124
+ if (!valid.includes(state)) {
87
125
  console.error(`invalid state "${state}" — valid states: backlog, wip, blocked, done, abandoned`)
88
126
  process.exitCode = 1
89
127
  return
@@ -97,9 +135,7 @@ function moveRoadmap(name, state) {
97
135
  }
98
136
  if (matches.length > 1) {
99
137
  console.log('Multiple roadmaps found — be more specific:')
100
- for (const m of matches) {
101
- console.log(` ${m}`)
102
- }
138
+ for (const m of matches) console.log(` ${m}`)
103
139
  console.error(`ambiguous match for "${name}"`)
104
140
  process.exitCode = 1
105
141
  return
@@ -107,22 +143,41 @@ function moveRoadmap(name, state) {
107
143
 
108
144
  const src = matches[0]
109
145
  const basename = path.basename(src)
110
- const fromState = path.basename(path.dirname(src))
146
+ let targetDir, fromState, logBasename
147
+
148
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
149
+ const agentDir = path.dirname(path.dirname(src))
150
+ const agent = path.basename(agentDir)
151
+ fromState = path.basename(path.dirname(src))
152
+ targetDir = agentStateDir(agent, state)
153
+ if (!targetDir) {
154
+ console.error(`invalid state "${state}"`)
155
+ process.exitCode = 1
156
+ return
157
+ }
158
+ logBasename = agent + '/' + basename
159
+ } else {
160
+ fromState = path.basename(path.dirname(src))
161
+ targetDir = stateDir(state)
162
+ if (!targetDir) {
163
+ console.error(`invalid state "${state}"`)
164
+ process.exitCode = 1
165
+ return
166
+ }
167
+ logBasename = basename
168
+ }
111
169
 
112
- try {
113
- fs.mkdirSync(targetDir, { recursive: true })
114
- } catch (_) {}
170
+ try { fs.mkdirSync(targetDir, { recursive: true }) } catch (_) {}
115
171
 
116
172
  const dst = path.join(targetDir, basename)
117
173
  fs.renameSync(src, dst)
118
174
 
119
- appendTransitionLog(basename, fromState, state)
175
+ appendTransitionLog(logBasename, fromState, state)
120
176
  console.log(`✓ moved ${basename} → ${targetDir}`)
121
177
  }
122
178
 
123
179
  /**
124
- * appendTransitionLog — append em docs/roadmaps/.trackfw-log.
125
- * Formato: `YYYY-MM-DD HH:mm <basename padded to 50> <fromState> → <toState>\n`
180
+ * appendTransitionLog — append em <roadmapDir>/.trackfw-log.
126
181
  */
127
182
  function appendTransitionLog(basename, fromState, toState) {
128
183
  const now = new Date()
@@ -135,26 +190,48 @@ function appendTransitionLog(basename, fromState, toState) {
135
190
  const line = `${timestamp} ${basename.padEnd(50)} ${fromState} → ${toState}\n`
136
191
 
137
192
  try {
138
- fs.mkdirSync(path.dirname(TRANSITION_LOG_PATH), { recursive: true })
139
- fs.appendFileSync(TRANSITION_LOG_PATH, line, 'utf8')
193
+ const lp = logPath()
194
+ fs.mkdirSync(path.dirname(lp), { recursive: true })
195
+ fs.appendFileSync(lp, line, 'utf8')
140
196
  } catch (_) {}
141
197
  }
142
198
 
143
199
  /**
144
- * newRoadmap — cria roadmap em docs/roadmaps/backlog/ROADMAP-YYYY-MM-DD-<slug>.md.
200
+ * newRoadmap — cria roadmap em <roadmapDir>/backlog/ROADMAP-YYYY-MM-DD-<slug>.md.
201
+ * Em modo by_agent, usa o primeiro agente configurado.
145
202
  */
146
203
  function newRoadmap(title, reqPath) {
204
+ const cfg = config.load()
147
205
  const now = new Date()
148
206
  const yyyy = now.getFullYear()
149
207
  const mm = String(now.getMonth() + 1).padStart(2, '0')
150
208
  const dd = String(now.getDate()).padStart(2, '0')
151
209
  const date = `${yyyy}-${mm}-${dd}`
152
210
  const slug = toSlug(title)
153
- const filename = `docs/roadmaps/backlog/ROADMAP-${date}-${slug}.md`
154
211
 
155
- fs.mkdirSync('docs/roadmaps/backlog', { recursive: true })
212
+ let backlogDir
213
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
214
+ backlogDir = agentStateDir(null, 'backlog')
215
+ if (!backlogDir) {
216
+ console.error('cannot resolve backlog dir in by_agent mode')
217
+ process.exitCode = 1
218
+ return
219
+ }
220
+ } else {
221
+ backlogDir = cfg.roadmapDir + '/backlog'
222
+ }
223
+
224
+ const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
225
+ fs.mkdirSync(backlogDir, { recursive: true })
156
226
 
157
- const body = `# Roadmap: ${title}
227
+ const body = `---
228
+ status: backlog
229
+ date: ${date}
230
+ req: ""
231
+ squad: ""
232
+ ---
233
+
234
+ # Roadmap: ${title}
158
235
 
159
236
  > Created: ${date} | Status: backlog
160
237
 
@@ -179,25 +256,180 @@ REQ: ${reqPath || ''}
179
256
  console.log(`✓ created ${filename}`)
180
257
  }
181
258
 
259
+ /**
260
+ * newRoadmapFromReq — lê uma REQ e gera roadmap pré-preenchido com MLs extraídos
261
+ * dos critérios de aceite.
262
+ */
263
+ function newRoadmapFromReq(reqPath) {
264
+ let data
265
+ try {
266
+ data = fs.readFileSync(reqPath, 'utf8')
267
+ } catch (err) {
268
+ console.error(`reading REQ: ${err.message}`)
269
+ process.exitCode = 1
270
+ return
271
+ }
272
+
273
+ const { title: parsedTitle, criteria, linkedADR } = parseReqForRoadmap(data)
274
+ const basename = path.basename(reqPath)
275
+ const title = parsedTitle || basename.replace(/\.md$/, '').replace(/^REQ-/, '')
276
+
277
+ const cfg = config.load()
278
+ const now = new Date()
279
+ const yyyy = now.getFullYear()
280
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
281
+ const dd = String(now.getDate()).padStart(2, '0')
282
+ const date = `${yyyy}-${mm}-${dd}`
283
+ const slug = toSlug(title)
284
+
285
+ let backlogDir
286
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
287
+ backlogDir = agentStateDir(null, 'backlog')
288
+ if (!backlogDir) {
289
+ console.error('cannot resolve backlog dir in by_agent mode')
290
+ process.exitCode = 1
291
+ return
292
+ }
293
+ } else {
294
+ backlogDir = cfg.roadmapDir + '/backlog'
295
+ }
296
+
297
+ const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
298
+ try { fs.mkdirSync(backlogDir, { recursive: true }) } catch (_) {}
299
+
300
+ // Gerar seção de MLs a partir dos critérios de aceite
301
+ const mlLines = ['## Wave 1 — Implementation (derived from REQ criteria)', '> Dependencies: none']
302
+ for (let i = 0; i < criteria.length; i++) {
303
+ const mlLabel = `ML-1${String.fromCharCode(65 + i)}`
304
+ const crit = criteria[i]
305
+ mlLines.push(`\n### ${mlLabel} — ${crit}`)
306
+ mlLines.push('**Status:** pending')
307
+ mlLines.push('**Files affected:**')
308
+ mlLines.push('**Actions:**')
309
+ mlLines.push('**Acceptance criteria:**')
310
+ mlLines.push(`- [ ] ${crit}`)
311
+ mlLines.push('- [ ] build passes')
312
+ mlLines.push('- [ ] tests green')
313
+ }
314
+ const mlSection = mlLines.join('\n')
315
+
316
+ const adrRef = linkedADR ? `\nADR: ${linkedADR}` : ''
317
+
318
+ const body = `---
319
+ status: backlog
320
+ date: ${date}
321
+ req: "${basename}"
322
+ squad: ""
323
+ ---
324
+
325
+ # Roadmap: ${title}
326
+
327
+ > Created: ${date} | Status: backlog
328
+
329
+ ## Context
330
+ <!-- Derived from REQ: ${basename} -->
331
+ REQ: ${reqPath}${adrRef}
332
+
333
+ ${mlSection}
334
+ `
335
+
336
+ fs.writeFileSync(filename, body, 'utf8')
337
+ console.log(`✓ created ${filename}`)
338
+ }
339
+
340
+ /**
341
+ * parseReqForRoadmap — extrai título, critérios de aceite e ADR linkada de conteúdo REQ.
342
+ */
343
+ function parseReqForRoadmap(content) {
344
+ const lines = content.split('\n')
345
+ let title = ''
346
+ let linkedADR = ''
347
+ const criteria = []
348
+ let inCriteria = false
349
+
350
+ for (const line of lines) {
351
+ if (line.startsWith('# REQ: ')) {
352
+ title = line.replace('# REQ: ', '').trim()
353
+ continue
354
+ }
355
+ if (line.startsWith('# REQ — ')) {
356
+ title = line.replace('# REQ — ', '').trim()
357
+ continue
358
+ }
359
+ if (line.startsWith('# REQ - ')) {
360
+ title = line.replace('# REQ - ', '').trim()
361
+ continue
362
+ }
363
+ if (line.startsWith('**ADR:**')) {
364
+ linkedADR = line.replace('**ADR:**', '').trim()
365
+ continue
366
+ }
367
+
368
+ const lower = line.trim().toLowerCase()
369
+ if (lower === '## critérios de aceite' || lower === '## acceptance criteria') {
370
+ inCriteria = true
371
+ continue
372
+ }
373
+ if (inCriteria && line.startsWith('## ')) {
374
+ inCriteria = false
375
+ continue
376
+ }
377
+ if (inCriteria) {
378
+ const trimmed = line.trim()
379
+ const checkboxPrefixes = ['- [ ]', '- [x]', '- [X]']
380
+ for (const prefix of checkboxPrefixes) {
381
+ if (trimmed.startsWith(prefix)) {
382
+ const item = trimmed.slice(prefix.length).trim().replace(/`/g, '')
383
+ if (item) criteria.push(item)
384
+ break
385
+ }
386
+ }
387
+ }
388
+ }
389
+ return { title, criteria, linkedADR }
390
+ }
391
+
182
392
  // --- helpers ---
183
393
 
184
394
  /**
185
395
  * findRoadmapMatches — retorna array de paths que contêm `name` (case-insensitive) em qualquer estado.
396
+ * Suporta modo flat (1 nível) e by_agent (2 níveis).
186
397
  */
187
398
  function findRoadmapMatches(name) {
399
+ const cfg = config.load()
188
400
  const matches = []
189
401
  const nameLower = name.toLowerCase()
190
- for (const state of STATE_ORDER) {
191
- const dir = VALID_STATES[state]
192
- let files = []
193
- try {
194
- files = fs.readdirSync(dir)
195
- } catch (_) {
196
- continue
402
+
403
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
404
+ let agents = cfg.agents || []
405
+ if (agents.length === 0) {
406
+ try {
407
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
408
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
409
+ })
410
+ } catch (_) { agents = ['default'] }
411
+ }
412
+ for (const agent of agents) {
413
+ for (const state of STATE_ORDER) {
414
+ const dir = cfg.roadmapDir + '/' + agent + '/' + state
415
+ let files = []
416
+ try { files = fs.readdirSync(dir) } catch (_) { continue }
417
+ for (const f of files) {
418
+ if (f.toLowerCase().includes(nameLower) && f.endsWith('.md')) {
419
+ matches.push(path.join(dir, f))
420
+ }
421
+ }
422
+ }
197
423
  }
198
- for (const f of files) {
199
- if (f.toLowerCase().includes(nameLower) && f.endsWith('.md')) {
200
- matches.push(path.join(dir, f))
424
+ } else {
425
+ for (const state of STATE_ORDER) {
426
+ const dir = cfg.roadmapDir + '/' + state
427
+ let files = []
428
+ try { files = fs.readdirSync(dir) } catch (_) { continue }
429
+ for (const f of files) {
430
+ if (f.toLowerCase().includes(nameLower) && f.endsWith('.md')) {
431
+ matches.push(path.join(dir, f))
432
+ }
201
433
  }
202
434
  }
203
435
  }
@@ -215,10 +447,12 @@ function toSlug(s) {
215
447
  }
216
448
 
217
449
  module.exports = {
218
- VALID_STATES,
219
450
  listRoadmaps,
220
451
  showRoadmap,
221
452
  moveRoadmap,
222
453
  appendTransitionLog,
223
454
  newRoadmap,
455
+ newRoadmapFromReq,
456
+ stateDir,
457
+ agentStateDir,
224
458
  }
@@ -11,6 +11,7 @@
11
11
  "gitHooks": "Git hooks?",
12
12
  "ci": "CI system?",
13
13
  "aiTools": "Which AI assistants do you use?",
14
+ "require_req_in_commit": "Require REQ reference in commit messages for feat/fix branches?",
14
15
  "projectType_fullstack": "Full-stack (frontend + backend)",
15
16
  "projectType_frontend": "Frontend only",
16
17
  "projectType_backend": "Backend only",
@@ -87,7 +88,8 @@
87
88
  "description": "Validate governance rules (use as CI gate)",
88
89
  "ok": "✓ No violations found.",
89
90
  "violations": "✗ Violations ({{count}}):",
90
- "warnings": "⚠ Warnings ({{count}}):"
91
+ "warnings": "⚠ Warnings ({{count}}):",
92
+ "lenient_mode": "Governance violations treated as warnings until {{date}}"
91
93
  },
92
94
  "status": {
93
95
  "description": "Show project governance status"
@@ -114,6 +116,12 @@
114
116
  "success": "Plugin \"{{name}}\" removed."
115
117
  }
116
118
  },
119
+ "metrics": {
120
+ "description": "Show delivery metrics",
121
+ "no_data": "No transitions recorded yet.",
122
+ "since": "Filter by period (e.g. 7d, 30d, 90d)",
123
+ "export": "Export metrics to CSV file"
124
+ },
117
125
  "errors": {
118
126
  "notFound": "Not found: {{path}}",
119
127
  "downloadFailed": "download failed: HTTP {{status}} for {{url}}",
@@ -11,6 +11,7 @@
11
11
  "gitHooks": "¿Git hooks?",
12
12
  "ci": "¿Sistema de CI?",
13
13
  "aiTools": "¿Qué asistentes de IA usas?",
14
+ "require_req_in_commit": "¿Requerir referencia REQ en mensajes de commit de ramas feat/fix?",
14
15
  "projectType_fullstack": "Full-stack (frontend + backend)",
15
16
  "projectType_frontend": "Solo frontend",
16
17
  "projectType_backend": "Solo backend",
@@ -87,7 +88,8 @@
87
88
  "description": "Validar reglas de gobernanza (úsalo como gate de CI)",
88
89
  "ok": "✓ No se encontraron violaciones.",
89
90
  "violations": "✗ Violaciones ({{count}}):",
90
- "warnings": "⚠ Avisos ({{count}}):"
91
+ "warnings": "⚠ Avisos ({{count}}):",
92
+ "lenient_mode": "Las violaciones de gobernanza se tratan como avisos hasta {{date}}"
91
93
  },
92
94
  "status": {
93
95
  "description": "Mostrar el estado actual de gobernanza del proyecto"
@@ -114,6 +116,12 @@
114
116
  "success": "Plugin \"{{name}}\" eliminado."
115
117
  }
116
118
  },
119
+ "metrics": {
120
+ "description": "Muestra métricas de entrega",
121
+ "no_data": "No hay transiciones registradas aún.",
122
+ "since": "Filtrar por período (ej: 7d, 30d, 90d)",
123
+ "export": "Exportar métricas a archivo CSV"
124
+ },
117
125
  "errors": {
118
126
  "notFound": "No encontrado: {{path}}",
119
127
  "downloadFailed": "fallo en la descarga: HTTP {{status}} para {{url}}",
@@ -11,6 +11,7 @@
11
11
  "gitHooks": "Git hooks?",
12
12
  "ci": "Sistema de CI?",
13
13
  "aiTools": "Quais assistentes de IA você usa?",
14
+ "require_req_in_commit": "Exigir referência REQ nas mensagens de commit de branches feat/fix?",
14
15
  "projectType_fullstack": "Full-stack (frontend + backend)",
15
16
  "projectType_frontend": "Somente frontend",
16
17
  "projectType_backend": "Somente backend",
@@ -87,7 +88,8 @@
87
88
  "description": "Validar regras de governança (use como gate de CI)",
88
89
  "ok": "✓ Nenhuma violação encontrada.",
89
90
  "violations": "✗ Violações ({{count}}):",
90
- "warnings": "⚠ Avisos ({{count}}):"
91
+ "warnings": "⚠ Avisos ({{count}}):",
92
+ "lenient_mode": "Violações de governança tratadas como avisos até {{date}}"
91
93
  },
92
94
  "status": {
93
95
  "description": "Exibir o estado atual de governança do projeto"
@@ -114,6 +116,12 @@
114
116
  "success": "Plugin \"{{name}}\" removido."
115
117
  }
116
118
  },
119
+ "metrics": {
120
+ "description": "Exibe métricas de delivery",
121
+ "no_data": "Nenhuma transição registrada ainda.",
122
+ "since": "Filtrar por período (ex: 7d, 30d, 90d)",
123
+ "export": "Exportar métricas para arquivo CSV"
124
+ },
117
125
  "errors": {
118
126
  "notFound": "Não encontrado: {{path}}",
119
127
  "downloadFailed": "falha no download: HTTP {{status}} para {{url}}",