trackfw 2.0.0 → 2.1.1
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 +10 -3
- package/src/commands/context.js +189 -0
- package/src/commands/index.js +3 -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/roadmap.js +6 -1
- package/src/commands/sync.js +362 -0
- package/src/commands/validate.js +9 -1
- package/src/generators/adr.js +14 -2
- package/src/generators/init.js +44 -1
- package/src/generators/req.js +9 -1
- package/src/generators/roadmap.js +142 -1
- 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 +188 -15
package/package.json
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.1",
|
|
4
|
+
"description": "CLI de governança para entrega de software: ADR → REQ → ROADMAP → kanban. Suporte nativo a agentes de IA (Claude Code, Gemini CLI, Cursor).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
7
7
|
"adr",
|
|
8
|
+
"architecture-decision-records",
|
|
8
9
|
"roadmap",
|
|
9
10
|
"governance",
|
|
10
|
-
"delivery"
|
|
11
|
+
"software-delivery",
|
|
12
|
+
"devops",
|
|
13
|
+
"ai-agents",
|
|
14
|
+
"developer-tools",
|
|
15
|
+
"kanban",
|
|
16
|
+
"req",
|
|
17
|
+
"trackfw"
|
|
11
18
|
],
|
|
12
19
|
"homepage": "https://github.com/kgsaran/trackfw",
|
|
13
20
|
"repository": {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const config = require('../config')
|
|
7
|
+
const { validate } = require('../validator')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* extractFrontmatterField — extrai valor de campo YAML dentro de bloco --- ... ---.
|
|
11
|
+
* Retorna string vazia se não encontrado ou valor '""'.
|
|
12
|
+
* @param {string} content
|
|
13
|
+
* @param {string} field
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function extractFrontmatterField(content, field) {
|
|
17
|
+
const lines = content.split('\n')
|
|
18
|
+
let started = false
|
|
19
|
+
let inFrontmatter = false
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
const trimmed = line.trim()
|
|
22
|
+
if (trimmed === '---') {
|
|
23
|
+
if (!started) {
|
|
24
|
+
started = true
|
|
25
|
+
inFrontmatter = true
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
break // segundo --- fecha o bloco
|
|
29
|
+
}
|
|
30
|
+
if (!inFrontmatter) break
|
|
31
|
+
const key = field + ':'
|
|
32
|
+
if (trimmed.startsWith(key)) {
|
|
33
|
+
let val = trimmed.slice(key.length).trim()
|
|
34
|
+
val = val.replace(/^["']|["']$/g, '') // remover aspas
|
|
35
|
+
return val
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* extractInlineStatus — extrai status da linha "| Status: ..." do markdown.
|
|
43
|
+
* @param {string} content
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function extractInlineStatus(content) {
|
|
47
|
+
for (const line of content.split('\n')) {
|
|
48
|
+
const idx = line.indexOf('| Status: ')
|
|
49
|
+
if (idx >= 0) {
|
|
50
|
+
let rest = line.slice(idx + '| Status: '.length)
|
|
51
|
+
const pipeIdx = rest.indexOf(' |')
|
|
52
|
+
if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
|
|
53
|
+
rest = rest.replace(/[\s>|]+$/, '').trim()
|
|
54
|
+
return rest || 'unknown'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return 'unknown'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* collectEntries — lê diretório e retorna lista de entradas com type, file, status, state.
|
|
62
|
+
* @param {string} dir
|
|
63
|
+
* @param {string} type - 'ADR' | 'REQ' | 'ROADMAP'
|
|
64
|
+
* @param {string} [state] - estado kanban (somente ROADMAP)
|
|
65
|
+
* @returns {Array<{type: string, file: string, status: string, state?: string}>}
|
|
66
|
+
*/
|
|
67
|
+
function collectEntries(dir, type, state) {
|
|
68
|
+
const entries = []
|
|
69
|
+
let files = []
|
|
70
|
+
try {
|
|
71
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && !fs.statSync(path.join(dir, f)).isDirectory())
|
|
72
|
+
} catch (_) {
|
|
73
|
+
return entries
|
|
74
|
+
}
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
let content = ''
|
|
77
|
+
try { content = fs.readFileSync(path.join(dir, file), 'utf8') } catch (_) {}
|
|
78
|
+
let status = extractFrontmatterField(content, 'status')
|
|
79
|
+
if (!status) status = extractInlineStatus(content)
|
|
80
|
+
if (!status) status = state || 'unknown'
|
|
81
|
+
const entry = { type, file, status }
|
|
82
|
+
if (state) entry.state = state
|
|
83
|
+
entries.push(entry)
|
|
84
|
+
}
|
|
85
|
+
return entries
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* getContext — coleta governança e imprime em md ou json.
|
|
90
|
+
* @param {string} format - 'md' | 'json'
|
|
91
|
+
*/
|
|
92
|
+
function getContext(format) {
|
|
93
|
+
const cfg = config.load()
|
|
94
|
+
|
|
95
|
+
// ADRs
|
|
96
|
+
const adrs = []
|
|
97
|
+
for (const adrDir of (cfg.adrDirs || ['docs/adr'])) {
|
|
98
|
+
adrs.push(...collectEntries(adrDir, 'ADR'))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// REQs
|
|
102
|
+
const reqs = collectEntries(cfg.reqDir || 'docs/req', 'REQ')
|
|
103
|
+
|
|
104
|
+
// Roadmaps
|
|
105
|
+
const roadmaps = []
|
|
106
|
+
const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
107
|
+
if (cfg.roadmapNamespacing === 'by_agent') {
|
|
108
|
+
let agents = cfg.agents || []
|
|
109
|
+
if (agents.length === 0) {
|
|
110
|
+
try {
|
|
111
|
+
agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
112
|
+
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
113
|
+
})
|
|
114
|
+
} catch (_) { agents = [] }
|
|
115
|
+
}
|
|
116
|
+
for (const agent of agents) {
|
|
117
|
+
for (const state of states) {
|
|
118
|
+
const dir = path.join(cfg.roadmapDir, agent, state)
|
|
119
|
+
roadmaps.push(...collectEntries(dir, 'ROADMAP', state))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
for (const state of states) {
|
|
124
|
+
const dir = path.join(cfg.roadmapDir, state)
|
|
125
|
+
roadmaps.push(...collectEntries(dir, 'ROADMAP', state))
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Validate
|
|
130
|
+
const { violations, warnings } = validate()
|
|
131
|
+
|
|
132
|
+
// Score
|
|
133
|
+
let score = 0
|
|
134
|
+
if (adrs.length > 0) score += 20
|
|
135
|
+
if (reqs.length > 0) score += 20
|
|
136
|
+
if (roadmaps.length > 0) score += 20
|
|
137
|
+
if (violations.length === 0) score += 40
|
|
138
|
+
|
|
139
|
+
if (format === 'json') {
|
|
140
|
+
console.log(JSON.stringify({ score, violations, warnings, adrs, reqs, roadmaps }, null, 2))
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Markdown
|
|
145
|
+
console.log('# trackfw governance context\n')
|
|
146
|
+
console.log(`**Governance score:** ${score}/100\n`)
|
|
147
|
+
|
|
148
|
+
console.log(`## ADRs (${adrs.length})`)
|
|
149
|
+
if (adrs.length === 0) {
|
|
150
|
+
console.log('- (none)')
|
|
151
|
+
} else {
|
|
152
|
+
for (const a of adrs) console.log(`- ${a.file} [${a.status}]`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(`\n## REQs (${reqs.length})`)
|
|
156
|
+
if (reqs.length === 0) {
|
|
157
|
+
console.log('- (none)')
|
|
158
|
+
} else {
|
|
159
|
+
for (const r of reqs) console.log(`- ${r.file} [${r.status}]`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`\n## Roadmaps (${roadmaps.length})`)
|
|
163
|
+
if (roadmaps.length === 0) {
|
|
164
|
+
console.log('- (none)')
|
|
165
|
+
} else {
|
|
166
|
+
for (const r of roadmaps) console.log(`- ${r.file} [${r.state}]`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (violations.length > 0) {
|
|
170
|
+
console.log(`\n## Violations (${violations.length})`)
|
|
171
|
+
for (const v of violations) console.log(`- ${v}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (warnings.length > 0) {
|
|
175
|
+
console.log(`\n## Warnings (${warnings.length})`)
|
|
176
|
+
for (const w of warnings) console.log(`- ${w}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = (function () {
|
|
181
|
+
const cmd = new Command('context')
|
|
182
|
+
cmd
|
|
183
|
+
.description('Print governance context for LLM consumption')
|
|
184
|
+
.option('--format <fmt>', 'Output format: md or json', 'md')
|
|
185
|
+
.action((opts) => {
|
|
186
|
+
getContext(opts.format)
|
|
187
|
+
})
|
|
188
|
+
return cmd
|
|
189
|
+
})()
|
package/src/commands/index.js
CHANGED
|
@@ -19,6 +19,9 @@ function createProgram() {
|
|
|
19
19
|
program.addCommand(require('./log'))
|
|
20
20
|
program.addCommand(require('./plugins'))
|
|
21
21
|
program.addCommand(require('./discover'))
|
|
22
|
+
program.addCommand(require('./metrics'))
|
|
23
|
+
program.addCommand(require('./sync'))
|
|
24
|
+
program.addCommand(require('./context'))
|
|
22
25
|
|
|
23
26
|
// plugin dispatch — comandos desconhecidos tentam executar plugin
|
|
24
27
|
program.hook('preSubcommand', () => {})
|
package/src/commands/init.js
CHANGED
|
@@ -26,7 +26,7 @@ cmd.action(async () => {
|
|
|
26
26
|
|
|
27
27
|
const { input, select, checkbox } = require('@inquirer/prompts')
|
|
28
28
|
|
|
29
|
-
let projectName, projectType, frontend, pkgManager, backend, backendFramework, hooks, ci, aiTools
|
|
29
|
+
let projectName, projectType, frontend, pkgManager, backend, backendFramework, hooks, ci, aiTools, requireReqInCommit
|
|
30
30
|
|
|
31
31
|
try {
|
|
32
32
|
projectName = await input({
|
|
@@ -127,6 +127,15 @@ cmd.action(async () => {
|
|
|
127
127
|
],
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
+
requireReqInCommit = false
|
|
131
|
+
if (hooks !== 'none') {
|
|
132
|
+
const { confirm: confirmPrompt } = require('@inquirer/prompts')
|
|
133
|
+
requireReqInCommit = await confirmPrompt({
|
|
134
|
+
message: t('init.prompt.require_req_in_commit'),
|
|
135
|
+
default: false,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
130
139
|
aiTools = await checkbox({
|
|
131
140
|
message: t('init.prompt.aiTools'),
|
|
132
141
|
choices: [
|
|
@@ -154,7 +163,7 @@ cmd.action(async () => {
|
|
|
154
163
|
return
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci }
|
|
166
|
+
const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci, requireReqInCommit }
|
|
158
167
|
await generators.scaffold(cfg)
|
|
159
168
|
|
|
160
169
|
for (const tool of (aiTools || [])) {
|
|
@@ -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/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)
|