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.
- package/README.md +67 -1
- package/package.json +1 -1
- package/src/commands/adr.js +2 -1
- package/src/commands/barrier.js +293 -26
- package/src/commands/context.js +42 -36
- package/src/commands/discover.js +72 -21
- package/src/commands/doctor.js +23 -3
- package/src/commands/init.js +2 -1
- package/src/commands/integrations.js +5 -4
- package/src/commands/req.js +2 -1
- package/src/commands/thirdparty.js +2 -1
- package/src/commands/update-harness.js +3 -2
- package/src/commands/update.js +127 -9
- package/src/config/index.js +4 -3
- package/src/generators/hooks.js +121 -6
- package/src/generators/init.js +103 -7
- package/src/generators/req.js +15 -53
- package/src/generators/roadmap.js +81 -28
- package/src/homedir.js +30 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +224 -0
- package/src/integrations/index.js +2 -1
- package/src/integrations/manager.js +10 -2
- package/src/integrations/render.js +39 -8
- package/src/integrations/scaffold_doctor.js +29 -0
- package/src/lib/pathfmt.js +43 -0
- package/src/lib/update-engine.js +15 -2
- package/src/serve/api_board.js +11 -10
- package/src/serve/api_chain.js +13 -17
- package/src/serve/api_metrics.js +2 -8
- package/src/validator/index.js +768 -167
package/src/validator/index.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
151
|
-
//
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 (!
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
}
|
|
981
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
1136
|
-
indexContent =
|
|
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('"') && !
|
|
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
|
-
|
|
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('$') && !
|
|
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 …".
|
|
1292
|
-
// posição, inclusive em wrappers sh -c / env) recebem a mensagem do $PWD;
|
|
1293
|
-
// "
|
|
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
|
-
// -
|
|
1368
|
-
// JSON
|
|
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
|
|
1796
|
+
let raw
|
|
1381
1797
|
try {
|
|
1382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2490
|
-
//
|
|
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 =
|
|
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
|
|
2993
|
+
let raw
|
|
2500
2994
|
try {
|
|
2501
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
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 =
|
|
3241
|
+
diskContent = readRegularFileSync(path.join(root, 'trackfw.yaml')).toString('utf8')
|
|
2690
3242
|
} catch (e) {
|
|
2691
3243
|
if (e.code === 'ENOENT') return [credentialGuardModeDowngradeMessage()]
|
|
2692
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|