trackfw 1.0.3 → 1.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.
@@ -0,0 +1,239 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ /**
6
+ * listREQs — lista arquivos .md em dir, imprimindo filename e status (coluna 60 chars).
7
+ * Extrai status da linha `> Date: ... | Status: ...`.
8
+ * Se dir não existe ou vazio: imprime "No REQs found in <dir>".
9
+ */
10
+ function listREQs(dir) {
11
+ let files = []
12
+ try {
13
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.md'))
14
+ } catch (_) {
15
+ // dir não existe
16
+ }
17
+
18
+ if (files.length === 0) {
19
+ console.log(`No REQs found in ${dir}`)
20
+ return
21
+ }
22
+
23
+ for (const filename of files) {
24
+ const filepath = path.join(dir, filename)
25
+ const status = parseREQStatus(filepath)
26
+ console.log(`${filename.padEnd(60)} ${status}`)
27
+ }
28
+ }
29
+
30
+ /**
31
+ * parseREQStatus — extrai o status da linha `> Date: ... | Status: ...` de um arquivo REQ.
32
+ * Status termina no próximo " |" ou fim da linha.
33
+ */
34
+ function parseREQStatus(filepath) {
35
+ let content
36
+ try {
37
+ content = fs.readFileSync(filepath, 'utf8')
38
+ } catch (_) {
39
+ return 'unknown'
40
+ }
41
+
42
+ for (const line of content.split('\n')) {
43
+ const idx = line.indexOf('| Status: ')
44
+ if (idx >= 0) {
45
+ let rest = line.slice(idx + '| Status: '.length)
46
+ const pipeIdx = rest.indexOf(' |')
47
+ if (pipeIdx >= 0) {
48
+ rest = rest.slice(0, pipeIdx)
49
+ }
50
+ rest = rest.replace(/[\s>|]+$/, '')
51
+ return rest.trim() || 'unknown'
52
+ }
53
+ }
54
+ return 'unknown'
55
+ }
56
+
57
+ /**
58
+ * toSlug — converte string em slug kebab-case lowercase.
59
+ * @param {string} s
60
+ * @returns {string}
61
+ */
62
+ function toSlug(s) {
63
+ return s.toLowerCase().replace(/ /g, '-')
64
+ }
65
+
66
+ /**
67
+ * newREQ — cria docs/req/REQ-YYYY-MM-DD-<slug>.md.
68
+ * @param {{ title: string, motivation?: string, criteria?: string, dependsOnADRs?: string[] }} content
69
+ * @returns {Promise<void>}
70
+ */
71
+ async function newREQ(content) {
72
+ fs.mkdirSync('docs/req', { recursive: true })
73
+
74
+ const slug = toSlug(content.title)
75
+ const date = new Date().toISOString().slice(0, 10)
76
+ const filename = `docs/req/REQ-${date}-${slug}.md`
77
+
78
+ const motivationSection = content.motivation || '<!-- Why is this requirement needed? What problem does it solve? -->'
79
+ const criteriaSection = content.criteria || '- [ ]\n- [ ]'
80
+ const linkedADRSection = ''
81
+ const linkedRoadmapSection = ''
82
+
83
+ const dependsOnADRs = content.dependsOnADRs || []
84
+
85
+ // Linha de status — inclui contador de ADRs bloqueantes quando presente
86
+ let statusLine = `> Date: ${date} | Status: Open`
87
+ if (dependsOnADRs.length > 0) {
88
+ statusLine = `> Date: ${date} | Status: Open | Blocked by ADRs: ${dependsOnADRs.length}`
89
+ }
90
+
91
+ // Seção "Blocked by ADRs"
92
+ let blockedSection
93
+ if (dependsOnADRs.length === 0) {
94
+ blockedSection = '<!-- none -->'
95
+ } else {
96
+ const lines = ['<!-- ADRs in Draft status that must be Accepted before a roadmap can be created -->']
97
+ for (const adr of dependsOnADRs) {
98
+ lines.push(`- ${adr} (Draft)`)
99
+ }
100
+ blockedSection = lines.join('\n')
101
+ }
102
+
103
+ const body = `# REQ: ${content.title}
104
+
105
+ ${statusLine}
106
+
107
+ ## Motivation
108
+ ${motivationSection}
109
+
110
+ ## Acceptance Criteria
111
+ ${criteriaSection}
112
+
113
+ ## Linked ADR
114
+ <!-- Reference the ADR that governs this requirement -->
115
+ ADR: ${linkedADRSection}
116
+
117
+ ## Blocked by ADRs
118
+ ${blockedSection}
119
+
120
+ ## Linked Roadmap
121
+ <!-- Reference the roadmap that implements this requirement -->
122
+ Roadmap: ${linkedRoadmapSection}
123
+ `
124
+
125
+ fs.writeFileSync(filename, body, 'utf8')
126
+ console.log(`created ${filename}`)
127
+ }
128
+
129
+ /**
130
+ * PROBES_CATALOG — catálogo de domínios técnicos detectáveis (porte exato do Go).
131
+ */
132
+ const PROBES_CATALOG = [
133
+ {
134
+ domain: 'authentication',
135
+ keywords: ['login', 'auth', 'senha', 'password', 'sso', 'jwt', 'session', 'token', 'autenticação', 'autenticar'],
136
+ questions: [
137
+ {
138
+ text: 'How will users authenticate?',
139
+ options: [
140
+ { label: 'Local login (email + password)', decided: true, adrSlug: '' },
141
+ { label: 'SSO (Google, Azure AD, Okta...)', decided: false, adrSlug: 'sso-provider' },
142
+ { label: 'Both (local + SSO)', decided: false, adrSlug: 'authentication-strategy' },
143
+ { label: 'Not decided yet', decided: false, adrSlug: 'authentication-strategy' },
144
+ ],
145
+ },
146
+ {
147
+ text: 'How will sessions be managed?',
148
+ options: [
149
+ { label: 'JWT (stateless)', decided: true, adrSlug: '' },
150
+ { label: 'Server-side sessions (cookies)', decided: true, adrSlug: '' },
151
+ { label: 'Not decided yet', decided: false, adrSlug: 'session-management' },
152
+ ],
153
+ },
154
+ ],
155
+ },
156
+ {
157
+ domain: 'ui',
158
+ keywords: ['tela', 'screen', 'ui', 'frontend', 'componente', 'component', 'design', 'layout', 'interface'],
159
+ questions: [
160
+ {
161
+ text: 'Is there an existing UI framework or design system?',
162
+ options: [
163
+ { label: 'Yes, already chosen', decided: true, adrSlug: '' },
164
+ { label: 'No, need to choose a UI framework', decided: false, adrSlug: 'ui-framework' },
165
+ { label: 'Not relevant for this REQ', decided: true, adrSlug: '' },
166
+ ],
167
+ },
168
+ ],
169
+ },
170
+ {
171
+ domain: 'persistence',
172
+ keywords: ['banco', 'database', 'db', 'tabela', 'table', 'migração', 'migration', 'modelo', 'model', 'persistência', 'persist'],
173
+ questions: [
174
+ {
175
+ text: 'Which database engine will be used?',
176
+ options: [
177
+ { label: 'Already decided', decided: true, adrSlug: '' },
178
+ { label: 'Not decided yet', decided: false, adrSlug: 'database-engine' },
179
+ ],
180
+ },
181
+ ],
182
+ },
183
+ {
184
+ domain: 'api',
185
+ keywords: ['api', 'endpoint', 'rest', 'grpc', 'graphql', 'rota', 'route', 'http'],
186
+ questions: [
187
+ {
188
+ text: 'Which API protocol will be used?',
189
+ options: [
190
+ { label: 'REST (already decided)', decided: true, adrSlug: '' },
191
+ { label: 'gRPC (already decided)', decided: true, adrSlug: '' },
192
+ { label: 'GraphQL (already decided)', decided: true, adrSlug: '' },
193
+ { label: 'Not decided yet', decided: false, adrSlug: 'api-protocol' },
194
+ ],
195
+ },
196
+ ],
197
+ },
198
+ {
199
+ domain: 'deploy',
200
+ keywords: ['deploy', 'cloud', 'container', 'kubernetes', 'k8s', 'docker', 'infra', 'aws', 'gcp', 'azure'],
201
+ questions: [
202
+ {
203
+ text: 'Is the deployment infrastructure already defined?',
204
+ options: [
205
+ { label: 'Yes, fully defined', decided: true, adrSlug: '' },
206
+ { label: 'Cloud provider not decided', decided: false, adrSlug: 'cloud-provider' },
207
+ { label: 'Container strategy not decided', decided: false, adrSlug: 'container-strategy' },
208
+ ],
209
+ },
210
+ ],
211
+ },
212
+ {
213
+ domain: 'events',
214
+ keywords: ['kafka', 'fila', 'queue', 'notificação', 'notification', 'evento', 'event', 'pubsub', 'pub/sub', 'broker', 'sqs', 'redis'],
215
+ questions: [
216
+ {
217
+ text: 'Which event broker will be used?',
218
+ options: [
219
+ { label: 'Already decided', decided: true, adrSlug: '' },
220
+ { label: 'Not decided yet', decided: false, adrSlug: 'event-broker' },
221
+ ],
222
+ },
223
+ ],
224
+ },
225
+ ]
226
+
227
+ /**
228
+ * detectDomains — retorna probes cujos keywords aparecem na intention (case-insensitive).
229
+ * @param {string} intention
230
+ * @returns {Array}
231
+ */
232
+ function detectDomains(intention) {
233
+ const lower = intention.toLowerCase()
234
+ return PROBES_CATALOG.filter(probe =>
235
+ probe.keywords.some(kw => lower.includes(kw.toLowerCase()))
236
+ )
237
+ }
238
+
239
+ module.exports = { listREQs, parseREQStatus, newREQ, PROBES_CATALOG, detectDomains }
@@ -0,0 +1,224 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
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',
11
+ }
12
+
13
+ const STATE_ORDER = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
14
+
15
+ const TRANSITION_LOG_PATH = 'docs/roadmaps/.trackfw-log'
16
+
17
+ /**
18
+ * listRoadmaps — lista roadmaps agrupados por estado (wip, backlog, blocked, done, abandoned).
19
+ * Se nenhum encontrado imprime mensagem orientando o usuário.
20
+ */
21
+ function listRoadmaps() {
22
+ let found = false
23
+
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
31
+ }
32
+ if (files.length === 0) continue
33
+
34
+ found = true
35
+ console.log(`[${state}]`)
36
+ for (const f of files) {
37
+ console.log(` ${f}`)
38
+ }
39
+ }
40
+
41
+ if (!found) {
42
+ console.log("Nenhum roadmap encontrado. Crie um com 'trackfw roadmap new'.")
43
+ }
44
+ }
45
+
46
+ /**
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.
49
+ */
50
+ function showRoadmap(name) {
51
+ const matches = findRoadmapMatches(name)
52
+
53
+ if (matches.length === 0) {
54
+ console.error(`no roadmap found matching "${name}"`)
55
+ process.exitCode = 1
56
+ return
57
+ }
58
+
59
+ if (matches.length > 1) {
60
+ console.log('Multiple roadmaps found — be more specific:')
61
+ for (const m of matches) {
62
+ console.log(` ${m}`)
63
+ }
64
+ console.error(`ambiguous match for "${name}"`)
65
+ process.exitCode = 1
66
+ return
67
+ }
68
+
69
+ const filepath = matches[0]
70
+ const basename = path.basename(filepath)
71
+ const state = path.basename(path.dirname(filepath)).toUpperCase()
72
+ const content = fs.readFileSync(filepath, 'utf8')
73
+
74
+ console.log(`── ${basename} ── [${state}] ──────────────────────\n`)
75
+ console.log(content)
76
+ console.log(`Location: ${filepath}`)
77
+ }
78
+
79
+ /**
80
+ * 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.
83
+ */
84
+ function moveRoadmap(name, state) {
85
+ const targetDir = VALID_STATES[state]
86
+ if (!targetDir) {
87
+ console.error(`invalid state "${state}" — valid states: backlog, wip, blocked, done, abandoned`)
88
+ process.exitCode = 1
89
+ return
90
+ }
91
+
92
+ const matches = findRoadmapMatches(name)
93
+ if (matches.length === 0) {
94
+ console.error(`roadmap "${name}" not found in any state directory`)
95
+ process.exitCode = 1
96
+ return
97
+ }
98
+ if (matches.length > 1) {
99
+ console.log('Multiple roadmaps found — be more specific:')
100
+ for (const m of matches) {
101
+ console.log(` ${m}`)
102
+ }
103
+ console.error(`ambiguous match for "${name}"`)
104
+ process.exitCode = 1
105
+ return
106
+ }
107
+
108
+ const src = matches[0]
109
+ const basename = path.basename(src)
110
+ const fromState = path.basename(path.dirname(src))
111
+
112
+ try {
113
+ fs.mkdirSync(targetDir, { recursive: true })
114
+ } catch (_) {}
115
+
116
+ const dst = path.join(targetDir, basename)
117
+ fs.renameSync(src, dst)
118
+
119
+ appendTransitionLog(basename, fromState, state)
120
+ console.log(`✓ moved ${basename} → ${targetDir}`)
121
+ }
122
+
123
+ /**
124
+ * appendTransitionLog — append em docs/roadmaps/.trackfw-log.
125
+ * Formato: `YYYY-MM-DD HH:mm <basename padded to 50> <fromState> → <toState>\n`
126
+ */
127
+ function appendTransitionLog(basename, fromState, toState) {
128
+ const now = new Date()
129
+ const yyyy = now.getFullYear()
130
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
131
+ const dd = String(now.getDate()).padStart(2, '0')
132
+ const hh = String(now.getHours()).padStart(2, '0')
133
+ const min = String(now.getMinutes()).padStart(2, '0')
134
+ const timestamp = `${yyyy}-${mm}-${dd} ${hh}:${min}`
135
+ const line = `${timestamp} ${basename.padEnd(50)} ${fromState} → ${toState}\n`
136
+
137
+ try {
138
+ fs.mkdirSync(path.dirname(TRANSITION_LOG_PATH), { recursive: true })
139
+ fs.appendFileSync(TRANSITION_LOG_PATH, line, 'utf8')
140
+ } catch (_) {}
141
+ }
142
+
143
+ /**
144
+ * newRoadmap — cria roadmap em docs/roadmaps/backlog/ROADMAP-YYYY-MM-DD-<slug>.md.
145
+ */
146
+ function newRoadmap(title, reqPath) {
147
+ const now = new Date()
148
+ const yyyy = now.getFullYear()
149
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
150
+ const dd = String(now.getDate()).padStart(2, '0')
151
+ const date = `${yyyy}-${mm}-${dd}`
152
+ const slug = toSlug(title)
153
+ const filename = `docs/roadmaps/backlog/ROADMAP-${date}-${slug}.md`
154
+
155
+ fs.mkdirSync('docs/roadmaps/backlog', { recursive: true })
156
+
157
+ const body = `# Roadmap: ${title}
158
+
159
+ > Created: ${date} | Status: backlog
160
+
161
+ ## Context
162
+ <!-- What problem does this roadmap solve? Link the REQ. -->
163
+ REQ: ${reqPath || ''}
164
+
165
+ ## Wave 1 — <name> (parallel MLs)
166
+ > Dependencies: none
167
+
168
+ ### ML-1A — ${title}
169
+ **Status:** pending
170
+ **Files affected:**
171
+ **Actions:**
172
+ **Acceptance criteria:**
173
+ - [ ] build passes
174
+ - [ ] tests green
175
+ - [ ] validate passes
176
+ `
177
+
178
+ fs.writeFileSync(filename, body, 'utf8')
179
+ console.log(`✓ created ${filename}`)
180
+ }
181
+
182
+ // --- helpers ---
183
+
184
+ /**
185
+ * findRoadmapMatches — retorna array de paths que contêm `name` (case-insensitive) em qualquer estado.
186
+ */
187
+ function findRoadmapMatches(name) {
188
+ const matches = []
189
+ 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
197
+ }
198
+ for (const f of files) {
199
+ if (f.toLowerCase().includes(nameLower) && f.endsWith('.md')) {
200
+ matches.push(path.join(dir, f))
201
+ }
202
+ }
203
+ }
204
+ return matches
205
+ }
206
+
207
+ /**
208
+ * toSlug — converte string para slug lowercase com hífens.
209
+ */
210
+ function toSlug(s) {
211
+ return s
212
+ .toLowerCase()
213
+ .replace(/[^a-z0-9]+/g, '-')
214
+ .replace(/^-+|-+$/g, '')
215
+ }
216
+
217
+ module.exports = {
218
+ VALID_STATES,
219
+ listRoadmaps,
220
+ showRoadmap,
221
+ moveRoadmap,
222
+ appendTransitionLog,
223
+ newRoadmap,
224
+ }
@@ -0,0 +1,53 @@
1
+ 'use strict'
2
+ const path = require('path')
3
+ const fs = require('fs')
4
+
5
+ function detectLocale() {
6
+ const raw = process.env.LANG || process.env.LC_ALL || process.env.LANGUAGE || ''
7
+ // Mapeia pt_BR.UTF-8 → pt-BR, es_ES.UTF-8 → es-ES, en_US.UTF-8 → en-US
8
+ const map = { pt: 'pt-BR', es: 'es-ES' }
9
+ const code = raw.split('.')[0].replace('_', '-') // pt-BR
10
+ const lang = code.split('-')[0] // pt
11
+ if (map[lang]) return map[lang]
12
+ // Fallback para Windows: usar Intl
13
+ try {
14
+ const loc = Intl.DateTimeFormat().resolvedOptions().locale
15
+ const l = loc.split('-')[0]
16
+ if (map[l]) return map[l]
17
+ } catch (_) {}
18
+ return 'en-US'
19
+ }
20
+
21
+ let _locale = null
22
+ let _messages = null
23
+
24
+ function load() {
25
+ if (_messages) return
26
+ _locale = detectLocale()
27
+ const filePath = path.join(__dirname, 'locales', `${_locale}.json`)
28
+ const fallback = path.join(__dirname, 'locales', 'en-US.json')
29
+ try {
30
+ _messages = JSON.parse(fs.readFileSync(fs.existsSync(filePath) ? filePath : fallback, 'utf8'))
31
+ } catch (_) {
32
+ _messages = {}
33
+ }
34
+ }
35
+
36
+ function t(key, vars = {}) {
37
+ load()
38
+ const keys = key.split('.')
39
+ let val = _messages
40
+ for (const k of keys) {
41
+ val = val?.[k]
42
+ if (val === undefined) break
43
+ }
44
+ if (typeof val !== 'string') return key
45
+ return val.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`)
46
+ }
47
+
48
+ function locale() {
49
+ load()
50
+ return _locale
51
+ }
52
+
53
+ module.exports = { t, locale }
@@ -0,0 +1,122 @@
1
+ {
2
+ "init": {
3
+ "description": "Initialize trackfw governance in the current project",
4
+ "prompt": {
5
+ "projectName": "Project name?",
6
+ "projectType": "Project type?",
7
+ "frontendStack": "Frontend stack?",
8
+ "pkgManager": "Package manager?",
9
+ "backendLang": "Backend language?",
10
+ "backendFramework": "Backend framework?",
11
+ "gitHooks": "Git hooks?",
12
+ "ci": "CI system?",
13
+ "aiTools": "Which AI assistants do you use?",
14
+ "projectType_fullstack": "Full-stack (frontend + backend)",
15
+ "projectType_frontend": "Frontend only",
16
+ "projectType_backend": "Backend only",
17
+ "projectType_governance": "Governance only (no build stack)"
18
+ },
19
+ "success": "✓ trackfw initialized — run 'trackfw status' to see your governance state."
20
+ },
21
+ "adr": {
22
+ "description": "Manage Architecture Decision Records",
23
+ "new": {
24
+ "description": "Create a new Architecture Decision Record",
25
+ "prompt": {
26
+ "title": "ADR title?",
27
+ "status": "Initial status?",
28
+ "context": "Context (what motivates this decision)?",
29
+ "decision": "Decision (what was decided)?",
30
+ "consequences": "Consequences (positive and negative)?",
31
+ "alternatives": "Alternatives considered?"
32
+ },
33
+ "created": "✓ ADR created: {{path}}"
34
+ },
35
+ "list": {
36
+ "description": "List all ADRs with status",
37
+ "empty": "No ADRs found in docs/adr/"
38
+ }
39
+ },
40
+ "req": {
41
+ "description": "Manage Requirements",
42
+ "new": {
43
+ "description": "Create a new requirement",
44
+ "prompt": {
45
+ "title": "Project requirement",
46
+ "motivation": "Motivation (why is this needed)?",
47
+ "criteria": "Acceptance Criteria (one per line)",
48
+ "domainQuestion_authentication": "How will users authenticate?",
49
+ "domainQuestion_ui": "Is there an existing UI framework or design system?",
50
+ "domainQuestion_persistence": "Which database engine will be used?",
51
+ "domainQuestion_api": "Which API protocol will be used?",
52
+ "domainQuestion_deploy": "What is the deployment target?",
53
+ "domainQuestion_events": "Which event broker will be used?"
54
+ },
55
+ "detectedDomains": "Detected domains: {{domains}}",
56
+ "created": "✓ REQ created: {{path}}",
57
+ "adrDraftsCreated": "ADR drafts created:",
58
+ "resolveADRs": "Resolve these ADRs (set Status: Accepted) before creating a roadmap.",
59
+ "adrWarning": "warning: could not create ADR draft for {{slug}}: {{message}}"
60
+ },
61
+ "list": {
62
+ "description": "List all REQs with status",
63
+ "empty": "No REQs found in docs/req/"
64
+ }
65
+ },
66
+ "roadmap": {
67
+ "description": "Manage Roadmaps",
68
+ "list": {
69
+ "description": "List all roadmaps grouped by state",
70
+ "empty": "No roadmaps found."
71
+ },
72
+ "show": {
73
+ "description": "Show a roadmap by name (partial match)",
74
+ "notFound": "Roadmap not found: {{name}}"
75
+ },
76
+ "move": {
77
+ "description": "Move a roadmap between states (backlog|wip|blocked|done|abandoned)",
78
+ "success": "✓ Moved {{name}} → {{state}}",
79
+ "notFound": "Roadmap not found: {{name}}"
80
+ },
81
+ "new": {
82
+ "description": "Create a new roadmap from a REQ",
83
+ "created": "✓ Roadmap created: {{path}}"
84
+ }
85
+ },
86
+ "validate": {
87
+ "description": "Validate governance rules (use as CI gate)",
88
+ "ok": "✓ No violations found.",
89
+ "violations": "✗ Violations ({{count}}):",
90
+ "warnings": "⚠ Warnings ({{count}}):"
91
+ },
92
+ "status": {
93
+ "description": "Show project governance status"
94
+ },
95
+ "log": {
96
+ "description": "Show roadmap state transition history",
97
+ "empty": "No transitions recorded yet.",
98
+ "tail": "Number of recent transitions to show",
99
+ "header": "── trackfw log ─────────────────────────"
100
+ },
101
+ "plugins": {
102
+ "description": "Manage trackfw plugins",
103
+ "list": {
104
+ "description": "List installed plugins",
105
+ "empty": "No plugins installed. Use `trackfw plugins add <user/repo>` to install one."
106
+ },
107
+ "add": {
108
+ "description": "Install a plugin from GitHub Releases (user/repo or user/repo@tag)",
109
+ "installing": "Installing plugin from {{repo}}...",
110
+ "success": "Plugin \"{{name}}\" installed successfully."
111
+ },
112
+ "remove": {
113
+ "description": "Remove an installed plugin",
114
+ "success": "Plugin \"{{name}}\" removed."
115
+ }
116
+ },
117
+ "errors": {
118
+ "notFound": "Not found: {{path}}",
119
+ "downloadFailed": "download failed: HTTP {{status}} for {{url}}",
120
+ "pluginNotFound": "plugin \"{{name}}\" not found"
121
+ }
122
+ }