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.
- package/package.json +1 -1
- package/src/commands/adr.js +1 -1
- package/src/commands/context.js +189 -0
- package/src/commands/discover.js +359 -0
- package/src/commands/index.js +4 -0
- package/src/commands/init.js +11 -2
- package/src/commands/metrics.js +235 -0
- package/src/commands/plugins.js +73 -0
- package/src/commands/req.js +1 -1
- package/src/commands/roadmap.js +6 -1
- package/src/commands/sync.js +362 -0
- package/src/commands/validate.js +9 -1
- package/src/config/index.js +92 -0
- package/src/generators/adr.js +20 -7
- package/src/generators/init.js +51 -1
- package/src/generators/req.js +12 -3
- package/src/generators/roadmap.js +292 -58
- package/src/i18n/locales/en-US.json +9 -1
- package/src/i18n/locales/es-ES.json +9 -1
- package/src/i18n/locales/pt-BR.json +9 -1
- package/src/validator/index.js +369 -97
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const { t } = require('../i18n')
|
|
7
|
+
|
|
8
|
+
// lineRe: faz match do formato do .trackfw-log
|
|
9
|
+
// 2026-06-12 14:30 ROADMAP-2026-06-12-auth.md backlog → wip
|
|
10
|
+
const LINE_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s{2,}(\S+)\s{2,}(\S+)\s+→\s+(\S+)/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* parseLog lê o arquivo .trackfw-log e retorna array de transições.
|
|
14
|
+
* Retorna [] se o arquivo não existe.
|
|
15
|
+
* @param {string} filePath
|
|
16
|
+
* @returns {{ timestamp: Date, basename: string, from: string, to: string }[]}
|
|
17
|
+
*/
|
|
18
|
+
function parseLog(filePath) {
|
|
19
|
+
if (!fs.existsSync(filePath)) return []
|
|
20
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
21
|
+
const lines = content.split('\n')
|
|
22
|
+
const transitions = []
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
if (!line.trim()) continue
|
|
25
|
+
const m = LINE_RE.exec(line)
|
|
26
|
+
if (!m) continue
|
|
27
|
+
const [, tsStr, basename, from, to] = m
|
|
28
|
+
const timestamp = new Date(tsStr.replace(' ', 'T') + ':00')
|
|
29
|
+
if (isNaN(timestamp.getTime())) continue
|
|
30
|
+
transitions.push({ timestamp, basename: basename.trim(), from: from.trim(), to: to.trim() })
|
|
31
|
+
}
|
|
32
|
+
return transitions
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* filter retorna transições com timestamp >= sinceMs.
|
|
37
|
+
* @param {Array} transitions
|
|
38
|
+
* @param {number} sinceMs - timestamp em milissegundos
|
|
39
|
+
* @returns {Array}
|
|
40
|
+
*/
|
|
41
|
+
function filter(transitions, sinceMs) {
|
|
42
|
+
return transitions.filter(t => t.timestamp.getTime() >= sinceMs)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* calculate computa cycle time médio, throughput e WIP age.
|
|
47
|
+
* @param {Array} transitions
|
|
48
|
+
* @returns {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }}
|
|
49
|
+
*/
|
|
50
|
+
function calculate(transitions) {
|
|
51
|
+
// Agrupar por basename
|
|
52
|
+
const byName = new Map()
|
|
53
|
+
for (const tr of transitions) {
|
|
54
|
+
if (!byName.has(tr.basename)) byName.set(tr.basename, [])
|
|
55
|
+
byName.get(tr.basename).push(tr)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Cycle time: da entrada em backlog ou wip até done
|
|
59
|
+
const cycleTimes = []
|
|
60
|
+
for (const [, entries] of byName) {
|
|
61
|
+
let startTs = null
|
|
62
|
+
let doneTs = null
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
if ((e.to === 'backlog' || e.to === 'wip') && startTs === null) {
|
|
65
|
+
startTs = e.timestamp.getTime()
|
|
66
|
+
}
|
|
67
|
+
if (e.to === 'done') {
|
|
68
|
+
doneTs = e.timestamp.getTime()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (startTs !== null && doneTs !== null) {
|
|
72
|
+
cycleTimes.push(doneTs - startTs)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let cycleTimeMeanMs = 0
|
|
77
|
+
if (cycleTimes.length > 0) {
|
|
78
|
+
cycleTimeMeanMs = cycleTimes.reduce((a, b) => a + b, 0) / cycleTimes.length
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Throughput: roadmaps done por semana
|
|
82
|
+
let doneCount = 0
|
|
83
|
+
let minTs = Infinity
|
|
84
|
+
let maxTs = -Infinity
|
|
85
|
+
for (const tr of transitions) {
|
|
86
|
+
const ms = tr.timestamp.getTime()
|
|
87
|
+
if (tr.to === 'done') doneCount++
|
|
88
|
+
if (ms < minTs) minTs = ms
|
|
89
|
+
if (ms > maxTs) maxTs = ms
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let throughput = 0
|
|
93
|
+
if (doneCount > 0) {
|
|
94
|
+
const msPerWeek = 7 * 24 * 60 * 60 * 1000
|
|
95
|
+
let weeks = (maxTs - minTs) / msPerWeek
|
|
96
|
+
if (weeks < 1) weeks = 1
|
|
97
|
+
throughput = doneCount / weeks
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// WIP age: basenames em wip sem done ou abandoned posterior
|
|
101
|
+
const wipEntries = []
|
|
102
|
+
const now = Date.now()
|
|
103
|
+
for (const [basename, entries] of byName) {
|
|
104
|
+
let wipTs = null
|
|
105
|
+
let concluded = false
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
if (e.to === 'wip') wipTs = e.timestamp.getTime()
|
|
108
|
+
if (e.to === 'done' || e.to === 'abandoned') concluded = true
|
|
109
|
+
}
|
|
110
|
+
if (wipTs !== null && !concluded) {
|
|
111
|
+
wipEntries.push({ basename, ageMs: now - wipTs })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { cycleTimeMeanMs, throughput, wipEntries }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* exportCSV grava transições e métricas em um arquivo CSV.
|
|
120
|
+
* @param {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }} metrics
|
|
121
|
+
* @param {Array} transitions
|
|
122
|
+
* @param {string} filePath
|
|
123
|
+
*/
|
|
124
|
+
function exportCSV(metrics, transitions, filePath) {
|
|
125
|
+
const rows = []
|
|
126
|
+
rows.push('basename,from,to,timestamp')
|
|
127
|
+
for (const tr of transitions) {
|
|
128
|
+
const ts = tr.timestamp.toISOString().slice(0, 16).replace('T', ' ')
|
|
129
|
+
rows.push(`${tr.basename},${tr.from},${tr.to},${ts}`)
|
|
130
|
+
}
|
|
131
|
+
rows.push('')
|
|
132
|
+
rows.push('metric,value')
|
|
133
|
+
const cycleHours = (metrics.cycleTimeMeanMs / (1000 * 3600)).toFixed(2)
|
|
134
|
+
rows.push(`cycle_time_mean_hours,${cycleHours}`)
|
|
135
|
+
rows.push(`throughput_per_week,${metrics.throughput.toFixed(2)}`)
|
|
136
|
+
rows.push(`wip_count,${metrics.wipEntries.length}`)
|
|
137
|
+
fs.writeFileSync(filePath, rows.join('\n') + '\n', 'utf8')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* parseSinceDuration converte "7d", "30d", "90d" em milissegundos.
|
|
142
|
+
* @param {string} s
|
|
143
|
+
* @returns {number}
|
|
144
|
+
*/
|
|
145
|
+
function parseSinceDuration(s) {
|
|
146
|
+
if (!s || s.length < 2) throw new Error(`formato inválido: "${s}"`)
|
|
147
|
+
const unit = s[s.length - 1]
|
|
148
|
+
if (unit !== 'd') throw new Error(`unidade não suportada "${unit}" (use 'd' para dias)`)
|
|
149
|
+
const n = parseInt(s.slice(0, -1), 10)
|
|
150
|
+
if (isNaN(n) || n <= 0) throw new Error(`número inválido em "${s}"`)
|
|
151
|
+
return n * 24 * 60 * 60 * 1000
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* formatDuration formata milissegundos em string legível.
|
|
156
|
+
* @param {number} ms
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
function formatDuration(ms) {
|
|
160
|
+
const totalHours = Math.floor(ms / (1000 * 3600))
|
|
161
|
+
const days = Math.floor(totalHours / 24)
|
|
162
|
+
const hours = totalHours % 24
|
|
163
|
+
if (days > 0) return `${days} days ${hours} hours`
|
|
164
|
+
return `${hours} hours`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* printMetrics imprime as métricas em formato tabela ASCII.
|
|
169
|
+
* @param {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }} metrics
|
|
170
|
+
*/
|
|
171
|
+
function printMetrics(metrics) {
|
|
172
|
+
console.log('── trackfw metrics ──────────────────────')
|
|
173
|
+
|
|
174
|
+
if (metrics.cycleTimeMeanMs > 0) {
|
|
175
|
+
console.log(` Cycle Time Mean : ${formatDuration(metrics.cycleTimeMeanMs)}`)
|
|
176
|
+
} else {
|
|
177
|
+
console.log(' Cycle Time Mean : n/a (no completed cycles)')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (metrics.throughput > 0) {
|
|
181
|
+
console.log(` Throughput : ${metrics.throughput.toFixed(2)} roadmaps/week`)
|
|
182
|
+
} else {
|
|
183
|
+
console.log(' Throughput : n/a (no completed roadmaps)')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (metrics.wipEntries.length === 0) {
|
|
187
|
+
console.log(' WIP Age : no items in progress')
|
|
188
|
+
} else {
|
|
189
|
+
console.log(` WIP Age (${metrics.wipEntries.length} items) :`)
|
|
190
|
+
for (const w of metrics.wipEntries) {
|
|
191
|
+
console.log(` - ${w.basename}: ${formatDuration(w.ageMs)}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('─────────────────────────────────────────')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const cmd = new Command('metrics')
|
|
199
|
+
cmd.description(t('metrics.description'))
|
|
200
|
+
cmd.option('--since <period>', t('metrics.since'))
|
|
201
|
+
cmd.option('--export <file>', t('metrics.export'))
|
|
202
|
+
cmd.action((opts) => {
|
|
203
|
+
const logPath = path.join('docs', 'roadmaps', '.trackfw-log')
|
|
204
|
+
let transitions = parseLog(logPath)
|
|
205
|
+
|
|
206
|
+
if (transitions.length === 0) {
|
|
207
|
+
console.log(t('metrics.no_data'))
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (opts.since) {
|
|
212
|
+
let sinceMs
|
|
213
|
+
try {
|
|
214
|
+
sinceMs = parseSinceDuration(opts.since)
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error(`invalid --since format (use: 7d, 30d, 90d): ${err.message}`)
|
|
217
|
+
process.exit(1)
|
|
218
|
+
}
|
|
219
|
+
transitions = filter(transitions, Date.now() - sinceMs)
|
|
220
|
+
if (transitions.length === 0) {
|
|
221
|
+
console.log(t('metrics.no_data'))
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const m = calculate(transitions)
|
|
227
|
+
printMetrics(m)
|
|
228
|
+
|
|
229
|
+
if (opts.export) {
|
|
230
|
+
exportCSV(m, transitions, opts.export)
|
|
231
|
+
console.log(`exported to ${opts.export}`)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
module.exports = cmd
|
package/src/commands/plugins.js
CHANGED
|
@@ -3,8 +3,59 @@ const { Command } = require('commander')
|
|
|
3
3
|
const os = require('os')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const fs = require('fs')
|
|
6
|
+
const https = require('https')
|
|
6
7
|
const { t } = require('../i18n')
|
|
7
8
|
|
|
9
|
+
const REGISTRY_URL = 'https://raw.githubusercontent.com/kgsaran/trackfw-plugins/main/registry.yaml'
|
|
10
|
+
|
|
11
|
+
function fetchRegistry() {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
https.get(REGISTRY_URL, (res) => {
|
|
14
|
+
let data = ''
|
|
15
|
+
res.on('data', chunk => { data += chunk })
|
|
16
|
+
res.on('end', () => resolve(data))
|
|
17
|
+
}).on('error', reject)
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseRegistryYAML(text) {
|
|
22
|
+
const entries = []
|
|
23
|
+
let current = null
|
|
24
|
+
const lines = text.split('\n')
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const trimmed = line.trim()
|
|
27
|
+
if (!trimmed || trimmed === 'plugins:') continue
|
|
28
|
+
if (trimmed.startsWith('- name:')) {
|
|
29
|
+
if (current) entries.push(current)
|
|
30
|
+
current = { name: trimmed.slice('- name:'.length).trim(), repo: '', description: '', tags: [] }
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (!current) continue
|
|
34
|
+
if (trimmed.startsWith('repo:')) {
|
|
35
|
+
current.repo = trimmed.slice('repo:'.length).trim()
|
|
36
|
+
} else if (trimmed.startsWith('description:')) {
|
|
37
|
+
let desc = trimmed.slice('description:'.length).trim()
|
|
38
|
+
desc = desc.replace(/^"|"$/g, '')
|
|
39
|
+
current.description = desc
|
|
40
|
+
} else if (trimmed.startsWith('tags:')) {
|
|
41
|
+
const raw = trimmed.slice('tags:'.length).trim().replace(/^\[|\]$/g, '')
|
|
42
|
+
current.tags = raw.split(',').map(s => s.trim()).filter(Boolean)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (current) entries.push(current)
|
|
46
|
+
return entries
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function matchesKeyword(entry, kw) {
|
|
50
|
+
const lkw = kw.toLowerCase()
|
|
51
|
+
if (entry.name.toLowerCase().includes(lkw)) return true
|
|
52
|
+
if (entry.description.toLowerCase().includes(lkw)) return true
|
|
53
|
+
for (const tag of entry.tags) {
|
|
54
|
+
if (tag.toLowerCase().includes(lkw)) return true
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
8
59
|
function pluginsDir() {
|
|
9
60
|
return path.join(os.homedir(), '.trackfw', 'plugins')
|
|
10
61
|
}
|
|
@@ -94,4 +145,26 @@ cmd.command('remove <name>')
|
|
|
94
145
|
}
|
|
95
146
|
})
|
|
96
147
|
|
|
148
|
+
cmd.command('search <keyword>')
|
|
149
|
+
.description('Search the plugin registry')
|
|
150
|
+
.action(async (keyword) => {
|
|
151
|
+
let entries
|
|
152
|
+
try {
|
|
153
|
+
const body = await fetchRegistry()
|
|
154
|
+
entries = parseRegistryYAML(body).filter(e => matchesKeyword(e, keyword))
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.log(`Registry unavailable: ${err.message}`)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (entries.length === 0) {
|
|
160
|
+
console.log(`No plugins found for "${keyword}"`)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
console.log(String('NAME').padEnd(30) + String('REPO').padEnd(30) + 'DESCRIPTION')
|
|
164
|
+
console.log('-'.repeat(90))
|
|
165
|
+
for (const e of entries) {
|
|
166
|
+
console.log(e.name.padEnd(30) + e.repo.padEnd(30) + e.description)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
97
170
|
module.exports = cmd
|
package/src/commands/req.js
CHANGED
package/src/commands/roadmap.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
const { Command } = require('commander')
|
|
3
|
-
const { listRoadmaps, showRoadmap, moveRoadmap, newRoadmap } = require('../generators/roadmap')
|
|
3
|
+
const { listRoadmaps, showRoadmap, moveRoadmap, newRoadmap, newRoadmapFromReq } = require('../generators/roadmap')
|
|
4
4
|
const { t } = require('../i18n')
|
|
5
5
|
|
|
6
6
|
const cmd = new Command('roadmap')
|
|
@@ -10,7 +10,12 @@ cmd.command('new')
|
|
|
10
10
|
.description(t('roadmap.new.description'))
|
|
11
11
|
.option('-t, --title <title>', 'Roadmap title')
|
|
12
12
|
.option('-r, --req <path>', 'Path to the linked REQ')
|
|
13
|
+
.option('--from-req <path>', 'Generate roadmap with ML stubs from REQ acceptance criteria')
|
|
13
14
|
.action(async (opts) => {
|
|
15
|
+
if (opts.fromReq) {
|
|
16
|
+
newRoadmapFromReq(opts.fromReq)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
14
19
|
const title = opts.title || 'New Roadmap'
|
|
15
20
|
const reqPath = opts.req || ''
|
|
16
21
|
newRoadmap(title, reqPath)
|