trackfw 7.4.0 → 7.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "7.4.0",
3
+ "version": "7.5.1",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
5
5
  "keywords": [
6
6
  "cli",
@@ -7,7 +7,7 @@ const identityStore = require('../identity')
7
7
  const projectConfig = require('../config')
8
8
  const { catalog, buildPlans, IntegrationManager, globalGroupPath } = require('../integrations')
9
9
  const { tildeify, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine')
10
- const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, generateGlobalCredentialGuardScript, generateGlobalGitBranchGuardScript } = require('../generators/hooks')
10
+ const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, GIT_BRANCH_GUARD_SCRIPT, GLOBAL_CREDENTIAL_GUARD_SCRIPT } = require('../generators/hooks')
11
11
  const { homedir } = require('../homedir')
12
12
 
13
13
  // `trackfw update harness` is the global counterpart to `trackfw update` —
@@ -73,6 +73,72 @@ Cadeia: **ADR → REQ → ROADMAP** · Estados: \`backlog / wip / blocked / done
73
73
  `
74
74
  }
75
75
 
76
+ // gitBranchGuardScriptTarget — evaluates (and, unless --dry-run, applies) the
77
+ // global git-branch-guard shell script at
78
+ // ~/.trackfw/scripts/trackfw-git-branch-guard.sh.
79
+ //
80
+ // This target OWNS the script file. It compares content before writing so that
81
+ // an already up-to-date script reports "skipped" and its mtime is left unchanged
82
+ // (REQ-2026-09-09 AC3). Creation does not require --install-missing because
83
+ // ~/.trackfw/scripts/ is 100% trackfw-owned.
84
+ function gitBranchGuardScriptTarget(homeRoot, { dryRun }) {
85
+ const id = 'git-branch-guard-script'
86
+ const filePath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
87
+ const displayPath = tildeify(homeRoot, filePath)
88
+ const desired = GIT_BRANCH_GUARD_SCRIPT
89
+
90
+ try {
91
+ const exists = fs.existsSync(filePath)
92
+ const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
93
+
94
+ if (exists && actual === desired) {
95
+ // Content identical — skip write, repair execute bit without touching mtime.
96
+ try { fs.chmodSync(filePath, 0o755) } catch (_) { /* best-effort */ }
97
+ return { id, state: 'skipped', path: displayPath }
98
+ }
99
+
100
+ if (dryRun) return { id, state: 'updated', path: displayPath }
101
+
102
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
103
+ fs.writeFileSync(filePath, desired, { encoding: 'utf8', mode: 0o755 })
104
+ return { id, state: 'updated', path: displayPath }
105
+ } catch (e) {
106
+ return { id, state: 'failed', path: displayPath, message: e.message }
107
+ }
108
+ }
109
+
110
+ // credentialGuardScriptTarget — evaluates (and, unless --dry-run, applies) the
111
+ // global credential-guard shell script at
112
+ // ~/.trackfw/scripts/trackfw-credential-guard.sh.
113
+ //
114
+ // Mirrors gitBranchGuardScriptTarget — same ownership model, same idempotency
115
+ // contract, same absence of --install-missing gate.
116
+ function credentialGuardScriptTarget(homeRoot, { dryRun }) {
117
+ const id = 'credential-guard-script'
118
+ const filePath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
119
+ const displayPath = tildeify(homeRoot, filePath)
120
+ const desired = GLOBAL_CREDENTIAL_GUARD_SCRIPT
121
+
122
+ try {
123
+ const exists = fs.existsSync(filePath)
124
+ const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
125
+
126
+ if (exists && actual === desired) {
127
+ // Content identical — skip write, repair execute bit without touching mtime.
128
+ try { fs.chmodSync(filePath, 0o755) } catch (_) { /* best-effort */ }
129
+ return { id, state: 'skipped', path: displayPath }
130
+ }
131
+
132
+ if (dryRun) return { id, state: 'updated', path: displayPath }
133
+
134
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
135
+ fs.writeFileSync(filePath, desired, { encoding: 'utf8', mode: 0o755 })
136
+ return { id, state: 'updated', path: displayPath }
137
+ } catch (e) {
138
+ return { id, state: 'failed', path: displayPath, message: e.message }
139
+ }
140
+ }
141
+
76
142
  function claudeSkillTarget(homeRoot, { dryRun, installMissing }) {
77
143
  const id = 'claude-skill'
78
144
  const filePath = path.join(homeRoot, '.claude', 'skills', 'trackfw', 'SKILL.md')
@@ -799,7 +865,10 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
799
865
  // which precede claude-agents/claude-skills), with credential-guard always
800
866
  // preceding git-branch-guard within a tool — see buildHarnessTargetIDs's
801
867
  // comment in update.go for the full rationale.
802
- const HARNESS_TARGET_IDS = ['claude-skill', 'claude-credential-guard', 'claude-git-branch-guard']
868
+ // Script targets precede every wiring target that references them so that even
869
+ // a --targets-filtered run that only requests a wiring target still finds the
870
+ // script already in place from a prior full run.
871
+ const HARNESS_TARGET_IDS = ['claude-skill', 'git-branch-guard-script', 'credential-guard-script', 'claude-credential-guard', 'claude-git-branch-guard']
803
872
  for (const target of catalog.targets) {
804
873
  if (target.id === 'codex') HARNESS_TARGET_IDS.push('codex-credential-guard', 'codex-git-branch-guard')
805
874
  if (target.id === 'gemini') HARNESS_TARGET_IDS.push('gemini-credential-guard', 'gemini-git-branch-guard')
@@ -818,6 +887,8 @@ function buildHarnessTargets(homeRoot, identityConfig, { dryRun, installMissing
818
887
  const include = (id) => !wanted || wanted.includes(id)
819
888
  const targets = []
820
889
  if (include('claude-skill')) targets.push(claudeSkillTarget(homeRoot, { dryRun, installMissing }))
890
+ if (include('git-branch-guard-script')) targets.push(gitBranchGuardScriptTarget(homeRoot, { dryRun }))
891
+ if (include('credential-guard-script')) targets.push(credentialGuardScriptTarget(homeRoot, { dryRun }))
821
892
  if (include('claude-credential-guard')) targets.push(credentialGuardTargetClaude(homeRoot, { dryRun, installMissing }))
822
893
  if (include('claude-git-branch-guard')) targets.push(gitBranchGuardTargetClaude(homeRoot, { dryRun, installMissing }))
823
894
  for (const target of catalog.targets) {
@@ -885,20 +956,11 @@ function run(options) {
885
956
  const dryRun = Boolean(options.dryRun)
886
957
  const installMissing = Boolean(options.installMissing)
887
958
 
888
- // The per-CLI *-credential-guard targets below only wire hook entries that
889
- // point at ~/.trackfw/scripts/trackfw-credential-guard.sh — none of them
890
- // write the script itself (ADR-2026-08-06, decision #2/#3). Without this
891
- // call the wiring is installed but every hook invocation fails with
892
- // "No such file or directory" because the script never exists.
893
- if (!dryRun) {
894
- if (options.json) {
895
- silenceConsole(() => generateGlobalCredentialGuardScript(homeRoot))
896
- silenceConsole(() => generateGlobalGitBranchGuardScript(homeRoot))
897
- } else {
898
- generateGlobalCredentialGuardScript(homeRoot)
899
- generateGlobalGitBranchGuardScript(homeRoot)
900
- }
901
- }
959
+ // NOTE: the script files are now first-class targets ("git-branch-guard-script",
960
+ // "credential-guard-script") positioned before the wiring targets in
961
+ // HARNESS_TARGET_IDS. The pre-loop unconditional writes that used to live here
962
+ // have been removed — the target functions handle creation and idempotency.
963
+ // (REQ-2026-09-09, ML-1A)
902
964
 
903
965
  // Identidade resolvida do disco antes de buildPlans — pular esta etapa
904
966
  // reverteria silenciosamente os nomes customizados para os defaults
@@ -428,9 +428,12 @@ const GLOBAL_CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_DETECTION_CORE + CG_GLOBAL
428
428
  // Byte-identical port of internal/generators/scaffold.go:gitBranchGuardScript
429
429
  // (Go's canonical reference). Blocks raw `git commit`/`git push`/
430
430
  // `git checkout -b` by a subagent, regardless of runtime contract: emits BOTH
431
- // `{"decision":"block","reason":"..."}` on stdout (Claude/Gemini JSON-stdout
432
- // style) AND `exit 2` (Codex/Windsurf/Cursor exit-code style) simultaneously,
433
- // same simplification decision as the Go const's doc comment. Unlike
431
+ // `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":
432
+ // "deny","permissionDecisionReason":"..."}}` on stdout (Claude/Gemini
433
+ // JSON-stdout style — UPDATED 2026-09-09, see the Go const's doc comment for
434
+ // why the previous `{"decision":"block",...}` shape stopped validating) AND
435
+ // `exit 2` (Codex/Windsurf/Cursor exit-code style) simultaneously, same
436
+ // simplification decision as the Go const's doc comment. Unlike
434
437
  // CREDENTIAL_GUARD_SCRIPT, this script is IDENTICAL between project and
435
438
  // global scope (no trackfw.yaml dependency, no mode/roadmap_dir resolution)
436
439
  // — a single constant is written by both generateGitBranchGuardScript and
@@ -495,12 +498,31 @@ set -f
495
498
  # --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
496
499
  # global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
497
500
  # quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
498
- # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. Só drena
499
- # se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
500
- # esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
501
- # leitura.
501
+ # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. O dreno
502
+ # roda incondicionalmente, com orçamento de tempo (ML-3A, ver abaixo). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda leitura.
503
+ #
504
+ # ML-3A (ROADMAP-2026-09-09-guard-emite-hookspecificoutput-e-a-razao-chega-ao-modelo-nos-3-
505
+ # clis.md): o discriminante que existia aqui era "-t 0" -- separa "terminal interativo" de
506
+ # "pipe", não "vai receber EOF" de "não vai". Qualquer chamador não-tty que segure stdin aberta
507
+ # (ex.: um runtime de hook que nunca fecha o descritor) travava o antigo "cat" para sempre, sem
508
+ # limite -- foi o que pendurou "make quality" por 1h05 quando o gate de schema (ML-2A) chamou o
509
+ # guard sem "</dev/null" em 3 sítios. Substituído por dreno com ORÇAMENTO DE TEMPO via
510
+ # "read -t <segundos> -d ''": builtin do próprio bash desde a 3.0 (não do coreutils) -- medido
511
+ # idêntico no bash 3.2 (padrão do macOS) e no bash 5.3 (Linux/Homebrew); o bash do MSYS2/Git-Bash
512
+ # do Windows é bash de verdade e traz o mesmo builtin. Evita depender de "timeout(1)", que NÃO
513
+ # existe no macOS base nem no MSYS2 mínimo. "-d ''" faz o read tentar ler até o EOF real (NUL
514
+ # nunca aparece em payload de hook); "-t" interrompe a espera no orçamento e, medido contra um
515
+ # FIFO nunca fechado (bash 3.2 e 5.3), preserva na variável qualquer prefixo já lido antes do
516
+ # timeout -- sem perda do que chegou, só do que nunca chegou. 2s de orçamento: folga generosa
517
+ # para o payload pequeno de um hook sob scheduler sob carga (CI, "make quality" paralelo), e
518
+ # ainda curto o bastante para o travamento continuar perceptível em vez de indefinido. Estourar
519
+ # o orçamento NÃO libera o bloqueio: o passo 1 abaixo já prefere "$*" quando há argumento
520
+ # posicional, e cai para o que foi lido até o limite quando não há -- "exit 2" e o fail-closed
521
+ # continuam idênticos nos dois casos. O antigo desvio para invocação manual em terminal (skip
522
+ # total quando "-t 0") foi removido de propósito: era o próprio discriminante errado que este ML
523
+ # elimina, não um caso a preservar -- agora toda invocação drena com o mesmo orçamento de 2s.
502
524
  _TRACKFW_STDIN=""
503
- [ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
525
+ IFS= read -r -t 2 -d '' _TRACKFW_STDIN || true
504
526
 
505
527
  # --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
506
528
  # projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
@@ -1030,7 +1052,7 @@ case "$SUBCOMMAND" in
1030
1052
  ;;
1031
1053
  esac
1032
1054
 
1033
- printf '{"decision":"block","reason":"%s"}\\n' "$REASON"
1055
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "$REASON"
1034
1056
  echo "$REASON" >&2
1035
1057
  exit 2
1036
1058
  `
@@ -2387,5 +2409,6 @@ module.exports = {
2387
2409
  SIGNAL_SCRIPT,
2388
2410
  CLEANUP_SCRIPT,
2389
2411
  CREDENTIAL_GUARD_SCRIPT,
2412
+ GLOBAL_CREDENTIAL_GUARD_SCRIPT,
2390
2413
  GIT_BRANCH_GUARD_SCRIPT,
2391
2414
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
- const { resolveAgentNamespaces } = require('../validator')
5
+ const { resolveAgentNamespaces, extractRefPath } = require('../validator')
6
6
  const { normalizeRefSeparator } = require('../lib/pathfmt')
7
7
 
8
8
  const ROADMAP_STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
@@ -81,7 +81,7 @@ function collectNodes(dir, type, state) {
81
81
  // mesmos operandos: normalizar esta nao pode afeta-la. O caminho de travessia
82
82
  // permanece nativo (ADR D2). Espelha internal/serve/api_chain.go:111.
83
83
  const id = normalizeRefSeparator(path.join(dir, file))
84
- nodes.push({ id, type, title, state, fm })
84
+ nodes.push({ id, type, title, state, fm, content })
85
85
  }
86
86
  return nodes
87
87
  }
@@ -169,8 +169,21 @@ function handleChain(cfg, req, res) {
169
169
 
170
170
  function resolveRef(val) {
171
171
  if (!val) return null
172
- // Tenta pelo nome de arquivo
173
- const base = val.replace(/\.md$/, '').toLowerCase().trim()
172
+ // Tenta pelo nome de arquivo — path.basename(val), não val cru: o formato canônico
173
+ // gravado por `trackfw req new`/`roadmap new` é o CAMINHO COMPLETO (ADR-2026-08-01), não o
174
+ // basename isolado. Sem o basename() aqui, um vínculo real como
175
+ // "docs/roadmaps/wip/ROADMAP-x.md" nunca batia contra a chave "roadmap-x" do fileIndex —
176
+ // achado do ML-3D: nenhuma aresta REQ/ADR/Roadmap gerada pelo CLI jamais resolvia por este
177
+ // caminho, mascarado porque os testes existentes só cobrem valor já-basename.
178
+ //
179
+ // Por que este arquivo NÃO importa validator.resolveRoadmapRef (ao contrário de
180
+ // internal/serve/api_chain.go, ML-3D): fileIndex é indexado por basename ACROSS todos os
181
+ // estados (wip/backlog/blocked/done/abandoned) desde antes desta correção — um vínculo
182
+ // `roadmap:` "wip/X.md" já resolve contra o node real em "done/X.md" sem fallback extra.
183
+ // O mecanismo do ML-3B/3D (caminho literal gravado com pasta de estado velha) não afeta
184
+ // este CLI porque ele nunca comparou por igualdade de caminho completo — só por basename/
185
+ // título. Ver docs/cli-parity.md, seção "serve: /api/chain".
186
+ const base = path.basename(val).replace(/\.md$/, '').toLowerCase().trim()
174
187
  if (fileIndex.has(base)) return fileIndex.get(base)
175
188
  // Tenta pelo título
176
189
  const norm = val.toLowerCase().trim()
@@ -202,6 +215,20 @@ function handleChain(cfg, req, res) {
202
215
  if (target && target !== id) addEdge(id, target)
203
216
  }
204
217
  }
218
+
219
+ // ML-3D: `trackfw req new` (npm/src/generators/req.js) grava `adr: ""` e `roadmap: ""`
220
+ // SEMPRE vazios no frontmatter — o valor real vive no corpo, em "## Linked ADR / ADR:
221
+ // <path>" e "## Linked Roadmap / Roadmap: <path>". O laço de frontmatter acima nunca
222
+ // encontra esse vínculo para uma REQ gerada pelo próprio CLI (não é caso de borda: é o
223
+ // formato canônico inteiro). validator.extractRefPath varre o CONTEÚDO inteiro
224
+ // linha-a-linha (mesma função usada por validateRefTargetsExist) — ponto único de
225
+ // extração, não duplicado aqui.
226
+ for (const field of ['req', 'adr', 'roadmap']) {
227
+ const val = extractRefPath(node.content || '', field)
228
+ if (!val) continue
229
+ const target = resolveRef(val)
230
+ if (target && target !== id) addEdge(id, target)
231
+ }
205
232
  }
206
233
 
207
234
  // Montar nodes de saída sem o campo fm interno
@@ -577,33 +577,65 @@ function contentHasMarker(content, markers) {
577
577
  return false
578
578
  }
579
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.
580
+ // contentHasMarkerValue retorna true se, em alguma LINHA de content, um dos markers aparece
581
+ // logo após espaços/tabs de indentação (nunca no meio de uma frase) e é seguido de um valor
582
+ // substantivo na mesma linha — não vazio e não um comentário HTML disfarçado de valor.
582
583
  //
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.
584
+ // issue #278 (achado A2 da auditoria externa, 2026-09-05): a versão anterior buscava o marker
585
+ // como SUBSTRING em qualquer lugar do conteúdo. Isso produzia dois defeitos medidos no acervo
586
+ // mergeado:
587
+ // 1. prosa que menciona o marker no meio de uma frase contava como vínculo real
588
+ // ("veja a secao ADR: mais abaixo" → true, sem nenhum vínculo).
589
+ // 2. um comentário HTML usado como placeholder contava como valor
590
+ // ("ADR: <!-- preencher depois -->" → true, campo de fato vazio).
591
+ //
592
+ // Ambos se resolvem tratando a detecção por LINHA em vez de por substring do documento inteiro:
593
+ // o marker só conta quando é a primeira coisa não-branca da linha (tolera espaços/tabs à
594
+ // esquerda, não tolera qualquer outra decoração — "> ", "- ", "# ", "**" não contam como início).
595
+ // Medido antes de decidir (não assumido): nas quatro pastas realmente varridas por estas 4
596
+ // regras (REQ files para ADR:/Roadmap:; roadmaps de wip/blocked para REQ:), a contagem de linhas
597
+ // com decoração diferente de espaço em branco antes do marker é ZERO — os três geradores
598
+ // canônicos sempre emitem o marker no início da linha. Restringir a espaço/tab não introduz
599
+ // falso-positivo novo neste acervo.
600
+ //
601
+ // NÃO resolve (fora de escopo desta ML): "REQ (**reaberta**):" — decoração ENTRE o nome do
602
+ // campo e ":", não ANTES do marker. A literal "REQ:" não aparece nessa linha nem por
603
+ // substring nem por prefixo; ancorar por linha não causa nem cura esse caso. A forma que
604
+ // funciona hoje é "REQ: docs/req/x.md (reaberta)" — o marker intacto, a anotação depois.
605
+ //
606
+ // "none", "TBD", "N/A", "-" continuam sendo tratados como VALOR (não como placeholder). Nenhum
607
+ // dos quatro casos exigidos por esta ML os menciona, e "none" é usado deliberadamente em
608
+ // scripts/check-gates-falsify.sh como placeholder inofensivo — para dizer "sem vínculo real,
609
+ // de propósito" nos fixtures que testam OUTRA regra — sem ficar vazio nem virar comentário
610
+ // HTML. Bloqueá-los moveria a contagem do acervo por um motivo alheio ao achado A2 e quebraria
611
+ // cenários de falsificação que dependem desse contrato.
612
+ //
613
+ // issue #278 (ML-1B): 5 de 7 grafias naturais de campo vazio escapavam da checagem anterior
614
+ // por literal — esta função decide por VALOR (trim do resto da linha), o que já cobre as 7
615
+ // grafias medidas na triagem original.
589
616
  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
617
+ for (const rawLine of content.split('\n')) {
618
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
619
+ const leading = line.replace(/^[ \t]+/, '')
620
+ for (const marker of markers) {
621
+ if (!leading.startsWith(marker)) continue
622
+ const rest = leading.slice(marker.length).trim()
623
+ if (rest === '') continue
624
+ if (isHTMLCommentOnlyValue(rest)) continue
625
+ return true
602
626
  }
603
627
  }
604
628
  return false
605
629
  }
606
630
 
631
+ // isHTMLCommentOnlyValue retorna true se value (já sem espaços nas duas pontas) é inteiramente
632
+ // um comentário HTML — "<!-- ... -->" do início ao fim, sem nada fora dele. É a forma medida no
633
+ // achado A2: "ADR: <!-- preencher depois -->" é sintaticamente um valor, mas semanticamente um
634
+ // placeholder de "ainda não preenchido" — mesma intenção de um campo vazio, só que disfarçado.
635
+ function isHTMLCommentOnlyValue(value) {
636
+ return value.startsWith('<!--') && value.endsWith('-->')
637
+ }
638
+
607
639
  // extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
608
640
  // ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
609
641
  // documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
@@ -707,7 +739,7 @@ function validateWIPHasREQ() {
707
739
  const content = readFileForRule('wip_has_req', path.join(wipDir, name), violations)
708
740
  if (content === null) continue
709
741
  if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
710
- violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
742
+ violations.push(`roadmap "${name}" is in wip but has no linked REQ (marker must start the line with a real, non-placeholder value)`)
711
743
  }
712
744
  }
713
745
  }
@@ -723,7 +755,7 @@ function validateREQsHaveADR() {
723
755
  const content = readFileForRule('req_has_adr', filePath, violations)
724
756
  if (content === null) continue
725
757
  if (!contentHasMarkerValue(content, cfg.linkFields.adr)) {
726
- violations.push(`req "${path.basename(filePath)}" has no linked ADR`)
758
+ violations.push(`req "${path.basename(filePath)}" has no linked ADR (marker must start the line with a real, non-placeholder value)`)
727
759
  }
728
760
  }
729
761
  return violations
@@ -739,7 +771,7 @@ function validateBlockedHasREQ() {
739
771
  const content = readFileForRule('blocked_has_req', path.join(blockedDir, name), violations)
740
772
  if (content === null) continue
741
773
  if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
742
- violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
774
+ violations.push(`roadmap "${name}" is in blocked but has no linked REQ (marker must start the line with a real, non-placeholder value)`)
743
775
  }
744
776
  }
745
777
  }
@@ -755,7 +787,7 @@ function validateREQsHaveRoadmap() {
755
787
  const content = readFileForRule('req_has_roadmap', filePath, violations)
756
788
  if (content === null) continue
757
789
  if (!contentHasMarkerValue(content, cfg.linkFields.roadmap)) {
758
- violations.push(`req "${path.basename(filePath)}" has no linked Roadmap`)
790
+ violations.push(`req "${path.basename(filePath)}" has no linked Roadmap (marker must start the line with a real, non-placeholder value)`)
759
791
  }
760
792
  }
761
793
  return violations
@@ -1197,8 +1229,22 @@ function validateRefTargetsExist() {
1197
1229
  warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
1198
1230
  }
1199
1231
  const roadmapRef = extractRefPath(content, 'Roadmap')
1200
- if (roadmapRef && !referenceExists(roadmapRef)) {
1201
- warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
1232
+ if (roadmapRef) {
1233
+ const { resolved, stale } = resolveRoadmapRefStatus(cfg, roadmapRef)
1234
+ if (resolved.length === 0) {
1235
+ warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
1236
+ } else if (resolved.length === 1) {
1237
+ if (stale) {
1238
+ // literal falhou, resolvido só pelo fallback de basename num outro estado: o
1239
+ // vínculo aponta para um caminho de estado velho — o serve casa edge.To pelo
1240
+ // literal e desenharia aresta órfã para o mesmo vínculo se calássemos aqui.
1241
+ const actualState = path.basename(path.dirname(resolved[0]))
1242
+ warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" but the file is now in ${actualState}/ (stale state path)`)
1243
+ }
1244
+ // senão: resolvido pelo caminho literal — nenhum aviso.
1245
+ } else {
1246
+ warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" is ambiguous: found in multiple states: ${resolved.join(', ')}`)
1247
+ }
1202
1248
  }
1203
1249
  }
1204
1250
 
@@ -1211,6 +1257,70 @@ function referenceExists(ref) {
1211
1257
  return false
1212
1258
  }
1213
1259
 
1260
+ // isStaleRoadmapStateRef reporta se `ref` (um valor de campo Roadmap: já extraído) parece ter sido
1261
+ // gravado com uma pasta de estado que pode ter ficado velha — condição de entrada do fallback por
1262
+ // basename (resolveRoadmapRefByBasename). Ver o comentário completo em
1263
+ // internal/validator/validator.go, isStaleRoadmapStateRef (fonte canônica, ROADMAP-2026-09-05, ML-3B).
1264
+ function isStaleRoadmapStateRef(ref) {
1265
+ const parent = path.basename(path.dirname(ref.replace(/\\/g, '/')))
1266
+ return AGENT_NAMESPACE_STATE_NAMES.has(parent)
1267
+ }
1268
+
1269
+ // resolveRoadmapRefByBasename procura, nos diretórios de ESTADO do roadmap_dir (backlog/analyzing/
1270
+ // wip/blocked/done/abandoned, honrando roadmap_namespacing), um arquivo cujo basename case com
1271
+ // path.basename(ref). Escopo restrito ao campo Roadmap: — ver a justificativa completa (REQ sem
1272
+ // dimensão de estado / ADR de REQ, árvore de ADR flat) no comentário gêmeo em
1273
+ // internal/validator/validator.go, resolveRoadmapRefByBasename.
1274
+ //
1275
+ // Retorna a lista de caminhos absolutos encontrados: 0 (sem match), 1 (resolvido) ou mais de 1
1276
+ // (ambíguo — decisão do chamador).
1277
+ function resolveRoadmapRefByBasename(cfg, ref) {
1278
+ const base = path.basename(ref)
1279
+ if (base === '' || base === '.' || base === path.sep) return []
1280
+ const found = []
1281
+ const seen = new Set()
1282
+ for (const state of Array.from(AGENT_NAMESPACE_STATE_NAMES).sort()) {
1283
+ for (const stateDir of resolveStateDirs(cfg, state)) {
1284
+ let entries
1285
+ try {
1286
+ entries = fs.readdirSync(stateDir, { withFileTypes: true })
1287
+ } catch {
1288
+ continue
1289
+ }
1290
+ for (const entry of entries) {
1291
+ if (entry.name !== base || entry.isDirectory()) continue
1292
+ const full = path.normalize(path.join(stateDir, entry.name))
1293
+ if (!seen.has(full)) {
1294
+ seen.add(full)
1295
+ found.push(full)
1296
+ }
1297
+ }
1298
+ }
1299
+ }
1300
+ found.sort()
1301
+ return found
1302
+ }
1303
+
1304
+ // resolveRoadmapRef resolve um valor de campo Roadmap: já extraído: caminho literal primeiro
1305
+ // (idêntico a referenceExists — preserva o branch de diretório existente hoje), e só cai para o
1306
+ // fallback por basename quando o literal falha e isStaleRoadmapStateRef(ref) indica que vale a
1307
+ // pena tentar. Retorna 0 (quebrado), 1 (resolvido) ou >1 (ambíguo) caminhos.
1308
+ function resolveRoadmapRef(cfg, ref) {
1309
+ return resolveRoadmapRefStatus(cfg, ref).resolved
1310
+ }
1311
+
1312
+ // resolveRoadmapRefStatus é resolveRoadmapRef com um flag adicional: stale=true quando a
1313
+ // resolução só teve sucesso via resolveRoadmapRefByBasename (o caminho literal falhou e o
1314
+ // arquivo foi encontrado em outra pasta de estado). Espelha
1315
+ // internal/validator/validator.go:resolveRoadmapRefStatus (fonte canônica, ROADMAP-2026-09-05, ML-3B).
1316
+ function resolveRoadmapRefStatus(cfg, ref) {
1317
+ const expandedRef = config.expandPath ? config.expandPath(ref) : ref
1318
+ if (fs.existsSync(expandedRef)) return { resolved: [expandedRef], stale: false }
1319
+ if (!isStaleRoadmapStateRef(ref)) return { resolved: [], stale: false }
1320
+ const found = resolveRoadmapRefByBasename(cfg, ref)
1321
+ return { resolved: found, stale: found.length > 0 }
1322
+ }
1323
+
1214
1324
  function validateREQRoadmapLifecycle() {
1215
1325
  const cfg = config.load()
1216
1326
  const warnings = []
@@ -1220,7 +1330,13 @@ function validateREQRoadmapLifecycle() {
1220
1330
  if (!reqStatusIsOpen(content)) continue
1221
1331
  const ref = extractRefPath(content, 'Roadmap')
1222
1332
  if (!ref) continue
1223
- const expandedRef = config.expandPath ? config.expandPath(ref) : ref
1333
+ const resolved = resolveRoadmapRef(cfg, ref)
1334
+ if (resolved.length !== 1) {
1335
+ // 0 = vínculo quebrado, >1 = ambíguo — ambos já reportados por validateRefTargetsExist.
1336
+ // Esta regra só decide estado quando há UM arquivo real de onde derivá-lo.
1337
+ continue
1338
+ }
1339
+ const expandedRef = resolved[0]
1224
1340
  if (!fs.existsSync(expandedRef)) continue
1225
1341
  if (path.basename(path.dirname(expandedRef)) === 'done') {
1226
1342
  warnings.push(`req "${path.basename(filePath)}" is Open but linked Roadmap "${ref}" is in done/`)
@@ -2200,12 +2316,31 @@ set -f
2200
2316
  # --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
2201
2317
  # global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
2202
2318
  # quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
2203
- # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. Só drena
2204
- # se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
2205
- # esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
2206
- # leitura.
2319
+ # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. O dreno
2320
+ # roda incondicionalmente, com orçamento de tempo (ML-3A, ver abaixo). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda leitura.
2321
+ #
2322
+ # ML-3A (ROADMAP-2026-09-09-guard-emite-hookspecificoutput-e-a-razao-chega-ao-modelo-nos-3-
2323
+ # clis.md): o discriminante que existia aqui era "-t 0" -- separa "terminal interativo" de
2324
+ # "pipe", não "vai receber EOF" de "não vai". Qualquer chamador não-tty que segure stdin aberta
2325
+ # (ex.: um runtime de hook que nunca fecha o descritor) travava o antigo "cat" para sempre, sem
2326
+ # limite -- foi o que pendurou "make quality" por 1h05 quando o gate de schema (ML-2A) chamou o
2327
+ # guard sem "</dev/null" em 3 sítios. Substituído por dreno com ORÇAMENTO DE TEMPO via
2328
+ # "read -t <segundos> -d ''": builtin do próprio bash desde a 3.0 (não do coreutils) -- medido
2329
+ # idêntico no bash 3.2 (padrão do macOS) e no bash 5.3 (Linux/Homebrew); o bash do MSYS2/Git-Bash
2330
+ # do Windows é bash de verdade e traz o mesmo builtin. Evita depender de "timeout(1)", que NÃO
2331
+ # existe no macOS base nem no MSYS2 mínimo. "-d ''" faz o read tentar ler até o EOF real (NUL
2332
+ # nunca aparece em payload de hook); "-t" interrompe a espera no orçamento e, medido contra um
2333
+ # FIFO nunca fechado (bash 3.2 e 5.3), preserva na variável qualquer prefixo já lido antes do
2334
+ # timeout -- sem perda do que chegou, só do que nunca chegou. 2s de orçamento: folga generosa
2335
+ # para o payload pequeno de um hook sob scheduler sob carga (CI, "make quality" paralelo), e
2336
+ # ainda curto o bastante para o travamento continuar perceptível em vez de indefinido. Estourar
2337
+ # o orçamento NÃO libera o bloqueio: o passo 1 abaixo já prefere "$*" quando há argumento
2338
+ # posicional, e cai para o que foi lido até o limite quando não há -- "exit 2" e o fail-closed
2339
+ # continuam idênticos nos dois casos. O antigo desvio para invocação manual em terminal (skip
2340
+ # total quando "-t 0") foi removido de propósito: era o próprio discriminante errado que este ML
2341
+ # elimina, não um caso a preservar -- agora toda invocação drena com o mesmo orçamento de 2s.
2207
2342
  _TRACKFW_STDIN=""
2208
- [ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
2343
+ IFS= read -r -t 2 -d '' _TRACKFW_STDIN || true
2209
2344
 
2210
2345
  # --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
2211
2346
  # projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
@@ -2735,7 +2870,7 @@ case "$SUBCOMMAND" in
2735
2870
  ;;
2736
2871
  esac
2737
2872
 
2738
- printf '{"decision":"block","reason":"%s"}\\n' "$REASON"
2873
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "$REASON"
2739
2874
  echo "$REASON" >&2
2740
2875
  exit 2
2741
2876
  `
@@ -3850,6 +3985,7 @@ module.exports = {
3850
3985
  normalizeBranchSlug,
3851
3986
  // novas funções ML-2B
3852
3987
  contentHasMarker,
3988
+ contentHasMarkerValue,
3853
3989
  ruleSeverity,
3854
3990
  applyRule,
3855
3991
  // novas funções ML-1B (v2.5.1)