trackfw 7.3.0 → 7.5.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.
@@ -8,6 +8,28 @@ const { gitOutput } = require('./git-exec')
8
8
  const config = require('../config')
9
9
  const { checkTraceIds } = require('./traceid')
10
10
  const { loadProvenance } = require('../thirdparty/provenance')
11
+ const { homedir } = require('../homedir')
12
+ const { normalizeRefSeparator } = require('../lib/pathfmt')
13
+ const { normalizeCRLF } = require('../integrations/render')
14
+
15
+ // _platform is seeded from process.platform at module load time. Tests override
16
+ // it via _setPlatformForTest to exercise the Windows guard on any host.
17
+ //
18
+ // Why the mode checks need it: on Windows the POSIX execute bit is not
19
+ // representable on NTFS. fs.statSync().mode & 0o111 is 0 for every regular file,
20
+ // even immediately after fs.chmodSync(path, 0o755). A check written as "the
21
+ // script is not executable" is therefore ALWAYS true there, and no action the
22
+ // user takes can make it false.
23
+ //
24
+ // Mirrors internal/validator/goos.go (Go, canonical) and
25
+ // pypi/trackfw/validator.py (Python).
26
+ let _platform = process.platform
27
+
28
+ function _setPlatformForTest(plat) {
29
+ const prev = _platform
30
+ _platform = plat
31
+ return () => { _platform = prev }
32
+ }
11
33
 
12
34
  const STALE_WIP_DAYS = 7
13
35
  let staleWipNowMs = () => Date.now()
@@ -60,13 +82,62 @@ function listDirForRule(rule, dir, messages) {
60
82
 
61
83
  function readFileForRule(rule, filePath, messages) {
62
84
  try {
63
- return fs.readFileSync(filePath, 'utf8')
85
+ return readRegularFileSync(filePath).toString('utf8')
64
86
  } catch (err) {
65
87
  messages.push(inspectionDiagnostic(rule, filePath, err))
66
88
  return null
67
89
  }
68
90
  }
69
91
 
92
+ // readRegularFileSync is the fail-safe replacement for fs.readFileSync used by every guard
93
+ // config/script read in this file: validateGuardHookResolvable / validateGuardGlobalHookResolvable,
94
+ // and the *_script_integrity family (validateCredentialGuardScriptIntegrity,
95
+ // validateGitBranchGuardScriptIntegrity, validateGuardGlobalScriptIntegrity).
96
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C. Port
97
+ // of Go's readRegularFile (internal/validator/regularfile.go) -- see that file's doc comment for
98
+ // the full design rationale.
99
+ //
100
+ // Why: hades-tf's ML-1B barrier found that a FIFO in place of any of these files makes
101
+ // fs.readFileSync block INDEFINITELY (`mkfifo .claude/settings.json` -- `trackfw validate --json`
102
+ // never returns), reproduced live in all 3 runtimes, pre-existing (not introduced by ML-1B).
103
+ //
104
+ // Opens with O_NONBLOCK (where the platform defines the flag -- POSIX only; fs.constants.O_NONBLOCK
105
+ // is undefined on win32, where this degrades to a plain open, matching Go's windows fallback and
106
+ // its documented "reduction, not elimination" caveat for that platform) so a FIFO with no writer on
107
+ // the other end never blocks the open() call itself, then fstats the resulting FILE DESCRIPTOR --
108
+ // not the path -- to confirm what was actually opened is a regular file.
109
+ //
110
+ // Fstat-on-fd is what makes the type check immune to TOCTOU on POSIX: a file descriptor is bound to
111
+ // the inode (or pipe, or device) it was opened against, not to the path string -- whatever happens
112
+ // to the PATH after fs.openSync returns cannot change what fd refers to.
113
+ //
114
+ // Checked via st.isFile(), a POSITIVE allowlist -- every special type (FIFO, socket, char/block
115
+ // device) is excluded by construction, not enumerated one by one.
116
+ //
117
+ // Throws the raw error from fs.openSync unchanged for ordinary open failures (e.g. ENOENT, EACCES)
118
+ // -- callers' existing `e.code === 'ENOENT'` branches keep working exactly as before. Throws a
119
+ // synthetic Error with code 'ENOTFILE' (never 'ENOENT') when the opened fd is not a regular file --
120
+ // this flows into the SAME "not ENOENT" accusing branch every caller already has, no new branch
121
+ // needed anywhere.
122
+ function readRegularFileSync(filePath) {
123
+ const nonblock = typeof fs.constants.O_NONBLOCK === 'number' ? fs.constants.O_NONBLOCK : 0
124
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | nonblock)
125
+ try {
126
+ const st = fs.fstatSync(fd)
127
+ if (!st.isFile()) {
128
+ const err = new Error(`${filePath} is not a regular file`)
129
+ err.code = 'ENOTFILE'
130
+ throw err
131
+ }
132
+ // raw-read-allowed: this IS the fail-safe primitive (readRegularFileSync) — reads from the
133
+ // already-fstat-confirmed-regular file descriptor `fd`, not from a path.
134
+ // raw-read-allowed: fd-based, not path-based — the FIFO-hang/TOCTOU class this file exists to close does not apply here.
135
+ return fs.readFileSync(fd)
136
+ } finally {
137
+ fs.closeSync(fd)
138
+ }
139
+ }
140
+
70
141
  // isInsideDir retorna true se childPath estiver contido ou for igual a parentDir.
71
142
  function isInsideDir(parentDir, childPath) {
72
143
  if (!parentDir || !childPath) return false
@@ -147,41 +218,288 @@ function gitLastModifiedTime(filePath) {
147
218
  return null
148
219
  }
149
220
 
150
- // resolveReqFiles retorna array de paths completos de arquivos .md de REQs.
151
- // Em modo by_agent percorre reqDir/<agente>/<estado>/; em modo flat varre reqDir/ diretamente.
221
+ // resolveAgentNamespaces é o resolvedor canônico de namespaces em modo by_agent — o ÚNICO lugar do
222
+ // módulo onde a lista `agents:` do trackfw.yaml é lida ao lado do disco. Devolve a UNIÃO entre
223
+ // cfg.agents (na ordem declarada, deduplicada) e os subdiretórios de primeiro nível encontrados em
224
+ // dir (ordenados). Todo outro ponto do módulo que precisar enumerar agentes DEVE chamar esta função
225
+ // — nunca reimplementar "if (!agents.length) { ler disco }": esse padrão SUBSTITUÍA o disco em vez
226
+ // de complementá-lo, deixando invisível qualquer namespace em disco não declarado em agents:
227
+ // (REQ-2026-08-29). O padrão `!agents.length`/`agents.length === 0` só pode existir aqui dentro.
228
+ //
229
+ // Segurança — NÃO segue symlink (AC12/AC13, bloqueante): usa fs.readdirSync(dir, {withFileTypes:
230
+ // true}) + dirent.isDirectory(), que reflete o tipo da própria entrada do diretório, não o alvo do
231
+ // link — um namespace que é symlink para fora do projeto nunca é tratado como diretório aqui. NÃO
232
+ // trocar por fs.statSync (que SEGUE symlink e reproduziu ao vivo escrita fora do projeto via
233
+ // `roadmap move` — ver ADR-2026-08-29, decisão 5, e
234
+ // vault/notes/update-segue-symlink-e-escreve-fora-do-projeto-2026-08-28.md).
235
+ function resolveAgentNamespaces(cfg, dir) {
236
+ const declared = cfg.agents || []
237
+ const seen = new Set()
238
+ const ordered = []
239
+ for (const a of declared) {
240
+ if (!a || seen.has(a)) continue
241
+ seen.add(a)
242
+ ordered.push(a)
243
+ }
244
+
245
+ let entries = []
246
+ try {
247
+ entries = fs.readdirSync(dir, { withFileTypes: true })
248
+ } catch (_) {
249
+ return ordered
250
+ }
251
+ const fromDisk = entries
252
+ .filter(e => e.isDirectory()) // symlinks retornam false aqui — nunca seguidos (AC12/AC13)
253
+ .map(e => e.name)
254
+ .filter(name => !isInfraDirName(name)) // ML-2A: nunca vira namespace, ver comentário na função
255
+ .sort()
256
+ for (const name of fromDisk) {
257
+ if (seen.has(name)) continue
258
+ seen.add(name)
259
+ ordered.push(name)
260
+ }
261
+ return ordered
262
+ }
263
+
264
+ // isInfraDirName decide, no ponto único de leitura de disco do resolvedor, se uma entrada é
265
+ // COMPROVADAMENTE infraestrutura e nunca um namespace de agente — filtrada da união (decisão 1 do
266
+ // ADR) e portanto invisível a todo consumidor.
267
+ //
268
+ // CORREÇÃO (ML-4A, achado 1 do parecer hades-tf 2026-08-30, REPROVA original — espelha
269
+ // internal/validator/validator.go, ver o comentário lá para a justificativa completa): esta lista já
270
+ // incluiu "qualquer nome iniciando com '.'", reabrindo a invisibilidade que a REQ existe para fechar
271
+ // (um namespace real ".ghost" desaparecia de union, status, wip limit e `move` sem sinal algum — um
272
+ // canal de ocultação deliberada). A lista fechada agora tem UMA entrada:
273
+ // - "node_modules": artefato de tooling JS. Nenhum operador digita isto como nome de agente por
274
+ // acidente ou por design — ruído inequívoco, sem a ambiguidade de um nome iniciado por ponto.
275
+ // Nomes iniciados por "." NÃO são mais filtrados aqui — continuam entrando na união (nunca
276
+ // invisíveis) — mas o sinal de "não declarado" é rebaixado de violação para aviso de baixo ruído
277
+ // (ver isDotPrefixedName / hiddenNamespaceWarnings), nunca zero sinal.
278
+ function isInfraDirName(name) {
279
+ return name === 'node_modules'
280
+ }
281
+
282
+ // isDotPrefixedName reporta se name começa com "." — sinal ambíguo (pode ser um namespace legítimo,
283
+ // ou tooling que nunca deveria estar dentro de roadmapDir/reqDir), não mais um filtro de
284
+ // invisibilidade (ver isInfraDirName). Só rebaixa "não declarado em agents:" de violação para aviso.
285
+ function isDotPrefixedName(name) {
286
+ return name.startsWith('.')
287
+ }
288
+
289
+ // AGENT_NAMESPACE_STATE_NAMES replica, só para esta regra, os 6 nomes de estado reservados de
290
+ // roadmap/REQ. Um diretório com um desses nomes no topo de roadmap_dir/req_dir é, na prática, resto
291
+ // de migração incompleta flat→by_agent (ex.: "wip" órfão) — não um agente. A união (decisão 1 do
292
+ // ADR) continua enumerando esses diretórios normalmente — nada fica invisível —, mas eles NÃO
293
+ // disparam validateAgentNamespaceUndeclared: pedir para declarar "wip" como agente em agents: seria
294
+ // ruído confuso, não uma correção real (ML-0A, seção 3, item 3; recomendação adotada sem alteração).
295
+ // Esta exclusão vive só aqui, não no resolvedor — a colisão de nome não é "comprovadamente
296
+ // infraestrutura" como isInfraDirName, é uma inferência sobre o significado do nome, então só afeta
297
+ // a violação, nunca a união/enumeração.
298
+ const AGENT_NAMESPACE_STATE_NAMES = new Set(['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned'])
299
+
300
+ // undeclaredNamespacesOnDisk devolve, a partir do resolvedor canônico (que já filtra infra e não
301
+ // segue symlink), os nomes de namespace presentes em dir e ausentes de agents:, excluindo colisões
302
+ // com nome de estado reservado (AGENT_NAMESPACE_STATE_NAMES).
303
+ function undeclaredNamespacesOnDisk(cfg, dir, declared) {
304
+ const out = []
305
+ for (const name of resolveAgentNamespaces(cfg, dir)) {
306
+ if (declared.has(name) || AGENT_NAMESPACE_STATE_NAMES.has(name) || isDotPrefixedName(name)) continue
307
+ out.push(name)
308
+ }
309
+ return out
310
+ }
311
+
312
+ // dotPrefixedUndeclaredOnDisk é o espelho de undeclaredNamespacesOnDisk para o caso ambíguo (nome
313
+ // iniciado por "."): mesmo resolvedor canônico, mesma exclusão de nomes já declarados, mas mantendo
314
+ // exatamente os nomes que undeclaredNamespacesOnDisk descarta por causa do ponto.
315
+ function dotPrefixedUndeclaredOnDisk(cfg, dir, declared) {
316
+ const out = []
317
+ for (const name of resolveAgentNamespaces(cfg, dir)) {
318
+ if (declared.has(name) || !isDotPrefixedName(name)) continue
319
+ out.push(name)
320
+ }
321
+ return out
322
+ }
323
+
324
+ // validateAgentNamespaceUndeclared implementa a regra "agent_namespace_undeclared"
325
+ // (ADR-2026-08-29, decisão 2 / REQ AC4, AC5, AC9): em modo by_agent, um namespace presente em disco
326
+ // (roadmapDir e/ou reqDir — AC2 estende a união às duas árvores, e esta violação segue) e ausente de
327
+ // agents: é VIOLAÇÃO, não aviso — usa o mesmo default 'error' de toda regra sem entrada em
328
+ // RULE_DEFAULTS (diskRuleSeverity).
329
+ //
330
+ // A união já garante (Wave 1) que o namespace continua sendo ENUMERADO por todo consumidor mesmo com
331
+ // esta violação ativa — esta função só ADICIONA o sinal de configuração incompleta, nunca CONDICIONA
332
+ // a enumeração a ele (AC5-b).
333
+ //
334
+ // Deduplicação por namespace, não por árvore: o caso motivador (cmdb, "zeus" ausente de agents: e em
335
+ // disco em roadmapDir E reqDir ao mesmo tempo) produziria duas violações quase-idênticas se o laço
336
+ // fosse por árvore — ruído no caso comum, não no caso raro. Uma violação por nome, nomeando todas as
337
+ // árvores onde ele foi encontrado.
338
+ function validateAgentNamespaceUndeclared(cfg) {
339
+ if (cfg.roadmapNamespacing !== config.NAMESPACING_BY_AGENT) return []
340
+ const declared = new Set(cfg.agents || [])
341
+
342
+ const roadmapNames = undeclaredNamespacesOnDisk(cfg, cfg.roadmapDir, declared)
343
+ const reqNames = undeclaredNamespacesOnDisk(cfg, cfg.reqDir || cfg.req_dir || '', declared)
344
+
345
+ const inRoadmap = new Set(roadmapNames)
346
+ const inReq = new Set(reqNames)
347
+
348
+ const names = []
349
+ const seen = new Set()
350
+ for (const n of [...roadmapNames, ...reqNames]) {
351
+ if (seen.has(n)) continue
352
+ seen.add(n)
353
+ names.push(n)
354
+ }
355
+ names.sort()
356
+
357
+ const msgs = []
358
+ for (const name of names) {
359
+ const trees = []
360
+ if (inRoadmap.has(name)) trees.push('roadmap_dir')
361
+ if (inReq.has(name)) trees.push('req_dir')
362
+ msgs.push(`agent namespace "${name}" exists in ${trees.join(', ')} but is not declared in agents: — add it to trackfw.yaml`)
363
+ }
364
+ return msgs
365
+ }
366
+
367
+ // hiddenNamespaceWarnings implementa a regra "agent_namespace_hidden" — o contraponto de baixo ruído
368
+ // de validateAgentNamespaceUndeclared para nomes iniciados por "." (ML-4A, achado 1 do parecer
369
+ // hades-tf 2026-08-30). Um diretório oculto/ambíguo em disco (roadmapDir e/ou reqDir), ausente de
370
+ // agents:, NÃO é filtrado da união (resolveAgentNamespaces mantém) e NÃO dispara a violação plena —
371
+ // mas também não é silêncio total: esta função emite um aviso nomeando explicitamente o diretório.
372
+ function hiddenNamespaceWarnings(cfg) {
373
+ if (cfg.roadmapNamespacing !== config.NAMESPACING_BY_AGENT) return []
374
+ const declared = new Set(cfg.agents || [])
375
+
376
+ const roadmapNames = dotPrefixedUndeclaredOnDisk(cfg, cfg.roadmapDir, declared)
377
+ const reqNames = dotPrefixedUndeclaredOnDisk(cfg, cfg.reqDir || cfg.req_dir || '', declared)
378
+
379
+ const inRoadmap = new Set(roadmapNames)
380
+ const inReq = new Set(reqNames)
381
+
382
+ const names = []
383
+ const seen = new Set()
384
+ for (const n of [...roadmapNames, ...reqNames]) {
385
+ if (seen.has(n)) continue
386
+ seen.add(n)
387
+ names.push(n)
388
+ }
389
+ names.sort()
390
+
391
+ const msgs = []
392
+ for (const name of names) {
393
+ const trees = []
394
+ if (inRoadmap.has(name)) trees.push('roadmap_dir')
395
+ if (inReq.has(name)) trees.push('req_dir')
396
+ msgs.push(`dot-prefixed directory "${name}" found in ${trees.join(', ')} is treated as an agent namespace (fully enumerated, not declared in agents:) — declare it in trackfw.yaml if intentional, or remove it if it is leftover tooling`)
397
+ }
398
+ return msgs
399
+ }
400
+
401
+ // REQ_LAYOUT_STATES é a lista fechada de nomes de pasta de ESTADO reconhecidos nos layouts LEGADOS
402
+ // de REQ. Existe só para o leitor tolerar árvores antigas: pelo invariante D1 da ADR-2026-09-03,
403
+ // REQ NÃO tem dimensão de estado. Nada aqui decide onde ESCREVER uma REQ.
404
+ const REQ_LAYOUT_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
405
+
406
+ // listReqMdFiles lista os .md diretamente dentro de dir (sem recursão, sem seguir para subpastas).
407
+ // Não usa glob: nomes de agente vêm do disco e metacaracteres ("*", "[") corromperiam a contagem.
408
+ function listReqMdFiles(dir) {
409
+ try {
410
+ return fs.readdirSync(dir)
411
+ .filter(n => n.endsWith('.md'))
412
+ .filter(n => { try { return !fs.statSync(path.join(dir, n)).isDirectory() } catch (_) { return false } })
413
+ .map(n => path.join(dir, n))
414
+ } catch (_) { return [] }
415
+ }
416
+
417
+ // reqWriteDir é o PONTO ÚNICO que decide ONDE uma REQ nova é gravada (ADR-2026-09-03, D2/D4):
418
+ // flat → req_dir/
419
+ // by_agent → req_dir/<agente>/ (primeiro de agents:, ou "default" se a lista é vazia)
420
+ // Consumido pelo gerador (newREQ); a união de leitura abaixo contém este diretório por construção.
421
+ function reqWriteDir(cfg) {
422
+ const reqDir = cfg.reqDir || cfg.req_dir || ''
423
+ if (!reqDir) return ''
424
+ const namespacing = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
425
+ if (namespacing === 'by_agent') {
426
+ const agents = (cfg.agents || []).filter(a => a)
427
+ const agent = agents.length > 0 ? agents[0] : 'default'
428
+ return path.join(reqDir, agent)
429
+ }
430
+ return reqDir
431
+ }
432
+
433
+ // resolveReqFiles é o PONTO ÚNICO de LEITURA de REQ (ADR-2026-09-03, D3/D4): devolve os paths de
434
+ // todos os .md de REQ como UNIÃO dos 4 layouts suportados, nunca como escolha exclusiva:
435
+ // req_dir/*.md flat legado
436
+ // req_dir/<estado>/*.md por-estado legado (apesar de D1)
437
+ // req_dir/<agente>/*.md CANÔNICO em by_agent
438
+ // req_dir/<agente>/<estado>/*.md legado
439
+ // Os dois últimos só valem em by_agent — fora dele não há noção de namespace de agente.
440
+ //
441
+ // Deduplicação é OBRIGATÓRIA: resolveAgentNamespaces devolve agents: ∪ disco, então um
442
+ // req_dir/backlog/ real entra na lista de agentes e o caso <agente>/*.md emite os mesmos paths do
443
+ // caso <estado>/*.md — sem o Set, toda REQ em layout por-estado seria contada (e violada) em dobro.
444
+ // Ordenação final: determinismo igual ao Go/Python (readdirSync não é ordenado por contrato).
152
445
  function resolveReqFiles(cfg) {
153
446
  const reqDir = cfg.reqDir || cfg.req_dir || ''
154
447
  if (!reqDir) return []
155
448
  const namespacing = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
449
+ const seen = new Set()
450
+ const files = []
451
+ const add = (paths) => {
452
+ for (const p of paths) {
453
+ const clean = path.normalize(p)
454
+ if (seen.has(clean)) continue
455
+ seen.add(clean)
456
+ files.push(clean)
457
+ }
458
+ }
459
+
460
+ // 🔴 §4 (hades-tf 2026-09-03): a dedup por STRING não vê req_dir/Backlog ≡ req_dir/backlog em
461
+ // filesystem case-INSENSITIVE (APFS, NTFS) — "Backlog" entra na lista de agentes pelo disco e
462
+ // emite req_dir/Backlog/*.md, enquanto o laço de estados emite req_dir/backlog/*.md hardcoded em
463
+ // minúscula. Mesmo diretório, strings diferentes: toda REQ contada em DOBRO (medido em APFS:
464
+ // 2 REQs e 4 violações para 1 arquivo real). Verde no CI Linux, vermelho na máquina do dev.
465
+ //
466
+ // MECANISMO: só enumeramos um candidato de subdiretório se o nome existir VERBATIM na listagem do
467
+ // pai — a grafia do disco é a autoridade, medimos o disco em vez de presumir a propriedade do FS.
468
+ // - NÃO troca dupla contagem por SUPRESSÃO: em FS case-SENSITIVE o readdir lista "Backlog" E
469
+ // "backlog" (dois diretórios reais distintos) e ambos seguem enumerados. Lowercase colapsaria.
470
+ // - NÃO usa ino/dev: fs.statSync().ino não tem contrato medido em NTFS, e ino colidente
471
+ // colapsaria arquivos distintos — supressão, a direção proibida.
472
+ // - Cobre também NFC/NFD, que case-folding não cobre.
473
+ // - FALLBACK É JOIN CEGO, NUNCA LISTA VAZIA: pai ilegível → não filtra nada (dupla contagem
474
+ // benigna). Devolver vazio seria supressão.
475
+ // - Não filtra por TIPO: um <estado> que seja symlink segue enumerado como antes (§3 é outro
476
+ // escopo).
477
+ // Paridade: mesma lógica em internal/validator/validator.go e pypi/trackfw/validator.py.
478
+ const childCache = new Map()
479
+ const hasChildVerbatim = (parent, name) => {
480
+ if (!childCache.has(parent)) {
481
+ try { childCache.set(parent, new Set(fs.readdirSync(parent))) } catch (_) { childCache.set(parent, null) }
482
+ }
483
+ const children = childCache.get(parent)
484
+ return children === null ? true : children.has(name)
485
+ }
486
+ const addChild = (parent, name) => {
487
+ if (!hasChildVerbatim(parent, name)) return
488
+ add(listReqMdFiles(path.join(parent, name)))
489
+ }
490
+
491
+ add(listReqMdFiles(reqDir))
492
+ for (const state of REQ_LAYOUT_STATES) addChild(reqDir, state)
493
+
156
494
  if (namespacing === 'by_agent') {
157
- const STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
158
- let agents = cfg.agents || []
159
- if (!agents.length) {
160
- try {
161
- agents = fs.readdirSync(reqDir).filter(e => {
162
- try { return fs.statSync(path.join(reqDir, e)).isDirectory() } catch (_) { return false }
163
- })
164
- } catch (_) { return [] }
165
- }
166
- const files = []
167
- for (const agent of agents) {
168
- for (const state of STATES) {
169
- const dir = path.join(reqDir, agent, state)
170
- try {
171
- for (const name of fs.readdirSync(dir)) {
172
- if (name.endsWith('.md')) files.push(path.join(dir, name))
173
- }
174
- } catch (_) {}
175
- }
495
+ for (const agent of resolveAgentNamespaces(cfg, reqDir)) {
496
+ addChild(reqDir, agent)
497
+ for (const state of REQ_LAYOUT_STATES) addChild(path.join(reqDir, agent), state)
176
498
  }
177
- return files
178
499
  }
179
- // flat (comportamento anterior) — retorna paths completos
180
- try {
181
- return fs.readdirSync(reqDir)
182
- .filter(n => n.endsWith('.md') && !fs.statSync(path.join(reqDir, n)).isDirectory())
183
- .map(n => path.join(reqDir, n))
184
- } catch (_) { return [] }
500
+
501
+ files.sort()
502
+ return files
185
503
  }
186
504
 
187
505
  // resolveStateDirs retorna todos os diretórios de um estado (ex: 'wip', 'done') conforme o modo de
@@ -190,14 +508,7 @@ function resolveReqFiles(cfg) {
190
508
  // anteriores (roadmap_dir divergente entre runtimes).
191
509
  function resolveStateDirs(cfg, state) {
192
510
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
193
- let agents = cfg.agents || []
194
- if (agents.length === 0) {
195
- try {
196
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
197
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
198
- })
199
- } catch (_) { agents = [] }
200
- }
511
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
201
512
  return agents.map(agent => cfg.roadmapDir + '/' + agent + '/' + state)
202
513
  }
203
514
  return [cfg.roadmapDir + '/' + state]
@@ -214,10 +525,19 @@ function resolveDoneDirs(cfg) {
214
525
  }
215
526
 
216
527
  // parseBlockedADRs extrai basenames de ADRs da seção "## Blocked by ADRs" de um arquivo REQ.
528
+ //
529
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1H:
530
+ // content is folded through normalizeCRLF (npm/src/integrations/render.js) before the exact
531
+ // `line === '## Blocked by ADRs'` comparison below -- same fix, same reason, as Go's
532
+ // parseBlockedADRs (internal/validator/validator.go) and Python's _parse_blocked_adrs
533
+ // (pypi/trackfw/validator.py): a REQ with CRLF (Windows author, or git checkout with
534
+ // core.autocrlf=true -- ADR-2026-09-04 D1's exact scenario) leaves a trailing "\r" on this line
535
+ // after content.split('\n'), and the exact-equality match silently never fires without this.
536
+ // Measured first in the Python port on Windows ARM64; closed identically in all 3 runtimes here.
217
537
  function parseBlockedADRs(filePath) {
218
538
  let content
219
539
  try {
220
- content = fs.readFileSync(filePath, 'utf8')
540
+ content = normalizeCRLF(readRegularFileSync(filePath).toString('utf8'))
221
541
  } catch (_) {
222
542
  return []
223
543
  }
@@ -245,6 +565,9 @@ function parseBlockedADRs(filePath) {
245
565
 
246
566
  // contentHasMarker retorna true se o conteúdo contém algum dos markers sem espaço em branco após.
247
567
  // P3: verifica tanto "\n" quanto "\r\n" para detectar campos vazios em arquivos CRLF.
568
+ //
569
+ // Usada apenas para markers de "existência de bloco" (ex: acceptanceMarkers, um heading de
570
+ // seção) — não para markers de link (REQ:/ADR:/Roadmap:), que usam contentHasMarkerValue.
248
571
  function contentHasMarker(content, markers) {
249
572
  for (const marker of markers) {
250
573
  if (content.includes(marker) && !content.includes(marker + ' \n') && !content.includes(marker + ' \r\n')) {
@@ -254,6 +577,65 @@ function contentHasMarker(content, markers) {
254
577
  return false
255
578
  }
256
579
 
580
+ // contentHasMarkerValue retorna true se, em alguma LINHA de content, um dos markers aparece
581
+ // logo após espaços/tabs de indentação (nunca no meio de uma frase) e é seguido de um valor
582
+ // substantivo na mesma linha — não vazio e não um comentário HTML disfarçado de valor.
583
+ //
584
+ // issue #278 (achado A2 da auditoria externa, 2026-09-05): a versão anterior buscava o marker
585
+ // como SUBSTRING em qualquer lugar do conteúdo. Isso produzia dois defeitos medidos no acervo
586
+ // mergeado:
587
+ // 1. prosa que menciona o marker no meio de uma frase contava como vínculo real
588
+ // ("veja a secao ADR: mais abaixo" → true, sem nenhum vínculo).
589
+ // 2. um comentário HTML usado como placeholder contava como valor
590
+ // ("ADR: <!-- preencher depois -->" → true, campo de fato vazio).
591
+ //
592
+ // Ambos se resolvem tratando a detecção por LINHA em vez de por substring do documento inteiro:
593
+ // o marker só conta quando é a primeira coisa não-branca da linha (tolera espaços/tabs à
594
+ // esquerda, não tolera qualquer outra decoração — "> ", "- ", "# ", "**" não contam como início).
595
+ // Medido antes de decidir (não assumido): nas quatro pastas realmente varridas por estas 4
596
+ // regras (REQ files para ADR:/Roadmap:; roadmaps de wip/blocked para REQ:), a contagem de linhas
597
+ // com decoração diferente de espaço em branco antes do marker é ZERO — os três geradores
598
+ // canônicos sempre emitem o marker no início da linha. Restringir a espaço/tab não introduz
599
+ // falso-positivo novo neste acervo.
600
+ //
601
+ // NÃO resolve (fora de escopo desta ML): "REQ (**reaberta**):" — decoração ENTRE o nome do
602
+ // campo e ":", não ANTES do marker. A literal "REQ:" não aparece nessa linha nem por
603
+ // substring nem por prefixo; ancorar por linha não causa nem cura esse caso. A forma que
604
+ // funciona hoje é "REQ: docs/req/x.md (reaberta)" — o marker intacto, a anotação depois.
605
+ //
606
+ // "none", "TBD", "N/A", "-" continuam sendo tratados como VALOR (não como placeholder). Nenhum
607
+ // dos quatro casos exigidos por esta ML os menciona, e "none" é usado deliberadamente em
608
+ // scripts/check-gates-falsify.sh como placeholder inofensivo — para dizer "sem vínculo real,
609
+ // de propósito" nos fixtures que testam OUTRA regra — sem ficar vazio nem virar comentário
610
+ // HTML. Bloqueá-los moveria a contagem do acervo por um motivo alheio ao achado A2 e quebraria
611
+ // cenários de falsificação que dependem desse contrato.
612
+ //
613
+ // issue #278 (ML-1B): 5 de 7 grafias naturais de campo vazio escapavam da checagem anterior
614
+ // por literal — esta função decide por VALOR (trim do resto da linha), o que já cobre as 7
615
+ // grafias medidas na triagem original.
616
+ function contentHasMarkerValue(content, markers) {
617
+ for (const rawLine of content.split('\n')) {
618
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
619
+ const leading = line.replace(/^[ \t]+/, '')
620
+ for (const marker of markers) {
621
+ if (!leading.startsWith(marker)) continue
622
+ const rest = leading.slice(marker.length).trim()
623
+ if (rest === '') continue
624
+ if (isHTMLCommentOnlyValue(rest)) continue
625
+ return true
626
+ }
627
+ }
628
+ return false
629
+ }
630
+
631
+ // isHTMLCommentOnlyValue retorna true se value (já sem espaços nas duas pontas) é inteiramente
632
+ // um comentário HTML — "<!-- ... -->" do início ao fim, sem nada fora dele. É a forma medida no
633
+ // achado A2: "ADR: <!-- preencher depois -->" é sintaticamente um valor, mas semanticamente um
634
+ // placeholder de "ainda não preenchido" — mesma intenção de um campo vazio, só que disfarçado.
635
+ function isHTMLCommentOnlyValue(value) {
636
+ return value.startsWith('<!--') && value.endsWith('-->')
637
+ }
638
+
257
639
  // extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
258
640
  // ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
259
641
  // documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
@@ -323,7 +705,7 @@ function adrNotAcceptedStatusForRule(rule, basename, messages) {
323
705
  const p = findAdrFile(basename)
324
706
  if (!p) return { notAccepted: false, status: '', inspected: true }
325
707
  try {
326
- const content = fs.readFileSync(p, 'utf8')
708
+ const content = readRegularFileSync(p).toString('utf8')
327
709
  const status = resolveAdrStatus(content)
328
710
  const notAccepted = status.toLowerCase() === 'draft' || status.toLowerCase() === 'proposed'
329
711
  return { notAccepted, status, inspected: true }
@@ -356,8 +738,8 @@ function validateWIPHasREQ() {
356
738
  for (const name of entries) {
357
739
  const content = readFileForRule('wip_has_req', path.join(wipDir, name), violations)
358
740
  if (content === null) continue
359
- if (!contentHasMarker(content, cfg.linkFields.req)) {
360
- violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
741
+ if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
742
+ violations.push(`roadmap "${name}" is in wip but has no linked REQ (marker must start the line with a real, non-placeholder value)`)
361
743
  }
362
744
  }
363
745
  }
@@ -370,13 +752,10 @@ function validateREQsHaveADR() {
370
752
  const files = resolveReqFiles(cfg)
371
753
  const violations = []
372
754
  for (const filePath of files) {
373
- try {
374
- const content = fs.readFileSync(filePath, 'utf8')
375
- if (!contentHasMarker(content, cfg.linkFields.adr)) {
376
- violations.push(`req "${path.basename(filePath)}" has no linked ADR`)
377
- }
378
- } catch (_) {
379
- // ignorar
755
+ const content = readFileForRule('req_has_adr', filePath, violations)
756
+ if (content === null) continue
757
+ if (!contentHasMarkerValue(content, cfg.linkFields.adr)) {
758
+ violations.push(`req "${path.basename(filePath)}" has no linked ADR (marker must start the line with a real, non-placeholder value)`)
380
759
  }
381
760
  }
382
761
  return violations
@@ -391,8 +770,8 @@ function validateBlockedHasREQ() {
391
770
  for (const name of entries) {
392
771
  const content = readFileForRule('blocked_has_req', path.join(blockedDir, name), violations)
393
772
  if (content === null) continue
394
- if (!contentHasMarker(content, cfg.linkFields.req)) {
395
- violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
773
+ if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
774
+ violations.push(`roadmap "${name}" is in blocked but has no linked REQ (marker must start the line with a real, non-placeholder value)`)
396
775
  }
397
776
  }
398
777
  }
@@ -405,13 +784,10 @@ function validateREQsHaveRoadmap() {
405
784
  const files = resolveReqFiles(cfg)
406
785
  const violations = []
407
786
  for (const filePath of files) {
408
- try {
409
- const content = fs.readFileSync(filePath, 'utf8')
410
- if (!contentHasMarker(content, cfg.linkFields.roadmap)) {
411
- violations.push(`req "${path.basename(filePath)}" has no linked Roadmap`)
412
- }
413
- } catch (_) {
414
- // ignorar
787
+ const content = readFileForRule('req_has_roadmap', filePath, violations)
788
+ if (content === null) continue
789
+ if (!contentHasMarkerValue(content, cfg.linkFields.roadmap)) {
790
+ violations.push(`req "${path.basename(filePath)}" has no linked Roadmap (marker must start the line with a real, non-placeholder value)`)
415
791
  }
416
792
  }
417
793
  return violations
@@ -505,7 +881,7 @@ function wipConfigFrom(cfg) {
505
881
  function parseSquadFromFrontmatter(filePath) {
506
882
  let content
507
883
  try {
508
- content = fs.readFileSync(filePath, 'utf8')
884
+ content = readRegularFileSync(filePath).toString('utf8')
509
885
  } catch (_) {
510
886
  return ''
511
887
  }
@@ -526,14 +902,7 @@ function validateWIPLimit() {
526
902
  const warnings = []
527
903
 
528
904
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
529
- let agents = cfg.agents || []
530
- if (agents.length === 0) {
531
- try {
532
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
533
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
534
- })
535
- } catch (_) { agents = [] }
536
- }
905
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
537
906
  const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
538
907
  for (const agent of agents) {
539
908
  const entries = listDir(cfg.roadmapDir + '/' + agent + '/wip')
@@ -606,7 +975,7 @@ function latestWipTransitionTime(cfg, filePath) {
606
975
  let content
607
976
  const logPath = path.join(cfg.roadmapDir, '.trackfw-log')
608
977
  try {
609
- content = fs.readFileSync(logPath, 'utf8')
978
+ content = readRegularFileSync(logPath).toString('utf8')
610
979
  } catch (err) {
611
980
  return { time: null, diagnostics: err && err.code === 'ENOENT' ? [] : [inspectionDiagnostic('stale_wip', logPath, err)] }
612
981
  }
@@ -743,7 +1112,7 @@ function blockedREQs() {
743
1112
  for (const filePath of files) {
744
1113
  let content
745
1114
  try {
746
- content = fs.readFileSync(filePath, 'utf8')
1115
+ content = readRegularFileSync(filePath).toString('utf8')
747
1116
  } catch (_) {
748
1117
  continue
749
1118
  }
@@ -796,23 +1165,21 @@ function validateFrontmatterPresence() {
796
1165
  for (const f of walkDirMd(adrDir)) {
797
1166
  const fullPath = findAdrFile(f)
798
1167
  if (!fullPath) continue
799
- try {
800
- const content = fs.readFileSync(fullPath, 'utf8')
801
- if (!content.startsWith('---')) {
802
- violations.push(`adr "${f}" has no frontmatter block`)
803
- }
804
- } catch (_) {}
1168
+ const content = readFileForRule('frontmatter_presence', fullPath, violations)
1169
+ if (content === null) continue
1170
+ if (!content.startsWith('---')) {
1171
+ violations.push(`adr "${f}" has no frontmatter block`)
1172
+ }
805
1173
  }
806
1174
  }
807
1175
 
808
1176
  const reqFilePaths = resolveReqFiles(cfg)
809
1177
  for (const filePath of reqFilePaths) {
810
- try {
811
- const content = fs.readFileSync(filePath, 'utf8')
812
- if (!content.startsWith('---')) {
813
- violations.push(`req "${path.basename(filePath)}" has no frontmatter block`)
814
- }
815
- } catch (_) {}
1178
+ const content = readFileForRule('frontmatter_presence', filePath, violations)
1179
+ if (content === null) continue
1180
+ if (!content.startsWith('---')) {
1181
+ violations.push(`req "${path.basename(filePath)}" has no frontmatter block`)
1182
+ }
816
1183
  }
817
1184
 
818
1185
  return violations
@@ -862,8 +1229,22 @@ function validateRefTargetsExist() {
862
1229
  warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
863
1230
  }
864
1231
  const roadmapRef = extractRefPath(content, 'Roadmap')
865
- if (roadmapRef && !referenceExists(roadmapRef)) {
866
- warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
1232
+ if (roadmapRef) {
1233
+ const { resolved, stale } = resolveRoadmapRefStatus(cfg, roadmapRef)
1234
+ if (resolved.length === 0) {
1235
+ warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
1236
+ } else if (resolved.length === 1) {
1237
+ if (stale) {
1238
+ // literal falhou, resolvido só pelo fallback de basename num outro estado: o
1239
+ // vínculo aponta para um caminho de estado velho — o serve casa edge.To pelo
1240
+ // literal e desenharia aresta órfã para o mesmo vínculo se calássemos aqui.
1241
+ const actualState = path.basename(path.dirname(resolved[0]))
1242
+ warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" but the file is now in ${actualState}/ (stale state path)`)
1243
+ }
1244
+ // senão: resolvido pelo caminho literal — nenhum aviso.
1245
+ } else {
1246
+ warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" is ambiguous: found in multiple states: ${resolved.join(', ')}`)
1247
+ }
867
1248
  }
868
1249
  }
869
1250
 
@@ -876,21 +1257,90 @@ function referenceExists(ref) {
876
1257
  return false
877
1258
  }
878
1259
 
1260
+ // isStaleRoadmapStateRef reporta se `ref` (um valor de campo Roadmap: já extraído) parece ter sido
1261
+ // gravado com uma pasta de estado que pode ter ficado velha — condição de entrada do fallback por
1262
+ // basename (resolveRoadmapRefByBasename). Ver o comentário completo em
1263
+ // internal/validator/validator.go, isStaleRoadmapStateRef (fonte canônica, ROADMAP-2026-09-05, ML-3B).
1264
+ function isStaleRoadmapStateRef(ref) {
1265
+ const parent = path.basename(path.dirname(ref.replace(/\\/g, '/')))
1266
+ return AGENT_NAMESPACE_STATE_NAMES.has(parent)
1267
+ }
1268
+
1269
+ // resolveRoadmapRefByBasename procura, nos diretórios de ESTADO do roadmap_dir (backlog/analyzing/
1270
+ // wip/blocked/done/abandoned, honrando roadmap_namespacing), um arquivo cujo basename case com
1271
+ // path.basename(ref). Escopo restrito ao campo Roadmap: — ver a justificativa completa (REQ sem
1272
+ // dimensão de estado / ADR de REQ, árvore de ADR flat) no comentário gêmeo em
1273
+ // internal/validator/validator.go, resolveRoadmapRefByBasename.
1274
+ //
1275
+ // Retorna a lista de caminhos absolutos encontrados: 0 (sem match), 1 (resolvido) ou mais de 1
1276
+ // (ambíguo — decisão do chamador).
1277
+ function resolveRoadmapRefByBasename(cfg, ref) {
1278
+ const base = path.basename(ref)
1279
+ if (base === '' || base === '.' || base === path.sep) return []
1280
+ const found = []
1281
+ const seen = new Set()
1282
+ for (const state of Array.from(AGENT_NAMESPACE_STATE_NAMES).sort()) {
1283
+ for (const stateDir of resolveStateDirs(cfg, state)) {
1284
+ let entries
1285
+ try {
1286
+ entries = fs.readdirSync(stateDir, { withFileTypes: true })
1287
+ } catch {
1288
+ continue
1289
+ }
1290
+ for (const entry of entries) {
1291
+ if (entry.name !== base || entry.isDirectory()) continue
1292
+ const full = path.normalize(path.join(stateDir, entry.name))
1293
+ if (!seen.has(full)) {
1294
+ seen.add(full)
1295
+ found.push(full)
1296
+ }
1297
+ }
1298
+ }
1299
+ }
1300
+ found.sort()
1301
+ return found
1302
+ }
1303
+
1304
+ // resolveRoadmapRef resolve um valor de campo Roadmap: já extraído: caminho literal primeiro
1305
+ // (idêntico a referenceExists — preserva o branch de diretório existente hoje), e só cai para o
1306
+ // fallback por basename quando o literal falha e isStaleRoadmapStateRef(ref) indica que vale a
1307
+ // pena tentar. Retorna 0 (quebrado), 1 (resolvido) ou >1 (ambíguo) caminhos.
1308
+ function resolveRoadmapRef(cfg, ref) {
1309
+ return resolveRoadmapRefStatus(cfg, ref).resolved
1310
+ }
1311
+
1312
+ // resolveRoadmapRefStatus é resolveRoadmapRef com um flag adicional: stale=true quando a
1313
+ // resolução só teve sucesso via resolveRoadmapRefByBasename (o caminho literal falhou e o
1314
+ // arquivo foi encontrado em outra pasta de estado). Espelha
1315
+ // internal/validator/validator.go:resolveRoadmapRefStatus (fonte canônica, ROADMAP-2026-09-05, ML-3B).
1316
+ function resolveRoadmapRefStatus(cfg, ref) {
1317
+ const expandedRef = config.expandPath ? config.expandPath(ref) : ref
1318
+ if (fs.existsSync(expandedRef)) return { resolved: [expandedRef], stale: false }
1319
+ if (!isStaleRoadmapStateRef(ref)) return { resolved: [], stale: false }
1320
+ const found = resolveRoadmapRefByBasename(cfg, ref)
1321
+ return { resolved: found, stale: found.length > 0 }
1322
+ }
1323
+
879
1324
  function validateREQRoadmapLifecycle() {
880
1325
  const cfg = config.load()
881
1326
  const warnings = []
882
1327
  for (const filePath of resolveReqFiles(cfg)) {
883
- try {
884
- const content = fs.readFileSync(filePath, 'utf8')
885
- if (!reqStatusIsOpen(content)) continue
886
- const ref = extractRefPath(content, 'Roadmap')
887
- if (!ref) continue
888
- const expandedRef = config.expandPath ? config.expandPath(ref) : ref
889
- if (!fs.existsSync(expandedRef)) continue
890
- if (path.basename(path.dirname(expandedRef)) === 'done') {
891
- warnings.push(`req "${path.basename(filePath)}" is Open but linked Roadmap "${ref}" is in done/`)
892
- }
893
- } catch (_) {}
1328
+ const content = readFileForRule('req_roadmap_lifecycle', filePath, warnings)
1329
+ if (content === null) continue
1330
+ if (!reqStatusIsOpen(content)) continue
1331
+ const ref = extractRefPath(content, 'Roadmap')
1332
+ if (!ref) continue
1333
+ const resolved = resolveRoadmapRef(cfg, ref)
1334
+ if (resolved.length !== 1) {
1335
+ // 0 = vínculo quebrado, >1 = ambíguo — ambos já reportados por validateRefTargetsExist.
1336
+ // Esta regra só decide estado quando há UM arquivo real de onde derivá-lo.
1337
+ continue
1338
+ }
1339
+ const expandedRef = resolved[0]
1340
+ if (!fs.existsSync(expandedRef)) continue
1341
+ if (path.basename(path.dirname(expandedRef)) === 'done') {
1342
+ warnings.push(`req "${path.basename(filePath)}" is Open but linked Roadmap "${ref}" is in done/`)
1343
+ }
894
1344
  }
895
1345
  return warnings
896
1346
  }
@@ -932,12 +1382,7 @@ function validateFolderStatusCoherence() {
932
1382
 
933
1383
  let dirs = []
934
1384
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
935
- let agents = cfg.agents || []
936
- if (agents.length === 0) {
937
- try { agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
938
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
939
- }) } catch (_) { agents = [] }
940
- }
1385
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
941
1386
  for (const agent of agents) {
942
1387
  for (const state of states) {
943
1388
  dirs.push({ dir: path.join(cfg.roadmapDir, agent, state), state })
@@ -957,28 +1402,27 @@ function validateFolderStatusCoherence() {
957
1402
  continue
958
1403
  }
959
1404
  for (const name of entries.filter(f => f.endsWith('.md'))) {
960
- try {
961
- const content = fs.readFileSync(path.join(dir, name), 'utf8')
962
- // Extrair status do frontmatter
963
- let declared = ''
964
- if (content.startsWith('---')) {
965
- const end = content.indexOf('\n---', 3)
966
- if (end > 0) {
967
- for (const line of content.slice(3, end).split('\n')) {
968
- const t = line.trim()
969
- if (t.startsWith('status:')) {
970
- declared = t.slice('status:'.length).trim().replace(/['"]/g, '')
971
- break
972
- }
1405
+ const content = readFileForRule('folder_status', path.join(dir, name), warnings)
1406
+ if (content === null) continue
1407
+ // Extrair status do frontmatter
1408
+ let declared = ''
1409
+ if (content.startsWith('---')) {
1410
+ const end = content.indexOf('\n---', 3)
1411
+ if (end > 0) {
1412
+ for (const line of content.slice(3, end).split('\n')) {
1413
+ const t = line.trim()
1414
+ if (t.startsWith('status:')) {
1415
+ declared = t.slice('status:'.length).trim().replace(/['"]/g, '')
1416
+ break
973
1417
  }
974
1418
  }
975
1419
  }
976
- if (!declared) continue
977
- const expected = FOLDER_TO_STATUS[state] || []
978
- if (!expected.some(e => e.toLowerCase() === declared.toLowerCase())) {
979
- warnings.push(`roadmap "${name}": folder is "${state}" but status declares "${declared}"`)
980
- }
981
- } catch (_) {}
1420
+ }
1421
+ if (!declared) continue
1422
+ const expected = FOLDER_TO_STATUS[state] || []
1423
+ if (!expected.some(e => e.toLowerCase() === declared.toLowerCase())) {
1424
+ warnings.push(`roadmap "${name}": folder is "${state}" but status declares "${declared}"`)
1425
+ }
982
1426
  }
983
1427
  }
984
1428
  return warnings
@@ -992,12 +1436,7 @@ function validateFilenameUniqueness() {
992
1436
 
993
1437
  const listErrors = []
994
1438
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
995
- let agents = cfg.agents || []
996
- if (agents.length === 0) {
997
- try { agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
998
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
999
- }) } catch (_) { agents = [] }
1000
- }
1439
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
1001
1440
  for (const agent of agents) {
1002
1441
  for (const state of states) {
1003
1442
  const dir = path.join(cfg.roadmapDir, agent, state)
@@ -1132,8 +1571,15 @@ function validateNoteOrphan(cwd) {
1132
1571
 
1133
1572
  const indexPath = path.join(vaultDir, 'index.md')
1134
1573
  let indexContent = ''
1135
- if (fs.existsSync(indexPath)) {
1136
- indexContent = fs.readFileSync(indexPath, 'utf8')
1574
+ try {
1575
+ indexContent = readRegularFileSync(indexPath).toString('utf8')
1576
+ } catch (err) {
1577
+ if (err.code !== 'ENOENT') {
1578
+ // index.md existe mas nao pode ser lido (permissao, FIFO...) -- diagnostica em vez de
1579
+ // tratar como indice vazio, que produziria um "not referenced" falso para toda nota
1580
+ // (ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1G).
1581
+ return [inspectionDiagnostic('note_orphan', indexPath, err)]
1582
+ }
1137
1583
  }
1138
1584
 
1139
1585
  const notes = fs.readdirSync(vaultDir).filter((f) => f.endsWith('.md') && f !== 'index.md')
@@ -1205,6 +1651,61 @@ const CREDENTIAL_GUARD_HOOK_FILES = [
1205
1651
  // Qualquer valor que não bata em nenhuma das 3 formas retorna null — o chamador NÃO deve tratar
1206
1652
  // isso como violação. Não é função desta regra adivinhar wiring próprio do usuário fora dos
1207
1653
  // formatos que o trackfw gera.
1654
+ // pathIsAnchoredForHookConfig reporta se raw é um caminho ANCORADO para os fins de um comando de
1655
+ // hook lido de config de CLI de agente e executado por bash — não se é absoluto para o SO host.
1656
+ // Decisão: ADR-2026-09-04-caminho-posix-ancorado-num-config-lido-por-cli-de-agente-e-absoluto-
1657
+ // independente-do-so-host. Quem interpreta esse caminho é o bash (ou o próprio CLI de agente),
1658
+ // nunca o process.platform do runtime Node — por isso este predicado é a UNIÃO de duas formas,
1659
+ // nunca a definição de "absoluto" de um único SO:
1660
+ // - POSIX: prefixo "/" — ancorado em qualquer host, inclusive rodando em Windows via Git Bash.
1661
+ // - Windows: letra de unidade ("C:\..." / "C:/...") ou UNC ("\\servidor\share") — ancorado em
1662
+ // qualquer host, inclusive medido a partir de macOS/Linux (D1 da ADR: uma letra de unidade
1663
+ // também é ancorada, independente de onde o validator roda).
1664
+ //
1665
+ // 🔴 Requisito duro da ADR: ZERO chamada dependente de SO aqui — nada de path.isAbsolute,
1666
+ // path.sep, ou qualquer predicado do módulo "path" que responda com base no process.platform.
1667
+ // `path.isAbsolute("/opt/foo")` é `false` no Windows — essa é exatamente a lacuna que motivou
1668
+ // esta função. Invariante por construção, verificável por grep.
1669
+ //
1670
+ // D2 da ADR: este predicado NÃO substitui path.isAbsolute nos sítios de travessia real de
1671
+ // sistema de arquivos (ex.: linha 95 e npm/src/integrations/manager.js) — ali o SO É a
1672
+ // autoridade certa. Uso restrito aos sítios de classificação de config de hook.
1673
+ function pathIsAnchoredForHookConfig(raw) {
1674
+ if (!raw) return false
1675
+ if (raw[0] === '/') return true
1676
+ // UNC: \\servidor\share\... — exige um segmento de SERVIDOR não vazio e diferente de "." ou
1677
+ // ".." (não são hostname válido), seguido de um separador, seguido de um segmento de SHARE não
1678
+ // vazio que não comece com outra barra invertida (evita componente vazio quando há barra dupla
1679
+ // no meio). "\\" e "\\x" sozinhos (sem separador de share), "\\.\x" / "\\..\evil" (server "."
1680
+ // ou ".."), e "\\..\\evil" (barra dupla no meio produz share vazio) NÃO são UNC válido — são
1681
+ // POSIX cwd-dependent (barra invertida não é separador em POSIX), e aceitá-los como ancorado
1682
+ // seria o afrouxamento inverso que este predicado existe para evitar (ressalva do parecer
1683
+ // hades-tf de 2026-09-04 sobre o ML-3A; a notação exata do exemplo "\\..\\evil" no parecer é
1684
+ // ambígua entre 1 e 2 barras antes de "evil" — a checagem de server !== "."/".." fecha as duas
1685
+ // leituras, não só uma). A forma POSIX equivalente, "//servidor/share", já é coberta pelo braço
1686
+ // raw[0]==='/' acima — este braço cobre só a forma com barra invertida.
1687
+ if (raw.length >= 2 && raw[0] === '\\' && raw[1] === '\\') {
1688
+ const rest = raw.slice(2)
1689
+ const sepIdx = rest.indexOf('\\')
1690
+ if (sepIdx > 0) {
1691
+ const server = rest.slice(0, sepIdx)
1692
+ const share = rest.slice(sepIdx + 1)
1693
+ if (server !== '.' && server !== '..' && share.length > 0 && share[0] !== '\\') return true
1694
+ }
1695
+ }
1696
+ // Letra de unidade: C:\... ou C:/...
1697
+ if (raw.length >= 3 && isASCIIDriveLetter(raw[0]) && raw[1] === ':' && (raw[2] === '\\' || raw[2] === '/')) {
1698
+ return true
1699
+ }
1700
+ return false
1701
+ }
1702
+
1703
+ // isASCIIDriveLetter reporta se ch é uma letra ASCII (a-z, A-Z) — reconhecimento de letra de
1704
+ // unidade do Windows em pathIsAnchoredForHookConfig.
1705
+ function isASCIIDriveLetter(ch) {
1706
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
1707
+ }
1708
+
1208
1709
  function resolveCredentialGuardHookPath(raw, root) {
1209
1710
  const claudePrefix = '$CLAUDE_PROJECT_DIR/'
1210
1711
  const geminiPrefix = '$GEMINI_PROJECT_DIR/'
@@ -1220,7 +1721,7 @@ function resolveCredentialGuardHookPath(raw, root) {
1220
1721
  const inner = raw.slice(codexPrefix.length, raw.length - 1)
1221
1722
  return path.join(root, inner)
1222
1723
  }
1223
- if (!raw.startsWith('$') && !raw.startsWith('"') && !path.isAbsolute(raw) && !raw.startsWith('~/')) {
1724
+ if (!raw.startsWith('$') && !raw.startsWith('"') && !pathIsAnchoredForHookConfig(raw) && !raw.startsWith('~/')) {
1224
1725
  // Caminho relativo puro — Cursor (beforeShellExecution/preToolUse), GitHub Copilot CLI
1225
1726
  // (campo "bash"), Kiro (action.command).
1226
1727
  // ~/… é excluído: é classe 1 (tilde expande para $HOME — ancorado) mas o validator não expande
@@ -1263,7 +1764,7 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
1263
1764
  rawStripped.startsWith('$CLAUDE_PROJECT_DIR/') ||
1264
1765
  rawStripped.startsWith('$GEMINI_PROJECT_DIR/') ||
1265
1766
  rawStripped.startsWith('$(git rev-parse --show-toplevel)/') ||
1266
- path.isAbsolute(rawStripped)
1767
+ pathIsAnchoredForHookConfig(rawStripped)
1267
1768
  ) {
1268
1769
  return HOOK_ANCHORAGE_CLASS_ANCHORED
1269
1770
  }
@@ -1279,7 +1780,7 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
1279
1780
  rawStripped.startsWith('${PWD}/') ||
1280
1781
  rawStripped.startsWith('./') ||
1281
1782
  rawStripped.startsWith('../') ||
1282
- (!rawStripped.startsWith('$') && !path.isAbsolute(rawStripped))
1783
+ (!rawStripped.startsWith('$') && !pathIsAnchoredForHookConfig(rawStripped))
1283
1784
  ) {
1284
1785
  return HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT
1285
1786
  }
@@ -1288,13 +1789,28 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
1288
1789
  }
1289
1790
 
1290
1791
  // cwdDependentReason retorna o sufixo de mensagem específico por forma para violações de
1291
- // classe 2, iniciando em "with a …". Dois formatos: formas contendo $PWD ou ${PWD} (em qualquer
1292
- // posição, inclusive em wrappers sh -c / env) recebem a mensagem do $PWD; demais recebem
1293
- // "bare relative path". Usa includes (não startsWith) para cobrir sh -c "$PWD/…" e env FOO=x $PWD/….
1792
+ // classe 2, iniciando em "with a …". Formatos: formas contendo $PWD ou ${PWD} (em qualquer
1793
+ // posição, inclusive em wrappers sh -c / env) recebem a mensagem do $PWD; "~/…" (D4 da
1794
+ // ADR-2026-09-04) recebe "quoted tilde path"; "~usuario/…" recebe "named-user tilde path";
1795
+ // demais recebem "bare relative path". Usa includes (não startsWith) para cobrir
1796
+ // sh -c "$PWD/…" e env FOO=x $PWD/….
1797
+ //
1798
+ // 🔴 D4 da ADR-2026-09-04: achado do ML-2B — "~usuario/" e "\"~/\"" caíam no catch-all e
1799
+ // recebiam "bare relative path", que não é o motivo real: ~user/ EXPANDE em POSIX (indecidível
1800
+ // sem executar shell, não relativo), e "~/" só falha por causa das ASPAS. O invariante que
1801
+ // permite reconhecer "~/" aqui sem receber wasQuoted como parâmetro: classifyHookAnchorage já
1802
+ // classifica "~/…" SEM aspas como classe 1 (ancorado) — logo todo "~/…" que chega aqui (classe
1803
+ // 2) só pode ter vindo de um valor originalmente citado; ver stripOuterQuotesForClassify.
1294
1804
  function cwdDependentReason(rawStripped) {
1295
1805
  if (rawStripped.includes('$PWD') || rawStripped.includes('${PWD}')) {
1296
1806
  return 'with a $PWD path — $PWD expands to the current working directory, not the project root; run `trackfw update` to fix it'
1297
1807
  }
1808
+ if (rawStripped.startsWith('~/')) {
1809
+ return 'with a quoted tilde path — double quotes prevent shell tilde expansion, so this never resolves to $HOME; run `trackfw update` to fix it'
1810
+ }
1811
+ if (rawStripped.startsWith('~')) {
1812
+ return "with a named-user tilde path — ~user/ expands to that user's home directory, not the project root, and this validator cannot resolve it without running a shell; run `trackfw update` to fix it"
1813
+ }
1298
1814
  return "with a bare relative path — this command only resolves from the project root and will silently fail when the agent's cwd is a subdirectory; run `trackfw update` to fix it"
1299
1815
  }
1300
1816
 
@@ -1364,31 +1880,96 @@ function collectCredentialGuardCommands(value, out) {
1364
1880
  // - A regra só avalia entradas que EXISTEM. Ausência de entrada de guard é estado legítimo
1365
1881
  // (guard global instalado via `trackfw update harness`) — nunca é violação por si só.
1366
1882
  // - Arquivo de hook ausente é pulado em silêncio.
1367
- // - Arquivo de hook presente mas com JSON inválido é pulado em silêncio — validar a forma do
1368
- // JSON não é escopo desta regra.
1883
+ // - ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1A:
1884
+ // arquivo de hook presente mas com JSON inválido deixou de ser pulado em silêncio — ver o
1885
+ // bloco catch do JSON.parse abaixo para a decisão e a medição que a sustentam.
1369
1886
  //
1370
1887
  // process.cwd() no Node já retorna o caminho FÍSICO (resolve symlinks via getcwd(3) diretamente),
1371
1888
  // diferente de os.Getwd() do Go — ver o comentário sobre EvalSymlinks em
1372
1889
  // internal/validator/validator_git_branch_guard.go's validateGuardHookResolvable equivalente
1373
1890
  // (validator_credential_guard.go). Nenhuma resolução extra de symlink é necessária aqui.
1891
+ // isValidUtf8Buffer reports whether buf decodes losslessly as UTF-8, using a fatal TextDecoder
1892
+ // (throws instead of substituting U+FFFD). Used purely to CLASSIFY which message to emit when
1893
+ // JSON.parse goes on to fail below — never to gate whether parsing is attempted. buf.toString(
1894
+ // 'utf8') (the actual parse input) stays lossy on purpose, mirroring the coercion Go's
1895
+ // json.Unmarshal already does on raw bytes — ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-
1896
+ // silencio, ML-1B; see validateGuardHookResolvable's JSON.parse catch block for why.
1897
+ function isValidUtf8Buffer(buf) {
1898
+ try {
1899
+ new TextDecoder('utf-8', { fatal: true }).decode(buf)
1900
+ return true
1901
+ } catch (_) {
1902
+ return false
1903
+ }
1904
+ }
1905
+
1374
1906
  function validateGuardHookResolvable(scriptMarker, cwd) {
1375
1907
  const root = cwd || process.cwd()
1376
1908
  const msgs = []
1377
1909
 
1378
1910
  for (const hf of CREDENTIAL_GUARD_HOOK_FILES) {
1379
1911
  const fullPath = path.join(root, hf.relPath)
1380
- let content
1912
+ let raw
1381
1913
  try {
1382
- content = fs.readFileSync(fullPath, 'utf8')
1914
+ raw = readRegularFileSync(fullPath)
1383
1915
  } catch (e) {
1384
1916
  if (e.code === 'ENOENT') continue
1917
+ // ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1C: readRegularFileSync
1918
+ // replaces the bare fs.readFileSync — a FIFO at fullPath made fs.readFileSync block
1919
+ // INDEFINITELY (hades-tf's ML-1B barrier, `mkfifo .claude/settings.json`), which this branch
1920
+ // could never reach because the call never returned. readRegularFileSync's synthetic
1921
+ // 'ENOTFILE' error (never 'ENOENT') falls into this SAME accusing branch below.
1922
+ //
1923
+ // ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1B: was an unconditional
1924
+ // `continue` regardless of e.code — a dead `if (e.code === 'ENOENT') continue` above it,
1925
+ // then `continue` again for EVERY other error (EACCES, EISDIR, ELOOP...). hades-tf's ML-1A
1926
+ // barrier reproduced this live (chmod 000, and a directory in place of the file): `trackfw
1927
+ // validate --json` exited 0, no violation, no mention of the file — success reported about
1928
+ // wiring the rule never actually inspected, the exact defect this ML exists to close, for a
1929
+ // different `continue` than the JSON-parse one ML-1A already fixed. Same remedy as the
1930
+ // invalid-JSON branch below.
1931
+ msgs.push(
1932
+ `${hf.relPath} (${hf.cli}) could not be read — trackfw cannot tell whether ${scriptMarker} is ` +
1933
+ `wired here, and ${hf.cli} cannot load any hook from a file it cannot read; fix the file, or run ` +
1934
+ '`trackfw update` to regenerate it'
1935
+ )
1385
1936
  continue
1386
1937
  }
1387
1938
 
1939
+ // ML-1B: content is the SAME lossy decode this function has always used (invalid bytes become
1940
+ // U+FFFD, never throws) — wasValidUtf8 only classifies which message to emit if JSON.parse
1941
+ // fails below; it never changes whether parsing is attempted.
1942
+ const wasValidUtf8 = isValidUtf8Buffer(raw)
1943
+ const content = raw.toString('utf8')
1944
+
1388
1945
  let parsed
1389
1946
  try {
1390
1947
  parsed = JSON.parse(content)
1391
1948
  } catch (_) {
1949
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio,
1950
+ // ML-1A: was a silent `continue` (fail-open) — port of Go's validateGuardHookResolvable
1951
+ // (internal/validator/validator_credential_guard.go) — see that function's json.Unmarshal
1952
+ // error branch for the full decision and measurement. CREDENTIAL_GUARD_HOOK_FILES is a
1953
+ // closed, enumerable list (6 entries) trackfw itself owns/merges into; invalid JSON in any
1954
+ // of them is never a legitimate state, since hf.cli itself requires strict JSON to load its
1955
+ // own hooks from that file.
1956
+ //
1957
+ // ML-1B: not-valid-UTF-8 gets its own message (D4 of ADR-2026-09-04 — classify right,
1958
+ // explain right, or the reader investigates the wrong thing; e.g. a file saved as UTF-16
1959
+ // looks fine in the user's editor).
1960
+ if (!wasValidUtf8) {
1961
+ msgs.push(
1962
+ `${hf.relPath} (${hf.cli}) is not valid UTF-8 — trackfw cannot tell whether ${scriptMarker} is ` +
1963
+ `wired here, and ${hf.cli} cannot load any hook from a file it cannot decode; fix the file's ` +
1964
+ 'encoding, or run `trackfw update` to regenerate it'
1965
+ )
1966
+ continue
1967
+ }
1968
+ msgs.push(
1969
+ `${hf.relPath} (${hf.cli}) is not valid JSON — trackfw cannot tell whether ${scriptMarker} is ` +
1970
+ `wired here, and ${hf.cli} cannot load any hook from a file it cannot parse; fix the file, or run ` +
1971
+ '`trackfw update` to regenerate it'
1972
+ )
1392
1973
  continue
1393
1974
  }
1394
1975
 
@@ -1435,7 +2016,7 @@ function validateGuardHookResolvable(scriptMarker, cwd) {
1435
2016
 
1436
2017
  if (!stat) {
1437
2018
  msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the script does not exist — run \`trackfw update\` to regenerate it`)
1438
- } else if ((stat.mode & 0o111) === 0) {
2019
+ } else if (_platform !== 'win32' && (stat.mode & 0o111) === 0) {
1439
2020
  msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the script is not executable — run \`trackfw update\` to regenerate it`)
1440
2021
  }
1441
2022
  }
@@ -1644,10 +2225,29 @@ function validateCredentialGuardScriptIntegrity(cwd) {
1644
2225
  const relPath = 'scripts/trackfw-credential-guard.sh'
1645
2226
  let content
1646
2227
  try {
1647
- content = fs.readFileSync(path.join(root, relPath), 'utf8')
2228
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
2229
+ // readRegularFileSync (defined above) replaces the bare fs.readFileSync — a FIFO at this path
2230
+ // made fs.readFileSync block INDEFINITELY (hades-tf's ML-1B barrier), a hang this catch block
2231
+ // could never reach because the read never returned. The non-ENOENT accusing branch below
2232
+ // (already correct pre-ML-1C, unlike its Go/Python siblings) needed no change for this fix.
2233
+ content = readRegularFileSync(path.join(root, relPath)).toString('utf8')
1648
2234
  } catch (e) {
1649
2235
  if (e.code === 'ENOENT') return []
1650
- return [inspectionDiagnostic('credential_guard_script_integrity', relPath, e)]
2236
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
2237
+ // was `[inspectionDiagnostic(...)]` — correct in SUBSTANCE before this ML (already a
2238
+ // violation, not a silence, unlike Go which aborted and Python which silenced), but its
2239
+ // 'rule: could not inspect "target": cause' shape put this runtime's message text out of
2240
+ // parity with Go/Python's "could not be read" phrasing FOR THIS SPECIFIC RULE FAMILY once all
2241
+ // 3 runtimes actually produce a message here — a divergence that had no observable effect
2242
+ // before ML-1C (Go/Python never reached this branch at all), but would become a real 3-CLI
2243
+ // text mismatch the moment all 3 runtimes accuse on the same fixture. Unified to the same
2244
+ // fixed text validateGuardHookResolvable / validateGuardGlobalScriptIntegrity already use,
2245
+ // matching Go's identical fix in validateCredentialGuardScriptIntegrity
2246
+ // (validator_credential_guard_integrity.go).
2247
+ return [
2248
+ `${relPath} could not be read — trackfw cannot tell whether this script matches the ` +
2249
+ 'template it should; fix the file, or run `trackfw update` to regenerate it',
2250
+ ]
1651
2251
  }
1652
2252
 
1653
2253
  if (content === CREDENTIAL_GUARD_SCRIPT_REFERENCE) return []
@@ -1716,12 +2316,31 @@ set -f
1716
2316
  # --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
1717
2317
  # global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
1718
2318
  # quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
1719
- # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. Só drena
1720
- # se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
1721
- # esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
1722
- # leitura.
2319
+ # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. O dreno
2320
+ # roda incondicionalmente, com orçamento de tempo (ML-3A, ver abaixo). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda leitura.
2321
+ #
2322
+ # ML-3A (ROADMAP-2026-09-09-guard-emite-hookspecificoutput-e-a-razao-chega-ao-modelo-nos-3-
2323
+ # clis.md): o discriminante que existia aqui era "-t 0" -- separa "terminal interativo" de
2324
+ # "pipe", não "vai receber EOF" de "não vai". Qualquer chamador não-tty que segure stdin aberta
2325
+ # (ex.: um runtime de hook que nunca fecha o descritor) travava o antigo "cat" para sempre, sem
2326
+ # limite -- foi o que pendurou "make quality" por 1h05 quando o gate de schema (ML-2A) chamou o
2327
+ # guard sem "</dev/null" em 3 sítios. Substituído por dreno com ORÇAMENTO DE TEMPO via
2328
+ # "read -t <segundos> -d ''": builtin do próprio bash desde a 3.0 (não do coreutils) -- medido
2329
+ # idêntico no bash 3.2 (padrão do macOS) e no bash 5.3 (Linux/Homebrew); o bash do MSYS2/Git-Bash
2330
+ # do Windows é bash de verdade e traz o mesmo builtin. Evita depender de "timeout(1)", que NÃO
2331
+ # existe no macOS base nem no MSYS2 mínimo. "-d ''" faz o read tentar ler até o EOF real (NUL
2332
+ # nunca aparece em payload de hook); "-t" interrompe a espera no orçamento e, medido contra um
2333
+ # FIFO nunca fechado (bash 3.2 e 5.3), preserva na variável qualquer prefixo já lido antes do
2334
+ # timeout -- sem perda do que chegou, só do que nunca chegou. 2s de orçamento: folga generosa
2335
+ # para o payload pequeno de um hook sob scheduler sob carga (CI, "make quality" paralelo), e
2336
+ # ainda curto o bastante para o travamento continuar perceptível em vez de indefinido. Estourar
2337
+ # o orçamento NÃO libera o bloqueio: o passo 1 abaixo já prefere "$*" quando há argumento
2338
+ # posicional, e cai para o que foi lido até o limite quando não há -- "exit 2" e o fail-closed
2339
+ # continuam idênticos nos dois casos. O antigo desvio para invocação manual em terminal (skip
2340
+ # total quando "-t 0") foi removido de propósito: era o próprio discriminante errado que este ML
2341
+ # elimina, não um caso a preservar -- agora toda invocação drena com o mesmo orçamento de 2s.
1723
2342
  _TRACKFW_STDIN=""
1724
- [ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
2343
+ IFS= read -r -t 2 -d '' _TRACKFW_STDIN || true
1725
2344
 
1726
2345
  # --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
1727
2346
  # projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
@@ -2251,7 +2870,7 @@ case "$SUBCOMMAND" in
2251
2870
  ;;
2252
2871
  esac
2253
2872
 
2254
- printf '{"decision":"block","reason":"%s"}\\n' "$REASON"
2873
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "$REASON"
2255
2874
  echo "$REASON" >&2
2256
2875
  exit 2
2257
2876
  `
@@ -2265,10 +2884,19 @@ function validateGitBranchGuardScriptIntegrity(cwd) {
2265
2884
  const relPath = 'scripts/trackfw-git-branch-guard.sh'
2266
2885
  let content
2267
2886
  try {
2268
- content = fs.readFileSync(path.join(root, relPath), 'utf8')
2887
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
2888
+ // same readRegularFileSync substitution as validateCredentialGuardScriptIntegrity's identical
2889
+ // comment above — closes the FIFO hang; the accusing branch below needed no change.
2890
+ content = readRegularFileSync(path.join(root, relPath)).toString('utf8')
2269
2891
  } catch (e) {
2270
2892
  if (e.code === 'ENOENT') return []
2271
- return [inspectionDiagnostic('git_branch_guard_script_integrity', relPath, e)]
2893
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
2894
+ // same message unification as validateCredentialGuardScriptIntegrity's identical comment
2895
+ // above — text parity with Go/Python for this rule family.
2896
+ return [
2897
+ `${relPath} could not be read — trackfw cannot tell whether this script matches the ` +
2898
+ 'template it should; fix the file, or run `trackfw update` to regenerate it',
2899
+ ]
2272
2900
  }
2273
2901
 
2274
2902
  if (content === GIT_BRANCH_GUARD_SCRIPT_REFERENCE) return []
@@ -2486,28 +3114,68 @@ function globalGuardConfigPath(gf, scriptMarker) {
2486
3114
  // prefix-stripping is needed here: any matched command that is NOT already an absolute path is
2487
3115
  // skipped (never treated as a violation).
2488
3116
  //
2489
- // Fail-open: unresolvable $HOME, unreadable file, or invalid JSON all skip that file in silence —
2490
- // same contract validateGuardHookResolvable already has for project-scope files.
3117
+ // Fail-open: unresolvable $HOME and unreadable file skip that file in silence — legitimate states.
3118
+ // Invalid JSON no longer does — see ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-
3119
+ // deixa-de-ser-silencio, ML-1A, and the JSON.parse catch block below for the decision and measurement.
2491
3120
  function validateGuardGlobalHookResolvable(scriptMarker) {
2492
- const home = os.homedir()
3121
+ const home = homedir()
2493
3122
  if (!home) return []
2494
3123
 
2495
3124
  const msgs = []
2496
3125
  for (const gf of GLOBAL_GUARD_CONFIG_FILES) {
2497
3126
  const relPath = globalGuardConfigPath(gf, scriptMarker)
2498
3127
  const fullPath = path.join(home, relPath)
2499
- let content
3128
+ let raw
2500
3129
  try {
2501
- content = fs.readFileSync(fullPath, 'utf8')
3130
+ raw = readRegularFileSync(fullPath)
2502
3131
  } catch (e) {
2503
3132
  if (e.code === 'ENOENT') continue
3133
+ // ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1C: same
3134
+ // readRegularFileSync substitution as the project-scope sibling — see that function's
3135
+ // identical comment for the FIFO hang this closes.
3136
+ //
3137
+ // ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1B: same decision as the
3138
+ // project-scope sibling (validateGuardHookResolvable, above) — was an unconditional
3139
+ // `continue` for every non-ENOENT read error. See that function's read-error catch branch
3140
+ // for the full measurement (hades-tf ML-1A barrier, reproduced live).
3141
+ msgs.push(
3142
+ `~/${relPath} (${gf.cli}, global scope) could not be read — trackfw cannot tell whether ${scriptMarker} is ` +
3143
+ `wired here, and ${gf.cli} cannot load any hook from a file it cannot read; fix the file, or run ` +
3144
+ '`trackfw update harness` to regenerate it'
3145
+ )
2504
3146
  continue
2505
3147
  }
2506
3148
 
3149
+ // ML-1B: see validateGuardHookResolvable's identical comment above for why decoding, not just
3150
+ // parsing, must be classified separately here.
3151
+ const wasValidUtf8 = isValidUtf8Buffer(raw)
3152
+ const content = raw.toString('utf8')
3153
+
2507
3154
  let parsed
2508
3155
  try {
2509
3156
  parsed = JSON.parse(content)
2510
3157
  } catch (_) {
3158
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio,
3159
+ // ML-1A: same decision as the project-scope sibling (validateGuardHookResolvable, above) —
3160
+ // see that function's json.parse error branch for the full measurement. Applies identically
3161
+ // here: GLOBAL_GUARD_CONFIG_FILES is the same kind of closed, enumerable list (6 entries),
3162
+ // each owned/consumed by gf.cli itself, so invalid JSON is never a legitimate state.
3163
+ //
3164
+ // ML-1B: not-valid-UTF-8 gets its own message — see validateGuardHookResolvable's identical
3165
+ // branch above for the reasoning.
3166
+ if (!wasValidUtf8) {
3167
+ msgs.push(
3168
+ `~/${relPath} (${gf.cli}, global scope) is not valid UTF-8 — trackfw cannot tell whether ${scriptMarker} is ` +
3169
+ `wired here, and ${gf.cli} cannot load any hook from a file it cannot decode; fix the file's ` +
3170
+ 'encoding, or run `trackfw update harness` to regenerate it'
3171
+ )
3172
+ continue
3173
+ }
3174
+ msgs.push(
3175
+ `~/${relPath} (${gf.cli}, global scope) is not valid JSON — trackfw cannot tell whether ${scriptMarker} is ` +
3176
+ `wired here, and ${gf.cli} cannot load any hook from a file it cannot parse; fix the file, or run ` +
3177
+ '`trackfw update harness` to regenerate it'
3178
+ )
2511
3179
  continue
2512
3180
  }
2513
3181
 
@@ -2520,7 +3188,10 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
2520
3188
  if (seen.has(seenKey)) continue
2521
3189
  seen.add(seenKey)
2522
3190
 
2523
- if (!path.isAbsolute(m.raw)) continue
3191
+ // ADR-2026-09-04-caminho-posix-ancorado-...: pathIsAnchoredForHookConfig classifica por
3192
+ // ancoragem (POSIX "/", letra de unidade, UNC), NÃO por path.isAbsolute — que no Windows
3193
+ // devolve false para "/opt/foo/guard.sh" e faria este `continue` pular a entrada inteira.
3194
+ if (!pathIsAnchoredForHookConfig(m.raw)) continue
2524
3195
 
2525
3196
  // ROADMAP-2026-08-17 ML-4B: reproduced by hades-tf (ML-4A barrier) — a global config entry
2526
3197
  // with the correct absolute command but missing/wrong "type" (hand-edited, an older trackfw
@@ -2541,7 +3212,7 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
2541
3212
 
2542
3213
  if (!stat) {
2543
3214
  msgs.push(`~/${relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${m.raw}", but the script does not exist — run \`trackfw update harness\` to regenerate it`)
2544
- } else if ((stat.mode & 0o111) === 0) {
3215
+ } else if (_platform !== 'win32' && (stat.mode & 0o111) === 0) {
2545
3216
  msgs.push(`~/${relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${m.raw}", but the script is not executable — run \`trackfw update harness\` to regenerate it`)
2546
3217
  }
2547
3218
  }
@@ -2574,17 +3245,33 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
2574
3245
  // scriptMarker doubles as the script's own filename (trackfw-credential-guard.sh /
2575
3246
  // trackfw-git-branch-guard.sh) in both call sites below — same equivalence the Go port relies on.
2576
3247
  function validateGuardGlobalScriptIntegrity(scriptFileName, referenceContent) {
2577
- const home = os.homedir()
3248
+ const home = homedir()
2578
3249
  if (!home) return []
2579
3250
 
2580
3251
  const scriptPath = path.join(home, '.trackfw', 'scripts', scriptFileName)
2581
3252
  let content
2582
3253
  try {
2583
- content = fs.readFileSync(scriptPath, 'utf8')
2584
- } catch (_) {
2585
- // Not installed is not a violation — same contract as every other *_script_integrity check
2586
- // (project-scope and global) in this file.
2587
- return []
3254
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
3255
+ // readRegularFileSync also closes the FIFO hang for this path (see its doc comment above).
3256
+ content = readRegularFileSync(scriptPath).toString('utf8')
3257
+ } catch (e) {
3258
+ if (e.code === 'ENOENT') {
3259
+ // Not installed is not a violation — same contract as every other *_script_integrity check
3260
+ // (project-scope and global) in this file.
3261
+ return []
3262
+ }
3263
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
3264
+ // was an UNCONDITIONAL `return []` on ANY read error (bare `catch (_)`) — hades-tf's ML-1B
3265
+ // barrier found this silenced EACCES/EISDIR/ELOOP exactly like the pre-ML-1B config-file
3266
+ // `continue` did (fail-open: validate reports health about an artifact it never actually
3267
+ // inspected). Distinguished from absence by e.code, mirroring every other read-error branch in
3268
+ // this file. Message shape mirrors Go's validateGuardGlobalScriptIntegrity — not
3269
+ // inspectionDiagnostic's shape, since this function serves both credential-guard and
3270
+ // git-branch-guard callers and has no rule name of its own to embed.
3271
+ return [
3272
+ `${scriptPath} (global scope) could not be read — trackfw cannot tell whether this script ` +
3273
+ 'matches the template it should; fix the file, or run `trackfw update harness` to regenerate it',
3274
+ ]
2588
3275
  }
2589
3276
 
2590
3277
  if (content === referenceContent) return []
@@ -2686,10 +3373,15 @@ function validateCredentialGuardModeDowngrade(cwd) {
2686
3373
 
2687
3374
  let diskContent
2688
3375
  try {
2689
- diskContent = fs.readFileSync(path.join(root, 'trackfw.yaml'), 'utf8')
3376
+ diskContent = readRegularFileSync(path.join(root, 'trackfw.yaml')).toString('utf8')
2690
3377
  } catch (e) {
2691
3378
  if (e.code === 'ENOENT') return [credentialGuardModeDowngradeMessage()]
2692
- return [inspectionDiagnostic('credential_guard_mode_downgrade', 'trackfw.yaml', e)]
3379
+ // Fixed-text message, NOT inspectionDiagnostic — e.message (e.g. "EACCES: permission denied,
3380
+ // open 'trackfw.yaml'") diverges from Go's %v and Python's str(e) for the exact same failure;
3381
+ // every sibling "could not be read" message in this guard family avoids raw OS error text for
3382
+ // the same reason (ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-
3383
+ // silencio, ML-1G).
3384
+ return [credentialGuardModeDowngradeReadFailureMessage()]
2693
3385
  }
2694
3386
 
2695
3387
  const diskMode = extractCredentialGuardMode(diskContent)
@@ -2704,6 +3396,13 @@ function credentialGuardModeDowngradeMessage() {
2704
3396
  'otherwise investigate before treating the credential guard as active'
2705
3397
  }
2706
3398
 
3399
+ // Fixed-text remedy for a non-ENOENT read failure on trackfw.yaml when HEAD had mode: block — see
3400
+ // its call site for why this avoids inspectionDiagnostic's raw OS error text.
3401
+ function credentialGuardModeDowngradeReadFailureMessage() {
3402
+ return 'trackfw.yaml could not be read — trackfw cannot tell whether credential_guard.mode ' +
3403
+ 'is still block; fix the file, or run `trackfw update` to regenerate it'
3404
+ }
3405
+
2707
3406
  // _itemMeta: mapa de message → { rule, file } para enriquecer saída JSON.
2708
3407
  // Populado em applyRule e nos pushs diretos do validateUnfiltered.
2709
3408
  // Permanece em memória apenas durante a execução de uma chamada validate*.
@@ -2740,6 +3439,11 @@ const RULE_DEFAULTS = {
2740
3439
  // git_branch_guard_hook_resolvable is deliberately absent from this map (falls through to
2741
3440
  // 'error'), mirroring credential_guard_hook_resolvable.
2742
3441
  git_branch_guard_script_integrity: 'warning',
3442
+ // ML-4A (REQ-2026-08-29, achado 1 do parecer hades-tf 2026-08-30): namespace oculto/ambíguo (nome
3443
+ // iniciado por ".") é sinal de baixo ruído por natureza, não um defeito de configuração como
3444
+ // agent_namespace_undeclared ('error'). Nunca 'off' por default — silêncio total é o defeito que
3445
+ // esta REQ existe para fechar.
3446
+ agent_namespace_hidden: 'warning',
2743
3447
  }
2744
3448
 
2745
3449
  // ROADMAP-2026-08-12-ancorar-rules-no-head-para-as-regras-de-credential-guard, ML-1A.
@@ -2841,7 +3545,7 @@ const BASELINE_FILE = '.trackfw-baseline.json'
2841
3545
  // Retorna null se o arquivo não existir.
2842
3546
  function loadBaseline() {
2843
3547
  try {
2844
- const data = fs.readFileSync(BASELINE_FILE, 'utf8')
3548
+ const data = readRegularFileSync(BASELINE_FILE).toString('utf8')
2845
3549
  return JSON.parse(data)
2846
3550
  } catch (e) {
2847
3551
  if (e.code === 'ENOENT') return null
@@ -2898,11 +3602,16 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
2898
3602
  const manifestPath = path.join(root, '.trackfw', 'integrations-manifest.json')
2899
3603
  let manifest
2900
3604
  try {
2901
- const raw = fs.readFileSync(manifestPath, 'utf8')
3605
+ const raw = readRegularFileSync(manifestPath).toString('utf8')
2902
3606
  manifest = JSON.parse(raw)
2903
3607
  } catch (err) {
2904
3608
  if (err.code === 'ENOENT') return []
2905
- throw new Error(`thirdparty_artifact_has_provenance: read ${manifestPath}: ${err.message}`)
3609
+ // Used to `throw` here — an unreadable/corrupt manifest crashed the whole `trackfw validate`
3610
+ // process instead of reporting a diagnostic for this one rule and letting every other rule
3611
+ // still run. Same defect class ML-1C fixed for *_script_integrity, found here by measurement
3612
+ // outside the guard family, in parity with the Go fix at the same call site (ROADMAP-2026-09-
3613
+ // 06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1G).
3614
+ return [inspectionDiagnostic('thirdparty_artifact_has_provenance', manifestPath, err)]
2906
3615
  }
2907
3616
 
2908
3617
  const destinations = []
@@ -2933,7 +3642,18 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
2933
3642
  // instead, which this rule intentionally never reads). path.relative
2934
3643
  // inverts resolve()'s path.resolve(root, relative) exactly. Mirrors
2935
3644
  // internal/validator/validator_thirdparty_provenance.go.
2936
- const provenanceKey = path.relative(root, destination)
3645
+ //
3646
+ // ML-2A (ADR-2026-09-04, D1 categoria 2 — "chave de dicionario ou identificador"):
3647
+ // path.relative devolve separador NATIVO, e a chave gravada em
3648
+ // thirdparty-provenance.json e sempre "/" (resolveThirdPartySkillDestination monta
3649
+ // o destino por concatenacao explicita com "/", nos 3 runtimes). Sem normalizar, no
3650
+ // Windows a chave nunca casa e TODO artefato de terceiro e reportado como sem
3651
+ // entrada — falso positivo em massa. Go (validator_thirdparty_provenance.go:160) e
3652
+ // Python (validator.py:3530) ja normalizavam; o Node era a divergencia (D4).
3653
+ //
3654
+ // 🔴 So a CHAVE DERIVADA e normalizada. `destination` continua cru e e o que aparece
3655
+ // nas mensagens e no manifest; nada aqui altera caminho passado a syscall (ADR D2).
3656
+ const provenanceKey = normalizeRefSeparator(path.relative(root, destination))
2937
3657
  const entry = (prov.entries || {})[provenanceKey]
2938
3658
  if (!entry) {
2939
3659
  msgs.push(
@@ -2946,7 +3666,7 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
2946
3666
 
2947
3667
  let installed
2948
3668
  try {
2949
- installed = fs.readFileSync(destination)
3669
+ installed = readRegularFileSync(destination)
2950
3670
  } catch (err) {
2951
3671
  msgs.push(
2952
3672
  `thirdparty_artifact_has_provenance: "${destination}" is claimed as a third-party artifact with an ` +
@@ -3027,6 +3747,15 @@ async function validateUnfiltered() {
3027
3747
 
3028
3748
  // Verificação bidirecional de trace ID (somente se traceIdField configurado)
3029
3749
  const cfg = config.load()
3750
+
3751
+ // ML-2A (REQ-2026-08-29): namespace de agente em disco e não declarado em agents: — violação, não
3752
+ // aviso (ver comentário em validateAgentNamespaceUndeclared).
3753
+ applyRule('agent_namespace_undeclared', validateAgentNamespaceUndeclared(cfg), violations, warnings)
3754
+
3755
+ // ML-4A (achado 1, hades-tf 2026-08-30): contraponto de baixo ruído para nomes ocultos/ambíguos
3756
+ // (iniciados por ".") — aviso, nunca silêncio total, nunca erro (RULE_DEFAULTS abaixo).
3757
+ applyRule('agent_namespace_hidden', hiddenNamespaceWarnings(cfg), violations, warnings)
3758
+
3030
3759
  if (cfg.traceIdField) {
3031
3760
  for (const msg of checkTraceIds(cfg.reqDir, cfg.roadmapDir, cfg.traceIdField)) {
3032
3761
  // O prefixo da mensagem traceid já carrega o nome da regra (ex: "traceid_orphan_roadmap: ...")
@@ -3090,16 +3819,18 @@ function buildInventorySection(cfg) {
3090
3819
  let reqOpen = 0
3091
3820
  let reqDone = 0
3092
3821
  let reqClosed = 0
3822
+ let reqOther = 0
3093
3823
  for (const filePath of reqFiles) {
3094
3824
  let content
3095
3825
  try {
3096
- content = fs.readFileSync(filePath, 'utf8')
3826
+ content = readRegularFileSync(filePath).toString('utf8')
3097
3827
  } catch (_) {
3098
3828
  continue
3099
3829
  }
3100
3830
  if (reqStatusEquals(content, 'open')) reqOpen++
3101
3831
  else if (reqStatusEquals(content, 'done')) reqDone++
3102
3832
  else if (reqStatusEquals(content, 'closed')) reqClosed++
3833
+ else reqOther++
3103
3834
  }
3104
3835
 
3105
3836
  const roadmapCounts = {}
@@ -3115,7 +3846,14 @@ function buildInventorySection(cfg) {
3115
3846
 
3116
3847
  let section = '\n📊 Inventory\n'
3117
3848
  section += ` ${'ADRs'.padEnd(12)}${adrCount}\n`
3118
- section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed)\n`
3849
+ // Toda grafia fora de open/done/closed cai em reqOther. Sem este bucket o total
3850
+ // bate e a quebra some com a diferenca EM SILENCIO: um acervo com
3851
+ // approved/backlog/abandoned mostra "53 (8 Open · 36 Done · 0 Closed)" sem
3852
+ // indicar que 9 existem e nao estao em lugar nenhum da conta.
3853
+ // O Python ja fazia isto (pypi/trackfw/commands/status.py:58,199).
3854
+ let reqDetail = `${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed`
3855
+ if (reqOther > 0) reqDetail += ` · ${reqOther} Other`
3856
+ section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqDetail})\n`
3119
3857
  section += ` ${'Roadmaps'.padEnd(12)}${roadmapTotal}\n`
3120
3858
  section += ` backlog ${roadmapCounts.backlog} · analyzing ${roadmapCounts.analyzing} · wip ${roadmapCounts.wip}\n`
3121
3859
  section += ` blocked ${roadmapCounts.blocked} · done ${roadmapCounts.done} · abandoned ${roadmapCounts.abandoned}\n`
@@ -3129,14 +3867,7 @@ async function getStatus() {
3129
3867
  out += buildInventorySection(cfg)
3130
3868
 
3131
3869
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
3132
- let agents = cfg.agents || []
3133
- if (agents.length === 0) {
3134
- try {
3135
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
3136
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
3137
- })
3138
- } catch (_) { agents = [] }
3139
- }
3870
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
3140
3871
  out += '\n⚙ WIP by Agent\n'
3141
3872
  for (const agent of agents) {
3142
3873
  const wip = listDir(cfg.roadmapDir + '/' + agent + '/wip')
@@ -3205,6 +3936,7 @@ async function getStatus() {
3205
3936
  }
3206
3937
 
3207
3938
  module.exports = {
3939
+ _setPlatformForTest,
3208
3940
  validate,
3209
3941
  validateUnfiltered,
3210
3942
  loadBaseline,
@@ -3229,9 +3961,11 @@ module.exports = {
3229
3961
  listDir,
3230
3962
  tryListDir,
3231
3963
  resolveReqFiles,
3964
+ reqWriteDir,
3232
3965
  resolveStateDirs,
3233
3966
  resolveWIPDirs,
3234
3967
  resolveDoneDirs,
3968
+ resolveAgentNamespaces,
3235
3969
  parseSquadFromFrontmatter,
3236
3970
  validateFrontmatterPresence,
3237
3971
  // novas funções ML-1B
@@ -3251,6 +3985,7 @@ module.exports = {
3251
3985
  normalizeBranchSlug,
3252
3986
  // novas funções ML-2B
3253
3987
  contentHasMarker,
3988
+ contentHasMarkerValue,
3254
3989
  ruleSeverity,
3255
3990
  applyRule,
3256
3991
  // novas funções ML-1B (v2.5.1)
@@ -3270,6 +4005,8 @@ module.exports = {
3270
4005
  resolveAdrStatus,
3271
4006
  // ROADMAP-2026-08-12-mitigacao-do-fail-open-do-credential-guard, ML-1A
3272
4007
  resolveCredentialGuardHookPath,
4008
+ // ADR-2026-09-04 (ML-3A): predicado de ancoragem invariante por GOOS
4009
+ pathIsAnchoredForHookConfig,
3273
4010
  // ADR-2026-08-22 (ML-1A): classificação por ancoragem
3274
4011
  HOOK_ANCHORAGE_CLASS_ANCHORED,
3275
4012
  HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT,