trackfw 1.0.4 → 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.
@@ -0,0 +1,122 @@
1
+ {
2
+ "init": {
3
+ "description": "Inicializa a governança trackfw no projeto atual",
4
+ "prompt": {
5
+ "projectName": "Nome do projeto?",
6
+ "projectType": "Tipo de projeto?",
7
+ "frontendStack": "Stack de frontend?",
8
+ "pkgManager": "Gerenciador de pacotes?",
9
+ "backendLang": "Linguagem de backend?",
10
+ "backendFramework": "Framework de backend?",
11
+ "gitHooks": "Git hooks?",
12
+ "ci": "Sistema de CI?",
13
+ "aiTools": "Quais assistentes de IA você usa?",
14
+ "projectType_fullstack": "Full-stack (frontend + backend)",
15
+ "projectType_frontend": "Somente frontend",
16
+ "projectType_backend": "Somente backend",
17
+ "projectType_governance": "Somente governança (sem stack de build)"
18
+ },
19
+ "success": "✓ trackfw inicializado — execute 'trackfw status' para ver o estado de governança."
20
+ },
21
+ "adr": {
22
+ "description": "Gerenciar Architecture Decision Records",
23
+ "new": {
24
+ "description": "Criar um novo Architecture Decision Record",
25
+ "prompt": {
26
+ "title": "Título do ADR?",
27
+ "status": "Status inicial?",
28
+ "context": "Contexto (o que motiva esta decisão)?",
29
+ "decision": "Decisão (o que foi decidido)?",
30
+ "consequences": "Consequências (positivas e negativas)?",
31
+ "alternatives": "Alternativas consideradas?"
32
+ },
33
+ "created": "✓ ADR criado: {{path}}"
34
+ },
35
+ "list": {
36
+ "description": "Listar todos os ADRs com status",
37
+ "empty": "Nenhum ADR encontrado em docs/adr/"
38
+ }
39
+ },
40
+ "req": {
41
+ "description": "Gerenciar Requisitos",
42
+ "new": {
43
+ "description": "Criar um novo requisito",
44
+ "prompt": {
45
+ "title": "Requisito do projeto",
46
+ "motivation": "Motivação (por que isso é necessário)?",
47
+ "criteria": "Critérios de aceite (um por linha)",
48
+ "domainQuestion_authentication": "Como os usuários serão autenticados?",
49
+ "domainQuestion_ui": "Existe um framework de UI ou design system já escolhido?",
50
+ "domainQuestion_persistence": "Qual engine de banco de dados será usada?",
51
+ "domainQuestion_api": "Qual protocolo de API será usado?",
52
+ "domainQuestion_deploy": "Qual é o destino de deploy?",
53
+ "domainQuestion_events": "Qual message broker será usado?"
54
+ },
55
+ "detectedDomains": "Domínios detectados: {{domains}}",
56
+ "created": "✓ REQ criado: {{path}}",
57
+ "adrDraftsCreated": "ADR drafts criados:",
58
+ "resolveADRs": "Resolva estes ADRs (defina Status: Accepted) antes de criar um roadmap.",
59
+ "adrWarning": "aviso: não foi possível criar ADR draft para {{slug}}: {{message}}"
60
+ },
61
+ "list": {
62
+ "description": "Listar todos os REQs com status",
63
+ "empty": "Nenhum REQ encontrado em docs/req/"
64
+ }
65
+ },
66
+ "roadmap": {
67
+ "description": "Gerenciar Roadmaps",
68
+ "list": {
69
+ "description": "Listar todos os roadmaps agrupados por estado",
70
+ "empty": "Nenhum roadmap encontrado."
71
+ },
72
+ "show": {
73
+ "description": "Exibir roadmap pelo nome (correspondência parcial)",
74
+ "notFound": "Roadmap não encontrado: {{name}}"
75
+ },
76
+ "move": {
77
+ "description": "Mover um roadmap entre estados (backlog|wip|blocked|done|abandoned)",
78
+ "success": "✓ Movido {{name}} → {{state}}",
79
+ "notFound": "Roadmap não encontrado: {{name}}"
80
+ },
81
+ "new": {
82
+ "description": "Criar um novo roadmap a partir de uma REQ",
83
+ "created": "✓ Roadmap criado: {{path}}"
84
+ }
85
+ },
86
+ "validate": {
87
+ "description": "Validar regras de governança (use como gate de CI)",
88
+ "ok": "✓ Nenhuma violação encontrada.",
89
+ "violations": "✗ Violações ({{count}}):",
90
+ "warnings": "⚠ Avisos ({{count}}):"
91
+ },
92
+ "status": {
93
+ "description": "Exibir o estado atual de governança do projeto"
94
+ },
95
+ "log": {
96
+ "description": "Exibir histórico de transições de estado dos roadmaps",
97
+ "empty": "Nenhuma transição registrada ainda.",
98
+ "tail": "Número de transições recentes a exibir",
99
+ "header": "── trackfw log ─────────────────────────"
100
+ },
101
+ "plugins": {
102
+ "description": "Gerenciar plugins do trackfw",
103
+ "list": {
104
+ "description": "Listar plugins instalados",
105
+ "empty": "Nenhum plugin instalado. Use `trackfw plugins add <user/repo>` para instalar um."
106
+ },
107
+ "add": {
108
+ "description": "Instalar um plugin do GitHub Releases (user/repo ou user/repo@tag)",
109
+ "installing": "Instalando plugin de {{repo}}...",
110
+ "success": "Plugin \"{{name}}\" instalado com sucesso."
111
+ },
112
+ "remove": {
113
+ "description": "Remover um plugin instalado",
114
+ "success": "Plugin \"{{name}}\" removido."
115
+ }
116
+ },
117
+ "errors": {
118
+ "notFound": "Não encontrado: {{path}}",
119
+ "downloadFailed": "falha no download: HTTP {{status}} para {{url}}",
120
+ "pluginNotFound": "plugin \"{{name}}\" não encontrado"
121
+ }
122
+ }
@@ -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
  }