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 +1 -1
- package/src/commands/adr.js +1 -1
- package/src/commands/discover.js +359 -0
- package/src/commands/index.js +1 -0
- package/src/commands/req.js +1 -1
- package/src/config/index.js +92 -0
- package/src/generators/adr.js +6 -5
- package/src/generators/init.js +7 -0
- package/src/generators/req.js +3 -2
- package/src/generators/roadmap.js +150 -57
- package/src/validator/index.js +196 -97
package/package.json
CHANGED
package/src/commands/adr.js
CHANGED
|
@@ -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('
|
|
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;
|
package/src/commands/index.js
CHANGED
|
@@ -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', () => {})
|
package/src/commands/req.js
CHANGED
|
@@ -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 };
|
package/src/generators/adr.js
CHANGED
|
@@ -40,11 +40,12 @@ function today() {
|
|
|
40
40
|
* @returns {Promise<void>}
|
|
41
41
|
*/
|
|
42
42
|
async function newADR(content) {
|
|
43
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
148
|
+
const filepath = path.join(adrDir, filename)
|
|
148
149
|
const title = slugToTitle(slug)
|
|
149
150
|
|
|
150
151
|
const body = `# ADR: ${title}
|
package/src/generators/init.js
CHANGED
|
@@ -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')
|
package/src/generators/req.js
CHANGED
|
@@ -69,11 +69,12 @@ function toSlug(s) {
|
|
|
69
69
|
* @returns {Promise<void>}
|
|
70
70
|
*/
|
|
71
71
|
async function newREQ(content) {
|
|
72
|
-
|
|
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 =
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
48
|
-
*
|
|
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
|
-
*
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
175
|
+
appendTransitionLog(logBasename, fromState, state)
|
|
120
176
|
console.log(`✓ moved ${basename} → ${targetDir}`)
|
|
121
177
|
}
|
|
122
178
|
|
|
123
179
|
/**
|
|
124
|
-
* appendTransitionLog — append em
|
|
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
|
-
|
|
139
|
-
fs.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
let
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
}
|
package/src/validator/index.js
CHANGED
|
@@ -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
|
|
71
|
+
// adrIsDraft verifica se <adrBasename> contém "Status: Draft" em alguma das adrDirs configuradas.
|
|
55
72
|
function adrIsDraft(basename) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
87
|
+
// validateWIPHasREQ — roadmaps em wip/ sem "REQ:" no conteúdo → violation
|
|
88
|
+
// Suporta modo by_agent via resolveWIPDirs.
|
|
65
89
|
function validateWIPHasREQ() {
|
|
66
|
-
const
|
|
90
|
+
const cfg = config.load()
|
|
91
|
+
const wipDirs = resolveWIPDirs(cfg)
|
|
67
92
|
const violations = []
|
|
68
|
-
for (const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
109
|
+
// validateREQsHaveADR — REQs em <reqDir>/ sem "ADR:" no conteúdo → violation
|
|
82
110
|
function validateREQsHaveADR() {
|
|
83
|
-
const
|
|
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(
|
|
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
|
|
127
|
+
// validateBlockedHasREQ — roadmaps em <roadmapDir>/blocked/ sem "REQ:" → violation
|
|
99
128
|
function validateBlockedHasREQ() {
|
|
100
|
-
const
|
|
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('
|
|
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
|
|
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(
|
|
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
|
|
163
|
+
// validateADRsAreReferenced — ADRs em adrDirs não referenciados em nenhuma REQ → violation
|
|
133
164
|
function validateADRsAreReferenced() {
|
|
134
|
-
const
|
|
135
|
-
|
|
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(
|
|
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
|
|
193
|
+
const cfg = config.load()
|
|
194
|
+
const wipDirs = resolveWIPDirs(cfg)
|
|
158
195
|
const violations = []
|
|
159
|
-
for (const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
content.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
263
|
+
|
|
264
|
+
for (const wipDir of wipDirs) {
|
|
265
|
+
let files = []
|
|
200
266
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
...
|
|
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
|
|
286
|
-
|
|
287
|
-
const done = listDir('docs/roadmaps/done')
|
|
363
|
+
const cfg = config.load()
|
|
364
|
+
let out = '── trackfw status ──────────────────────\n'
|
|
288
365
|
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
388
|
+
out += `\n🔄 WIP (${wip.length})\n`
|
|
389
|
+
for (const f of wip) out += ` ${f}\n`
|
|
294
390
|
|
|
295
|
-
|
|
296
|
-
|
|
391
|
+
out += `\n❌ Blocked (${blocked.length})\n`
|
|
392
|
+
for (const f of blocked) out += ` ${f}\n`
|
|
297
393
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
}
|