trackfw 1.1.0 → 2.1.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.
@@ -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,173 @@ 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
+ // readWIPConfig wip_limit e wip_by_squad do trackfw.yaml no CWD.
218
+ // Retorna { limit: 1, bySquad: false } se o arquivo não existe ou campos ausentes.
219
+ function readWIPConfig() {
220
+ const cfg = { limit: 1, bySquad: false }
221
+ let content
222
+ try {
223
+ content = fs.readFileSync('trackfw.yaml', 'utf8')
224
+ } catch (_) {
225
+ return cfg
182
226
  }
183
- return []
227
+ for (const line of content.split('\n')) {
228
+ const trimmed = line.trim()
229
+ if (trimmed.startsWith('wip_limit:')) {
230
+ const val = trimmed.slice('wip_limit:'.length).trim().split(/\s+/)[0]
231
+ const n = parseInt(val, 10)
232
+ if (!isNaN(n) && n > 0) cfg.limit = n
233
+ }
234
+ if (trimmed.startsWith('wip_by_squad:')) {
235
+ const val = trimmed.slice('wip_by_squad:'.length).trim().split(/\s+/)[0]
236
+ if (val === 'true') cfg.bySquad = true
237
+ }
238
+ }
239
+ return cfg
184
240
  }
185
241
 
186
- // validateStaleWIP roadmaps wip com mtime >= 7 dias warning
187
- function validateStaleWIP() {
242
+ // parseSquadFromFrontmatter extrai o valor do campo "squad:" de um arquivo markdown.
243
+ // Retorna string vazia se ausente ou vazio.
244
+ function parseSquadFromFrontmatter(filePath) {
245
+ let content
246
+ try {
247
+ content = fs.readFileSync(filePath, 'utf8')
248
+ } catch (_) {
249
+ return ''
250
+ }
251
+ for (const line of content.split('\n')) {
252
+ const trimmed = line.trim()
253
+ if (trimmed.startsWith('squad:')) {
254
+ return trimmed.slice('squad:'.length).trim()
255
+ }
256
+ }
257
+ return ''
258
+ }
259
+
260
+ // validateWIPLimit — verifica o WIP limit por agente, por squad ou global conforme trackfw.yaml.
261
+ // Retorna { violations: [], warnings: [] }.
262
+ function validateWIPLimit() {
263
+ const cfg = config.load()
264
+ const violations = []
265
+ const warnings = []
266
+
267
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
268
+ let agents = cfg.agents || []
269
+ if (agents.length === 0) {
270
+ try {
271
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
272
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
273
+ })
274
+ } catch (_) { agents = [] }
275
+ }
276
+ const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
277
+ for (const agent of agents) {
278
+ const entries = listDir(cfg.roadmapDir + '/' + agent + '/wip')
279
+ if (entries.length > limit) {
280
+ warnings.push(`${entries.length} roadmaps in wip/ for agent "${agent}" (limit: ${limit}) — consider focusing`)
281
+ }
282
+ }
283
+ return { violations, warnings }
284
+ }
285
+
286
+ // modo flat (global ou por squad)
188
287
  let files = []
189
288
  try {
190
- files = fs.readdirSync('docs/roadmaps/wip')
191
- .filter(f => f.endsWith('.md'))
192
- .map(f => path.join('docs/roadmaps/wip', f))
289
+ files = fs.readdirSync(path.join(cfg.roadmapDir, 'wip'))
290
+ .filter(f => { try { return !fs.statSync(path.join(cfg.roadmapDir, 'wip', f)).isDirectory() } catch (_) { return false } })
291
+ .map(f => path.join(cfg.roadmapDir, 'wip', f))
193
292
  } catch (_) {
194
- return []
293
+ return { violations, warnings }
195
294
  }
196
295
 
296
+ const wipCfg = readWIPConfig()
297
+
298
+ if (!wipCfg.bySquad) {
299
+ if (files.length > wipCfg.limit) {
300
+ warnings.push(`${files.length} roadmaps in wip/ (limit: ${wipCfg.limit}) — consider focusing`)
301
+ }
302
+ return { violations, warnings }
303
+ }
304
+
305
+ const bySquad = {}
306
+ for (const f of files) {
307
+ let squad = parseSquadFromFrontmatter(f)
308
+ if (!squad) squad = '(no squad)'
309
+ if (!bySquad[squad]) bySquad[squad] = []
310
+ bySquad[squad].push(path.basename(f))
311
+ }
312
+ for (const [squad, items] of Object.entries(bySquad)) {
313
+ if (items.length > wipCfg.limit) {
314
+ warnings.push(`squad "${squad}" has ${items.length} roadmaps in wip/ (limit: ${wipCfg.limit})`)
315
+ }
316
+ }
317
+ return { violations, warnings }
318
+ }
319
+
320
+ // validateSingleWIP — alias retrocompatível de validateWIPLimit (modo flat)
321
+ function validateSingleWIP() {
322
+ return validateWIPLimit()
323
+ }
324
+
325
+ // validateStaleWIP — roadmaps wip com mtime >= 7 dias → warning
326
+ // Suporta modo by_agent via resolveWIPDirs.
327
+ function validateStaleWIP() {
328
+ const cfg = config.load()
329
+ const wipDirs = resolveWIPDirs(cfg)
197
330
  const warnings = []
198
331
  const now = Date.now()
199
- for (const filePath of files) {
332
+
333
+ for (const wipDir of wipDirs) {
334
+ let files = []
200
335
  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
- }
336
+ files = fs.readdirSync(wipDir)
337
+ .filter(f => f.endsWith('.md'))
338
+ .map(f => path.join(wipDir, f))
211
339
  } catch (_) {
212
- // ignorar
340
+ continue
341
+ }
342
+
343
+ for (const filePath of files) {
344
+ try {
345
+ const stat = fs.statSync(filePath)
346
+ const ageMs = now - stat.mtimeMs
347
+ const days = Math.floor(ageMs / (1000 * 60 * 60 * 24))
348
+ if (days >= STALE_WIP_DAYS) {
349
+ const lastModified = stat.mtime.toISOString().slice(0, 10)
350
+ const basename = path.basename(filePath)
351
+ warnings.push(
352
+ `roadmap/wip/${basename} has been in WIP for ${days} days (last modified ${lastModified})`
353
+ )
354
+ }
355
+ } catch (_) {
356
+ // ignorar
357
+ }
213
358
  }
214
359
  }
215
360
  return warnings
@@ -217,10 +362,11 @@ function validateStaleWIP() {
217
362
 
218
363
  // validateREQsNotBlockedByDraftADRs — REQs Open com ADRs Draft na seção "## Blocked by ADRs" → violation
219
364
  function validateREQsNotBlockedByDraftADRs() {
220
- const entries = listDir('docs/req')
365
+ const cfg = config.load()
366
+ const entries = listDir(cfg.reqDir)
221
367
  const violations = []
222
368
  for (const name of entries) {
223
- const filePath = path.join('docs/req', name)
369
+ const filePath = path.join(cfg.reqDir, name)
224
370
  let content
225
371
  try {
226
372
  content = fs.readFileSync(filePath, 'utf8')
@@ -241,10 +387,11 @@ function validateREQsNotBlockedByDraftADRs() {
241
387
 
242
388
  // blockedREQs retorna mapa de reqBasename → [adrBasenames Draft] para uso em getStatus()
243
389
  function blockedREQs() {
244
- const entries = listDir('docs/req')
390
+ const cfg = config.load()
391
+ const entries = listDir(cfg.reqDir)
245
392
  const result = {}
246
393
  for (const name of entries) {
247
- const filePath = path.join('docs/req', name)
394
+ const filePath = path.join(cfg.reqDir, name)
248
395
  let content
249
396
  try {
250
397
  content = fs.readFileSync(filePath, 'utf8')
@@ -262,9 +409,84 @@ function blockedREQs() {
262
409
  return result
263
410
  }
264
411
 
412
+ // readGovernanceMode lê governance_mode e lenient_until do trackfw.yaml no CWD.
413
+ // Retorna { mode: 'strict', lenientUntil: null } se o arquivo não existe ou campos ausentes.
414
+ function readGovernanceMode() {
415
+ let content
416
+ try {
417
+ content = fs.readFileSync('trackfw.yaml', 'utf8')
418
+ } catch (_) {
419
+ return { mode: 'strict', lenientUntil: null }
420
+ }
421
+ let mode = 'strict'
422
+ let lenientUntil = null
423
+ for (const line of content.split('\n')) {
424
+ const trimmed = line.trim()
425
+ if (trimmed.startsWith('governance_mode:')) {
426
+ const val = trimmed.slice('governance_mode:'.length).trim().split(/\s+/)[0]
427
+ if (val) mode = val
428
+ }
429
+ if (trimmed.startsWith('lenient_until:')) {
430
+ const val = trimmed.slice('lenient_until:'.length).trim().split(/\s+/)[0]
431
+ if (val) {
432
+ const d = new Date(val)
433
+ if (!isNaN(d.getTime())) lenientUntil = d
434
+ }
435
+ }
436
+ }
437
+ return { mode, lenientUntil }
438
+ }
439
+
440
+ // isLenient retorna true se o projeto está em modo lenient e o prazo não expirou.
441
+ function isLenient() {
442
+ const gm = readGovernanceMode()
443
+ if (gm.mode !== 'lenient') return false
444
+ if (!gm.lenientUntil) return true
445
+ return new Date() < gm.lenientUntil
446
+ }
447
+
448
+ // lenientUntilDate retorna a data de expiração formatada (YYYY-MM-DD) ou ''.
449
+ function lenientUntilDate() {
450
+ const gm = readGovernanceMode()
451
+ if (gm.mode !== 'lenient' || !gm.lenientUntil) return ''
452
+ return gm.lenientUntil.toISOString().slice(0, 10)
453
+ }
454
+
455
+ // validateFrontmatterPresence — verifica presença de frontmatter em ADRs e REQs
456
+ function validateFrontmatterPresence() {
457
+ const cfg = config.load()
458
+ const violations = []
459
+
460
+ for (const adrDir of cfg.adrDirs) {
461
+ const files = listDir(adrDir).filter(f => f.endsWith('.md'))
462
+ for (const f of files) {
463
+ try {
464
+ const content = fs.readFileSync(path.join(adrDir, f), 'utf8')
465
+ if (!content.startsWith('---')) {
466
+ violations.push(`adr "${f}" has no frontmatter block`)
467
+ }
468
+ } catch (_) {}
469
+ }
470
+ }
471
+
472
+ let reqFiles = []
473
+ try { reqFiles = listDir(cfg.reqDir).filter(f => f.endsWith('.md')) } catch (_) {}
474
+ for (const f of reqFiles) {
475
+ try {
476
+ const content = fs.readFileSync(path.join(cfg.reqDir, f), 'utf8')
477
+ if (!content.startsWith('---')) {
478
+ violations.push(`req "${f}" has no frontmatter block`)
479
+ }
480
+ } catch (_) {}
481
+ }
482
+
483
+ return violations
484
+ }
485
+
265
486
  // validate executa todas as validações e retorna { violations, warnings }
266
487
  async function validate() {
267
- const violations = [
488
+ const wipLimitResult = validateWIPLimit()
489
+ let violations = [
268
490
  ...validateWIPHasREQ(),
269
491
  ...validateREQsHaveADR(),
270
492
  ...validateBlockedHasREQ(),
@@ -272,50 +494,92 @@ async function validate() {
272
494
  ...validateADRsAreReferenced(),
273
495
  ...validateWIPHasAcceptanceCriteria(),
274
496
  ...validateREQsNotBlockedByDraftADRs(),
497
+ ...validateFrontmatterPresence(),
498
+ ...wipLimitResult.violations,
275
499
  ]
276
- const warnings = [
277
- ...validateSingleWIP(),
500
+ let warnings = [
501
+ ...wipLimitResult.warnings,
278
502
  ...validateStaleWIP(),
279
503
  ]
504
+ // Modo lenient: mover violations para warnings, exit code 0
505
+ if (isLenient()) {
506
+ warnings = [...warnings, ...violations]
507
+ violations = []
508
+ }
280
509
  return { violations, warnings }
281
510
  }
282
511
 
283
512
  // getStatus retorna string formatada com o status de governança do projeto
284
513
  async function getStatus() {
285
- const wip = listDir('docs/roadmaps/wip')
286
- const blocked = listDir('docs/roadmaps/blocked')
287
- const done = listDir('docs/roadmaps/done')
514
+ const cfg = config.load()
515
+ let out = '── trackfw status ──────────────────────\n'
288
516
 
289
- let out = ''
290
- out += '── trackfw status ──────────────────────\n'
517
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
518
+ let agents = cfg.agents || []
519
+ if (agents.length === 0) {
520
+ try {
521
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
522
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
523
+ })
524
+ } catch (_) { agents = [] }
525
+ }
526
+ out += '\n⚙ WIP by Agent\n'
527
+ for (const agent of agents) {
528
+ const wip = listDir(cfg.roadmapDir + '/' + agent + '/wip')
529
+ if (wip.length > 0) {
530
+ out += ` [${agent}] WIP (${wip.length})\n`
531
+ wip.forEach(f => { out += ` ${f}\n` })
532
+ }
533
+ }
534
+ } else {
535
+ const wip = listDir(cfg.roadmapDir + '/wip')
536
+ const blocked = listDir(cfg.roadmapDir + '/blocked')
537
+ const done = listDir(cfg.roadmapDir + '/done')
291
538
 
292
- out += `\n🔄 WIP (${wip.length})\n`
293
- for (const f of wip) out += ` ${f}\n`
539
+ out += `\n🔄 WIP (${wip.length})\n`
540
+ for (const f of wip) out += ` ${f}\n`
294
541
 
295
- out += `\n❌ Blocked (${blocked.length})\n`
296
- for (const f of blocked) out += ` ${f}\n`
542
+ const wipCfg = readWIPConfig()
543
+ if (wipCfg.bySquad && wip.length > 0) {
544
+ const bySquad = {}
545
+ for (const f of wip) {
546
+ let squad = parseSquadFromFrontmatter(path.join(cfg.roadmapDir, 'wip', f))
547
+ if (!squad) squad = '(no squad)'
548
+ bySquad[squad] = (bySquad[squad] || 0) + 1
549
+ }
550
+ out += `\n⚙ WIP by Squad (limit: ${wipCfg.limit} per squad)\n`
551
+ for (const [squad, count] of Object.entries(bySquad)) {
552
+ const status = count > wipCfg.limit ? '⚠' : '✓'
553
+ const noun = count === 1 ? 'roadmap' : 'roadmaps'
554
+ out += ` ${(squad + ':').padEnd(20)} ${count} ${noun} ${status}\n`
555
+ }
556
+ }
297
557
 
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
- }
558
+ out += `\n❌ Blocked (${blocked.length})\n`
559
+ for (const f of blocked) out += ` ${f}\n`
560
+
561
+ const staleWIPs = validateStaleWIP()
562
+ if (staleWIPs.length > 0) {
563
+ out += `\n⚠ Stale WIP (${staleWIPs.length})\n`
564
+ for (const w of staleWIPs) out += ` ${w}\n`
565
+ }
303
566
 
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`
567
+ const blockedByDraft = blockedREQs()
568
+ const blockedKeys = Object.keys(blockedByDraft)
569
+ if (blockedKeys.length > 0) {
570
+ out += `\n⏳ REQs blocked by Draft ADRs (${blockedKeys.length})\n`
571
+ for (const reqFile of blockedKeys) {
572
+ out += ` ${reqFile}\n`
573
+ for (const adr of blockedByDraft[reqFile]) {
574
+ out += ` → ${adr} (Draft)\n`
575
+ }
312
576
  }
313
577
  }
314
- }
315
578
 
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`
579
+ out += `\n✅ Done (last 5)\n`
580
+ const last5 = done.length > 5 ? done.slice(done.length - 5) : done
581
+ for (const f of last5) out += ` ${f}\n`
582
+ }
319
583
 
320
584
  out += '\n────────────────────────────────────────\n'
321
585
  return out
@@ -324,6 +588,8 @@ async function getStatus() {
324
588
  module.exports = {
325
589
  validate,
326
590
  getStatus,
591
+ isLenient,
592
+ lenientUntilDate,
327
593
  // exportadas para testes unitários
328
594
  validateWIPHasREQ,
329
595
  validateREQsHaveADR,
@@ -331,10 +597,16 @@ module.exports = {
331
597
  validateREQsHaveRoadmap,
332
598
  validateADRsAreReferenced,
333
599
  validateWIPHasAcceptanceCriteria,
600
+ validateWIPLimit,
334
601
  validateSingleWIP,
335
602
  validateStaleWIP,
336
603
  validateREQsNotBlockedByDraftADRs,
337
604
  parseBlockedADRs,
338
605
  adrIsDraft,
339
606
  listDir,
607
+ resolveWIPDirs,
608
+ readGovernanceMode,
609
+ readWIPConfig,
610
+ parseSquadFromFrontmatter,
611
+ validateFrontmatterPresence,
340
612
  }