trackfw 7.3.0 → 7.4.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,33 @@ function contentHasMarker(content, markers) {
254
577
  return false
255
578
  }
256
579
 
580
+ // contentHasMarkerValue retorna true se algum dos markers aparece em content seguido de
581
+ // conteúdo não-branco na mesma linha — isto é, o campo tem um valor real, não apenas o marker.
582
+ //
583
+ // issue #278: contentHasMarker (acima) detectava "vazio" só pela grafia literal
584
+ // "MARKER + um espaço + \n"/"\r\n" — 5 de 7 grafias naturais de campo vazio escapavam
585
+ // ("MARKER:\n" sem espaço, dois espaços, tab, CRLF sem espaço, três espaços). Esta função
586
+ // decide por VALOR: pega o resto da linha após o marker, descarta \r/\t/espaços das duas
587
+ // pontas, e só considera "tem valor" se sobrar algo. Indiferente a CRLF, tabs e contagem de
588
+ // espaços — cobre as 7 grafias medidas na triagem, não apenas a literal do template.
589
+ function contentHasMarkerValue(content, markers) {
590
+ for (const marker of markers) {
591
+ let start = 0
592
+ while (true) {
593
+ const idx = content.indexOf(marker, start)
594
+ if (idx === -1) break
595
+ const pos = idx + marker.length
596
+ let rest = content.slice(pos)
597
+ const nl = rest.indexOf('\n')
598
+ if (nl !== -1) rest = rest.slice(0, nl)
599
+ if (rest.endsWith('\r')) rest = rest.slice(0, -1)
600
+ if (rest.trim() !== '') return true
601
+ start = pos
602
+ }
603
+ }
604
+ return false
605
+ }
606
+
257
607
  // extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
258
608
  // ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
259
609
  // documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
@@ -323,7 +673,7 @@ function adrNotAcceptedStatusForRule(rule, basename, messages) {
323
673
  const p = findAdrFile(basename)
324
674
  if (!p) return { notAccepted: false, status: '', inspected: true }
325
675
  try {
326
- const content = fs.readFileSync(p, 'utf8')
676
+ const content = readRegularFileSync(p).toString('utf8')
327
677
  const status = resolveAdrStatus(content)
328
678
  const notAccepted = status.toLowerCase() === 'draft' || status.toLowerCase() === 'proposed'
329
679
  return { notAccepted, status, inspected: true }
@@ -356,7 +706,7 @@ function validateWIPHasREQ() {
356
706
  for (const name of entries) {
357
707
  const content = readFileForRule('wip_has_req', path.join(wipDir, name), violations)
358
708
  if (content === null) continue
359
- if (!contentHasMarker(content, cfg.linkFields.req)) {
709
+ if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
360
710
  violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
361
711
  }
362
712
  }
@@ -370,13 +720,10 @@ function validateREQsHaveADR() {
370
720
  const files = resolveReqFiles(cfg)
371
721
  const violations = []
372
722
  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
723
+ const content = readFileForRule('req_has_adr', filePath, violations)
724
+ if (content === null) continue
725
+ if (!contentHasMarkerValue(content, cfg.linkFields.adr)) {
726
+ violations.push(`req "${path.basename(filePath)}" has no linked ADR`)
380
727
  }
381
728
  }
382
729
  return violations
@@ -391,7 +738,7 @@ function validateBlockedHasREQ() {
391
738
  for (const name of entries) {
392
739
  const content = readFileForRule('blocked_has_req', path.join(blockedDir, name), violations)
393
740
  if (content === null) continue
394
- if (!contentHasMarker(content, cfg.linkFields.req)) {
741
+ if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
395
742
  violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
396
743
  }
397
744
  }
@@ -405,13 +752,10 @@ function validateREQsHaveRoadmap() {
405
752
  const files = resolveReqFiles(cfg)
406
753
  const violations = []
407
754
  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
755
+ const content = readFileForRule('req_has_roadmap', filePath, violations)
756
+ if (content === null) continue
757
+ if (!contentHasMarkerValue(content, cfg.linkFields.roadmap)) {
758
+ violations.push(`req "${path.basename(filePath)}" has no linked Roadmap`)
415
759
  }
416
760
  }
417
761
  return violations
@@ -505,7 +849,7 @@ function wipConfigFrom(cfg) {
505
849
  function parseSquadFromFrontmatter(filePath) {
506
850
  let content
507
851
  try {
508
- content = fs.readFileSync(filePath, 'utf8')
852
+ content = readRegularFileSync(filePath).toString('utf8')
509
853
  } catch (_) {
510
854
  return ''
511
855
  }
@@ -526,14 +870,7 @@ function validateWIPLimit() {
526
870
  const warnings = []
527
871
 
528
872
  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
- }
873
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
537
874
  const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
538
875
  for (const agent of agents) {
539
876
  const entries = listDir(cfg.roadmapDir + '/' + agent + '/wip')
@@ -606,7 +943,7 @@ function latestWipTransitionTime(cfg, filePath) {
606
943
  let content
607
944
  const logPath = path.join(cfg.roadmapDir, '.trackfw-log')
608
945
  try {
609
- content = fs.readFileSync(logPath, 'utf8')
946
+ content = readRegularFileSync(logPath).toString('utf8')
610
947
  } catch (err) {
611
948
  return { time: null, diagnostics: err && err.code === 'ENOENT' ? [] : [inspectionDiagnostic('stale_wip', logPath, err)] }
612
949
  }
@@ -743,7 +1080,7 @@ function blockedREQs() {
743
1080
  for (const filePath of files) {
744
1081
  let content
745
1082
  try {
746
- content = fs.readFileSync(filePath, 'utf8')
1083
+ content = readRegularFileSync(filePath).toString('utf8')
747
1084
  } catch (_) {
748
1085
  continue
749
1086
  }
@@ -796,23 +1133,21 @@ function validateFrontmatterPresence() {
796
1133
  for (const f of walkDirMd(adrDir)) {
797
1134
  const fullPath = findAdrFile(f)
798
1135
  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 (_) {}
1136
+ const content = readFileForRule('frontmatter_presence', fullPath, violations)
1137
+ if (content === null) continue
1138
+ if (!content.startsWith('---')) {
1139
+ violations.push(`adr "${f}" has no frontmatter block`)
1140
+ }
805
1141
  }
806
1142
  }
807
1143
 
808
1144
  const reqFilePaths = resolveReqFiles(cfg)
809
1145
  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 (_) {}
1146
+ const content = readFileForRule('frontmatter_presence', filePath, violations)
1147
+ if (content === null) continue
1148
+ if (!content.startsWith('---')) {
1149
+ violations.push(`req "${path.basename(filePath)}" has no frontmatter block`)
1150
+ }
816
1151
  }
817
1152
 
818
1153
  return violations
@@ -880,17 +1215,16 @@ function validateREQRoadmapLifecycle() {
880
1215
  const cfg = config.load()
881
1216
  const warnings = []
882
1217
  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 (_) {}
1218
+ const content = readFileForRule('req_roadmap_lifecycle', filePath, warnings)
1219
+ if (content === null) continue
1220
+ if (!reqStatusIsOpen(content)) continue
1221
+ const ref = extractRefPath(content, 'Roadmap')
1222
+ if (!ref) continue
1223
+ const expandedRef = config.expandPath ? config.expandPath(ref) : ref
1224
+ if (!fs.existsSync(expandedRef)) continue
1225
+ if (path.basename(path.dirname(expandedRef)) === 'done') {
1226
+ warnings.push(`req "${path.basename(filePath)}" is Open but linked Roadmap "${ref}" is in done/`)
1227
+ }
894
1228
  }
895
1229
  return warnings
896
1230
  }
@@ -932,12 +1266,7 @@ function validateFolderStatusCoherence() {
932
1266
 
933
1267
  let dirs = []
934
1268
  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
- }
1269
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
941
1270
  for (const agent of agents) {
942
1271
  for (const state of states) {
943
1272
  dirs.push({ dir: path.join(cfg.roadmapDir, agent, state), state })
@@ -957,28 +1286,27 @@ function validateFolderStatusCoherence() {
957
1286
  continue
958
1287
  }
959
1288
  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
- }
1289
+ const content = readFileForRule('folder_status', path.join(dir, name), warnings)
1290
+ if (content === null) continue
1291
+ // Extrair status do frontmatter
1292
+ let declared = ''
1293
+ if (content.startsWith('---')) {
1294
+ const end = content.indexOf('\n---', 3)
1295
+ if (end > 0) {
1296
+ for (const line of content.slice(3, end).split('\n')) {
1297
+ const t = line.trim()
1298
+ if (t.startsWith('status:')) {
1299
+ declared = t.slice('status:'.length).trim().replace(/['"]/g, '')
1300
+ break
973
1301
  }
974
1302
  }
975
1303
  }
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 (_) {}
1304
+ }
1305
+ if (!declared) continue
1306
+ const expected = FOLDER_TO_STATUS[state] || []
1307
+ if (!expected.some(e => e.toLowerCase() === declared.toLowerCase())) {
1308
+ warnings.push(`roadmap "${name}": folder is "${state}" but status declares "${declared}"`)
1309
+ }
982
1310
  }
983
1311
  }
984
1312
  return warnings
@@ -992,12 +1320,7 @@ function validateFilenameUniqueness() {
992
1320
 
993
1321
  const listErrors = []
994
1322
  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
- }
1323
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
1001
1324
  for (const agent of agents) {
1002
1325
  for (const state of states) {
1003
1326
  const dir = path.join(cfg.roadmapDir, agent, state)
@@ -1132,8 +1455,15 @@ function validateNoteOrphan(cwd) {
1132
1455
 
1133
1456
  const indexPath = path.join(vaultDir, 'index.md')
1134
1457
  let indexContent = ''
1135
- if (fs.existsSync(indexPath)) {
1136
- indexContent = fs.readFileSync(indexPath, 'utf8')
1458
+ try {
1459
+ indexContent = readRegularFileSync(indexPath).toString('utf8')
1460
+ } catch (err) {
1461
+ if (err.code !== 'ENOENT') {
1462
+ // index.md existe mas nao pode ser lido (permissao, FIFO...) -- diagnostica em vez de
1463
+ // tratar como indice vazio, que produziria um "not referenced" falso para toda nota
1464
+ // (ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1G).
1465
+ return [inspectionDiagnostic('note_orphan', indexPath, err)]
1466
+ }
1137
1467
  }
1138
1468
 
1139
1469
  const notes = fs.readdirSync(vaultDir).filter((f) => f.endsWith('.md') && f !== 'index.md')
@@ -1205,6 +1535,61 @@ const CREDENTIAL_GUARD_HOOK_FILES = [
1205
1535
  // Qualquer valor que não bata em nenhuma das 3 formas retorna null — o chamador NÃO deve tratar
1206
1536
  // isso como violação. Não é função desta regra adivinhar wiring próprio do usuário fora dos
1207
1537
  // formatos que o trackfw gera.
1538
+ // pathIsAnchoredForHookConfig reporta se raw é um caminho ANCORADO para os fins de um comando de
1539
+ // hook lido de config de CLI de agente e executado por bash — não se é absoluto para o SO host.
1540
+ // Decisão: ADR-2026-09-04-caminho-posix-ancorado-num-config-lido-por-cli-de-agente-e-absoluto-
1541
+ // independente-do-so-host. Quem interpreta esse caminho é o bash (ou o próprio CLI de agente),
1542
+ // nunca o process.platform do runtime Node — por isso este predicado é a UNIÃO de duas formas,
1543
+ // nunca a definição de "absoluto" de um único SO:
1544
+ // - POSIX: prefixo "/" — ancorado em qualquer host, inclusive rodando em Windows via Git Bash.
1545
+ // - Windows: letra de unidade ("C:\..." / "C:/...") ou UNC ("\\servidor\share") — ancorado em
1546
+ // qualquer host, inclusive medido a partir de macOS/Linux (D1 da ADR: uma letra de unidade
1547
+ // também é ancorada, independente de onde o validator roda).
1548
+ //
1549
+ // 🔴 Requisito duro da ADR: ZERO chamada dependente de SO aqui — nada de path.isAbsolute,
1550
+ // path.sep, ou qualquer predicado do módulo "path" que responda com base no process.platform.
1551
+ // `path.isAbsolute("/opt/foo")` é `false` no Windows — essa é exatamente a lacuna que motivou
1552
+ // esta função. Invariante por construção, verificável por grep.
1553
+ //
1554
+ // D2 da ADR: este predicado NÃO substitui path.isAbsolute nos sítios de travessia real de
1555
+ // sistema de arquivos (ex.: linha 95 e npm/src/integrations/manager.js) — ali o SO É a
1556
+ // autoridade certa. Uso restrito aos sítios de classificação de config de hook.
1557
+ function pathIsAnchoredForHookConfig(raw) {
1558
+ if (!raw) return false
1559
+ if (raw[0] === '/') return true
1560
+ // UNC: \\servidor\share\... — exige um segmento de SERVIDOR não vazio e diferente de "." ou
1561
+ // ".." (não são hostname válido), seguido de um separador, seguido de um segmento de SHARE não
1562
+ // vazio que não comece com outra barra invertida (evita componente vazio quando há barra dupla
1563
+ // no meio). "\\" e "\\x" sozinhos (sem separador de share), "\\.\x" / "\\..\evil" (server "."
1564
+ // ou ".."), e "\\..\\evil" (barra dupla no meio produz share vazio) NÃO são UNC válido — são
1565
+ // POSIX cwd-dependent (barra invertida não é separador em POSIX), e aceitá-los como ancorado
1566
+ // seria o afrouxamento inverso que este predicado existe para evitar (ressalva do parecer
1567
+ // hades-tf de 2026-09-04 sobre o ML-3A; a notação exata do exemplo "\\..\\evil" no parecer é
1568
+ // ambígua entre 1 e 2 barras antes de "evil" — a checagem de server !== "."/".." fecha as duas
1569
+ // leituras, não só uma). A forma POSIX equivalente, "//servidor/share", já é coberta pelo braço
1570
+ // raw[0]==='/' acima — este braço cobre só a forma com barra invertida.
1571
+ if (raw.length >= 2 && raw[0] === '\\' && raw[1] === '\\') {
1572
+ const rest = raw.slice(2)
1573
+ const sepIdx = rest.indexOf('\\')
1574
+ if (sepIdx > 0) {
1575
+ const server = rest.slice(0, sepIdx)
1576
+ const share = rest.slice(sepIdx + 1)
1577
+ if (server !== '.' && server !== '..' && share.length > 0 && share[0] !== '\\') return true
1578
+ }
1579
+ }
1580
+ // Letra de unidade: C:\... ou C:/...
1581
+ if (raw.length >= 3 && isASCIIDriveLetter(raw[0]) && raw[1] === ':' && (raw[2] === '\\' || raw[2] === '/')) {
1582
+ return true
1583
+ }
1584
+ return false
1585
+ }
1586
+
1587
+ // isASCIIDriveLetter reporta se ch é uma letra ASCII (a-z, A-Z) — reconhecimento de letra de
1588
+ // unidade do Windows em pathIsAnchoredForHookConfig.
1589
+ function isASCIIDriveLetter(ch) {
1590
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
1591
+ }
1592
+
1208
1593
  function resolveCredentialGuardHookPath(raw, root) {
1209
1594
  const claudePrefix = '$CLAUDE_PROJECT_DIR/'
1210
1595
  const geminiPrefix = '$GEMINI_PROJECT_DIR/'
@@ -1220,7 +1605,7 @@ function resolveCredentialGuardHookPath(raw, root) {
1220
1605
  const inner = raw.slice(codexPrefix.length, raw.length - 1)
1221
1606
  return path.join(root, inner)
1222
1607
  }
1223
- if (!raw.startsWith('$') && !raw.startsWith('"') && !path.isAbsolute(raw) && !raw.startsWith('~/')) {
1608
+ if (!raw.startsWith('$') && !raw.startsWith('"') && !pathIsAnchoredForHookConfig(raw) && !raw.startsWith('~/')) {
1224
1609
  // Caminho relativo puro — Cursor (beforeShellExecution/preToolUse), GitHub Copilot CLI
1225
1610
  // (campo "bash"), Kiro (action.command).
1226
1611
  // ~/… é excluído: é classe 1 (tilde expande para $HOME — ancorado) mas o validator não expande
@@ -1263,7 +1648,7 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
1263
1648
  rawStripped.startsWith('$CLAUDE_PROJECT_DIR/') ||
1264
1649
  rawStripped.startsWith('$GEMINI_PROJECT_DIR/') ||
1265
1650
  rawStripped.startsWith('$(git rev-parse --show-toplevel)/') ||
1266
- path.isAbsolute(rawStripped)
1651
+ pathIsAnchoredForHookConfig(rawStripped)
1267
1652
  ) {
1268
1653
  return HOOK_ANCHORAGE_CLASS_ANCHORED
1269
1654
  }
@@ -1279,7 +1664,7 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
1279
1664
  rawStripped.startsWith('${PWD}/') ||
1280
1665
  rawStripped.startsWith('./') ||
1281
1666
  rawStripped.startsWith('../') ||
1282
- (!rawStripped.startsWith('$') && !path.isAbsolute(rawStripped))
1667
+ (!rawStripped.startsWith('$') && !pathIsAnchoredForHookConfig(rawStripped))
1283
1668
  ) {
1284
1669
  return HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT
1285
1670
  }
@@ -1288,13 +1673,28 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
1288
1673
  }
1289
1674
 
1290
1675
  // 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/….
1676
+ // classe 2, iniciando em "with a …". Formatos: formas contendo $PWD ou ${PWD} (em qualquer
1677
+ // posição, inclusive em wrappers sh -c / env) recebem a mensagem do $PWD; "~/…" (D4 da
1678
+ // ADR-2026-09-04) recebe "quoted tilde path"; "~usuario/…" recebe "named-user tilde path";
1679
+ // demais recebem "bare relative path". Usa includes (não startsWith) para cobrir
1680
+ // sh -c "$PWD/…" e env FOO=x $PWD/….
1681
+ //
1682
+ // 🔴 D4 da ADR-2026-09-04: achado do ML-2B — "~usuario/" e "\"~/\"" caíam no catch-all e
1683
+ // recebiam "bare relative path", que não é o motivo real: ~user/ EXPANDE em POSIX (indecidível
1684
+ // sem executar shell, não relativo), e "~/" só falha por causa das ASPAS. O invariante que
1685
+ // permite reconhecer "~/" aqui sem receber wasQuoted como parâmetro: classifyHookAnchorage já
1686
+ // classifica "~/…" SEM aspas como classe 1 (ancorado) — logo todo "~/…" que chega aqui (classe
1687
+ // 2) só pode ter vindo de um valor originalmente citado; ver stripOuterQuotesForClassify.
1294
1688
  function cwdDependentReason(rawStripped) {
1295
1689
  if (rawStripped.includes('$PWD') || rawStripped.includes('${PWD}')) {
1296
1690
  return 'with a $PWD path — $PWD expands to the current working directory, not the project root; run `trackfw update` to fix it'
1297
1691
  }
1692
+ if (rawStripped.startsWith('~/')) {
1693
+ return 'with a quoted tilde path — double quotes prevent shell tilde expansion, so this never resolves to $HOME; run `trackfw update` to fix it'
1694
+ }
1695
+ if (rawStripped.startsWith('~')) {
1696
+ 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"
1697
+ }
1298
1698
  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
1699
  }
1300
1700
 
@@ -1364,31 +1764,96 @@ function collectCredentialGuardCommands(value, out) {
1364
1764
  // - A regra só avalia entradas que EXISTEM. Ausência de entrada de guard é estado legítimo
1365
1765
  // (guard global instalado via `trackfw update harness`) — nunca é violação por si só.
1366
1766
  // - 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.
1767
+ // - ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1A:
1768
+ // arquivo de hook presente mas com JSON inválido deixou de ser pulado em silêncio — ver o
1769
+ // bloco catch do JSON.parse abaixo para a decisão e a medição que a sustentam.
1369
1770
  //
1370
1771
  // process.cwd() no Node já retorna o caminho FÍSICO (resolve symlinks via getcwd(3) diretamente),
1371
1772
  // diferente de os.Getwd() do Go — ver o comentário sobre EvalSymlinks em
1372
1773
  // internal/validator/validator_git_branch_guard.go's validateGuardHookResolvable equivalente
1373
1774
  // (validator_credential_guard.go). Nenhuma resolução extra de symlink é necessária aqui.
1775
+ // isValidUtf8Buffer reports whether buf decodes losslessly as UTF-8, using a fatal TextDecoder
1776
+ // (throws instead of substituting U+FFFD). Used purely to CLASSIFY which message to emit when
1777
+ // JSON.parse goes on to fail below — never to gate whether parsing is attempted. buf.toString(
1778
+ // 'utf8') (the actual parse input) stays lossy on purpose, mirroring the coercion Go's
1779
+ // json.Unmarshal already does on raw bytes — ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-
1780
+ // silencio, ML-1B; see validateGuardHookResolvable's JSON.parse catch block for why.
1781
+ function isValidUtf8Buffer(buf) {
1782
+ try {
1783
+ new TextDecoder('utf-8', { fatal: true }).decode(buf)
1784
+ return true
1785
+ } catch (_) {
1786
+ return false
1787
+ }
1788
+ }
1789
+
1374
1790
  function validateGuardHookResolvable(scriptMarker, cwd) {
1375
1791
  const root = cwd || process.cwd()
1376
1792
  const msgs = []
1377
1793
 
1378
1794
  for (const hf of CREDENTIAL_GUARD_HOOK_FILES) {
1379
1795
  const fullPath = path.join(root, hf.relPath)
1380
- let content
1796
+ let raw
1381
1797
  try {
1382
- content = fs.readFileSync(fullPath, 'utf8')
1798
+ raw = readRegularFileSync(fullPath)
1383
1799
  } catch (e) {
1384
1800
  if (e.code === 'ENOENT') continue
1801
+ // ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1C: readRegularFileSync
1802
+ // replaces the bare fs.readFileSync — a FIFO at fullPath made fs.readFileSync block
1803
+ // INDEFINITELY (hades-tf's ML-1B barrier, `mkfifo .claude/settings.json`), which this branch
1804
+ // could never reach because the call never returned. readRegularFileSync's synthetic
1805
+ // 'ENOTFILE' error (never 'ENOENT') falls into this SAME accusing branch below.
1806
+ //
1807
+ // ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1B: was an unconditional
1808
+ // `continue` regardless of e.code — a dead `if (e.code === 'ENOENT') continue` above it,
1809
+ // then `continue` again for EVERY other error (EACCES, EISDIR, ELOOP...). hades-tf's ML-1A
1810
+ // barrier reproduced this live (chmod 000, and a directory in place of the file): `trackfw
1811
+ // validate --json` exited 0, no violation, no mention of the file — success reported about
1812
+ // wiring the rule never actually inspected, the exact defect this ML exists to close, for a
1813
+ // different `continue` than the JSON-parse one ML-1A already fixed. Same remedy as the
1814
+ // invalid-JSON branch below.
1815
+ msgs.push(
1816
+ `${hf.relPath} (${hf.cli}) could not be read — trackfw cannot tell whether ${scriptMarker} is ` +
1817
+ `wired here, and ${hf.cli} cannot load any hook from a file it cannot read; fix the file, or run ` +
1818
+ '`trackfw update` to regenerate it'
1819
+ )
1385
1820
  continue
1386
1821
  }
1387
1822
 
1823
+ // ML-1B: content is the SAME lossy decode this function has always used (invalid bytes become
1824
+ // U+FFFD, never throws) — wasValidUtf8 only classifies which message to emit if JSON.parse
1825
+ // fails below; it never changes whether parsing is attempted.
1826
+ const wasValidUtf8 = isValidUtf8Buffer(raw)
1827
+ const content = raw.toString('utf8')
1828
+
1388
1829
  let parsed
1389
1830
  try {
1390
1831
  parsed = JSON.parse(content)
1391
1832
  } catch (_) {
1833
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio,
1834
+ // ML-1A: was a silent `continue` (fail-open) — port of Go's validateGuardHookResolvable
1835
+ // (internal/validator/validator_credential_guard.go) — see that function's json.Unmarshal
1836
+ // error branch for the full decision and measurement. CREDENTIAL_GUARD_HOOK_FILES is a
1837
+ // closed, enumerable list (6 entries) trackfw itself owns/merges into; invalid JSON in any
1838
+ // of them is never a legitimate state, since hf.cli itself requires strict JSON to load its
1839
+ // own hooks from that file.
1840
+ //
1841
+ // ML-1B: not-valid-UTF-8 gets its own message (D4 of ADR-2026-09-04 — classify right,
1842
+ // explain right, or the reader investigates the wrong thing; e.g. a file saved as UTF-16
1843
+ // looks fine in the user's editor).
1844
+ if (!wasValidUtf8) {
1845
+ msgs.push(
1846
+ `${hf.relPath} (${hf.cli}) is not valid UTF-8 — trackfw cannot tell whether ${scriptMarker} is ` +
1847
+ `wired here, and ${hf.cli} cannot load any hook from a file it cannot decode; fix the file's ` +
1848
+ 'encoding, or run `trackfw update` to regenerate it'
1849
+ )
1850
+ continue
1851
+ }
1852
+ msgs.push(
1853
+ `${hf.relPath} (${hf.cli}) is not valid JSON — trackfw cannot tell whether ${scriptMarker} is ` +
1854
+ `wired here, and ${hf.cli} cannot load any hook from a file it cannot parse; fix the file, or run ` +
1855
+ '`trackfw update` to regenerate it'
1856
+ )
1392
1857
  continue
1393
1858
  }
1394
1859
 
@@ -1435,7 +1900,7 @@ function validateGuardHookResolvable(scriptMarker, cwd) {
1435
1900
 
1436
1901
  if (!stat) {
1437
1902
  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) {
1903
+ } else if (_platform !== 'win32' && (stat.mode & 0o111) === 0) {
1439
1904
  msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the script is not executable — run \`trackfw update\` to regenerate it`)
1440
1905
  }
1441
1906
  }
@@ -1644,10 +2109,29 @@ function validateCredentialGuardScriptIntegrity(cwd) {
1644
2109
  const relPath = 'scripts/trackfw-credential-guard.sh'
1645
2110
  let content
1646
2111
  try {
1647
- content = fs.readFileSync(path.join(root, relPath), 'utf8')
2112
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
2113
+ // readRegularFileSync (defined above) replaces the bare fs.readFileSync — a FIFO at this path
2114
+ // made fs.readFileSync block INDEFINITELY (hades-tf's ML-1B barrier), a hang this catch block
2115
+ // could never reach because the read never returned. The non-ENOENT accusing branch below
2116
+ // (already correct pre-ML-1C, unlike its Go/Python siblings) needed no change for this fix.
2117
+ content = readRegularFileSync(path.join(root, relPath)).toString('utf8')
1648
2118
  } catch (e) {
1649
2119
  if (e.code === 'ENOENT') return []
1650
- return [inspectionDiagnostic('credential_guard_script_integrity', relPath, e)]
2120
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
2121
+ // was `[inspectionDiagnostic(...)]` — correct in SUBSTANCE before this ML (already a
2122
+ // violation, not a silence, unlike Go which aborted and Python which silenced), but its
2123
+ // 'rule: could not inspect "target": cause' shape put this runtime's message text out of
2124
+ // parity with Go/Python's "could not be read" phrasing FOR THIS SPECIFIC RULE FAMILY once all
2125
+ // 3 runtimes actually produce a message here — a divergence that had no observable effect
2126
+ // before ML-1C (Go/Python never reached this branch at all), but would become a real 3-CLI
2127
+ // text mismatch the moment all 3 runtimes accuse on the same fixture. Unified to the same
2128
+ // fixed text validateGuardHookResolvable / validateGuardGlobalScriptIntegrity already use,
2129
+ // matching Go's identical fix in validateCredentialGuardScriptIntegrity
2130
+ // (validator_credential_guard_integrity.go).
2131
+ return [
2132
+ `${relPath} could not be read — trackfw cannot tell whether this script matches the ` +
2133
+ 'template it should; fix the file, or run `trackfw update` to regenerate it',
2134
+ ]
1651
2135
  }
1652
2136
 
1653
2137
  if (content === CREDENTIAL_GUARD_SCRIPT_REFERENCE) return []
@@ -2265,10 +2749,19 @@ function validateGitBranchGuardScriptIntegrity(cwd) {
2265
2749
  const relPath = 'scripts/trackfw-git-branch-guard.sh'
2266
2750
  let content
2267
2751
  try {
2268
- content = fs.readFileSync(path.join(root, relPath), 'utf8')
2752
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
2753
+ // same readRegularFileSync substitution as validateCredentialGuardScriptIntegrity's identical
2754
+ // comment above — closes the FIFO hang; the accusing branch below needed no change.
2755
+ content = readRegularFileSync(path.join(root, relPath)).toString('utf8')
2269
2756
  } catch (e) {
2270
2757
  if (e.code === 'ENOENT') return []
2271
- return [inspectionDiagnostic('git_branch_guard_script_integrity', relPath, e)]
2758
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
2759
+ // same message unification as validateCredentialGuardScriptIntegrity's identical comment
2760
+ // above — text parity with Go/Python for this rule family.
2761
+ return [
2762
+ `${relPath} could not be read — trackfw cannot tell whether this script matches the ` +
2763
+ 'template it should; fix the file, or run `trackfw update` to regenerate it',
2764
+ ]
2272
2765
  }
2273
2766
 
2274
2767
  if (content === GIT_BRANCH_GUARD_SCRIPT_REFERENCE) return []
@@ -2486,28 +2979,68 @@ function globalGuardConfigPath(gf, scriptMarker) {
2486
2979
  // prefix-stripping is needed here: any matched command that is NOT already an absolute path is
2487
2980
  // skipped (never treated as a violation).
2488
2981
  //
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.
2982
+ // Fail-open: unresolvable $HOME and unreadable file skip that file in silence — legitimate states.
2983
+ // Invalid JSON no longer does — see ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-
2984
+ // deixa-de-ser-silencio, ML-1A, and the JSON.parse catch block below for the decision and measurement.
2491
2985
  function validateGuardGlobalHookResolvable(scriptMarker) {
2492
- const home = os.homedir()
2986
+ const home = homedir()
2493
2987
  if (!home) return []
2494
2988
 
2495
2989
  const msgs = []
2496
2990
  for (const gf of GLOBAL_GUARD_CONFIG_FILES) {
2497
2991
  const relPath = globalGuardConfigPath(gf, scriptMarker)
2498
2992
  const fullPath = path.join(home, relPath)
2499
- let content
2993
+ let raw
2500
2994
  try {
2501
- content = fs.readFileSync(fullPath, 'utf8')
2995
+ raw = readRegularFileSync(fullPath)
2502
2996
  } catch (e) {
2503
2997
  if (e.code === 'ENOENT') continue
2998
+ // ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1C: same
2999
+ // readRegularFileSync substitution as the project-scope sibling — see that function's
3000
+ // identical comment for the FIFO hang this closes.
3001
+ //
3002
+ // ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1B: same decision as the
3003
+ // project-scope sibling (validateGuardHookResolvable, above) — was an unconditional
3004
+ // `continue` for every non-ENOENT read error. See that function's read-error catch branch
3005
+ // for the full measurement (hades-tf ML-1A barrier, reproduced live).
3006
+ msgs.push(
3007
+ `~/${relPath} (${gf.cli}, global scope) could not be read — trackfw cannot tell whether ${scriptMarker} is ` +
3008
+ `wired here, and ${gf.cli} cannot load any hook from a file it cannot read; fix the file, or run ` +
3009
+ '`trackfw update harness` to regenerate it'
3010
+ )
2504
3011
  continue
2505
3012
  }
2506
3013
 
3014
+ // ML-1B: see validateGuardHookResolvable's identical comment above for why decoding, not just
3015
+ // parsing, must be classified separately here.
3016
+ const wasValidUtf8 = isValidUtf8Buffer(raw)
3017
+ const content = raw.toString('utf8')
3018
+
2507
3019
  let parsed
2508
3020
  try {
2509
3021
  parsed = JSON.parse(content)
2510
3022
  } catch (_) {
3023
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio,
3024
+ // ML-1A: same decision as the project-scope sibling (validateGuardHookResolvable, above) —
3025
+ // see that function's json.parse error branch for the full measurement. Applies identically
3026
+ // here: GLOBAL_GUARD_CONFIG_FILES is the same kind of closed, enumerable list (6 entries),
3027
+ // each owned/consumed by gf.cli itself, so invalid JSON is never a legitimate state.
3028
+ //
3029
+ // ML-1B: not-valid-UTF-8 gets its own message — see validateGuardHookResolvable's identical
3030
+ // branch above for the reasoning.
3031
+ if (!wasValidUtf8) {
3032
+ msgs.push(
3033
+ `~/${relPath} (${gf.cli}, global scope) is not valid UTF-8 — trackfw cannot tell whether ${scriptMarker} is ` +
3034
+ `wired here, and ${gf.cli} cannot load any hook from a file it cannot decode; fix the file's ` +
3035
+ 'encoding, or run `trackfw update harness` to regenerate it'
3036
+ )
3037
+ continue
3038
+ }
3039
+ msgs.push(
3040
+ `~/${relPath} (${gf.cli}, global scope) is not valid JSON — trackfw cannot tell whether ${scriptMarker} is ` +
3041
+ `wired here, and ${gf.cli} cannot load any hook from a file it cannot parse; fix the file, or run ` +
3042
+ '`trackfw update harness` to regenerate it'
3043
+ )
2511
3044
  continue
2512
3045
  }
2513
3046
 
@@ -2520,7 +3053,10 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
2520
3053
  if (seen.has(seenKey)) continue
2521
3054
  seen.add(seenKey)
2522
3055
 
2523
- if (!path.isAbsolute(m.raw)) continue
3056
+ // ADR-2026-09-04-caminho-posix-ancorado-...: pathIsAnchoredForHookConfig classifica por
3057
+ // ancoragem (POSIX "/", letra de unidade, UNC), NÃO por path.isAbsolute — que no Windows
3058
+ // devolve false para "/opt/foo/guard.sh" e faria este `continue` pular a entrada inteira.
3059
+ if (!pathIsAnchoredForHookConfig(m.raw)) continue
2524
3060
 
2525
3061
  // ROADMAP-2026-08-17 ML-4B: reproduced by hades-tf (ML-4A barrier) — a global config entry
2526
3062
  // with the correct absolute command but missing/wrong "type" (hand-edited, an older trackfw
@@ -2541,7 +3077,7 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
2541
3077
 
2542
3078
  if (!stat) {
2543
3079
  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) {
3080
+ } else if (_platform !== 'win32' && (stat.mode & 0o111) === 0) {
2545
3081
  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
3082
  }
2547
3083
  }
@@ -2574,17 +3110,33 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
2574
3110
  // scriptMarker doubles as the script's own filename (trackfw-credential-guard.sh /
2575
3111
  // trackfw-git-branch-guard.sh) in both call sites below — same equivalence the Go port relies on.
2576
3112
  function validateGuardGlobalScriptIntegrity(scriptFileName, referenceContent) {
2577
- const home = os.homedir()
3113
+ const home = homedir()
2578
3114
  if (!home) return []
2579
3115
 
2580
3116
  const scriptPath = path.join(home, '.trackfw', 'scripts', scriptFileName)
2581
3117
  let content
2582
3118
  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 []
3119
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
3120
+ // readRegularFileSync also closes the FIFO hang for this path (see its doc comment above).
3121
+ content = readRegularFileSync(scriptPath).toString('utf8')
3122
+ } catch (e) {
3123
+ if (e.code === 'ENOENT') {
3124
+ // Not installed is not a violation — same contract as every other *_script_integrity check
3125
+ // (project-scope and global) in this file.
3126
+ return []
3127
+ }
3128
+ // ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
3129
+ // was an UNCONDITIONAL `return []` on ANY read error (bare `catch (_)`) — hades-tf's ML-1B
3130
+ // barrier found this silenced EACCES/EISDIR/ELOOP exactly like the pre-ML-1B config-file
3131
+ // `continue` did (fail-open: validate reports health about an artifact it never actually
3132
+ // inspected). Distinguished from absence by e.code, mirroring every other read-error branch in
3133
+ // this file. Message shape mirrors Go's validateGuardGlobalScriptIntegrity — not
3134
+ // inspectionDiagnostic's shape, since this function serves both credential-guard and
3135
+ // git-branch-guard callers and has no rule name of its own to embed.
3136
+ return [
3137
+ `${scriptPath} (global scope) could not be read — trackfw cannot tell whether this script ` +
3138
+ 'matches the template it should; fix the file, or run `trackfw update harness` to regenerate it',
3139
+ ]
2588
3140
  }
2589
3141
 
2590
3142
  if (content === referenceContent) return []
@@ -2686,10 +3238,15 @@ function validateCredentialGuardModeDowngrade(cwd) {
2686
3238
 
2687
3239
  let diskContent
2688
3240
  try {
2689
- diskContent = fs.readFileSync(path.join(root, 'trackfw.yaml'), 'utf8')
3241
+ diskContent = readRegularFileSync(path.join(root, 'trackfw.yaml')).toString('utf8')
2690
3242
  } catch (e) {
2691
3243
  if (e.code === 'ENOENT') return [credentialGuardModeDowngradeMessage()]
2692
- return [inspectionDiagnostic('credential_guard_mode_downgrade', 'trackfw.yaml', e)]
3244
+ // Fixed-text message, NOT inspectionDiagnostic — e.message (e.g. "EACCES: permission denied,
3245
+ // open 'trackfw.yaml'") diverges from Go's %v and Python's str(e) for the exact same failure;
3246
+ // every sibling "could not be read" message in this guard family avoids raw OS error text for
3247
+ // the same reason (ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-
3248
+ // silencio, ML-1G).
3249
+ return [credentialGuardModeDowngradeReadFailureMessage()]
2693
3250
  }
2694
3251
 
2695
3252
  const diskMode = extractCredentialGuardMode(diskContent)
@@ -2704,6 +3261,13 @@ function credentialGuardModeDowngradeMessage() {
2704
3261
  'otherwise investigate before treating the credential guard as active'
2705
3262
  }
2706
3263
 
3264
+ // Fixed-text remedy for a non-ENOENT read failure on trackfw.yaml when HEAD had mode: block — see
3265
+ // its call site for why this avoids inspectionDiagnostic's raw OS error text.
3266
+ function credentialGuardModeDowngradeReadFailureMessage() {
3267
+ return 'trackfw.yaml could not be read — trackfw cannot tell whether credential_guard.mode ' +
3268
+ 'is still block; fix the file, or run `trackfw update` to regenerate it'
3269
+ }
3270
+
2707
3271
  // _itemMeta: mapa de message → { rule, file } para enriquecer saída JSON.
2708
3272
  // Populado em applyRule e nos pushs diretos do validateUnfiltered.
2709
3273
  // Permanece em memória apenas durante a execução de uma chamada validate*.
@@ -2740,6 +3304,11 @@ const RULE_DEFAULTS = {
2740
3304
  // git_branch_guard_hook_resolvable is deliberately absent from this map (falls through to
2741
3305
  // 'error'), mirroring credential_guard_hook_resolvable.
2742
3306
  git_branch_guard_script_integrity: 'warning',
3307
+ // ML-4A (REQ-2026-08-29, achado 1 do parecer hades-tf 2026-08-30): namespace oculto/ambíguo (nome
3308
+ // iniciado por ".") é sinal de baixo ruído por natureza, não um defeito de configuração como
3309
+ // agent_namespace_undeclared ('error'). Nunca 'off' por default — silêncio total é o defeito que
3310
+ // esta REQ existe para fechar.
3311
+ agent_namespace_hidden: 'warning',
2743
3312
  }
2744
3313
 
2745
3314
  // ROADMAP-2026-08-12-ancorar-rules-no-head-para-as-regras-de-credential-guard, ML-1A.
@@ -2841,7 +3410,7 @@ const BASELINE_FILE = '.trackfw-baseline.json'
2841
3410
  // Retorna null se o arquivo não existir.
2842
3411
  function loadBaseline() {
2843
3412
  try {
2844
- const data = fs.readFileSync(BASELINE_FILE, 'utf8')
3413
+ const data = readRegularFileSync(BASELINE_FILE).toString('utf8')
2845
3414
  return JSON.parse(data)
2846
3415
  } catch (e) {
2847
3416
  if (e.code === 'ENOENT') return null
@@ -2898,11 +3467,16 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
2898
3467
  const manifestPath = path.join(root, '.trackfw', 'integrations-manifest.json')
2899
3468
  let manifest
2900
3469
  try {
2901
- const raw = fs.readFileSync(manifestPath, 'utf8')
3470
+ const raw = readRegularFileSync(manifestPath).toString('utf8')
2902
3471
  manifest = JSON.parse(raw)
2903
3472
  } catch (err) {
2904
3473
  if (err.code === 'ENOENT') return []
2905
- throw new Error(`thirdparty_artifact_has_provenance: read ${manifestPath}: ${err.message}`)
3474
+ // Used to `throw` here — an unreadable/corrupt manifest crashed the whole `trackfw validate`
3475
+ // process instead of reporting a diagnostic for this one rule and letting every other rule
3476
+ // still run. Same defect class ML-1C fixed for *_script_integrity, found here by measurement
3477
+ // outside the guard family, in parity with the Go fix at the same call site (ROADMAP-2026-09-
3478
+ // 06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1G).
3479
+ return [inspectionDiagnostic('thirdparty_artifact_has_provenance', manifestPath, err)]
2906
3480
  }
2907
3481
 
2908
3482
  const destinations = []
@@ -2933,7 +3507,18 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
2933
3507
  // instead, which this rule intentionally never reads). path.relative
2934
3508
  // inverts resolve()'s path.resolve(root, relative) exactly. Mirrors
2935
3509
  // internal/validator/validator_thirdparty_provenance.go.
2936
- const provenanceKey = path.relative(root, destination)
3510
+ //
3511
+ // ML-2A (ADR-2026-09-04, D1 categoria 2 — "chave de dicionario ou identificador"):
3512
+ // path.relative devolve separador NATIVO, e a chave gravada em
3513
+ // thirdparty-provenance.json e sempre "/" (resolveThirdPartySkillDestination monta
3514
+ // o destino por concatenacao explicita com "/", nos 3 runtimes). Sem normalizar, no
3515
+ // Windows a chave nunca casa e TODO artefato de terceiro e reportado como sem
3516
+ // entrada — falso positivo em massa. Go (validator_thirdparty_provenance.go:160) e
3517
+ // Python (validator.py:3530) ja normalizavam; o Node era a divergencia (D4).
3518
+ //
3519
+ // 🔴 So a CHAVE DERIVADA e normalizada. `destination` continua cru e e o que aparece
3520
+ // nas mensagens e no manifest; nada aqui altera caminho passado a syscall (ADR D2).
3521
+ const provenanceKey = normalizeRefSeparator(path.relative(root, destination))
2937
3522
  const entry = (prov.entries || {})[provenanceKey]
2938
3523
  if (!entry) {
2939
3524
  msgs.push(
@@ -2946,7 +3531,7 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
2946
3531
 
2947
3532
  let installed
2948
3533
  try {
2949
- installed = fs.readFileSync(destination)
3534
+ installed = readRegularFileSync(destination)
2950
3535
  } catch (err) {
2951
3536
  msgs.push(
2952
3537
  `thirdparty_artifact_has_provenance: "${destination}" is claimed as a third-party artifact with an ` +
@@ -3027,6 +3612,15 @@ async function validateUnfiltered() {
3027
3612
 
3028
3613
  // Verificação bidirecional de trace ID (somente se traceIdField configurado)
3029
3614
  const cfg = config.load()
3615
+
3616
+ // ML-2A (REQ-2026-08-29): namespace de agente em disco e não declarado em agents: — violação, não
3617
+ // aviso (ver comentário em validateAgentNamespaceUndeclared).
3618
+ applyRule('agent_namespace_undeclared', validateAgentNamespaceUndeclared(cfg), violations, warnings)
3619
+
3620
+ // ML-4A (achado 1, hades-tf 2026-08-30): contraponto de baixo ruído para nomes ocultos/ambíguos
3621
+ // (iniciados por ".") — aviso, nunca silêncio total, nunca erro (RULE_DEFAULTS abaixo).
3622
+ applyRule('agent_namespace_hidden', hiddenNamespaceWarnings(cfg), violations, warnings)
3623
+
3030
3624
  if (cfg.traceIdField) {
3031
3625
  for (const msg of checkTraceIds(cfg.reqDir, cfg.roadmapDir, cfg.traceIdField)) {
3032
3626
  // O prefixo da mensagem traceid já carrega o nome da regra (ex: "traceid_orphan_roadmap: ...")
@@ -3090,16 +3684,18 @@ function buildInventorySection(cfg) {
3090
3684
  let reqOpen = 0
3091
3685
  let reqDone = 0
3092
3686
  let reqClosed = 0
3687
+ let reqOther = 0
3093
3688
  for (const filePath of reqFiles) {
3094
3689
  let content
3095
3690
  try {
3096
- content = fs.readFileSync(filePath, 'utf8')
3691
+ content = readRegularFileSync(filePath).toString('utf8')
3097
3692
  } catch (_) {
3098
3693
  continue
3099
3694
  }
3100
3695
  if (reqStatusEquals(content, 'open')) reqOpen++
3101
3696
  else if (reqStatusEquals(content, 'done')) reqDone++
3102
3697
  else if (reqStatusEquals(content, 'closed')) reqClosed++
3698
+ else reqOther++
3103
3699
  }
3104
3700
 
3105
3701
  const roadmapCounts = {}
@@ -3115,7 +3711,14 @@ function buildInventorySection(cfg) {
3115
3711
 
3116
3712
  let section = '\n📊 Inventory\n'
3117
3713
  section += ` ${'ADRs'.padEnd(12)}${adrCount}\n`
3118
- section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed)\n`
3714
+ // Toda grafia fora de open/done/closed cai em reqOther. Sem este bucket o total
3715
+ // bate e a quebra some com a diferenca EM SILENCIO: um acervo com
3716
+ // approved/backlog/abandoned mostra "53 (8 Open · 36 Done · 0 Closed)" sem
3717
+ // indicar que 9 existem e nao estao em lugar nenhum da conta.
3718
+ // O Python ja fazia isto (pypi/trackfw/commands/status.py:58,199).
3719
+ let reqDetail = `${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed`
3720
+ if (reqOther > 0) reqDetail += ` · ${reqOther} Other`
3721
+ section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqDetail})\n`
3119
3722
  section += ` ${'Roadmaps'.padEnd(12)}${roadmapTotal}\n`
3120
3723
  section += ` backlog ${roadmapCounts.backlog} · analyzing ${roadmapCounts.analyzing} · wip ${roadmapCounts.wip}\n`
3121
3724
  section += ` blocked ${roadmapCounts.blocked} · done ${roadmapCounts.done} · abandoned ${roadmapCounts.abandoned}\n`
@@ -3129,14 +3732,7 @@ async function getStatus() {
3129
3732
  out += buildInventorySection(cfg)
3130
3733
 
3131
3734
  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
- }
3735
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
3140
3736
  out += '\n⚙ WIP by Agent\n'
3141
3737
  for (const agent of agents) {
3142
3738
  const wip = listDir(cfg.roadmapDir + '/' + agent + '/wip')
@@ -3205,6 +3801,7 @@ async function getStatus() {
3205
3801
  }
3206
3802
 
3207
3803
  module.exports = {
3804
+ _setPlatformForTest,
3208
3805
  validate,
3209
3806
  validateUnfiltered,
3210
3807
  loadBaseline,
@@ -3229,9 +3826,11 @@ module.exports = {
3229
3826
  listDir,
3230
3827
  tryListDir,
3231
3828
  resolveReqFiles,
3829
+ reqWriteDir,
3232
3830
  resolveStateDirs,
3233
3831
  resolveWIPDirs,
3234
3832
  resolveDoneDirs,
3833
+ resolveAgentNamespaces,
3235
3834
  parseSquadFromFrontmatter,
3236
3835
  validateFrontmatterPresence,
3237
3836
  // novas funções ML-1B
@@ -3270,6 +3869,8 @@ module.exports = {
3270
3869
  resolveAdrStatus,
3271
3870
  // ROADMAP-2026-08-12-mitigacao-do-fail-open-do-credential-guard, ML-1A
3272
3871
  resolveCredentialGuardHookPath,
3872
+ // ADR-2026-09-04 (ML-3A): predicado de ancoragem invariante por GOOS
3873
+ pathIsAnchoredForHookConfig,
3273
3874
  // ADR-2026-08-22 (ML-1A): classificação por ancoragem
3274
3875
  HOOK_ANCHORAGE_CLASS_ANCHORED,
3275
3876
  HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT,