trackfw 2.6.0 → 2.7.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/README.md +88 -3
- package/package.json +1 -1
- package/src/commands/index.js +3 -0
- package/src/commands/metrics.js +7 -0
- package/src/commands/serve.js +184 -0
- package/src/serve/api_board.js +104 -0
- package/src/serve/api_chain.js +223 -0
- package/src/serve/api_file.js +80 -0
- package/src/serve/api_metrics.js +145 -0
- package/src/serve/static/app.js +687 -0
- package/src/serve/static/index.html +199 -0
- package/src/serve/static/style.css +153 -0
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# trackfw
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> The AI-native governance layer for software delivery — ADR → REQ → ROADMAP → backlog / wip / blocked / done / abandoned
|
|
4
4
|
|
|
5
5
|
[](https://github.com/kgsaran/trackfw/releases/latest)
|
|
6
6
|
[](go.mod)
|
|
7
|
+
[](https://www.npmjs.com/package/trackfw)
|
|
8
|
+
[](https://pypi.org/project/trackfw/)
|
|
7
9
|
[](LICENSE)
|
|
8
10
|
|
|
9
11
|
**trackfw** is an open-source CLI that enforces a traceable chain from architectural decision to shipped code — without SaaS, accounts, or databases. Markdown files are state.
|
|
@@ -23,6 +25,9 @@ Most teams accumulate technical debt not because they lack tools, but because th
|
|
|
23
25
|
- **ADR tools** manage decision records, but don't connect them to delivery.
|
|
24
26
|
- **Kanban tools** track tasks, but don't enforce that tasks are backed by a decision.
|
|
25
27
|
- **CI tools** validate code, but don't validate governance.
|
|
28
|
+
- **AI coding assistants** generate code at unprecedented speed, but without traceability: who decided what? Why? Which requirement authorized this roadmap?
|
|
29
|
+
|
|
30
|
+
trackfw adds the governance layer that makes AI-assisted delivery auditable.
|
|
26
31
|
|
|
27
32
|
trackfw closes the loop — connective tissue between *why*, *what*, and *when*.
|
|
28
33
|
|
|
@@ -85,7 +90,7 @@ go install github.com/kgsaran/trackfw/cmd/trackfw@latest
|
|
|
85
90
|
npm install -g trackfw
|
|
86
91
|
```
|
|
87
92
|
|
|
88
|
-
The npm package is pure Node.js — no compiled binary, no postinstall download. Works everywhere Node.js ≥ 18 is installed, including corporate Windows environments where unsigned `.exe` files are blocked by antivirus.
|
|
93
|
+
The npm package is pure Node.js — no compiled binary, no postinstall download. Works everywhere Node.js ≥ 18 is installed, including corporate Windows environments where unsigned `.exe` files are blocked by antivirus. Full feature parity with the Go CLI — all commands, all validation rules, all by_agent features.
|
|
89
94
|
|
|
90
95
|
### pip
|
|
91
96
|
|
|
@@ -93,6 +98,8 @@ The npm package is pure Node.js — no compiled binary, no postinstall download.
|
|
|
93
98
|
pip install trackfw
|
|
94
99
|
```
|
|
95
100
|
|
|
101
|
+
The pip package is pure Python — no compiled binary, no postinstall download. Full feature parity with the Go CLI — all commands, all validation rules, all by_agent features.
|
|
102
|
+
|
|
96
103
|
---
|
|
97
104
|
|
|
98
105
|
## Quick start
|
|
@@ -133,6 +140,8 @@ trackfw status
|
|
|
133
140
|
| `trackfw roadmap move <name> <state>` | Move roadmap between states |
|
|
134
141
|
| `trackfw roadmap list` | List all roadmaps grouped by state |
|
|
135
142
|
| `trackfw validate` | Check governance consistency (use as CI gate) |
|
|
143
|
+
| `trackfw context` | Print a structured summary of the project's governance state (REQs, Roadmaps, ADRs with counts and statuses) |
|
|
144
|
+
| `trackfw serve` | Start a local governance dashboard (no cloud, no accounts) |
|
|
136
145
|
| `trackfw status` | Show wip, blocked, REQs waiting on ADRs |
|
|
137
146
|
| `trackfw log [--tail N]` | Show roadmap state transition history |
|
|
138
147
|
| `trackfw plugins list` | List installed plugins |
|
|
@@ -174,6 +183,60 @@ Moving a file between folders **is** the state transition. No database, no API.
|
|
|
174
183
|
|
|
175
184
|
---
|
|
176
185
|
|
|
186
|
+
## AI-native governance
|
|
187
|
+
|
|
188
|
+
trackfw v2.6.0 introduces features designed for teams where AI agents are first-class contributors.
|
|
189
|
+
|
|
190
|
+
### Multi-agent namespacing
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
# trackfw.yaml
|
|
194
|
+
roadmap_namespacing: by_agent
|
|
195
|
+
agents: [claude, gemini, copilot]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Artifacts are organized by agent: `docs/roadmaps/claude/wip/`, `docs/req/gemini/done/`. `trackfw validate` and `trackfw context` are fully by_agent-aware — no false positives.
|
|
199
|
+
|
|
200
|
+
### Bidirectional traceability (`trace_id_field`)
|
|
201
|
+
|
|
202
|
+
```yaml
|
|
203
|
+
# trackfw.yaml
|
|
204
|
+
trace_id_field: req_id
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Automatically verifies the REQ↔ROADMAP link in both directions. Reports 5 check types:
|
|
208
|
+
- `traceid_orphan_req` — REQ with no matching ROADMAP
|
|
209
|
+
- `traceid_orphan_roadmap` — ROADMAP with no matching REQ
|
|
210
|
+
- `traceid_state_mismatch` — REQ and ROADMAP in different states
|
|
211
|
+
- `traceid_duplicate_req` / `traceid_duplicate_roadmap` — duplicate trace IDs
|
|
212
|
+
|
|
213
|
+
### Configurable rules
|
|
214
|
+
|
|
215
|
+
Every governance rule has configurable severity:
|
|
216
|
+
|
|
217
|
+
```yaml
|
|
218
|
+
# trackfw.yaml
|
|
219
|
+
rules:
|
|
220
|
+
req_has_adr: "error" # default
|
|
221
|
+
req_has_roadmap: "warning" # relax for tactical REQs
|
|
222
|
+
blocked_has_req: "error"
|
|
223
|
+
wip_limit: "warning"
|
|
224
|
+
stale_wip: "warning"
|
|
225
|
+
adr_orphan: "off" # silence during migration
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
15+ rules available. Adopt progressively — start with `warning`, tighten to `error` as your team builds the habit.
|
|
229
|
+
|
|
230
|
+
### Governance gate
|
|
231
|
+
|
|
232
|
+
```yaml
|
|
233
|
+
# trackfw.yaml
|
|
234
|
+
governance_mode: strict # CI fails on any violation
|
|
235
|
+
# governance_mode: lenient # CI passes with warnings only
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
177
240
|
## REQ-driven ADR discovery
|
|
178
241
|
|
|
179
242
|
When you run `trackfw req new`, the wizard analyzes your intent and asks targeted questions for each detected domain — authentication, UI, persistence, API, deploy, events. Unanswered architectural decisions become ADR drafts automatically.
|
|
@@ -209,6 +272,26 @@ $ trackfw validate
|
|
|
209
272
|
|
|
210
273
|
Designed to run as a **pre-commit hook** and a **CI quality gate**. `trackfw init` wires both automatically for your stack.
|
|
211
274
|
|
|
275
|
+
### JSON output for CI integration
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
trackfw validate --json
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
{
|
|
283
|
+
"summary": { "violations": 2, "warnings": 1, "mode": "strict", "exit_code": 1 },
|
|
284
|
+
"violations": [
|
|
285
|
+
{ "rule": "wip_has_req", "file": "roadmaps/wip/auth-service.md" }
|
|
286
|
+
],
|
|
287
|
+
"warnings": [
|
|
288
|
+
{ "rule": "stale_wip", "file": "roadmaps/wip/auth-service.md" }
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Use `--json` for programmatic CI parsing, Slack notifications, or custom reporting.
|
|
294
|
+
|
|
212
295
|
---
|
|
213
296
|
|
|
214
297
|
## `trackfw status` — current state at a glance
|
|
@@ -277,12 +360,14 @@ The governance structure (`docs/adr/`, `docs/req/`, `docs/roadmaps/`) is always
|
|
|
277
360
|
4. **One active roadmap at a time** — parallel work without traceability is the root of most delivery chaos.
|
|
278
361
|
5. **Human-readable, machine-parseable** — every artifact is a Markdown file with a predictable structure.
|
|
279
362
|
6. **Guided, not prescriptive** — the wizard surfaces decisions you might not know to ask; it never blocks work unnecessarily.
|
|
363
|
+
7. **Configurable by design** — every governance rule has a severity (`off`/`warning`/`error`). Start permissive, tighten as your team builds the habit.
|
|
364
|
+
8. **AI-agent aware** — `roadmap_namespacing: by_agent` and `trace_id_field` make AI-generated work traceable and auditable by default.
|
|
280
365
|
|
|
281
366
|
---
|
|
282
367
|
|
|
283
368
|
## What trackfw is not
|
|
284
369
|
|
|
285
|
-
- Not a project management SaaS — no
|
|
370
|
+
- Not a project management SaaS — no accounts, no cloud sync, no data leaving your repository. A local dashboard is available via `trackfw serve`.
|
|
286
371
|
- Not a replacement for Git history — it complements, not duplicates
|
|
287
372
|
- Not a task tracker — use GitHub Issues, Linear, or Jira for tasks; trackfw governs the *why*
|
|
288
373
|
- Not opinionated about how you write code — only about how you document decisions
|
package/package.json
CHANGED
package/src/commands/index.js
CHANGED
|
@@ -26,6 +26,9 @@ function createProgram() {
|
|
|
26
26
|
program.addCommand(require('./help'))
|
|
27
27
|
program.addCommand(require('./configure'))
|
|
28
28
|
|
|
29
|
+
const { createServeCommand } = require('./serve')
|
|
30
|
+
program.addCommand(createServeCommand())
|
|
31
|
+
|
|
29
32
|
// plugin dispatch — comandos desconhecidos tentam executar plugin
|
|
30
33
|
program.hook('preSubcommand', () => {})
|
|
31
34
|
|
package/src/commands/metrics.js
CHANGED
|
@@ -232,4 +232,11 @@ cmd.action((opts) => {
|
|
|
232
232
|
}
|
|
233
233
|
})
|
|
234
234
|
|
|
235
|
+
// Exportar o comando como default para compatibilidade com index.js
|
|
236
|
+
// e expor funções utilitárias para reutilização (ex: api_metrics.js do serve)
|
|
237
|
+
cmd.parseLog = parseLog
|
|
238
|
+
cmd.calculate = calculate
|
|
239
|
+
|
|
235
240
|
module.exports = cmd
|
|
241
|
+
module.exports.parseLog = parseLog
|
|
242
|
+
module.exports.calculate = calculate
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const http = require('http')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const { Command } = require('commander')
|
|
7
|
+
const config = require('../config')
|
|
8
|
+
const { handleBoard } = require('../serve/api_board')
|
|
9
|
+
const { handleChain } = require('../serve/api_chain')
|
|
10
|
+
const { handleMetrics } = require('../serve/api_metrics')
|
|
11
|
+
const { handleFile } = require('../serve/api_file')
|
|
12
|
+
|
|
13
|
+
const STATIC_DIR = path.join(__dirname, '..', 'serve', 'static')
|
|
14
|
+
|
|
15
|
+
// Mapa de extensão → Content-Type
|
|
16
|
+
const MIME = {
|
|
17
|
+
'.html': 'text/html; charset=utf-8',
|
|
18
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
19
|
+
'.css': 'text/css; charset=utf-8',
|
|
20
|
+
'.json': 'application/json',
|
|
21
|
+
'.svg': 'image/svg+xml',
|
|
22
|
+
'.ico': 'image/x-icon',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* serveStatic serve arquivos do STATIC_DIR.
|
|
27
|
+
* Retorna 404 se o arquivo não existe ou se o path tenta escape (path traversal).
|
|
28
|
+
* @param {string} urlPath - pathname da URL (ex: '/static/app.js')
|
|
29
|
+
* @param {http.ServerResponse} res
|
|
30
|
+
*/
|
|
31
|
+
function serveStatic(urlPath, res) {
|
|
32
|
+
// Remove o prefixo '/static'
|
|
33
|
+
const relative = urlPath.replace(/^\/static/, '') || '/index.html'
|
|
34
|
+
const resolved = path.resolve(path.join(STATIC_DIR, relative))
|
|
35
|
+
|
|
36
|
+
// Segurança: path traversal
|
|
37
|
+
if (!resolved.startsWith(path.resolve(STATIC_DIR) + path.sep) && resolved !== path.resolve(STATIC_DIR)) {
|
|
38
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
39
|
+
res.end('Forbidden')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(resolved)) {
|
|
44
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
45
|
+
res.end('Not Found')
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ext = path.extname(resolved).toLowerCase()
|
|
50
|
+
const contentType = MIME[ext] || 'application/octet-stream'
|
|
51
|
+
|
|
52
|
+
let content
|
|
53
|
+
try {
|
|
54
|
+
content = fs.readFileSync(resolved)
|
|
55
|
+
} catch (_) {
|
|
56
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
57
|
+
res.end('Internal Server Error')
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
res.writeHead(200, { 'Content-Type': contentType })
|
|
62
|
+
res.end(content)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* createServer cria o servidor HTTP do trackfw serve.
|
|
67
|
+
* @param {object} cfg - configuração do trackfw (resultado de config.load())
|
|
68
|
+
* @param {number} port
|
|
69
|
+
* @returns {http.Server}
|
|
70
|
+
*/
|
|
71
|
+
function createServer(cfg, port) {
|
|
72
|
+
return http.createServer((req, res) => {
|
|
73
|
+
// CORS permissivo para desenvolvimento local
|
|
74
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
75
|
+
|
|
76
|
+
let urlObj
|
|
77
|
+
try {
|
|
78
|
+
urlObj = new URL(req.url, `http://localhost:${port}`)
|
|
79
|
+
} catch (_) {
|
|
80
|
+
res.writeHead(400)
|
|
81
|
+
res.end('Bad Request')
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pathname = urlObj.pathname
|
|
86
|
+
|
|
87
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
88
|
+
const indexPath = path.join(STATIC_DIR, 'index.html')
|
|
89
|
+
if (!fs.existsSync(indexPath)) {
|
|
90
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
91
|
+
res.end('index.html not found')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
95
|
+
res.end(fs.readFileSync(indexPath))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (pathname.startsWith('/static/')) {
|
|
100
|
+
serveStatic(pathname, res)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (pathname === '/api/board') {
|
|
105
|
+
handleBoard(cfg, req, res)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (pathname === '/api/chain') {
|
|
110
|
+
handleChain(cfg, req, res)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (pathname === '/api/metrics') {
|
|
115
|
+
handleMetrics(cfg, req, res)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (pathname === '/api/file') {
|
|
120
|
+
handleFile(cfg, req, res)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
125
|
+
res.end('Not found')
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* createServeCommand retorna o comando commander 'serve'.
|
|
131
|
+
* @returns {Command}
|
|
132
|
+
*/
|
|
133
|
+
function createServeCommand() {
|
|
134
|
+
const cmd = new Command('serve')
|
|
135
|
+
cmd
|
|
136
|
+
.description('Inicia o servidor HTTP do trackfw dashboard (kanban + chain + metrics)')
|
|
137
|
+
.option('--port <port>', 'Porta do servidor', '8080')
|
|
138
|
+
.option('--no-open', 'Não abrir o browser automaticamente')
|
|
139
|
+
.action((opts) => {
|
|
140
|
+
const cfg = config.load()
|
|
141
|
+
const port = parseInt(opts.port, 10) || 8080
|
|
142
|
+
|
|
143
|
+
const server = createServer(cfg, port)
|
|
144
|
+
|
|
145
|
+
server.listen(port, '127.0.0.1', () => {
|
|
146
|
+
console.log(`trackfw serve: http://localhost:${port}`)
|
|
147
|
+
|
|
148
|
+
if (opts.open !== false) {
|
|
149
|
+
// Tentar abrir o browser
|
|
150
|
+
const url = `http://localhost:${port}`
|
|
151
|
+
const { exec } = require('child_process')
|
|
152
|
+
const platform = process.platform
|
|
153
|
+
let openCmd
|
|
154
|
+
if (platform === 'darwin') openCmd = `open "${url}"`
|
|
155
|
+
else if (platform === 'win32') openCmd = `start "" "${url}"`
|
|
156
|
+
else openCmd = `xdg-open "${url}"`
|
|
157
|
+
exec(openCmd, (err) => {
|
|
158
|
+
if (err) console.warn(`Não foi possível abrir o browser: ${err.message}`)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
server.on('error', (err) => {
|
|
164
|
+
if (err.code === 'EADDRINUSE') {
|
|
165
|
+
console.error(`Porta ${port} já está em uso. Use --port para especificar outra.`)
|
|
166
|
+
} else {
|
|
167
|
+
console.error(`Erro no servidor: ${err.message}`)
|
|
168
|
+
}
|
|
169
|
+
process.exit(1)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Graceful shutdown
|
|
173
|
+
process.on('SIGINT', () => {
|
|
174
|
+
server.close(() => {
|
|
175
|
+
console.log('\ntrackfw serve: encerrado.')
|
|
176
|
+
process.exit(0)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
return cmd
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { createServeCommand, createServer }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* extractTitle retorna o título do arquivo: primeira linha '# ...' ou nome do arquivo sem extensão.
|
|
10
|
+
* @param {string} content
|
|
11
|
+
* @param {string} filename
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function extractTitle(content, filename) {
|
|
15
|
+
for (const line of content.split('\n')) {
|
|
16
|
+
const trimmed = line.trim()
|
|
17
|
+
if (trimmed.startsWith('# ')) {
|
|
18
|
+
return trimmed.slice(2).trim()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return filename.replace(/\.md$/, '')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* scanState lê todos os .md de um diretório e retorna os itens do kanban.
|
|
26
|
+
* @param {string} dir
|
|
27
|
+
* @param {string} state
|
|
28
|
+
* @param {string} agent - agente ou '' para flat
|
|
29
|
+
* @param {string} roadmapDir - diretório base (para montar path relativo)
|
|
30
|
+
* @returns {Array<{file: string, title: string, state: string, agent: string, path: string}>}
|
|
31
|
+
*/
|
|
32
|
+
function scanState(dir, state, agent, roadmapDir) {
|
|
33
|
+
const items = []
|
|
34
|
+
let files = []
|
|
35
|
+
try {
|
|
36
|
+
files = fs.readdirSync(dir).filter(f => {
|
|
37
|
+
if (!f.endsWith('.md')) return false
|
|
38
|
+
try { return !fs.statSync(path.join(dir, f)).isDirectory() } catch (_) { return false }
|
|
39
|
+
})
|
|
40
|
+
} catch (_) {
|
|
41
|
+
return items
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
let content = ''
|
|
46
|
+
try { content = fs.readFileSync(path.join(dir, file), 'utf8') } catch (_) {}
|
|
47
|
+
const title = extractTitle(content, file)
|
|
48
|
+
const relPath = agent
|
|
49
|
+
? path.join(roadmapDir, agent, state, file)
|
|
50
|
+
: path.join(roadmapDir, state, file)
|
|
51
|
+
|
|
52
|
+
items.push({ file, title, state, agent: agent || '', path: relPath })
|
|
53
|
+
}
|
|
54
|
+
return items
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* handleBoard responde ao GET /api/board com o kanban em JSON.
|
|
59
|
+
* @param {object} cfg - configuração do trackfw
|
|
60
|
+
* @param {http.IncomingMessage} req
|
|
61
|
+
* @param {http.ServerResponse} res
|
|
62
|
+
*/
|
|
63
|
+
function handleBoard(cfg, req, res) {
|
|
64
|
+
const roadmapDir = cfg.roadmapDir || 'docs/roadmaps'
|
|
65
|
+
const namespacing = cfg.roadmapNamespacing || 'flat'
|
|
66
|
+
|
|
67
|
+
const columns = { wip: [], backlog: [], blocked: [], done: [], abandoned: [] }
|
|
68
|
+
const agentSet = new Set()
|
|
69
|
+
|
|
70
|
+
if (namespacing === 'by_agent') {
|
|
71
|
+
let agents = cfg.agents || []
|
|
72
|
+
if (!agents.length) {
|
|
73
|
+
try {
|
|
74
|
+
agents = fs.readdirSync(roadmapDir).filter(f => {
|
|
75
|
+
try { return fs.statSync(path.join(roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
76
|
+
})
|
|
77
|
+
} catch (_) { agents = [] }
|
|
78
|
+
}
|
|
79
|
+
for (const agent of agents) {
|
|
80
|
+
agentSet.add(agent)
|
|
81
|
+
for (const state of STATES) {
|
|
82
|
+
const dir = path.join(roadmapDir, agent, state)
|
|
83
|
+
const items = scanState(dir, state, agent, roadmapDir)
|
|
84
|
+
columns[state].push(...items)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
for (const state of STATES) {
|
|
89
|
+
const dir = path.join(roadmapDir, state)
|
|
90
|
+
const items = scanState(dir, state, '', roadmapDir)
|
|
91
|
+
columns[state].push(...items)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const result = {
|
|
96
|
+
columns,
|
|
97
|
+
agents: Array.from(agentSet).sort(),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
101
|
+
res.end(JSON.stringify(result))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { handleBoard }
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const ROADMAP_STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* parseFrontmatter extrai todos os campos de um bloco frontmatter YAML (entre --- e ---).
|
|
10
|
+
* Retorna objeto com chaves/valores string.
|
|
11
|
+
* @param {string} content
|
|
12
|
+
* @returns {Object<string, string>}
|
|
13
|
+
*/
|
|
14
|
+
function parseFrontmatter(content) {
|
|
15
|
+
const fields = {}
|
|
16
|
+
const lines = content.split('\n')
|
|
17
|
+
let started = false
|
|
18
|
+
let inFm = false
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const trimmed = line.trim()
|
|
21
|
+
if (trimmed === '---') {
|
|
22
|
+
if (!started) { started = true; inFm = true; continue }
|
|
23
|
+
break // fecha frontmatter
|
|
24
|
+
}
|
|
25
|
+
if (!inFm) break
|
|
26
|
+
const colonIdx = trimmed.indexOf(':')
|
|
27
|
+
if (colonIdx < 0) continue
|
|
28
|
+
const key = trimmed.slice(0, colonIdx).trim()
|
|
29
|
+
let val = trimmed.slice(colonIdx + 1).trim()
|
|
30
|
+
val = val.replace(/^["']|["']$/g, '')
|
|
31
|
+
if (key) fields[key] = val
|
|
32
|
+
}
|
|
33
|
+
return fields
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* extractTitle retorna a primeira linha '# ...' ou o nome do arquivo sem extensão.
|
|
38
|
+
* @param {string} content
|
|
39
|
+
* @param {string} filename
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function extractTitle(content, filename) {
|
|
43
|
+
for (const line of content.split('\n')) {
|
|
44
|
+
const t = line.trim()
|
|
45
|
+
if (t.startsWith('# ')) return t.slice(2).trim()
|
|
46
|
+
}
|
|
47
|
+
return filename.replace(/\.md$/, '')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* collectNodes lê arquivos .md de um diretório e retorna nós do grafo.
|
|
52
|
+
* @param {string} dir
|
|
53
|
+
* @param {'adr'|'req'|'roadmap'} type
|
|
54
|
+
* @param {string} state
|
|
55
|
+
* @returns {Array<{id: string, type: string, title: string, state: string, fm: Object}>}
|
|
56
|
+
*/
|
|
57
|
+
function collectNodes(dir, type, state) {
|
|
58
|
+
const nodes = []
|
|
59
|
+
let files = []
|
|
60
|
+
try {
|
|
61
|
+
files = fs.readdirSync(dir).filter(f => {
|
|
62
|
+
if (!f.endsWith('.md')) return false
|
|
63
|
+
try { return !fs.statSync(path.join(dir, f)).isDirectory() } catch (_) { return false }
|
|
64
|
+
})
|
|
65
|
+
} catch (_) {
|
|
66
|
+
return nodes
|
|
67
|
+
}
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
let content = ''
|
|
70
|
+
try { content = fs.readFileSync(path.join(dir, file), 'utf8') } catch (_) {}
|
|
71
|
+
const fm = parseFrontmatter(content)
|
|
72
|
+
const title = extractTitle(content, file)
|
|
73
|
+
const id = path.join(dir, file)
|
|
74
|
+
nodes.push({ id, type, title, state, fm })
|
|
75
|
+
}
|
|
76
|
+
return nodes
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* handleChain responde ao GET /api/chain com nodes e edges em JSON.
|
|
81
|
+
* @param {object} cfg
|
|
82
|
+
* @param {http.IncomingMessage} req
|
|
83
|
+
* @param {http.ServerResponse} res
|
|
84
|
+
*/
|
|
85
|
+
function handleChain(cfg, req, res) {
|
|
86
|
+
const adrDirs = cfg.adrDirs || ['docs/adr']
|
|
87
|
+
const reqDir = cfg.reqDir || 'docs/req'
|
|
88
|
+
const roadmapDir = cfg.roadmapDir || 'docs/roadmaps'
|
|
89
|
+
const namespacing = cfg.roadmapNamespacing || 'flat'
|
|
90
|
+
|
|
91
|
+
const allNodes = []
|
|
92
|
+
|
|
93
|
+
// ADRs — scan recursivo de subpastas (done/, wip/, etc.)
|
|
94
|
+
for (const adrDir of adrDirs) {
|
|
95
|
+
const subfolders = ['done', 'wip', 'backlog', 'blocked', 'abandoned', '']
|
|
96
|
+
for (const sub of subfolders) {
|
|
97
|
+
const dir = sub ? path.join(adrDir, sub) : adrDir
|
|
98
|
+
const nodes = collectNodes(dir, 'adr', sub || 'unknown')
|
|
99
|
+
allNodes.push(...nodes)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// REQs
|
|
104
|
+
if (namespacing === 'by_agent') {
|
|
105
|
+
let agents = cfg.agents || []
|
|
106
|
+
if (!agents.length) {
|
|
107
|
+
try {
|
|
108
|
+
agents = fs.readdirSync(reqDir).filter(f => {
|
|
109
|
+
try { return fs.statSync(path.join(reqDir, f)).isDirectory() } catch (_) { return false }
|
|
110
|
+
})
|
|
111
|
+
} catch (_) {}
|
|
112
|
+
}
|
|
113
|
+
for (const agent of agents) {
|
|
114
|
+
for (const state of ROADMAP_STATES) {
|
|
115
|
+
const dir = path.join(reqDir, agent, state)
|
|
116
|
+
allNodes.push(...collectNodes(dir, 'req', state))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
allNodes.push(...collectNodes(reqDir, 'req', 'unknown'))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Roadmaps
|
|
124
|
+
if (namespacing === 'by_agent') {
|
|
125
|
+
let agents = cfg.agents || []
|
|
126
|
+
if (!agents.length) {
|
|
127
|
+
try {
|
|
128
|
+
agents = fs.readdirSync(roadmapDir).filter(f => {
|
|
129
|
+
try { return fs.statSync(path.join(roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
130
|
+
})
|
|
131
|
+
} catch (_) {}
|
|
132
|
+
}
|
|
133
|
+
for (const agent of agents) {
|
|
134
|
+
for (const state of ROADMAP_STATES) {
|
|
135
|
+
const dir = path.join(roadmapDir, agent, state)
|
|
136
|
+
allNodes.push(...collectNodes(dir, 'roadmap', state))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
for (const state of ROADMAP_STATES) {
|
|
141
|
+
const dir = path.join(roadmapDir, state)
|
|
142
|
+
allNodes.push(...collectNodes(dir, 'roadmap', state))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Deduplicar por id (path)
|
|
147
|
+
const nodeMap = new Map()
|
|
148
|
+
for (const n of allNodes) {
|
|
149
|
+
if (!nodeMap.has(n.id)) nodeMap.set(n.id, n)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Construir índice por título e nome de arquivo para resolver referências
|
|
153
|
+
const titleIndex = new Map() // titulo normalizado -> id
|
|
154
|
+
const fileIndex = new Map() // basename -> id
|
|
155
|
+
for (const [id, n] of nodeMap) {
|
|
156
|
+
const norm = n.title.toLowerCase().trim()
|
|
157
|
+
if (!titleIndex.has(norm)) titleIndex.set(norm, id)
|
|
158
|
+
const base = path.basename(id).replace(/\.md$/, '').toLowerCase()
|
|
159
|
+
if (!fileIndex.has(base)) fileIndex.set(base, id)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Construir edges a partir dos campos de frontmatter
|
|
163
|
+
const edges = []
|
|
164
|
+
const edgeSet = new Set()
|
|
165
|
+
|
|
166
|
+
function addEdge(from, to) {
|
|
167
|
+
const key = `${from}→${to}`
|
|
168
|
+
if (!edgeSet.has(key)) {
|
|
169
|
+
edgeSet.add(key)
|
|
170
|
+
edges.push({ from, to })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveRef(val) {
|
|
175
|
+
if (!val) return null
|
|
176
|
+
// Tenta pelo nome de arquivo
|
|
177
|
+
const base = val.replace(/\.md$/, '').toLowerCase().trim()
|
|
178
|
+
if (fileIndex.has(base)) return fileIndex.get(base)
|
|
179
|
+
// Tenta pelo título
|
|
180
|
+
const norm = val.toLowerCase().trim()
|
|
181
|
+
if (titleIndex.has(norm)) return titleIndex.get(norm)
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Campos de link padrão
|
|
186
|
+
const reqFields = (cfg.linkFields && cfg.linkFields.req) || ['REQ:', 'req:']
|
|
187
|
+
const adrFields = (cfg.linkFields && cfg.linkFields.adr) || ['ADR:', 'adr:']
|
|
188
|
+
const roadmapFields = (cfg.linkFields && cfg.linkFields.roadmap) || ['Roadmap:', 'roadmap:']
|
|
189
|
+
|
|
190
|
+
for (const [id, node] of nodeMap) {
|
|
191
|
+
const fm = node.fm || {}
|
|
192
|
+
|
|
193
|
+
// Checar campos de frontmatter para criar arestas
|
|
194
|
+
for (const [fmKey, fmVal] of Object.entries(fm)) {
|
|
195
|
+
const keyLower = fmKey.toLowerCase()
|
|
196
|
+
if (keyLower === 'req' || reqFields.some(f => f.toLowerCase().replace(':', '') === keyLower)) {
|
|
197
|
+
const target = resolveRef(fmVal)
|
|
198
|
+
if (target && target !== id) addEdge(id, target)
|
|
199
|
+
}
|
|
200
|
+
if (keyLower === 'adr' || adrFields.some(f => f.toLowerCase().replace(':', '') === keyLower)) {
|
|
201
|
+
const target = resolveRef(fmVal)
|
|
202
|
+
if (target && target !== id) addEdge(id, target)
|
|
203
|
+
}
|
|
204
|
+
if (keyLower === 'roadmap' || roadmapFields.some(f => f.toLowerCase().replace(':', '') === keyLower)) {
|
|
205
|
+
const target = resolveRef(fmVal)
|
|
206
|
+
if (target && target !== id) addEdge(id, target)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Montar nodes de saída sem o campo fm interno
|
|
212
|
+
const outputNodes = Array.from(nodeMap.values()).map(n => ({
|
|
213
|
+
id: n.id,
|
|
214
|
+
type: n.type,
|
|
215
|
+
title: n.title,
|
|
216
|
+
state: n.state,
|
|
217
|
+
}))
|
|
218
|
+
|
|
219
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
220
|
+
res.end(JSON.stringify({ nodes: outputNodes, edges }))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { handleChain }
|