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 CHANGED
@@ -1,9 +1,11 @@
1
1
  # trackfw
2
2
 
3
- > Governed software delivery — ADR → REQ → ROADMAP → backlog / wip / done
3
+ > The AI-native governance layer for software delivery — ADR → REQ → ROADMAP → backlog / wip / blocked / done / abandoned
4
4
 
5
5
  [![Release](https://img.shields.io/github/v/release/kgsaran/trackfw)](https://github.com/kgsaran/trackfw/releases/latest)
6
6
  [![Go](https://img.shields.io/badge/go-1.21+-00ADD8?logo=go)](go.mod)
7
+ [![npm](https://img.shields.io/npm/v/trackfw?logo=npm&color=CB3837)](https://www.npmjs.com/package/trackfw)
8
+ [![PyPI](https://img.shields.io/pypi/v/trackfw?logo=python&color=3776AB)](https://pypi.org/project/trackfw/)
7
9
  [![License](https://img.shields.io/github/license/kgsaran/trackfw)](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 UI, no accounts, no cloud sync
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
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",
@@ -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
 
@@ -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 }