trackfw 1.1.0 → 2.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Governed software delivery framework: ADR → REQ → ROADMAP → kanban",
5
5
  "keywords": [
6
6
  "cli",
@@ -25,7 +25,7 @@ cmd.command('new <title>')
25
25
  cmd.command('list')
26
26
  .description(t('adr.list.description'))
27
27
  .action(async () => {
28
- await generators.listADRs('docs/adr')
28
+ await generators.listADRs(require('../config').load().adrDirs[0])
29
29
  })
30
30
 
31
31
  module.exports = cmd
@@ -0,0 +1,359 @@
1
+ 'use strict';
2
+
3
+ const { Command } = require('commander');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ function scan(rootDir) {
8
+ const r = {
9
+ adrDirs: [],
10
+ reqDir: '',
11
+ roadmapDir: '',
12
+ roadmapNamespacing: 'flat',
13
+ agents: [],
14
+ adrCount: 0,
15
+ reqCount: 0,
16
+ roadmapCount: 0,
17
+ hasTrackfwYAML: false,
18
+ hasTrackfwLog: false,
19
+ governanceScore: 0,
20
+ hookFramework: 'none',
21
+ ciSystem: 'none',
22
+ };
23
+
24
+ // trackfw.yaml
25
+ r.hasTrackfwYAML = fs.existsSync(path.join(rootDir, 'trackfw.yaml'));
26
+
27
+ // REQ dir
28
+ for (const candidate of ['docs/req', 'docs/requisições', 'docs/requirements', 'docs/reqs']) {
29
+ const full = path.join(rootDir, candidate);
30
+ if (isDir(full)) {
31
+ r.reqDir = candidate;
32
+ r.reqCount = countMD(full);
33
+ break;
34
+ }
35
+ }
36
+
37
+ // ADR dirs
38
+ const adrRoot = path.join(rootDir, 'docs', 'adr');
39
+ if (isDir(adrRoot)) {
40
+ const subDirs = listSubDirs(adrRoot);
41
+ if (subDirs.length > 0) {
42
+ for (const sub of subDirs) {
43
+ const rel = 'docs/adr/' + sub;
44
+ r.adrDirs.push(rel);
45
+ r.adrCount += countMD(path.join(rootDir, rel));
46
+ }
47
+ } else {
48
+ r.adrDirs = ['docs/adr'];
49
+ r.adrCount = countMD(adrRoot);
50
+ }
51
+ }
52
+
53
+ // Roadmap dir e namespacing
54
+ const roadmapRoot = path.join(rootDir, 'docs', 'roadmaps');
55
+ if (isDir(roadmapRoot)) {
56
+ r.roadmapDir = 'docs/roadmaps';
57
+ const agentDirs = listSubDirs(roadmapRoot);
58
+ let byAgent = false;
59
+ const agents = [];
60
+ for (const sub of agentDirs) {
61
+ const wipDir = path.join(roadmapRoot, sub, 'wip');
62
+ const backlogDir = path.join(roadmapRoot, sub, 'backlog');
63
+ const doneDir = path.join(roadmapRoot, sub, 'done');
64
+ const abandonedDir = path.join(roadmapRoot, sub, 'abandoned');
65
+ const blockedDir = path.join(roadmapRoot, sub, 'blocked');
66
+ if (isDir(wipDir) || isDir(backlogDir) || isDir(doneDir) || isDir(abandonedDir) || isDir(blockedDir)) {
67
+ byAgent = true;
68
+ agents.push(sub);
69
+ }
70
+ }
71
+ if (byAgent) {
72
+ r.roadmapNamespacing = 'by_agent';
73
+ r.agents = agents;
74
+ for (const agent of agents) {
75
+ for (const state of ['backlog', 'wip', 'blocked', 'done', 'abandoned']) {
76
+ r.roadmapCount += countMD(path.join(roadmapRoot, agent, state));
77
+ }
78
+ }
79
+ } else {
80
+ r.roadmapNamespacing = 'flat';
81
+ for (const state of ['backlog', 'wip', 'blocked', 'done', 'abandoned']) {
82
+ r.roadmapCount += countMD(path.join(roadmapRoot, state));
83
+ }
84
+ }
85
+
86
+ r.hasTrackfwLog = fs.existsSync(path.join(roadmapRoot, '.trackfw-log'));
87
+ }
88
+
89
+ // Hook framework
90
+ if (isFile(path.join(rootDir, 'lefthook.yml')) || isFile(path.join(rootDir, '.lefthook.yml'))) {
91
+ r.hookFramework = 'lefthook';
92
+ } else if (isDir(path.join(rootDir, '.husky'))) {
93
+ r.hookFramework = 'husky';
94
+ } else if (isFile(path.join(rootDir, '.pre-commit-config.yaml'))) {
95
+ r.hookFramework = 'pre-commit';
96
+ } else {
97
+ r.hookFramework = 'none';
98
+ }
99
+
100
+ // CI
101
+ if (isDir(path.join(rootDir, '.github', 'workflows'))) {
102
+ r.ciSystem = 'github-actions';
103
+ } else if (isFile(path.join(rootDir, '.gitlab-ci.yml'))) {
104
+ r.ciSystem = 'gitlab';
105
+ } else {
106
+ r.ciSystem = 'none';
107
+ }
108
+
109
+ r.governanceScore = calcScore(r);
110
+ return r;
111
+ }
112
+
113
+ function calcScore(r) {
114
+ let score = 0;
115
+ if (r.adrCount > 0) score += 20;
116
+ if (r.reqCount > 0) score += 20;
117
+ if (r.roadmapCount > 0) score += 20;
118
+ if (r.hasTrackfwYAML) score += 20;
119
+ if (r.hasTrackfwLog) score += 20;
120
+ return score;
121
+ }
122
+
123
+ function generateYAML(r) {
124
+ let out = '# trackfw configuration — gerado por trackfw discover\n';
125
+ out += '# governance_mode: lenient permite validação não-bloqueante durante onboarding\n\n';
126
+ out += 'governance_mode: lenient\n\n';
127
+
128
+ if (r.adrDirs.length > 0) {
129
+ out += 'adr_dirs:\n';
130
+ r.adrDirs.forEach(d => { out += ` - ${d}\n`; });
131
+ } else {
132
+ out += 'adr_dirs:\n - docs/adr\n';
133
+ }
134
+
135
+ out += `req_dir: ${r.reqDir || 'docs/req'}\n`;
136
+ out += `roadmap_dir: ${r.roadmapDir || 'docs/roadmaps'}\n`;
137
+ out += `roadmap_namespacing: ${r.roadmapNamespacing}\n`;
138
+
139
+ if (r.agents.length > 0) {
140
+ out += 'agents:\n';
141
+ r.agents.forEach(a => { out += ` - ${a}\n`; });
142
+ }
143
+
144
+ out += `hooks: ${r.hookFramework}\n`;
145
+ out += `ci: ${r.ciSystem}\n`;
146
+
147
+ return out;
148
+ }
149
+
150
+ function generateBootstrapLog(r, rootDir) {
151
+ let out = '';
152
+ const roadmapRoot = path.join(rootDir, r.roadmapDir);
153
+
154
+ const appendEntries = (dir, agent) => {
155
+ if (!isDir(dir)) return;
156
+ for (const entry of fs.readdirSync(dir)) {
157
+ if (!entry.endsWith('.md')) continue;
158
+ const filePath = path.join(dir, entry);
159
+ const stat = fs.statSync(filePath);
160
+ const ts = stat.mtime.toISOString().slice(0, 16).replace('T', ' ');
161
+ const basename = agent ? agent + '/' + entry : entry;
162
+ out += `${ts} ${basename.padEnd(50)} backlog → done\n`;
163
+ }
164
+ };
165
+
166
+ if (r.roadmapNamespacing === 'by_agent') {
167
+ for (const agent of r.agents) {
168
+ appendEntries(path.join(roadmapRoot, agent, 'done'), agent);
169
+ }
170
+ } else {
171
+ appendEntries(path.join(roadmapRoot, 'done'), '');
172
+ }
173
+
174
+ return out;
175
+ }
176
+
177
+ // installGates instala artefatos de governança: validate script, hook entry, CI workflow.
178
+ function installGates(r, rootDir) {
179
+ writeValidateScript(rootDir);
180
+ installHook(r.hookFramework, rootDir);
181
+ if (r.ciSystem === 'github-actions') {
182
+ writeCIWorkflow(rootDir);
183
+ }
184
+ }
185
+
186
+ function writeValidateScript(rootDir) {
187
+ const scriptsDir = path.join(rootDir, 'scripts');
188
+ if (!isDir(scriptsDir)) fs.mkdirSync(scriptsDir, { recursive: true });
189
+ const content = '#!/usr/bin/env bash\nset -euo pipefail\ntrackfw validate\n';
190
+ const dest = path.join(scriptsDir, 'trackfw-validate.sh');
191
+ fs.writeFileSync(dest, content, { mode: 0o755 });
192
+ }
193
+
194
+ function installHook(framework, rootDir) {
195
+ const hookEntry = '\npre-commit:\n commands:\n trackfw-validate:\n run: scripts/trackfw-validate.sh\n';
196
+ const huskyEntry = '\nscripts/trackfw-validate.sh\n';
197
+
198
+ if (framework === 'lefthook') {
199
+ let cfgPath = path.join(rootDir, 'lefthook.yml');
200
+ if (!isFile(cfgPath)) cfgPath = path.join(rootDir, '.lefthook.yml');
201
+ const content = fs.readFileSync(cfgPath, 'utf8');
202
+ if (content.includes('trackfw')) return; // idempotente
203
+ fs.appendFileSync(cfgPath, hookEntry, 'utf8');
204
+ } else if (framework === 'husky') {
205
+ const huskyHook = path.join(rootDir, '.husky', 'pre-commit');
206
+ fs.appendFileSync(huskyHook, huskyEntry, 'utf8');
207
+ } else {
208
+ console.log('⚠ No hook framework detected — skipping hook installation');
209
+ }
210
+ }
211
+
212
+ function writeCIWorkflow(rootDir) {
213
+ const workflowsDir = path.join(rootDir, '.github', 'workflows');
214
+ if (!isDir(workflowsDir)) fs.mkdirSync(workflowsDir, { recursive: true });
215
+ const dest = path.join(workflowsDir, 'trackfw-validate.yml');
216
+ if (isFile(dest)) return; // idempotente
217
+ const content = `name: trackfw validate
218
+ on: [push, pull_request]
219
+ jobs:
220
+ governance:
221
+ runs-on: ubuntu-latest
222
+ steps:
223
+ - uses: actions/checkout@v4
224
+ - uses: actions/setup-go@v5
225
+ with:
226
+ go-version: "1.22"
227
+ - run: go install github.com/kgsaran/trackfw/cmd/trackfw@latest
228
+ - run: trackfw validate
229
+ `;
230
+ fs.writeFileSync(dest, content, 'utf8');
231
+ }
232
+
233
+ // helpers
234
+ function isDir(p) {
235
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
236
+ }
237
+
238
+ function isFile(p) {
239
+ try { return fs.statSync(p).isFile(); } catch { return false; }
240
+ }
241
+
242
+ function countMD(dir) {
243
+ let n = 0;
244
+ function walk(d) {
245
+ let entries;
246
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
247
+ for (const e of entries) {
248
+ if (e.isDirectory()) walk(path.join(d, e.name));
249
+ else if (e.name.endsWith('.md')) n++;
250
+ }
251
+ }
252
+ walk(dir);
253
+ return n;
254
+ }
255
+
256
+ function listSubDirs(dir) {
257
+ try {
258
+ return fs.readdirSync(dir).filter(f => {
259
+ try { return fs.statSync(path.join(dir, f)).isDirectory(); } catch { return false; }
260
+ });
261
+ } catch { return []; }
262
+ }
263
+
264
+ const cmd = new Command('discover');
265
+ cmd.description('Scan the repository and auto-detect the governance structure');
266
+ cmd.option('--init', 'generate trackfw.yaml calibrated for this project');
267
+ cmd.option('--bootstrap-log', 'create retroactive .trackfw-log from done/ files');
268
+ cmd.action((opts) => {
269
+ const cwd = process.cwd();
270
+ console.log(`trackfw discover — scanning ${cwd}\n`);
271
+
272
+ const r = scan(cwd);
273
+
274
+ // ADR dirs
275
+ if (r.adrCount > 0) {
276
+ const dirs = r.adrDirs.join(', ');
277
+ console.log(`✓ ADRs found: ${String(r.adrCount).padEnd(4)} (${dirs})`);
278
+ } else {
279
+ console.log('⚠ No ADRs found');
280
+ }
281
+
282
+ // REQ dir
283
+ if (r.reqCount > 0) {
284
+ console.log(`✓ REQs found: ${String(r.reqCount).padEnd(4)} (${r.reqDir})`);
285
+ } else {
286
+ console.log('⚠ No REQs found');
287
+ }
288
+
289
+ // Roadmaps
290
+ if (r.roadmapCount > 0) {
291
+ const mode = r.roadmapNamespacing === 'by_agent' ? 'by_agent mode' : r.roadmapNamespacing;
292
+ console.log(`✓ Roadmaps found: ${String(r.roadmapCount).padEnd(4)} (${r.roadmapDir} — ${mode})`);
293
+ } else {
294
+ console.log('⚠ No roadmaps found');
295
+ }
296
+
297
+ // Agents
298
+ if (r.agents.length > 0) {
299
+ console.log(`✓ Agents detected: ${r.agents.join(', ')}`);
300
+ }
301
+
302
+ // trackfw.yaml
303
+ if (!r.hasTrackfwYAML) {
304
+ console.log('⚠ No trackfw.yaml — run with --init to generate one');
305
+ } else {
306
+ console.log('✓ trackfw.yaml found');
307
+ }
308
+
309
+ // .trackfw-log
310
+ if (!r.hasTrackfwLog) {
311
+ console.log('⚠ No .trackfw-log — run with --bootstrap-log to create retroactive history');
312
+ } else {
313
+ console.log('✓ .trackfw-log found');
314
+ }
315
+
316
+ // hooks
317
+ if (r.hookFramework !== 'none') {
318
+ console.log(`✓ Hooks: ${r.hookFramework}`);
319
+ } else {
320
+ console.log('⚠ No hook framework detected');
321
+ }
322
+
323
+ // CI
324
+ if (r.ciSystem !== 'none') {
325
+ console.log(`✓ CI: ${r.ciSystem}`);
326
+ } else {
327
+ console.log('⚠ No CI system detected');
328
+ }
329
+
330
+ console.log(`\nGovernance Score: ${r.governanceScore}/100`);
331
+
332
+ if (opts.init) {
333
+ const yaml = generateYAML(r);
334
+ fs.writeFileSync('trackfw.yaml', yaml, 'utf8');
335
+ console.log('\n✓ trackfw.yaml generated');
336
+ try {
337
+ installGates(r, cwd);
338
+ console.log('✓ governance gates installed');
339
+ } catch (e) {
340
+ console.log(`⚠ gates install partial: ${e.message}`);
341
+ }
342
+ }
343
+
344
+ if (opts.bootstrapLog) {
345
+ if (!r.roadmapDir) {
346
+ console.error('⚠ No roadmap dir detected — cannot bootstrap log');
347
+ return;
348
+ }
349
+ const logContent = generateBootstrapLog(r, cwd);
350
+ const logPath = r.roadmapDir + '/.trackfw-log';
351
+ fs.appendFileSync(logPath, logContent, 'utf8');
352
+ console.log(`✓ bootstrap log written to ${logPath}`);
353
+ }
354
+ });
355
+
356
+ module.exports = cmd;
357
+ module.exports.scan = scan;
358
+ module.exports.generateYAML = generateYAML;
359
+ module.exports.generateBootstrapLog = generateBootstrapLog;
@@ -18,6 +18,7 @@ function createProgram() {
18
18
  program.addCommand(require('./status'))
19
19
  program.addCommand(require('./log'))
20
20
  program.addCommand(require('./plugins'))
21
+ program.addCommand(require('./discover'))
21
22
 
22
23
  // plugin dispatch — comandos desconhecidos tentam executar plugin
23
24
  program.hook('preSubcommand', () => {})
@@ -64,7 +64,7 @@ cmd.command('new <title>')
64
64
  cmd.command('list')
65
65
  .description(t('req.list.description'))
66
66
  .action(async () => {
67
- listREQs('docs/req')
67
+ listREQs(require('../config').load().reqDir)
68
68
  })
69
69
 
70
70
  module.exports = cmd
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function defaults() {
7
+ return {
8
+ adrDirs: ['docs/adr'],
9
+ reqDir: 'docs/req',
10
+ roadmapDir: 'docs/roadmaps',
11
+ roadmapNamespacing: 'flat',
12
+ agents: [],
13
+ governanceMode: '',
14
+ lenientUntil: '',
15
+ wipLimit: 1,
16
+ wipBySquad: false,
17
+ requireReqInCommit: false,
18
+ };
19
+ }
20
+
21
+ let _instance = null;
22
+
23
+ function load(cwd) {
24
+ if (_instance) return _instance;
25
+ _instance = defaults();
26
+ const yamlPath = path.join(cwd || process.cwd(), 'trackfw.yaml');
27
+ if (!fs.existsSync(yamlPath)) return _instance;
28
+ const content = fs.readFileSync(yamlPath, 'utf8');
29
+ parse(content, _instance);
30
+ return _instance;
31
+ }
32
+
33
+ function reset() {
34
+ _instance = null;
35
+ }
36
+
37
+ function parse(content, cfg) {
38
+ const lines = content.split('\n');
39
+ let inAdrDirs = false;
40
+ let inAgents = false;
41
+ let adrDirs = [];
42
+ let agents = [];
43
+
44
+ for (const rawLine of lines) {
45
+ const line = rawLine.trim();
46
+
47
+ if (inAdrDirs) {
48
+ if (line.startsWith('- ')) {
49
+ adrDirs.push(line.slice(2).trim());
50
+ continue;
51
+ }
52
+ inAdrDirs = false;
53
+ if (adrDirs.length) cfg.adrDirs = adrDirs;
54
+ }
55
+ if (inAgents) {
56
+ if (line.startsWith('- ')) {
57
+ agents.push(line.slice(2).trim());
58
+ continue;
59
+ }
60
+ inAgents = false;
61
+ if (agents.length) cfg.agents = agents;
62
+ }
63
+
64
+ const colonIdx = line.indexOf(':');
65
+ if (colonIdx < 0) continue;
66
+ const key = line.slice(0, colonIdx).trim();
67
+ const val = line.slice(colonIdx + 1).trim();
68
+ if (!key) continue;
69
+
70
+ switch (key) {
71
+ case 'adr_dirs': inAdrDirs = true; adrDirs = []; break;
72
+ case 'req_dir': cfg.reqDir = val; break;
73
+ case 'roadmap_dir': cfg.roadmapDir = val; break;
74
+ case 'roadmap_namespacing': cfg.roadmapNamespacing = val; break;
75
+ case 'agents': inAgents = true; agents = []; break;
76
+ case 'governance_mode': cfg.governanceMode = val; break;
77
+ case 'lenient_until': cfg.lenientUntil = val; break;
78
+ case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
79
+ case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
80
+ case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
81
+ }
82
+ }
83
+
84
+ // flush pending lists at EOF
85
+ if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs;
86
+ if (inAgents && agents.length) cfg.agents = agents;
87
+ }
88
+
89
+ const NAMESPACING_FLAT = 'flat'
90
+ const NAMESPACING_BY_AGENT = 'by_agent'
91
+
92
+ module.exports = { load, reset, defaults, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
@@ -40,11 +40,12 @@ function today() {
40
40
  * @returns {Promise<void>}
41
41
  */
42
42
  async function newADR(content) {
43
- fs.mkdirSync('docs/adr', { recursive: true })
43
+ const adrDir = require('../config').load().adrDirs[0]
44
+ fs.mkdirSync(adrDir, { recursive: true })
44
45
 
45
46
  const slug = toSlug(content.title)
46
47
  const date = today()
47
- const filename = `docs/adr/ADR-${date}-${slug}.md`
48
+ const filename = `${adrDir}/ADR-${date}-${slug}.md`
48
49
 
49
50
  const contextSection = content.context || '<!-- What is the situation that motivates this decision? -->'
50
51
  const decisionSection = content.decision || '<!-- What was decided? -->'
@@ -129,10 +130,10 @@ function parseADRStatus(filepath) {
129
130
  * @returns {Promise<string>} basename do arquivo criado
130
131
  */
131
132
  async function newADRDraft(slug) {
132
- fs.mkdirSync('docs/adr', { recursive: true })
133
+ const adrDir = require('../config').load().adrDirs[0]
134
+ fs.mkdirSync(adrDir, { recursive: true })
133
135
 
134
136
  // Verificar idempotência: buscar arquivo existente com o mesmo slug
135
- const adrDir = 'docs/adr'
136
137
  const existing = fs.existsSync(adrDir)
137
138
  ? fs.readdirSync(adrDir).find((f) => f.match(new RegExp(`^ADR-.*-${slug}\\.md$`)))
138
139
  : null
@@ -144,7 +145,7 @@ async function newADRDraft(slug) {
144
145
 
145
146
  const date = today()
146
147
  const filename = `ADR-${date}-${slug}.md`
147
- const filepath = path.join('docs/adr', filename)
148
+ const filepath = path.join(adrDir, filename)
148
149
  const title = slugToTitle(slug)
149
150
 
150
151
  const body = `# ADR: ${title}
@@ -48,6 +48,13 @@ backend_framework: ${cfg.backendFramework || ''}
48
48
  pkg_manager: ${cfg.pkgManager || ''}
49
49
  hooks: ${cfg.hooks || ''}
50
50
  ci: ${cfg.ci || ''}
51
+
52
+ # governance paths (edit to match your project structure)
53
+ adr_dirs:
54
+ - docs/adr
55
+ req_dir: docs/req
56
+ roadmap_dir: docs/roadmaps
57
+ roadmap_namespacing: flat
51
58
  `
52
59
  fs.writeFileSync('trackfw.yaml', content, 'utf8')
53
60
  console.log(' ✓ trackfw.yaml')
@@ -69,11 +69,12 @@ function toSlug(s) {
69
69
  * @returns {Promise<void>}
70
70
  */
71
71
  async function newREQ(content) {
72
- fs.mkdirSync('docs/req', { recursive: true })
72
+ const reqDir = require('../config').load().reqDir
73
+ fs.mkdirSync(reqDir, { recursive: true })
73
74
 
74
75
  const slug = toSlug(content.title)
75
76
  const date = new Date().toISOString().slice(0, 10)
76
- const filename = `docs/req/REQ-${date}-${slug}.md`
77
+ const filename = `${reqDir}/REQ-${date}-${slug}.md`
77
78
 
78
79
  const motivationSection = content.motivation || '<!-- Why is this requirement needed? What problem does it solve? -->'
79
80
  const criteriaSection = content.criteria || '- [ ]\n- [ ]'
@@ -1,40 +1,80 @@
1
1
  'use strict'
2
2
  const fs = require('fs')
3
3
  const path = require('path')
4
+ const config = require('../config')
4
5
 
5
- const VALID_STATES = {
6
- backlog: 'docs/roadmaps/backlog',
7
- wip: 'docs/roadmaps/wip',
8
- blocked: 'docs/roadmaps/blocked',
9
- done: 'docs/roadmaps/done',
10
- abandoned: 'docs/roadmaps/abandoned',
6
+ const STATE_ORDER = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
7
+
8
+ // stateDir retorna o caminho do diretório para um estado válido no modo flat, ou null se inválido.
9
+ function stateDir(state) {
10
+ const cfg = config.load()
11
+ const valid = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
12
+ if (!valid.includes(state)) return null
13
+ return cfg.roadmapDir + '/' + state
11
14
  }
12
15
 
13
- const STATE_ORDER = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
16
+ // agentStateDir retorna o diretório para um agente+estado em modo by_agent.
17
+ // agent=null usa o primeiro agente configurado (ou "default" se lista vazia).
18
+ function agentStateDir(agent, state) {
19
+ const cfg = config.load()
20
+ const valid = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
21
+ if (!valid.includes(state)) return null
22
+ if (!agent) {
23
+ agent = cfg.agents && cfg.agents.length > 0 ? cfg.agents[0] : 'default'
24
+ }
25
+ return cfg.roadmapDir + '/' + agent + '/' + state
26
+ }
14
27
 
15
- const TRANSITION_LOG_PATH = 'docs/roadmaps/.trackfw-log'
28
+ // logPath retorna o caminho do arquivo de log de transições.
29
+ function logPath() {
30
+ return config.load().roadmapDir + '/.trackfw-log'
31
+ }
16
32
 
17
33
  /**
18
- * listRoadmaps — lista roadmaps agrupados por estado (wip, backlog, blocked, done, abandoned).
34
+ * listRoadmaps — lista roadmaps agrupados por estado (e por agente em modo by_agent).
19
35
  * Se nenhum encontrado imprime mensagem orientando o usuário.
20
36
  */
21
37
  function listRoadmaps() {
38
+ const cfg = config.load()
22
39
  let found = false
23
40
 
24
- for (const state of STATE_ORDER) {
25
- const dir = VALID_STATES[state]
26
- let files = []
27
- try {
28
- files = fs.readdirSync(dir).filter(f => !fs.statSync(path.join(dir, f)).isDirectory() && f.endsWith('.md'))
29
- } catch (_) {
30
- continue
41
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
42
+ let agents = cfg.agents || []
43
+ if (agents.length === 0) {
44
+ try {
45
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
46
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
47
+ })
48
+ } catch (_) { agents = [] }
31
49
  }
32
- if (files.length === 0) continue
33
-
34
- found = true
35
- console.log(`[${state}]`)
36
- for (const f of files) {
37
- console.log(` ${f}`)
50
+ for (const agent of agents) {
51
+ for (const state of STATE_ORDER) {
52
+ const dir = cfg.roadmapDir + '/' + agent + '/' + state
53
+ let files = []
54
+ try {
55
+ files = fs.readdirSync(dir).filter(f => {
56
+ try { return !fs.statSync(path.join(dir, f)).isDirectory() && f.endsWith('.md') } catch (_) { return false }
57
+ })
58
+ } catch (_) { continue }
59
+ if (files.length === 0) continue
60
+ found = true
61
+ console.log(`[${agent}/${state}]`)
62
+ for (const f of files) console.log(` ${f}`)
63
+ }
64
+ }
65
+ } else {
66
+ for (const state of STATE_ORDER) {
67
+ const dir = cfg.roadmapDir + '/' + state
68
+ let files = []
69
+ try {
70
+ files = fs.readdirSync(dir).filter(f => {
71
+ try { return !fs.statSync(path.join(dir, f)).isDirectory() && f.endsWith('.md') } catch (_) { return false }
72
+ })
73
+ } catch (_) { continue }
74
+ if (files.length === 0) continue
75
+ found = true
76
+ console.log(`[${state}]`)
77
+ for (const f of files) console.log(` ${f}`)
38
78
  }
39
79
  }
40
80
 
@@ -44,8 +84,8 @@ function listRoadmaps() {
44
84
  }
45
85
 
46
86
  /**
47
- * showRoadmap — busca docs/roadmaps/ESTADO/NOME*.md (partial match), imprime cabeçalho + conteúdo.
48
- * 0 matches: erro. múltiplos: lista + erro. 1 match: imprime cabeçalho e conteúdo.
87
+ * showRoadmap — busca <roadmapDir>/ESTADO/NOME*.md (partial match, flat) ou
88
+ * <roadmapDir>/AGENTE/ESTADO/NOME*.md (by_agent), imprime cabeçalho + conteúdo.
49
89
  */
50
90
  function showRoadmap(name) {
51
91
  const matches = findRoadmapMatches(name)
@@ -58,9 +98,7 @@ function showRoadmap(name) {
58
98
 
59
99
  if (matches.length > 1) {
60
100
  console.log('Multiple roadmaps found — be more specific:')
61
- for (const m of matches) {
62
- console.log(` ${m}`)
63
- }
101
+ for (const m of matches) console.log(` ${m}`)
64
102
  console.error(`ambiguous match for "${name}"`)
65
103
  process.exitCode = 1
66
104
  return
@@ -78,12 +116,12 @@ function showRoadmap(name) {
78
116
 
79
117
  /**
80
118
  * moveRoadmap — move arquivo para diretório do estado alvo.
81
- * Valida estado, procura arquivo em qualquer estado (case-insensitive partial match),
82
- * move com fs.renameSync, chama appendTransitionLog, imprime confirmação.
119
+ * Em modo by_agent, mantém o agente na hierarquia.
83
120
  */
84
121
  function moveRoadmap(name, state) {
85
- const targetDir = VALID_STATES[state]
86
- if (!targetDir) {
122
+ const cfg = config.load()
123
+ const valid = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
124
+ if (!valid.includes(state)) {
87
125
  console.error(`invalid state "${state}" — valid states: backlog, wip, blocked, done, abandoned`)
88
126
  process.exitCode = 1
89
127
  return
@@ -97,9 +135,7 @@ function moveRoadmap(name, state) {
97
135
  }
98
136
  if (matches.length > 1) {
99
137
  console.log('Multiple roadmaps found — be more specific:')
100
- for (const m of matches) {
101
- console.log(` ${m}`)
102
- }
138
+ for (const m of matches) console.log(` ${m}`)
103
139
  console.error(`ambiguous match for "${name}"`)
104
140
  process.exitCode = 1
105
141
  return
@@ -107,22 +143,41 @@ function moveRoadmap(name, state) {
107
143
 
108
144
  const src = matches[0]
109
145
  const basename = path.basename(src)
110
- const fromState = path.basename(path.dirname(src))
146
+ let targetDir, fromState, logBasename
147
+
148
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
149
+ const agentDir = path.dirname(path.dirname(src))
150
+ const agent = path.basename(agentDir)
151
+ fromState = path.basename(path.dirname(src))
152
+ targetDir = agentStateDir(agent, state)
153
+ if (!targetDir) {
154
+ console.error(`invalid state "${state}"`)
155
+ process.exitCode = 1
156
+ return
157
+ }
158
+ logBasename = agent + '/' + basename
159
+ } else {
160
+ fromState = path.basename(path.dirname(src))
161
+ targetDir = stateDir(state)
162
+ if (!targetDir) {
163
+ console.error(`invalid state "${state}"`)
164
+ process.exitCode = 1
165
+ return
166
+ }
167
+ logBasename = basename
168
+ }
111
169
 
112
- try {
113
- fs.mkdirSync(targetDir, { recursive: true })
114
- } catch (_) {}
170
+ try { fs.mkdirSync(targetDir, { recursive: true }) } catch (_) {}
115
171
 
116
172
  const dst = path.join(targetDir, basename)
117
173
  fs.renameSync(src, dst)
118
174
 
119
- appendTransitionLog(basename, fromState, state)
175
+ appendTransitionLog(logBasename, fromState, state)
120
176
  console.log(`✓ moved ${basename} → ${targetDir}`)
121
177
  }
122
178
 
123
179
  /**
124
- * appendTransitionLog — append em docs/roadmaps/.trackfw-log.
125
- * Formato: `YYYY-MM-DD HH:mm <basename padded to 50> <fromState> → <toState>\n`
180
+ * appendTransitionLog — append em <roadmapDir>/.trackfw-log.
126
181
  */
127
182
  function appendTransitionLog(basename, fromState, toState) {
128
183
  const now = new Date()
@@ -135,24 +190,39 @@ function appendTransitionLog(basename, fromState, toState) {
135
190
  const line = `${timestamp} ${basename.padEnd(50)} ${fromState} → ${toState}\n`
136
191
 
137
192
  try {
138
- fs.mkdirSync(path.dirname(TRANSITION_LOG_PATH), { recursive: true })
139
- fs.appendFileSync(TRANSITION_LOG_PATH, line, 'utf8')
193
+ const lp = logPath()
194
+ fs.mkdirSync(path.dirname(lp), { recursive: true })
195
+ fs.appendFileSync(lp, line, 'utf8')
140
196
  } catch (_) {}
141
197
  }
142
198
 
143
199
  /**
144
- * newRoadmap — cria roadmap em docs/roadmaps/backlog/ROADMAP-YYYY-MM-DD-<slug>.md.
200
+ * newRoadmap — cria roadmap em <roadmapDir>/backlog/ROADMAP-YYYY-MM-DD-<slug>.md.
201
+ * Em modo by_agent, usa o primeiro agente configurado.
145
202
  */
146
203
  function newRoadmap(title, reqPath) {
204
+ const cfg = config.load()
147
205
  const now = new Date()
148
206
  const yyyy = now.getFullYear()
149
207
  const mm = String(now.getMonth() + 1).padStart(2, '0')
150
208
  const dd = String(now.getDate()).padStart(2, '0')
151
209
  const date = `${yyyy}-${mm}-${dd}`
152
210
  const slug = toSlug(title)
153
- const filename = `docs/roadmaps/backlog/ROADMAP-${date}-${slug}.md`
154
211
 
155
- fs.mkdirSync('docs/roadmaps/backlog', { recursive: true })
212
+ let backlogDir
213
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
214
+ backlogDir = agentStateDir(null, 'backlog')
215
+ if (!backlogDir) {
216
+ console.error('cannot resolve backlog dir in by_agent mode')
217
+ process.exitCode = 1
218
+ return
219
+ }
220
+ } else {
221
+ backlogDir = cfg.roadmapDir + '/backlog'
222
+ }
223
+
224
+ const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
225
+ fs.mkdirSync(backlogDir, { recursive: true })
156
226
 
157
227
  const body = `# Roadmap: ${title}
158
228
 
@@ -183,21 +253,43 @@ REQ: ${reqPath || ''}
183
253
 
184
254
  /**
185
255
  * findRoadmapMatches — retorna array de paths que contêm `name` (case-insensitive) em qualquer estado.
256
+ * Suporta modo flat (1 nível) e by_agent (2 níveis).
186
257
  */
187
258
  function findRoadmapMatches(name) {
259
+ const cfg = config.load()
188
260
  const matches = []
189
261
  const nameLower = name.toLowerCase()
190
- for (const state of STATE_ORDER) {
191
- const dir = VALID_STATES[state]
192
- let files = []
193
- try {
194
- files = fs.readdirSync(dir)
195
- } catch (_) {
196
- continue
262
+
263
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
264
+ let agents = cfg.agents || []
265
+ if (agents.length === 0) {
266
+ try {
267
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
268
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
269
+ })
270
+ } catch (_) { agents = ['default'] }
271
+ }
272
+ for (const agent of agents) {
273
+ for (const state of STATE_ORDER) {
274
+ const dir = cfg.roadmapDir + '/' + agent + '/' + state
275
+ let files = []
276
+ try { files = fs.readdirSync(dir) } catch (_) { continue }
277
+ for (const f of files) {
278
+ if (f.toLowerCase().includes(nameLower) && f.endsWith('.md')) {
279
+ matches.push(path.join(dir, f))
280
+ }
281
+ }
282
+ }
197
283
  }
198
- for (const f of files) {
199
- if (f.toLowerCase().includes(nameLower) && f.endsWith('.md')) {
200
- matches.push(path.join(dir, f))
284
+ } else {
285
+ for (const state of STATE_ORDER) {
286
+ const dir = cfg.roadmapDir + '/' + state
287
+ let files = []
288
+ try { files = fs.readdirSync(dir) } catch (_) { continue }
289
+ for (const f of files) {
290
+ if (f.toLowerCase().includes(nameLower) && f.endsWith('.md')) {
291
+ matches.push(path.join(dir, f))
292
+ }
201
293
  }
202
294
  }
203
295
  }
@@ -215,10 +307,11 @@ function toSlug(s) {
215
307
  }
216
308
 
217
309
  module.exports = {
218
- VALID_STATES,
219
310
  listRoadmaps,
220
311
  showRoadmap,
221
312
  moveRoadmap,
222
313
  appendTransitionLog,
223
314
  newRoadmap,
315
+ stateDir,
316
+ agentStateDir,
224
317
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
+ const config = require('../config')
5
6
 
6
7
  const STALE_WIP_DAYS = 7
7
8
 
@@ -21,6 +22,22 @@ function listDir(dir) {
21
22
  }
22
23
  }
23
24
 
25
+ // resolveWIPDirs retorna todos os diretórios wip/ conforme o modo de namespacing.
26
+ function resolveWIPDirs(cfg) {
27
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
28
+ let agents = cfg.agents || []
29
+ if (agents.length === 0) {
30
+ try {
31
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
32
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
33
+ })
34
+ } catch (_) { agents = [] }
35
+ }
36
+ return agents.map(agent => cfg.roadmapDir + '/' + agent + '/wip')
37
+ }
38
+ return [cfg.roadmapDir + '/wip']
39
+ }
40
+
24
41
  // parseBlockedADRs extrai basenames de ADRs da seção "## Blocked by ADRs" de um arquivo REQ.
25
42
  function parseBlockedADRs(filePath) {
26
43
  let content
@@ -51,40 +68,52 @@ function parseBlockedADRs(filePath) {
51
68
  return adrs
52
69
  }
53
70
 
54
- // adrIsDraft verifica se docs/adr/<basename> contém "Status: Draft".
71
+ // adrIsDraft verifica se <adrBasename> contém "Status: Draft" em alguma das adrDirs configuradas.
55
72
  function adrIsDraft(basename) {
56
- try {
57
- const content = fs.readFileSync(path.join('docs', 'adr', basename), 'utf8')
58
- return content.includes('Status: Draft')
59
- } catch (_) {
60
- return false
73
+ const cfg = config.load()
74
+ for (const adrDir of cfg.adrDirs) {
75
+ const p = path.join(adrDir, basename)
76
+ if (fs.existsSync(p)) {
77
+ try {
78
+ return fs.readFileSync(p, 'utf8').includes('Status: Draft')
79
+ } catch (_) {
80
+ // ignorar erro de leitura
81
+ }
82
+ }
61
83
  }
84
+ return false
62
85
  }
63
86
 
64
- // validateWIPHasREQ — roadmaps em docs/roadmaps/wip/ sem "REQ:" no conteúdo → violation
87
+ // validateWIPHasREQ — roadmaps em wip/ sem "REQ:" no conteúdo → violation
88
+ // Suporta modo by_agent via resolveWIPDirs.
65
89
  function validateWIPHasREQ() {
66
- const entries = listDir('docs/roadmaps/wip')
90
+ const cfg = config.load()
91
+ const wipDirs = resolveWIPDirs(cfg)
67
92
  const violations = []
68
- for (const name of entries) {
69
- try {
70
- const content = fs.readFileSync(path.join('docs/roadmaps/wip', name), 'utf8')
71
- if (!content.includes('REQ:') || content.includes('REQ: \n')) {
72
- violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
93
+ for (const wipDir of wipDirs) {
94
+ const entries = listDir(wipDir)
95
+ for (const name of entries) {
96
+ try {
97
+ const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
98
+ if (!content.includes('REQ:') || content.includes('REQ: \n')) {
99
+ violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
100
+ }
101
+ } catch (_) {
102
+ // ignorar erro de leitura
73
103
  }
74
- } catch (_) {
75
- // ignorar erro de leitura
76
104
  }
77
105
  }
78
106
  return violations
79
107
  }
80
108
 
81
- // validateREQsHaveADR — REQs em docs/req/ sem "ADR:" no conteúdo → violation
109
+ // validateREQsHaveADR — REQs em <reqDir>/ sem "ADR:" no conteúdo → violation
82
110
  function validateREQsHaveADR() {
83
- const entries = listDir('docs/req')
111
+ const cfg = config.load()
112
+ const entries = listDir(cfg.reqDir)
84
113
  const violations = []
85
114
  for (const name of entries) {
86
115
  try {
87
- const content = fs.readFileSync(path.join('docs/req', name), 'utf8')
116
+ const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
88
117
  if (!content.includes('ADR:') || content.includes('ADR: \n')) {
89
118
  violations.push(`req "${name}" has no linked ADR`)
90
119
  }
@@ -95,13 +124,14 @@ function validateREQsHaveADR() {
95
124
  return violations
96
125
  }
97
126
 
98
- // validateBlockedHasREQ — roadmaps em docs/roadmaps/blocked/ sem "REQ:" → violation
127
+ // validateBlockedHasREQ — roadmaps em <roadmapDir>/blocked/ sem "REQ:" → violation
99
128
  function validateBlockedHasREQ() {
100
- const entries = listDir('docs/roadmaps/blocked')
129
+ const cfg = config.load()
130
+ const entries = listDir(cfg.roadmapDir + '/blocked')
101
131
  const violations = []
102
132
  for (const name of entries) {
103
133
  try {
104
- const content = fs.readFileSync(path.join('docs/roadmaps/blocked', name), 'utf8')
134
+ const content = fs.readFileSync(path.join(cfg.roadmapDir + '/blocked', name), 'utf8')
105
135
  if (!content.includes('REQ:') || content.includes('REQ: \n')) {
106
136
  violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
107
137
  }
@@ -114,11 +144,12 @@ function validateBlockedHasREQ() {
114
144
 
115
145
  // validateREQsHaveRoadmap — REQs sem "Roadmap:" → violation
116
146
  function validateREQsHaveRoadmap() {
117
- const entries = listDir('docs/req')
147
+ const cfg = config.load()
148
+ const entries = listDir(cfg.reqDir)
118
149
  const violations = []
119
150
  for (const name of entries) {
120
151
  try {
121
- const content = fs.readFileSync(path.join('docs/req', name), 'utf8')
152
+ const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
122
153
  if (!content.includes('Roadmap:') || content.includes('Roadmap: \n')) {
123
154
  violations.push(`req "${name}" has no linked Roadmap`)
124
155
  }
@@ -129,15 +160,19 @@ function validateREQsHaveRoadmap() {
129
160
  return violations
130
161
  }
131
162
 
132
- // validateADRsAreReferenced — ADRs em docs/adr/ não referenciados em nenhuma REQ → violation
163
+ // validateADRsAreReferenced — ADRs em adrDirs não referenciados em nenhuma REQ → violation
133
164
  function validateADRsAreReferenced() {
134
- const adrs = listDir('docs/adr')
135
- const reqEntries = listDir('docs/req')
165
+ const cfg = config.load()
166
+ let adrs = []
167
+ for (const adrDir of cfg.adrDirs) {
168
+ adrs = adrs.concat(listDir(adrDir))
169
+ }
136
170
 
171
+ const reqEntries = listDir(cfg.reqDir)
137
172
  let combined = ''
138
173
  for (const name of reqEntries) {
139
174
  try {
140
- combined += fs.readFileSync(path.join('docs/req', name), 'utf8')
175
+ combined += fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
141
176
  } catch (_) {
142
177
  // ignorar
143
178
  }
@@ -153,63 +188,104 @@ function validateADRsAreReferenced() {
153
188
  }
154
189
 
155
190
  // validateWIPHasAcceptanceCriteria — roadmaps wip sem bloco de critérios de aceite → violation
191
+ // Suporta modo by_agent via resolveWIPDirs.
156
192
  function validateWIPHasAcceptanceCriteria() {
157
- const entries = listDir('docs/roadmaps/wip')
193
+ const cfg = config.load()
194
+ const wipDirs = resolveWIPDirs(cfg)
158
195
  const violations = []
159
- for (const name of entries) {
160
- try {
161
- const content = fs.readFileSync(path.join('docs/roadmaps/wip', name), 'utf8')
162
- const hasBlock =
163
- content.includes('## Acceptance Criteria') ||
164
- content.includes('## Critérios de Aceite') ||
165
- content.includes('acceptance criteria') ||
166
- content.includes('Acceptance Criteria:')
167
- if (!hasBlock) {
168
- violations.push(`roadmap "${name}" is in wip but has no acceptance criteria block`)
196
+ for (const wipDir of wipDirs) {
197
+ const entries = listDir(wipDir)
198
+ for (const name of entries) {
199
+ try {
200
+ const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
201
+ const hasBlock =
202
+ content.includes('## Acceptance Criteria') ||
203
+ content.includes('## Critérios de Aceite') ||
204
+ content.includes('acceptance criteria') ||
205
+ content.includes('Acceptance Criteria:')
206
+ if (!hasBlock) {
207
+ violations.push(`roadmap "${name}" is in wip but has no acceptance criteria block`)
208
+ }
209
+ } catch (_) {
210
+ // ignorar
169
211
  }
170
- } catch (_) {
171
- // ignorar
172
212
  }
173
213
  }
174
214
  return violations
175
215
  }
176
216
 
177
- // validateSingleWIP — mais de 1 roadmap em wip → warning
178
- function validateSingleWIP() {
179
- const entries = listDir('docs/roadmaps/wip')
180
- if (entries.length > 1) {
181
- return [`${entries.length} roadmaps in wip/ (recommended: keep only 1 active at a time)`]
217
+ // validateWIPLimit — mais de wipLimit roadmaps em wip → warning.
218
+ // Em modo by_agent, verifica por agente individualmente.
219
+ function validateWIPLimit() {
220
+ const cfg = config.load()
221
+
222
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
223
+ let agents = cfg.agents || []
224
+ if (agents.length === 0) {
225
+ try {
226
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
227
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
228
+ })
229
+ } catch (_) { agents = [] }
230
+ }
231
+ const warnings = []
232
+ const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
233
+ for (const agent of agents) {
234
+ const dir = cfg.roadmapDir + '/' + agent + '/wip'
235
+ const entries = listDir(dir)
236
+ if (entries.length > limit) {
237
+ warnings.push(`${entries.length} roadmaps in wip/ for agent "${agent}" (limit: ${limit})`)
238
+ }
239
+ }
240
+ return warnings
241
+ }
242
+
243
+ const entries = listDir(cfg.roadmapDir + '/wip')
244
+ const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
245
+ if (entries.length > limit) {
246
+ return [`${entries.length} roadmaps in wip/ (limit: ${limit})`]
182
247
  }
183
248
  return []
184
249
  }
185
250
 
251
+ // validateSingleWIP — alias retrocompatível de validateWIPLimit (modo flat)
252
+ function validateSingleWIP() {
253
+ return validateWIPLimit()
254
+ }
255
+
186
256
  // validateStaleWIP — roadmaps wip com mtime >= 7 dias → warning
257
+ // Suporta modo by_agent via resolveWIPDirs.
187
258
  function validateStaleWIP() {
188
- let files = []
189
- try {
190
- files = fs.readdirSync('docs/roadmaps/wip')
191
- .filter(f => f.endsWith('.md'))
192
- .map(f => path.join('docs/roadmaps/wip', f))
193
- } catch (_) {
194
- return []
195
- }
196
-
259
+ const cfg = config.load()
260
+ const wipDirs = resolveWIPDirs(cfg)
197
261
  const warnings = []
198
262
  const now = Date.now()
199
- for (const filePath of files) {
263
+
264
+ for (const wipDir of wipDirs) {
265
+ let files = []
200
266
  try {
201
- const stat = fs.statSync(filePath)
202
- const ageMs = now - stat.mtimeMs
203
- const days = Math.floor(ageMs / (1000 * 60 * 60 * 24))
204
- if (days >= STALE_WIP_DAYS) {
205
- const lastModified = stat.mtime.toISOString().slice(0, 10)
206
- const basename = path.basename(filePath)
207
- warnings.push(
208
- `roadmap/wip/${basename} has been in WIP for ${days} days (last modified ${lastModified})`
209
- )
210
- }
267
+ files = fs.readdirSync(wipDir)
268
+ .filter(f => f.endsWith('.md'))
269
+ .map(f => path.join(wipDir, f))
211
270
  } catch (_) {
212
- // ignorar
271
+ continue
272
+ }
273
+
274
+ for (const filePath of files) {
275
+ try {
276
+ const stat = fs.statSync(filePath)
277
+ const ageMs = now - stat.mtimeMs
278
+ const days = Math.floor(ageMs / (1000 * 60 * 60 * 24))
279
+ if (days >= STALE_WIP_DAYS) {
280
+ const lastModified = stat.mtime.toISOString().slice(0, 10)
281
+ const basename = path.basename(filePath)
282
+ warnings.push(
283
+ `roadmap/wip/${basename} has been in WIP for ${days} days (last modified ${lastModified})`
284
+ )
285
+ }
286
+ } catch (_) {
287
+ // ignorar
288
+ }
213
289
  }
214
290
  }
215
291
  return warnings
@@ -217,10 +293,11 @@ function validateStaleWIP() {
217
293
 
218
294
  // validateREQsNotBlockedByDraftADRs — REQs Open com ADRs Draft na seção "## Blocked by ADRs" → violation
219
295
  function validateREQsNotBlockedByDraftADRs() {
220
- const entries = listDir('docs/req')
296
+ const cfg = config.load()
297
+ const entries = listDir(cfg.reqDir)
221
298
  const violations = []
222
299
  for (const name of entries) {
223
- const filePath = path.join('docs/req', name)
300
+ const filePath = path.join(cfg.reqDir, name)
224
301
  let content
225
302
  try {
226
303
  content = fs.readFileSync(filePath, 'utf8')
@@ -241,10 +318,11 @@ function validateREQsNotBlockedByDraftADRs() {
241
318
 
242
319
  // blockedREQs retorna mapa de reqBasename → [adrBasenames Draft] para uso em getStatus()
243
320
  function blockedREQs() {
244
- const entries = listDir('docs/req')
321
+ const cfg = config.load()
322
+ const entries = listDir(cfg.reqDir)
245
323
  const result = {}
246
324
  for (const name of entries) {
247
- const filePath = path.join('docs/req', name)
325
+ const filePath = path.join(cfg.reqDir, name)
248
326
  let content
249
327
  try {
250
328
  content = fs.readFileSync(filePath, 'utf8')
@@ -274,7 +352,7 @@ async function validate() {
274
352
  ...validateREQsNotBlockedByDraftADRs(),
275
353
  ]
276
354
  const warnings = [
277
- ...validateSingleWIP(),
355
+ ...validateWIPLimit(),
278
356
  ...validateStaleWIP(),
279
357
  ]
280
358
  return { violations, warnings }
@@ -282,40 +360,59 @@ async function validate() {
282
360
 
283
361
  // getStatus retorna string formatada com o status de governança do projeto
284
362
  async function getStatus() {
285
- const wip = listDir('docs/roadmaps/wip')
286
- const blocked = listDir('docs/roadmaps/blocked')
287
- const done = listDir('docs/roadmaps/done')
363
+ const cfg = config.load()
364
+ let out = '── trackfw status ──────────────────────\n'
288
365
 
289
- let out = ''
290
- out += '── trackfw status ──────────────────────\n'
366
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
367
+ let agents = cfg.agents || []
368
+ if (agents.length === 0) {
369
+ try {
370
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
371
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
372
+ })
373
+ } catch (_) { agents = [] }
374
+ }
375
+ out += '\n⚙ WIP by Agent\n'
376
+ for (const agent of agents) {
377
+ const wip = listDir(cfg.roadmapDir + '/' + agent + '/wip')
378
+ if (wip.length > 0) {
379
+ out += ` [${agent}] WIP (${wip.length})\n`
380
+ wip.forEach(f => { out += ` ${f}\n` })
381
+ }
382
+ }
383
+ } else {
384
+ const wip = listDir(cfg.roadmapDir + '/wip')
385
+ const blocked = listDir(cfg.roadmapDir + '/blocked')
386
+ const done = listDir(cfg.roadmapDir + '/done')
291
387
 
292
- out += `\n🔄 WIP (${wip.length})\n`
293
- for (const f of wip) out += ` ${f}\n`
388
+ out += `\n🔄 WIP (${wip.length})\n`
389
+ for (const f of wip) out += ` ${f}\n`
294
390
 
295
- out += `\n❌ Blocked (${blocked.length})\n`
296
- for (const f of blocked) out += ` ${f}\n`
391
+ out += `\n❌ Blocked (${blocked.length})\n`
392
+ for (const f of blocked) out += ` ${f}\n`
297
393
 
298
- const staleWIPs = validateStaleWIP()
299
- if (staleWIPs.length > 0) {
300
- out += `\n⚠ Stale WIP (${staleWIPs.length})\n`
301
- for (const w of staleWIPs) out += ` ${w}\n`
302
- }
394
+ const staleWIPs = validateStaleWIP()
395
+ if (staleWIPs.length > 0) {
396
+ out += `\n⚠ Stale WIP (${staleWIPs.length})\n`
397
+ for (const w of staleWIPs) out += ` ${w}\n`
398
+ }
303
399
 
304
- const blockedByDraft = blockedREQs()
305
- const blockedKeys = Object.keys(blockedByDraft)
306
- if (blockedKeys.length > 0) {
307
- out += `\n⏳ REQs blocked by Draft ADRs (${blockedKeys.length})\n`
308
- for (const reqFile of blockedKeys) {
309
- out += ` ${reqFile}\n`
310
- for (const adr of blockedByDraft[reqFile]) {
311
- out += ` → ${adr} (Draft)\n`
400
+ const blockedByDraft = blockedREQs()
401
+ const blockedKeys = Object.keys(blockedByDraft)
402
+ if (blockedKeys.length > 0) {
403
+ out += `\n⏳ REQs blocked by Draft ADRs (${blockedKeys.length})\n`
404
+ for (const reqFile of blockedKeys) {
405
+ out += ` ${reqFile}\n`
406
+ for (const adr of blockedByDraft[reqFile]) {
407
+ out += ` → ${adr} (Draft)\n`
408
+ }
312
409
  }
313
410
  }
314
- }
315
411
 
316
- out += `\n✅ Done (last 5)\n`
317
- const last5 = done.length > 5 ? done.slice(done.length - 5) : done
318
- for (const f of last5) out += ` ${f}\n`
412
+ out += `\n✅ Done (last 5)\n`
413
+ const last5 = done.length > 5 ? done.slice(done.length - 5) : done
414
+ for (const f of last5) out += ` ${f}\n`
415
+ }
319
416
 
320
417
  out += '\n────────────────────────────────────────\n'
321
418
  return out
@@ -331,10 +428,12 @@ module.exports = {
331
428
  validateREQsHaveRoadmap,
332
429
  validateADRsAreReferenced,
333
430
  validateWIPHasAcceptanceCriteria,
431
+ validateWIPLimit,
334
432
  validateSingleWIP,
335
433
  validateStaleWIP,
336
434
  validateREQsNotBlockedByDraftADRs,
337
435
  parseBlockedADRs,
338
436
  adrIsDraft,
339
437
  listDir,
438
+ resolveWIPDirs,
340
439
  }