trackfw 6.0.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/package.json +3 -2
- package/src/commands/status.js +1 -1
- package/src/commands/sync.js +16 -30
- package/src/commands/update.js +17 -19
- package/src/config/index.js +194 -131
- package/src/generators/roadmap.js +14 -2
- package/src/serve/static/app.js +295 -18
- package/src/serve/static/index.html +106 -6
- package/src/serve/static/style.css +79 -0
- package/src/validator/index.js +222 -68
package/src/validator/index.js
CHANGED
|
@@ -254,22 +254,97 @@ function contentHasMarker(content, markers) {
|
|
|
254
254
|
return false
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
257
|
+
// extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
|
|
258
|
+
// ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
|
|
259
|
+
// documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
|
|
260
|
+
// (ex: citações de código, exemplos) com o status real do artefato. Retorna '' se a linha não
|
|
261
|
+
// existir.
|
|
262
|
+
function extractAdrHeaderStatus(content) {
|
|
263
|
+
const marker = '| Status: '
|
|
264
|
+
for (const line of content.split('\n')) {
|
|
265
|
+
const trimmed = line.trim()
|
|
266
|
+
const idx = trimmed.indexOf(marker)
|
|
267
|
+
if (idx >= 0) {
|
|
268
|
+
let rest = trimmed.slice(idx + marker.length)
|
|
269
|
+
const pipeIdx = rest.indexOf(' |')
|
|
270
|
+
if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
|
|
271
|
+
return rest.trim()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return ''
|
|
260
275
|
}
|
|
261
276
|
|
|
262
|
-
|
|
277
|
+
// extractFrontmatterField extrai o valor de um campo do bloco frontmatter YAML
|
|
278
|
+
// (delimitado por "---" ... "---" no início do arquivo). Retorna '' se o frontmatter
|
|
279
|
+
// estiver ausente ou o campo não existir/estiver vazio.
|
|
280
|
+
function extractFrontmatterField(content, field) {
|
|
281
|
+
if (!content.startsWith('---')) return ''
|
|
282
|
+
const rest = content.slice(3)
|
|
283
|
+
const end = rest.indexOf('\n---')
|
|
284
|
+
if (end < 0) return ''
|
|
285
|
+
const block = rest.slice(0, end)
|
|
286
|
+
for (const line of block.split('\n')) {
|
|
287
|
+
const trimmed = line.trim()
|
|
288
|
+
if (trimmed.startsWith(field + ':')) {
|
|
289
|
+
let val = trimmed.slice(field.length + 1).trim()
|
|
290
|
+
val = val.replace(/^["']|["']$/g, '')
|
|
291
|
+
return val
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return ''
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// resolveAdrStatus extrai o valor bruto do status de um ADR: frontmatter `status:`
|
|
298
|
+
// primeiro — é o campo machine-readable canônico, o mesmo que os geradores (`adr new`,
|
|
299
|
+
// NewADRDraft) escrevem e que a regra folder_status já usa como fonte de verdade — com
|
|
300
|
+
// fallback para a linha de cabeçalho ("> Date: ... | Status: X") somente se o
|
|
301
|
+
// frontmatter estiver ausente ou sem o campo (cobre ADRs legados sem frontmatter). Em um
|
|
302
|
+
// ADR bem formado os dois concordam. ML-1D (2026-08-01): alinha o Node ao Go e ao
|
|
303
|
+
// Python, que já liam frontmatter-first (ADR-2026-08-01-nocao-canonica-de-adr-nao-aceito).
|
|
304
|
+
function resolveAdrStatus(content) {
|
|
305
|
+
const fm = extractFrontmatterField(content, 'status')
|
|
306
|
+
if (fm) return fm
|
|
307
|
+
return extractAdrHeaderStatus(content)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// adrNotAcceptedStatusForRule é o helper canônico de "ADR não aceito": único lugar que conhece o
|
|
311
|
+
// vocabulário Draft/Proposed. Verdadeiro para ADR cujo status seja "Draft" ou "Proposed"; qualquer
|
|
312
|
+
// outro status (Accepted, Superseded, Deprecated, Rejected, ...) conta como aceito por exclusão —
|
|
313
|
+
// não há allowlist fechada de status aceitos.
|
|
314
|
+
//
|
|
315
|
+
// A fonte do status é resolveAdrStatus (frontmatter-first, fallback de cabeçalho — ver acima).
|
|
316
|
+
// Crucial: o fallback de cabeçalho extrai o valor de UMA linha específica
|
|
317
|
+
// (extractAdrHeaderStatus), não faz content.includes('Status: Draft') sobre o documento
|
|
318
|
+
// inteiro — esse era o defeito do código herdado (adrDraftStatusForRule original): um ADR
|
|
319
|
+
// com status real "Accepted" mas cuja prosa cita literalmente a string "Status: Draft"
|
|
320
|
+
// (ex: este próprio ADR, que documenta o bug) seria classificado como não aceito. Ver
|
|
321
|
+
// vault/notes para o caso concreto que expôs isso.
|
|
322
|
+
function adrNotAcceptedStatusForRule(rule, basename, messages) {
|
|
263
323
|
const p = findAdrFile(basename)
|
|
264
|
-
if (!p) return {
|
|
324
|
+
if (!p) return { notAccepted: false, status: '', inspected: true }
|
|
265
325
|
try {
|
|
266
|
-
|
|
326
|
+
const content = fs.readFileSync(p, 'utf8')
|
|
327
|
+
const status = resolveAdrStatus(content)
|
|
328
|
+
const notAccepted = status.toLowerCase() === 'draft' || status.toLowerCase() === 'proposed'
|
|
329
|
+
return { notAccepted, status, inspected: true }
|
|
267
330
|
} catch (err) {
|
|
268
|
-
if (messages) messages.push(inspectionDiagnostic(
|
|
269
|
-
return {
|
|
331
|
+
if (messages) messages.push(inspectionDiagnostic(rule, p, err))
|
|
332
|
+
return { notAccepted: false, status: '', inspected: false }
|
|
270
333
|
}
|
|
271
334
|
}
|
|
272
335
|
|
|
336
|
+
// adrIsDraft verifica se <adrBasename> está em status não aceito (Draft ou Proposed) buscando
|
|
337
|
+
// recursivamente nas adrDirs. Nome mantido por compatibilidade (chamadores existentes); delega no
|
|
338
|
+
// helper canônico adrNotAcceptedStatusForRule.
|
|
339
|
+
function adrIsDraft(basename) {
|
|
340
|
+
return adrDraftStatusForRule(basename, null).draft
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function adrDraftStatusForRule(basename, messages) {
|
|
344
|
+
const result = adrNotAcceptedStatusForRule('blocked_by_draft_adr', basename, messages)
|
|
345
|
+
return { draft: result.notAccepted, inspected: result.inspected }
|
|
346
|
+
}
|
|
347
|
+
|
|
273
348
|
// validateWIPHasREQ — roadmaps em wip/ sem marker REQ no conteúdo → violation
|
|
274
349
|
// Suporta modo by_agent via resolveWIPDirs.
|
|
275
350
|
function validateWIPHasREQ() {
|
|
@@ -419,29 +494,10 @@ function validateWIPHasAcceptanceCriteria() {
|
|
|
419
494
|
return violations
|
|
420
495
|
}
|
|
421
496
|
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
function
|
|
425
|
-
|
|
426
|
-
let content
|
|
427
|
-
try {
|
|
428
|
-
content = fs.readFileSync('trackfw.yaml', 'utf8')
|
|
429
|
-
} catch (_) {
|
|
430
|
-
return cfg
|
|
431
|
-
}
|
|
432
|
-
for (const line of content.split('\n')) {
|
|
433
|
-
const trimmed = line.trim()
|
|
434
|
-
if (trimmed.startsWith('wip_limit:')) {
|
|
435
|
-
const val = trimmed.slice('wip_limit:'.length).trim().split(/\s+/)[0]
|
|
436
|
-
const n = parseInt(val, 10)
|
|
437
|
-
if (!isNaN(n) && n > 0) cfg.limit = n
|
|
438
|
-
}
|
|
439
|
-
if (trimmed.startsWith('wip_by_squad:')) {
|
|
440
|
-
const val = trimmed.slice('wip_by_squad:'.length).trim().split(/\s+/)[0]
|
|
441
|
-
if (val === 'true') cfg.bySquad = true
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return cfg
|
|
497
|
+
// wipConfigFrom deriva { limit, bySquad } a partir do ProjectConfig já normalizado por
|
|
498
|
+
// config.load() — nenhuma releitura de trackfw.yaml acontece aqui.
|
|
499
|
+
function wipConfigFrom(cfg) {
|
|
500
|
+
return { limit: cfg.wipLimit > 0 ? cfg.wipLimit : 1, bySquad: !!cfg.wipBySquad }
|
|
445
501
|
}
|
|
446
502
|
|
|
447
503
|
// parseSquadFromFrontmatter extrai o valor do campo "squad:" de um arquivo markdown.
|
|
@@ -498,7 +554,7 @@ function validateWIPLimit() {
|
|
|
498
554
|
return { violations, warnings }
|
|
499
555
|
}
|
|
500
556
|
|
|
501
|
-
const wipCfg =
|
|
557
|
+
const wipCfg = wipConfigFrom(cfg)
|
|
502
558
|
|
|
503
559
|
if (!wipCfg.bySquad) {
|
|
504
560
|
if (files.length > wipCfg.limit) {
|
|
@@ -622,13 +678,63 @@ function validateREQsNotBlockedByDraftADRs() {
|
|
|
622
678
|
const blockedADRs = parseBlockedADRs(filePath)
|
|
623
679
|
for (const adrBasename of blockedADRs) {
|
|
624
680
|
if (adrDraftStatusForRule(adrBasename, violations).draft) {
|
|
625
|
-
violations.push(`REQ ${path.basename(filePath)} is blocked by
|
|
681
|
+
violations.push(`REQ ${path.basename(filePath)} is blocked by not-accepted ADR: ${adrBasename}`)
|
|
626
682
|
}
|
|
627
683
|
}
|
|
628
684
|
}
|
|
629
685
|
return violations
|
|
630
686
|
}
|
|
631
687
|
|
|
688
|
+
// reqStatusEquals compara o status de uma REQ (case-insensitive) contra o valor esperado.
|
|
689
|
+
// Casa tanto o frontmatter ("status: X") quanto a linha de cabeçalho ("> Date: ... | Status: X"),
|
|
690
|
+
// mesma lógica de detecção usada por reqStatusIsOpen — duplicada aqui (em vez de generalizar
|
|
691
|
+
// reqStatusIsOpen) para não alterar o comportamento de req_roadmap_lifecycle, que já a consome.
|
|
692
|
+
function reqStatusEquals(content, status) {
|
|
693
|
+
const target = String(status).toLowerCase()
|
|
694
|
+
for (const line of content.split('\n')) {
|
|
695
|
+
const trimmed = line.trim()
|
|
696
|
+
const idx = trimmed.indexOf(':')
|
|
697
|
+
if (idx >= 0 && trimmed.slice(0, idx).trim().toLowerCase() === 'status') {
|
|
698
|
+
return trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, '').toLowerCase() === target
|
|
699
|
+
}
|
|
700
|
+
const marker = '| Status: '
|
|
701
|
+
const markerIdx = trimmed.indexOf(marker)
|
|
702
|
+
if (markerIdx >= 0) {
|
|
703
|
+
let rest = trimmed.slice(markerIdx + marker.length)
|
|
704
|
+
const pipeIdx = rest.indexOf(' |')
|
|
705
|
+
if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
|
|
706
|
+
return rest.trim().toLowerCase() === target
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return false
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// validateADRAcceptedWhenREQDone — REQ Done cujo ADR vinculado (campo "ADR:") não está aceito
|
|
713
|
+
// (Draft ou Proposed, via o helper canônico adrNotAcceptedStatusForRule) → violation. Fecha a
|
|
714
|
+
// lacuna que deixou um ADR Proposed atravessar sete REQs Done sem nenhum gate detectar.
|
|
715
|
+
function validateADRAcceptedWhenREQDone() {
|
|
716
|
+
const cfg = config.load()
|
|
717
|
+
const files = resolveReqFiles(cfg)
|
|
718
|
+
const violations = []
|
|
719
|
+
for (const filePath of files) {
|
|
720
|
+
const content = readFileForRule('adr_accepted_when_req_done', filePath, violations)
|
|
721
|
+
if (content === null) continue
|
|
722
|
+
if (!reqStatusEquals(content, 'done')) continue
|
|
723
|
+
|
|
724
|
+
const adrRef = extractRefPath(content, 'ADR')
|
|
725
|
+
if (!adrRef) continue
|
|
726
|
+
const adrBasename = path.basename(adrRef)
|
|
727
|
+
const status = adrNotAcceptedStatusForRule('adr_accepted_when_req_done', adrBasename, violations)
|
|
728
|
+
if (status.notAccepted) {
|
|
729
|
+
const reqBasename = path.basename(filePath)
|
|
730
|
+
violations.push(
|
|
731
|
+
`REQ "${reqBasename}" is Done but linked ADR "${adrBasename}" is not accepted (status: ${status.status})`
|
|
732
|
+
)
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return violations
|
|
736
|
+
}
|
|
737
|
+
|
|
632
738
|
// blockedREQs retorna mapa de reqBasename → [adrBasenames Draft] para uso em getStatus()
|
|
633
739
|
function blockedREQs() {
|
|
634
740
|
const cfg = config.load()
|
|
@@ -652,37 +758,23 @@ function blockedREQs() {
|
|
|
652
758
|
return result
|
|
653
759
|
}
|
|
654
760
|
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
} catch (_) {
|
|
662
|
-
return { mode: 'strict', lenientUntil: null }
|
|
663
|
-
}
|
|
664
|
-
let mode = 'strict'
|
|
761
|
+
// governanceModeFrom deriva { mode, lenientUntil } a partir do ProjectConfig já normalizado por
|
|
762
|
+
// config.load() — nenhuma releitura de trackfw.yaml acontece aqui. cfg.governanceMode chega como
|
|
763
|
+
// o valor bruto do campo (string vazia se ausente); cfg.lenientUntil chega como a data literal
|
|
764
|
+
// (ex.: "2026-08-02"), convertida aqui para Date.
|
|
765
|
+
function governanceModeFrom(cfg) {
|
|
766
|
+
const mode = cfg.governanceMode ? cfg.governanceMode : 'strict'
|
|
665
767
|
let lenientUntil = null
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
if (
|
|
669
|
-
const val = trimmed.slice('governance_mode:'.length).trim().split(/\s+/)[0]
|
|
670
|
-
if (val) mode = val
|
|
671
|
-
}
|
|
672
|
-
if (trimmed.startsWith('lenient_until:')) {
|
|
673
|
-
const val = trimmed.slice('lenient_until:'.length).trim().split(/\s+/)[0]
|
|
674
|
-
if (val) {
|
|
675
|
-
const d = new Date(val)
|
|
676
|
-
if (!isNaN(d.getTime())) lenientUntil = d
|
|
677
|
-
}
|
|
678
|
-
}
|
|
768
|
+
if (cfg.lenientUntil) {
|
|
769
|
+
const d = new Date(cfg.lenientUntil)
|
|
770
|
+
if (!isNaN(d.getTime())) lenientUntil = d
|
|
679
771
|
}
|
|
680
772
|
return { mode, lenientUntil }
|
|
681
773
|
}
|
|
682
774
|
|
|
683
775
|
// isLenient retorna true se o projeto está em modo lenient e o prazo não expirou.
|
|
684
776
|
function isLenient() {
|
|
685
|
-
const gm =
|
|
777
|
+
const gm = governanceModeFrom(config.load())
|
|
686
778
|
if (gm.mode !== 'lenient') return false
|
|
687
779
|
if (!gm.lenientUntil) return true
|
|
688
780
|
return new Date() < gm.lenientUntil
|
|
@@ -690,7 +782,7 @@ function isLenient() {
|
|
|
690
782
|
|
|
691
783
|
// lenientUntilDate retorna a data de expiração formatada (YYYY-MM-DD) ou ''.
|
|
692
784
|
function lenientUntilDate() {
|
|
693
|
-
const gm =
|
|
785
|
+
const gm = governanceModeFrom(config.load())
|
|
694
786
|
if (gm.mode !== 'lenient' || !gm.lenientUntil) return ''
|
|
695
787
|
return gm.lenientUntil.toISOString().slice(0, 10)
|
|
696
788
|
}
|
|
@@ -735,7 +827,7 @@ function extractRefPath(content, field) {
|
|
|
735
827
|
let val = trimmed.slice(idx + 1).trim()
|
|
736
828
|
if (!val || val === '—' || val === '-' || val === '–') return null
|
|
737
829
|
val = val.split(/\s+/)[0]
|
|
738
|
-
val = val.replace(/^["']|["']$/g, '')
|
|
830
|
+
val = val.replace(/^["'`]|["'`]$/g, '')
|
|
739
831
|
if (val.endsWith('.md')) return val
|
|
740
832
|
}
|
|
741
833
|
}
|
|
@@ -754,7 +846,7 @@ function validateRefTargetsExist() {
|
|
|
754
846
|
const content = readFileForRule('ref_targets_exist', path.join(dir, name), warnings)
|
|
755
847
|
if (content === null) continue
|
|
756
848
|
const ref = extractRefPath(content, 'REQ')
|
|
757
|
-
if (ref && !referenceExists(ref
|
|
849
|
+
if (ref && !referenceExists(ref)) {
|
|
758
850
|
warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
|
|
759
851
|
}
|
|
760
852
|
}
|
|
@@ -766,11 +858,11 @@ function validateRefTargetsExist() {
|
|
|
766
858
|
if (content === null) continue
|
|
767
859
|
const name = path.basename(filePath)
|
|
768
860
|
const adrRef = extractRefPath(content, 'ADR')
|
|
769
|
-
if (adrRef && !referenceExists(adrRef
|
|
861
|
+
if (adrRef && !referenceExists(adrRef)) {
|
|
770
862
|
warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
|
|
771
863
|
}
|
|
772
864
|
const roadmapRef = extractRefPath(content, 'Roadmap')
|
|
773
|
-
if (roadmapRef && !referenceExists(roadmapRef
|
|
865
|
+
if (roadmapRef && !referenceExists(roadmapRef)) {
|
|
774
866
|
warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
|
|
775
867
|
}
|
|
776
868
|
}
|
|
@@ -778,7 +870,7 @@ function validateRefTargetsExist() {
|
|
|
778
870
|
return warnings
|
|
779
871
|
}
|
|
780
872
|
|
|
781
|
-
function referenceExists(ref
|
|
873
|
+
function referenceExists(ref) {
|
|
782
874
|
const expandedRef = config.expandPath ? config.expandPath(ref) : ref
|
|
783
875
|
if (fs.existsSync(expandedRef)) return true
|
|
784
876
|
return false
|
|
@@ -1131,6 +1223,7 @@ async function validateUnfiltered() {
|
|
|
1131
1223
|
applyRule('filename_uniqueness', validateFilenameUniqueness(), violations, warnings)
|
|
1132
1224
|
applyRule('branch_has_wip_roadmap', validateBranchHasWIPRoadmap(), violations, warnings)
|
|
1133
1225
|
applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
|
|
1226
|
+
applyRule('adr_accepted_when_req_done', validateADRAcceptedWhenREQDone(), violations, warnings)
|
|
1134
1227
|
applyRule('note_orphan', validateNoteOrphan(), violations, warnings)
|
|
1135
1228
|
|
|
1136
1229
|
// Regras configuráveis via applyRule (popula _itemMeta automaticamente)
|
|
@@ -1187,10 +1280,60 @@ async function validate() {
|
|
|
1187
1280
|
return { violations, warnings }
|
|
1188
1281
|
}
|
|
1189
1282
|
|
|
1283
|
+
// ROADMAP_STATES enumera os 6 estados de roadmap na ordem exibida pelo bloco Inventory.
|
|
1284
|
+
const ROADMAP_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
|
|
1285
|
+
|
|
1286
|
+
// buildInventorySection monta o bloco "📊 Inventory" — contagem agregada de ADRs, REQs
|
|
1287
|
+
// (discriminadas por status real via reqStatusEquals) e roadmaps (pelos 6 estados, incluindo
|
|
1288
|
+
// "analyzing", historicamente omitido). Namespacing-agnóstico: agrega através de
|
|
1289
|
+
// resolveStateDirs()/resolveReqFiles(), que já resolvem flat vs by_agent.
|
|
1290
|
+
function buildInventorySection(cfg) {
|
|
1291
|
+
let adrCount = 0
|
|
1292
|
+
for (const adrDir of cfg.adrDirs || []) {
|
|
1293
|
+
adrCount += walkDirMdWithPathsForRule('status_inventory', adrDir, []).length
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const reqFiles = resolveReqFiles(cfg)
|
|
1297
|
+
let reqOpen = 0
|
|
1298
|
+
let reqDone = 0
|
|
1299
|
+
let reqClosed = 0
|
|
1300
|
+
for (const filePath of reqFiles) {
|
|
1301
|
+
let content
|
|
1302
|
+
try {
|
|
1303
|
+
content = fs.readFileSync(filePath, 'utf8')
|
|
1304
|
+
} catch (_) {
|
|
1305
|
+
continue
|
|
1306
|
+
}
|
|
1307
|
+
if (reqStatusEquals(content, 'open')) reqOpen++
|
|
1308
|
+
else if (reqStatusEquals(content, 'done')) reqDone++
|
|
1309
|
+
else if (reqStatusEquals(content, 'closed')) reqClosed++
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const roadmapCounts = {}
|
|
1313
|
+
let roadmapTotal = 0
|
|
1314
|
+
for (const state of ROADMAP_STATES) {
|
|
1315
|
+
let count = 0
|
|
1316
|
+
for (const dir of resolveStateDirs(cfg, state)) {
|
|
1317
|
+
count += listDir(dir).length
|
|
1318
|
+
}
|
|
1319
|
+
roadmapCounts[state] = count
|
|
1320
|
+
roadmapTotal += count
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
let section = '\n📊 Inventory\n'
|
|
1324
|
+
section += ` ${'ADRs'.padEnd(12)}${adrCount}\n`
|
|
1325
|
+
section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed)\n`
|
|
1326
|
+
section += ` ${'Roadmaps'.padEnd(12)}${roadmapTotal}\n`
|
|
1327
|
+
section += ` backlog ${roadmapCounts.backlog} · analyzing ${roadmapCounts.analyzing} · wip ${roadmapCounts.wip}\n`
|
|
1328
|
+
section += ` blocked ${roadmapCounts.blocked} · done ${roadmapCounts.done} · abandoned ${roadmapCounts.abandoned}\n`
|
|
1329
|
+
return section
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1190
1332
|
// getStatus retorna string formatada com o status de governança do projeto
|
|
1191
1333
|
async function getStatus() {
|
|
1192
1334
|
const cfg = config.load()
|
|
1193
1335
|
let out = '── trackfw status ──────────────────────\n'
|
|
1336
|
+
out += buildInventorySection(cfg)
|
|
1194
1337
|
|
|
1195
1338
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
1196
1339
|
let agents = cfg.agents || []
|
|
@@ -1217,7 +1360,7 @@ async function getStatus() {
|
|
|
1217
1360
|
out += `\n🔄 WIP (${wip.length})\n`
|
|
1218
1361
|
for (const f of wip) out += ` ${f}\n`
|
|
1219
1362
|
|
|
1220
|
-
const wipCfg =
|
|
1363
|
+
const wipCfg = wipConfigFrom(cfg)
|
|
1221
1364
|
if (wipCfg.bySquad && wip.length > 0) {
|
|
1222
1365
|
const bySquad = {}
|
|
1223
1366
|
for (const f of wip) {
|
|
@@ -1242,14 +1385,19 @@ async function getStatus() {
|
|
|
1242
1385
|
for (const w of staleWIPs) out += ` ${w}\n`
|
|
1243
1386
|
}
|
|
1244
1387
|
|
|
1388
|
+
// Seção: REQs bloqueadas por ADRs não aceitos (Draft ou Proposed). O status exibido por
|
|
1389
|
+
// ADR é resolvido via adrNotAcceptedStatusForRule (helper canônico) em vez de hardcodar
|
|
1390
|
+
// "Draft" — blockedREQs() cobre ambos os status desde que delega em adrIsDraft/
|
|
1391
|
+
// adrDraftStatusForRule, e um rótulo fixo "(Draft)" mentiria para um ADR Proposed.
|
|
1245
1392
|
const blockedByDraft = blockedREQs()
|
|
1246
1393
|
const blockedKeys = Object.keys(blockedByDraft)
|
|
1247
1394
|
if (blockedKeys.length > 0) {
|
|
1248
|
-
out += `\n⏳ REQs blocked by
|
|
1395
|
+
out += `\n⏳ REQs blocked by not-accepted ADRs (${blockedKeys.length})\n`
|
|
1249
1396
|
for (const reqFile of blockedKeys) {
|
|
1250
1397
|
out += ` ${reqFile}\n`
|
|
1251
1398
|
for (const adr of blockedByDraft[reqFile]) {
|
|
1252
|
-
|
|
1399
|
+
const { status } = adrNotAcceptedStatusForRule('blocked_by_draft_adr', adr, null)
|
|
1400
|
+
out += ` → ${adr} (${status})\n`
|
|
1253
1401
|
}
|
|
1254
1402
|
}
|
|
1255
1403
|
}
|
|
@@ -1291,8 +1439,6 @@ module.exports = {
|
|
|
1291
1439
|
resolveStateDirs,
|
|
1292
1440
|
resolveWIPDirs,
|
|
1293
1441
|
resolveDoneDirs,
|
|
1294
|
-
readGovernanceMode,
|
|
1295
|
-
readWIPConfig,
|
|
1296
1442
|
parseSquadFromFrontmatter,
|
|
1297
1443
|
validateFrontmatterPresence,
|
|
1298
1444
|
// novas funções ML-1B
|
|
@@ -1316,4 +1462,12 @@ module.exports = {
|
|
|
1316
1462
|
isInsideDir,
|
|
1317
1463
|
walkDirMdWithPaths,
|
|
1318
1464
|
validateADRDirsExist,
|
|
1465
|
+
// novas funções ML-1B (2026-08-01 — adr_accepted_when_req_done)
|
|
1466
|
+
extractAdrHeaderStatus,
|
|
1467
|
+
adrNotAcceptedStatusForRule,
|
|
1468
|
+
reqStatusEquals,
|
|
1469
|
+
validateADRAcceptedWhenREQDone,
|
|
1470
|
+
// ML-1D (2026-08-01 — reconciliação de paridade: frontmatter-first)
|
|
1471
|
+
extractFrontmatterField,
|
|
1472
|
+
resolveAdrStatus,
|
|
1319
1473
|
}
|