trackfw 7.0.0 → 7.1.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.
@@ -1167,13 +1167,20 @@ const GIT_BRANCH_GUARD_SCRIPT_MARKER = 'trackfw-git-branch-guard.sh'
1167
1167
  // -fail-open-do-credential-guard, ML-1A). Hooks de escopo GLOBAL (~/.trackfw/..., trackfw update
1168
1168
  // harness) ficam fora — caso distinto, fora do repositório do usuário, e a checagem de dedup
1169
1169
  // globalCredentialGuardInstalled*() já os pula de propósito nas entradas de projeto.
1170
+ //
1171
+ // requiresCommandType (ROADMAP-2026-08-17 ML-4B, port of Go's credentialGuardHookFile.
1172
+ // requiresCommandType): true for every CLI whose writer always emits a sibling "type":"command"
1173
+ // field (Claude/Codex/Gemini, GitHub Copilot CLI, Kiro) — a command match found WITHOUT that
1174
+ // sibling is a structurally malformed entry the CLI silently never executes (hades-tf ML-4A
1175
+ // barrier finding), not merely "absent". false only for Cursor, whose schema
1176
+ // ({"command":...}) never carries a "type" field at all.
1170
1177
  const CREDENTIAL_GUARD_HOOK_FILES = [
1171
- { relPath: '.claude/settings.json', cli: 'Claude Code' },
1172
- { relPath: '.codex/hooks.json', cli: 'Codex CLI' },
1173
- { relPath: '.gemini/settings.json', cli: 'Gemini CLI' },
1174
- { relPath: '.cursor/hooks.json', cli: 'Cursor' },
1175
- { relPath: '.github/hooks/trackfw-attention.json', cli: 'GitHub Copilot CLI' },
1176
- { relPath: '.kiro/hooks/trackfw-attention.json', cli: 'Kiro' },
1178
+ { relPath: '.claude/settings.json', cli: 'Claude Code', requiresCommandType: true },
1179
+ { relPath: '.codex/hooks.json', cli: 'Codex CLI', requiresCommandType: true },
1180
+ { relPath: '.gemini/settings.json', cli: 'Gemini CLI', requiresCommandType: true },
1181
+ { relPath: '.cursor/hooks.json', cli: 'Cursor', requiresCommandType: false },
1182
+ { relPath: '.github/hooks/trackfw-attention.json', cli: 'GitHub Copilot CLI', requiresCommandType: true },
1183
+ { relPath: '.kiro/hooks/trackfw-attention.json', cli: 'Kiro', requiresCommandType: true },
1177
1184
  ]
1178
1185
 
1179
1186
  // resolveCredentialGuardHookPath resolve o valor bruto de um comando de hook (string extraída do
@@ -1214,11 +1221,18 @@ function resolveCredentialGuardHookPath(raw, root) {
1214
1221
  }
1215
1222
 
1216
1223
  // collectCommandsWithMarker percorre recursivamente um valor JSON já decodificado e coleta todo
1217
- // valor-string que contém marker, independentemente do nome do campo que o contém.
1224
+ // valor-string que contém marker, independentemente do nome do campo que o contém — junto com um
1225
+ // sinal estrutural (typeIsCommand) de que o objeto imediato que o contém também tem
1226
+ // "type":"command" como campo irmão (ROADMAP-2026-08-17 ML-4B, port de guardCommandMatch em
1227
+ // internal/validator/validator_credential_guard.go). Cada entrada de `out` é
1228
+ // {raw, typeIsCommand}.
1218
1229
  //
1219
1230
  // Os 6 formatos de hook usam campos diferentes para o comando: "command" (Claude/Codex/
1220
1231
  // Gemini/Cursor), "bash" (GitHub Copilot CLI), "action.command" (Kiro). Varrer por VALOR em vez
1221
- // de por caminho de chave evita acoplar esta regra à forma exata de cada schema.
1232
+ // de por caminho de chave evita acoplar esta regra à forma exata de cada schema. Todo schema aqui
1233
+ // coloca "type" como IRMÃO do campo do comando dentro do MESMO objeto — nunca aninhado mais fundo
1234
+ // — então basta ler "type" do objeto que collectCommandsWithMarker já está visitando; não é
1235
+ // necessária uma travessia schema-aware separada.
1222
1236
  //
1223
1237
  // Generalizado (ROADMAP-2026-08-15-trackfw-validate-deve-detectar-scripts-de-hook-ausentes-ou-
1224
1238
  // desatualizados, ML-2A, port de collectCommandsWithMarker em
@@ -1227,7 +1241,10 @@ function resolveCredentialGuardHookPath(raw, root) {
1227
1241
  // também para GIT_BRANCH_GUARD_SCRIPT_MARKER, sem duplicar a travessia recursiva.
1228
1242
  function collectCommandsWithMarker(value, marker, out) {
1229
1243
  if (typeof value === 'string') {
1230
- if (value.includes(marker)) out.push(value)
1244
+ // Top-level/loose string match, outside any enclosing object — defensive fallback, not
1245
+ // expected to fire since every hook file read here is rooted at a JSON object. No enclosing
1246
+ // object means no "type" sibling to read.
1247
+ if (value.includes(marker)) out.push({ raw: value, typeIsCommand: false })
1231
1248
  return
1232
1249
  }
1233
1250
  if (Array.isArray(value)) {
@@ -1235,7 +1252,15 @@ function collectCommandsWithMarker(value, marker, out) {
1235
1252
  return
1236
1253
  }
1237
1254
  if (value && typeof value === 'object') {
1238
- for (const key of Object.keys(value)) collectCommandsWithMarker(value[key], marker, out)
1255
+ const typeIsCommand = value.type === 'command'
1256
+ for (const key of Object.keys(value)) {
1257
+ const val = value[key]
1258
+ if (typeof val === 'string') {
1259
+ if (val.includes(marker)) out.push({ raw: val, typeIsCommand })
1260
+ continue
1261
+ }
1262
+ collectCommandsWithMarker(val, marker, out)
1263
+ }
1239
1264
  }
1240
1265
  }
1241
1266
 
@@ -1293,13 +1318,24 @@ function validateGuardHookResolvable(scriptMarker, cwd) {
1293
1318
  collectCommandsWithMarker(parsed, scriptMarker, commands)
1294
1319
 
1295
1320
  const seen = new Set()
1296
- for (const raw of commands) {
1297
- if (seen.has(raw)) continue
1298
- seen.add(raw)
1321
+ for (const m of commands) {
1322
+ const seenKey = `${m.raw}${m.typeIsCommand}`
1323
+ if (seen.has(seenKey)) continue
1324
+ seen.add(seenKey)
1299
1325
 
1300
- const resolved = resolveCredentialGuardHookPath(raw, root)
1326
+ const resolved = resolveCredentialGuardHookPath(m.raw, root)
1301
1327
  if (resolved === null) continue
1302
1328
 
1329
+ // ROADMAP-2026-08-17 ML-4B: a command that resolves to a real path but sits inside a
1330
+ // structurally malformed entry (missing/wrong "type" where this CLI's schema requires it —
1331
+ // hades-tf ML-4A barrier finding) will NEVER be executed by the CLI, regardless of whether
1332
+ // the script itself exists and is executable. Reported instead of the exists/executable
1333
+ // checks below, which assume a structurally valid entry.
1334
+ if (hf.requiresCommandType && !m.typeIsCommand) {
1335
+ msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the hook entry is missing "type":"command" (or has an invalid type) — ${hf.cli} will silently never execute it; run \`trackfw update\` to regenerate it`)
1336
+ continue
1337
+ }
1338
+
1303
1339
  let stat = null
1304
1340
  try {
1305
1341
  stat = fs.statSync(resolved)
@@ -1568,15 +1604,63 @@ const GBG_REF_BACKTICK = '\\`'
1568
1604
  // (npm/tests/git_branch_guard.test.js): it regenerates the script via generateGitBranchGuardScript
1569
1605
  // into a temp dir and asserts byte-equality against this constant.
1570
1606
  const GIT_BRANCH_GUARD_SCRIPT_REFERENCE = `#!/usr/bin/env bash
1571
- # trackfw git branch guard — bloqueia git commit/push/checkout -b brutos por subagente
1607
+ # trackfw git branch guard — bloqueia git commit/push/checkout -b/branch/worktree add -b
1608
+ # brutos por subagente
1609
+ #
1610
+ # TRIPWIRE, NÃO FRONTEIRA DE SEGURANÇA: detecta o caso óbvio — comando git literal, sem
1611
+ # indireção de shell — não é defesa contra um agente adversário competente. Evasões que
1612
+ # exigem tokenizar como o bash (ex.: git\${IFS}push, {git,push}, g""it push) permanecem
1613
+ # abertas por decisão: ver docs/adr/ADR-2026-08-12-nao-ha-prevencao-contra-agente-induzido-
1614
+ # com-escrita-irrestrita-a-resposta-e-deteccao-ancorada-no-git.md. O stripping de
1615
+ # env/command abaixo reconhece as formas SEM argumentos antes de git (env git ...,
1616
+ # command git ...) e o env seguido de uma sequência de atribuições CHAVE=valor
1617
+ # (env FOO=bar git ..., env FOO=bar BAZ=qux git ...) — env com FLAGS (env -i git ...,
1618
+ # env --ignore-environment git ...) e command com flags (command -p git ...) continuam
1619
+ # evadindo; declarado, não fechado (ver AC5 do ML que adicionou esse stripping). A
1620
+ # segmentação abaixo
1621
+ # (quote_aware_split) evita falso-positivo em texto citado — não deve ser lida como imune a
1622
+ # evasão por citação/tokenização do shell.
1572
1623
  set -euo pipefail
1573
1624
  set -f
1574
1625
 
1626
+ # --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
1627
+ # global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
1628
+ # quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
1629
+ # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. Só drena
1630
+ # se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
1631
+ # esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
1632
+ # leitura.
1633
+ _TRACKFW_STDIN=""
1634
+ [ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
1635
+
1636
+ # --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
1637
+ # projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
1638
+ # achar trackfw.yaml na raiz do projeto. Sem trackfw.yaml em nenhum ancestral, o guard não se
1639
+ # aplica — fora de projeto trackfw não há trackfw ship como alternativa, e bloquear ali é custo
1640
+ # sem contrapartida. Custo medido: só parameter expansion e test -f por nível, nenhum fork de
1641
+ # processo; limitado pela profundidade do caminho.
1642
+ _TRACKFW_ROOT_DIR=$(pwd -P)
1643
+ _TRACKFW_FOUND=0
1644
+ while :; do
1645
+ if [ -f "$_TRACKFW_ROOT_DIR/trackfw.yaml" ]; then
1646
+ _TRACKFW_FOUND=1
1647
+ break
1648
+ fi
1649
+ if [ "$_TRACKFW_ROOT_DIR" = "/" ]; then
1650
+ break
1651
+ fi
1652
+ _TRACKFW_ROOT_DIR="\${_TRACKFW_ROOT_DIR%/*}"
1653
+ if [ -z "$_TRACKFW_ROOT_DIR" ]; then
1654
+ _TRACKFW_ROOT_DIR="/"
1655
+ fi
1656
+ done
1657
+ [ "$_TRACKFW_FOUND" -eq 1 ] || exit 0
1658
+
1575
1659
  # --- 1. Obter o comando git bruto ------------------------------------------------------------
1576
1660
  if [ "$#" -gt 0 ]; then
1577
1661
  CMD_RAW="$*"
1578
1662
  else
1579
- INPUT=$(cat 2>/dev/null || true)
1663
+ INPUT="$_TRACKFW_STDIN"
1580
1664
  TRIMMED=$(printf '%s' "$INPUT" | sed -e 's/^[[:space:]]*//')
1581
1665
  case "$TRIMMED" in
1582
1666
  \\{*)
@@ -1609,15 +1693,143 @@ fi
1609
1693
 
1610
1694
  [ -n "$CMD_RAW" ] || exit 0
1611
1695
 
1612
- # --- 2. Casar contra "git (commit|push|checkout -b)", segmento por segmento -----------------
1613
- # Cada segmento é um comando real (dividido por ; && || | e por quebra de linha). "git" só
1614
- # conta se for o PRIMEIRO token do segmento (por basename, então /usr/bin/git também casa) —
1615
- # nunca uma ocorrência solta em qualquer posição da string inteira. Isso evita: (a) o segundo
1616
- # comando de uma cadeia escapar da checagem, (b) um path absoluto para o git escapar por
1617
- # comparação de igualdade exata, e (c) texto de prosa (ex.: mensagem de commit mencionando
1618
- # "git commit" no meio de uma frase) ser tratado como comando.
1696
+ # --- 2. Pré-processamento anti-falso-positivo: neutraliza separadores reais (';', '&&',
1697
+ # '||', '|', quebra de linha) que estão DENTRO de aspas ou de corpo de heredoc, para que
1698
+ # conteúdo de mensagem (ex.: ` + "`" + `-m "linha 1\\nlinha 2"` + "`" + `) nunca seja fatiado em pseudo-segmentos
1699
+ # e lido como comando -------------------------------------------------------------------
1700
+ #
1701
+ # strip_heredoc_bodies: remove o CORPO de blocos heredoc (<<DELIM ... DELIM), preservando a
1702
+ # linha de abertura e a linha terminadora — cobre o padrão ` + "`" + `git commit -F- <<'EOF' ... EOF` + "`" + `
1703
+ # (heredoc não citado, fora do escopo de quote_aware_split abaixo). Heurística por linha, não
1704
+ # sintaxe completa de shell: só remove o corpo quando encontra a linha terminadora
1705
+ # correspondente. Se o heredoc nunca fecha (terminador ausente ou não localizado), devolve o
1706
+ # texto ORIGINAL sem qualquer alteração — lado seguro: mais restritivo é preferível a esconder
1707
+ # um comando real atrás de um heredoc mal-formado.
1708
+ strip_heredoc_bodies() {
1709
+ printf '%s' "$1" | awk '
1710
+ BEGIN {
1711
+ dq = sprintf("%c", 34)
1712
+ sq = sprintf("%c", 39)
1713
+ in_heredoc = 0
1714
+ delim = ""
1715
+ ok = 1
1716
+ }
1717
+ {
1718
+ raw = raw $0 "\\n"
1719
+ if (in_heredoc) {
1720
+ trimmed = $0
1721
+ sub(/^[ \\t]+/, "", trimmed)
1722
+ sub(/[ \\t]+$/, "", trimmed)
1723
+ if (trimmed == delim) {
1724
+ in_heredoc = 0
1725
+ out = out $0 "\\n"
1726
+ }
1727
+ next
1728
+ }
1729
+ if (match($0, /<<-?[ \\t]*[^ \\t]+/)) {
1730
+ d = substr($0, RSTART, RLENGTH)
1731
+ sub(/^<<-?[ \\t]*/, "", d)
1732
+ gsub(dq, "", d)
1733
+ gsub(sq, "", d)
1734
+ if (d != "") {
1735
+ delim = d
1736
+ in_heredoc = 1
1737
+ }
1738
+ }
1739
+ out = out $0 "\\n"
1740
+ }
1741
+ END {
1742
+ if (in_heredoc) ok = 0
1743
+ if (ok) { printf "%s", out } else { printf "%s", raw }
1744
+ }
1745
+ '
1746
+ }
1747
+
1748
+ # quote_aware_split: emite o texto com ';' isolado, '&&', '||' e '|' isolado convertidos em
1749
+ # quebra de linha — EXCETO quando ocorrem dentro de uma string entre aspas simples ou duplas,
1750
+ # caso em que são preservados como texto e uma quebra de linha real dentro das aspas vira
1751
+ # espaço (nunca gera um novo pseudo-segmento). Substitui o antigo ` + "`" + `sed` + "`" + ` cego, que não
1752
+ # distinguia texto citado de sintaxe de comando — a causa raiz do falso-positivo de linha de
1753
+ # mensagem de commit iniciada por "git ...". Aspas não fechadas até o fim da entrada
1754
+ # permanecem "abertas" até o fim — mesma semântica do shell real: uma aspa não fechada nunca
1755
+ # deixa o texto seguinte executar como comando novo, só torna o restante parte da mesma
1756
+ # string.
1757
+ quote_aware_split() {
1758
+ printf '%s' "$1" | awk '
1759
+ BEGIN {
1760
+ dq = sprintf("%c", 34)
1761
+ sq = sprintf("%c", 39)
1762
+ bs = sprintf("%c", 92)
1763
+ nl = sprintf("%c", 10)
1764
+ }
1765
+ { s = (NR == 1) ? $0 : s nl $0 }
1766
+ END {
1767
+ n = length(s)
1768
+ q = ""
1769
+ out = ""
1770
+ i = 1
1771
+ while (i <= n) {
1772
+ c = substr(s, i, 1)
1773
+ if (q != "") {
1774
+ if (q == dq && c == bs && i < n) {
1775
+ nx = substr(s, i + 1, 1)
1776
+ out = out c (nx == nl ? " " : nx)
1777
+ i += 2
1778
+ continue
1779
+ }
1780
+ if (c == q) {
1781
+ q = ""
1782
+ out = out c
1783
+ i++
1784
+ continue
1785
+ }
1786
+ out = out (c == nl ? " " : c)
1787
+ i++
1788
+ continue
1789
+ }
1790
+ if (c == dq || c == sq) {
1791
+ q = c
1792
+ out = out c
1793
+ i++
1794
+ continue
1795
+ }
1796
+ if (substr(s, i, 2) == "&&" || substr(s, i, 2) == "||") {
1797
+ out = out nl
1798
+ i += 2
1799
+ continue
1800
+ }
1801
+ if (c == ";" || c == "|") {
1802
+ out = out nl
1803
+ i++
1804
+ continue
1805
+ }
1806
+ out = out c
1807
+ i++
1808
+ }
1809
+ printf "%s", out
1810
+ }
1811
+ '
1812
+ }
1813
+
1814
+ # match_subcommand — casa contra "git (commit|push|checkout -b|switch -c)", segmento por
1815
+ # segmento. Cada segmento é um comando real, obtido depois do pré-processamento acima
1816
+ # (strip_heredoc_bodies + quote_aware_split), que converte ';', '&&', '||', '|' fora de aspas
1817
+ # em quebra de linha e neutraliza os mesmos separadores quando aparecem dentro de
1818
+ # aspas/heredoc. "git" só conta se for o PRIMEIRO token do segmento (por basename, então
1819
+ # /usr/bin/git também casa) — nunca uma ocorrência solta em qualquer posição da string
1820
+ # inteira. Isso evita: (a) o segundo comando de uma cadeia escapar da checagem, (b) um path
1821
+ # absoluto para o git escapar por comparação de igualdade exata, e (c) texto de prosa —
1822
+ # inclusive linha de mensagem de commit que COMEÇA com "git <sub>" (ex.: uma tabela
1823
+ # documentando comandos bloqueados) — ser tratado como comando, porque esse texto agora nunca
1824
+ # produz um novo segmento. ` + "`" + `git switch -c/-C/--create` + "`" + ` (forma alternativa a ` + "`" + `checkout -b` + "`" + `
1825
+ # para criar branch) é reconhecido varrendo TODOS os tokens após o subcomando, não só o
1826
+ # primeiro — cobre ` + "`" + `git switch --track -c feat/x` + "`" + ` (flag antes de -c).
1827
+ # checkout -b é reconhecido do mesmo jeito: varre TODOS os tokens até achar -b/-B/--orphan,
1828
+ # não só o primeiro. Prefixos env e command antes de git são descartados antes da checagem do
1829
+ # basename — cobre env git push/command git push sem exigir tokenizar como o bash.
1619
1830
  match_subcommand() {
1620
- normalized=$(printf '%s' "$1" | sed -e 's/&&/\\n/g' -e 's/||/\\n/g' -e 's/[;|]/\\n/g')
1831
+ normalized=$(strip_heredoc_bodies "$1")
1832
+ normalized=$(quote_aware_split "$normalized")
1621
1833
  while IFS= read -r seg; do
1622
1834
  seg_trimmed=$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//')
1623
1835
  [ -n "$seg_trimmed" ] || continue
@@ -1625,6 +1837,29 @@ match_subcommand() {
1625
1837
  set -- $seg_trimmed
1626
1838
  first="$1"
1627
1839
  base="\${first##*/}"
1840
+ while [ "$base" = "env" ] || [ "$base" = "command" ]; do
1841
+ is_env="$base"
1842
+ shift
1843
+ [ "$#" -gt 0 ] || break
1844
+ if [ "$is_env" = "env" ]; then
1845
+ while [ "$#" -gt 0 ]; do
1846
+ case "$1" in
1847
+ -*)
1848
+ break
1849
+ ;;
1850
+ *=*)
1851
+ shift
1852
+ ;;
1853
+ *)
1854
+ break
1855
+ ;;
1856
+ esac
1857
+ done
1858
+ [ "$#" -gt 0 ] || break
1859
+ fi
1860
+ first="$1"
1861
+ base="\${first##*/}"
1862
+ done
1628
1863
  [ "$base" = "git" ] || continue
1629
1864
  shift
1630
1865
 
@@ -1658,9 +1893,80 @@ match_subcommand() {
1658
1893
  return 0
1659
1894
  ;;
1660
1895
  checkout)
1661
- if [ "\${1:-}" = "-b" ]; then
1662
- echo "checkout-b"
1663
- return 0
1896
+ for tok2 in "$@"; do
1897
+ case "$tok2" in
1898
+ -b|-B|--orphan|--orphan=*)
1899
+ echo "checkout-b"
1900
+ return 0
1901
+ ;;
1902
+ esac
1903
+ done
1904
+ ;;
1905
+ switch)
1906
+ for tok2 in "$@"; do
1907
+ case "$tok2" in
1908
+ -c|-C|--create|--create=*|--force-create|--force-create=*)
1909
+ echo "switch-c"
1910
+ return 0
1911
+ ;;
1912
+ esac
1913
+ done
1914
+ ;;
1915
+ branch)
1916
+ # git branch é majoritariamente leitura (sem args, -a, -r, -l, --list, -v/-vv,
1917
+ # --show-current, --contains, --no-contains, --merged, --no-merged, --sort=,
1918
+ # --format=, --points-at, -d/-D/--delete) — bloquear leitura seria pior que a
1919
+ # brecha. Só bloqueia: (a) -c/-C/-m/-M/--copy/--move (cria/renomeia branch,
1920
+ # qualquer posição de token) ou (b) um argumento posicional puro (nome da branch a
1921
+ # criar), a menos que -d/-D/--delete também esteja presente (delete tem
1922
+ # posicional legítimo — o nome a apagar). Flags de valor conhecidas (--contains,
1923
+ # --no-contains, --sort, --format, --points-at, --merged, --no-merged) têm seu
1924
+ # valor seguinte pulado quando vem em token separado, para não ser lido como
1925
+ # posicional de criação.
1926
+ branch_action=0
1927
+ has_delete=0
1928
+ saw_positional=0
1929
+ skip_next=0
1930
+ for tok2 in "$@"; do
1931
+ if [ "$skip_next" = "1" ]; then
1932
+ skip_next=0
1933
+ continue
1934
+ fi
1935
+ case "$tok2" in
1936
+ -c|-C|-m|-M|--copy|--copy=*|--move|--move=*)
1937
+ branch_action=1
1938
+ ;;
1939
+ -d|-D|--delete|--delete=*)
1940
+ has_delete=1
1941
+ ;;
1942
+ --contains|--no-contains|--sort|--format|--points-at|--merged|--no-merged)
1943
+ skip_next=1
1944
+ ;;
1945
+ -*)
1946
+ ;;
1947
+ *)
1948
+ saw_positional=1
1949
+ ;;
1950
+ esac
1951
+ done
1952
+ if [ "$has_delete" != "1" ]; then
1953
+ if [ "$branch_action" = "1" ] || [ "$saw_positional" = "1" ]; then
1954
+ echo "branch-create"
1955
+ return 0
1956
+ fi
1957
+ fi
1958
+ ;;
1959
+ worktree)
1960
+ if [ "\${1:-}" = "add" ]; then
1961
+ shift
1962
+ for tok2 in "$@"; do
1963
+ case "$tok2" in
1964
+ -b|-B)
1965
+ echo "worktree-add-b"
1966
+ return 0
1967
+ ;;
1968
+ esac
1969
+ done
1664
1970
  fi
1665
1971
  ;;
1666
1972
  esac
@@ -1676,6 +1982,15 @@ case "$SUBCOMMAND" in
1676
1982
  checkout-b)
1677
1983
  REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
1678
1984
  ;;
1985
+ switch-c)
1986
+ REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
1987
+ ;;
1988
+ branch-create)
1989
+ REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
1990
+ ;;
1991
+ worktree-add-b)
1992
+ REASON="trackfw: git worktree add -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
1993
+ ;;
1679
1994
  commit)
1680
1995
  REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
1681
1996
  ;;
@@ -1882,15 +2197,34 @@ exit 0
1882
2197
  // (internal/validator/validator_git_branch_guard.go) — note this list DIFFERS from
1883
2198
  // CREDENTIAL_GUARD_HOOK_FILES for Copilot and Kiro (global scope uses .copilot/settings.json and
1884
2199
  // .kiro/hooks/trackfw-credential-guard.json, not the project-scope attention-hook paths).
2200
+ // requiresCommandType (ROADMAP-2026-08-17 ML-4B) mirrors CREDENTIAL_GUARD_HOOK_FILES'
2201
+ // requiresCommandType above — true for every CLI except Cursor.
1885
2202
  const GLOBAL_GUARD_CONFIG_FILES = [
1886
- { relPath: '.claude/settings.json', cli: 'Claude Code' },
1887
- { relPath: '.codex/hooks.json', cli: 'Codex CLI' },
1888
- { relPath: '.gemini/settings.json', cli: 'Gemini CLI' },
1889
- { relPath: '.cursor/hooks.json', cli: 'Cursor' },
1890
- { relPath: '.copilot/settings.json', cli: 'GitHub Copilot CLI' },
1891
- { relPath: '.kiro/hooks/trackfw-credential-guard.json', cli: 'Kiro' },
2203
+ { relPath: '.claude/settings.json', cli: 'Claude Code', requiresCommandType: true },
2204
+ { relPath: '.codex/hooks.json', cli: 'Codex CLI', requiresCommandType: true },
2205
+ { relPath: '.gemini/settings.json', cli: 'Gemini CLI', requiresCommandType: true },
2206
+ { relPath: '.cursor/hooks.json', cli: 'Cursor', requiresCommandType: false },
2207
+ { relPath: '.copilot/settings.json', cli: 'GitHub Copilot CLI', requiresCommandType: true },
2208
+ { relPath: '.kiro/hooks/trackfw-credential-guard.json', cli: 'Kiro', requiresCommandType: true },
1892
2209
  ]
1893
2210
 
2211
+ // globalGuardConfigPath resolves the actual on-disk path (relative to $HOME) that
2212
+ // validateGuardGlobalHookResolvable must read for a given (GLOBAL_GUARD_CONFIG_FILES entry,
2213
+ // scriptMarker) pair. Port of Go's globalGuardConfigPath (internal/validator/
2214
+ // validator_git_branch_guard.go) — see that function's doc comment for the full rationale
2215
+ // (5 CLIs share one merge-based file across both guards; Kiro is the sole exception, with a
2216
+ // dedicated file per guard because its writer rewrites the whole document wholesale).
2217
+ //
2218
+ // ROADMAP-2026-08-17 ML-3B: before this function existed, GLOBAL_GUARD_CONFIG_FILES only ever
2219
+ // pointed Kiro at trackfw-credential-guard.json for BOTH guards, so git_branch_guard_hook_resolvable
2220
+ // never inspected ~/.kiro/hooks/trackfw-git-branch-guard.json at all.
2221
+ function globalGuardConfigPath(gf, scriptMarker) {
2222
+ if (gf.cli === 'Kiro' && scriptMarker === GIT_BRANCH_GUARD_SCRIPT_MARKER) {
2223
+ return '.kiro/hooks/trackfw-git-branch-guard.json'
2224
+ }
2225
+ return gf.relPath
2226
+ }
2227
+
1894
2228
  // validateGuardGlobalHookResolvable is the GLOBAL-scope counterpart of
1895
2229
  // validateGuardHookResolvable: for each of the 6 GLOBAL_GUARD_CONFIG_FILES that exists AND
1896
2230
  // references scriptMarker, verifies the referenced script exists and is executable. Port of Go's
@@ -1911,7 +2245,8 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
1911
2245
 
1912
2246
  const msgs = []
1913
2247
  for (const gf of GLOBAL_GUARD_CONFIG_FILES) {
1914
- const fullPath = path.join(home, gf.relPath)
2248
+ const relPath = globalGuardConfigPath(gf, scriptMarker)
2249
+ const fullPath = path.join(home, relPath)
1915
2250
  let content
1916
2251
  try {
1917
2252
  content = fs.readFileSync(fullPath, 'utf8')
@@ -1931,23 +2266,34 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
1931
2266
  collectCommandsWithMarker(parsed, scriptMarker, commands)
1932
2267
 
1933
2268
  const seen = new Set()
1934
- for (const raw of commands) {
1935
- if (seen.has(raw)) continue
1936
- seen.add(raw)
1937
-
1938
- if (!path.isAbsolute(raw)) continue
2269
+ for (const m of commands) {
2270
+ const seenKey = `${m.raw} ${m.typeIsCommand}`
2271
+ if (seen.has(seenKey)) continue
2272
+ seen.add(seenKey)
2273
+
2274
+ if (!path.isAbsolute(m.raw)) continue
2275
+
2276
+ // ROADMAP-2026-08-17 ML-4B: reproduced by hades-tf (ML-4A barrier) — a global config entry
2277
+ // with the correct absolute command but missing/wrong "type" (hand-edited, an older trackfw
2278
+ // version, another tool's merge) is silently never executed by the CLI, even though the
2279
+ // script itself exists and is fine. Reported instead of the exists/executable checks below,
2280
+ // which assume the entry is structurally valid.
2281
+ if (gf.requiresCommandType && !m.typeIsCommand) {
2282
+ msgs.push(`~/${relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${m.raw}", but the hook entry is missing "type":"command" (or has an invalid type) — ${gf.cli} will silently never execute it; run \`trackfw update harness\` to regenerate it`)
2283
+ continue
2284
+ }
1939
2285
 
1940
2286
  let stat = null
1941
2287
  try {
1942
- stat = fs.statSync(raw)
2288
+ stat = fs.statSync(m.raw)
1943
2289
  } catch (_) {
1944
2290
  stat = null
1945
2291
  }
1946
2292
 
1947
2293
  if (!stat) {
1948
- msgs.push(`~/${gf.relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${raw}", but the script does not exist — run \`trackfw update harness\` to regenerate it`)
2294
+ 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`)
1949
2295
  } else if ((stat.mode & 0o111) === 0) {
1950
- msgs.push(`~/${gf.relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${raw}", but the script is not executable — run \`trackfw update harness\` to regenerate it`)
2296
+ 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`)
1951
2297
  }
1952
2298
  }
1953
2299
  }
@@ -1956,61 +2302,45 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
1956
2302
  }
1957
2303
 
1958
2304
  // validateGuardGlobalScriptIntegrity is the GLOBAL-scope counterpart of
1959
- // validateCredentialGuardScriptIntegrity/validateGitBranchGuardScriptIntegrity: for each of the 6
1960
- // GLOBAL_GUARD_CONFIG_FILES that references scriptMarker, verifies the referenced script's content
1961
- // matches referenceContent byte-for-byte. Port of Go's validateGuardGlobalScriptIntegrity
1962
- // (internal/validator/validator_git_branch_guard.go) — same trigger condition and fail-open
1963
- // contract as validateGuardGlobalHookResolvable above; does not re-validate existence/
1964
- // executability, only content, matching the project-scope split between *_hook_resolvable and
1965
- // *_script_integrity.
1966
- function validateGuardGlobalScriptIntegrity(scriptMarker, referenceContent) {
2305
+ // validateCredentialGuardScriptIntegrity/validateGitBranchGuardScriptIntegrity: verifies the
2306
+ // content of ~/.trackfw/scripts/<scriptFileName> (the fixed location the global generators write
2307
+ // to) against referenceContent byte-for-byte. Port of Go's validateGuardGlobalScriptIntegrity
2308
+ // (internal/validator/validator_git_branch_guard.go).
2309
+ //
2310
+ // ROADMAP-2026-08-17 (guard global cabeado com no-op / integridade independente de fiação), ML-3A:
2311
+ // deliberately triggers on ARTIFACT EXISTENCE, not on any GLOBAL_GUARD_CONFIG_FILES entry
2312
+ // referencing scriptMarker — the old per-config trigger meant a script trackfw itself wrote (via
2313
+ // `trackfw update harness`) but that no config yet pointed at could rot indefinitely with
2314
+ // `validate` green. "If trackfw wrote the script, trackfw verifies the script" — existence is the
2315
+ // only precondition; wiring is irrelevant to whether the artifact itself has drifted.
2316
+ //
2317
+ // Fail-open on absence: a script trackfw never wrote (user never ran `update harness`) is not an
2318
+ // error.
2319
+ //
2320
+ // Single evaluation per script (not per referencing config): caps the message count at 1
2321
+ // regardless of how many (or how few — including zero) configs reference the same on-disk path —
2322
+ // this is what prevents double-reporting now that git-branch-guard has both global wiring (Wave 2)
2323
+ // and a global artifact.
2324
+ //
2325
+ // scriptMarker doubles as the script's own filename (trackfw-credential-guard.sh /
2326
+ // trackfw-git-branch-guard.sh) in both call sites below — same equivalence the Go port relies on.
2327
+ function validateGuardGlobalScriptIntegrity(scriptFileName, referenceContent) {
1967
2328
  const home = os.homedir()
1968
2329
  if (!home) return []
1969
2330
 
1970
- const msgs = []
1971
- for (const gf of GLOBAL_GUARD_CONFIG_FILES) {
1972
- const fullPath = path.join(home, gf.relPath)
1973
- let content
1974
- try {
1975
- content = fs.readFileSync(fullPath, 'utf8')
1976
- } catch (e) {
1977
- if (e.code === 'ENOENT') continue
1978
- continue
1979
- }
1980
-
1981
- let parsed
1982
- try {
1983
- parsed = JSON.parse(content)
1984
- } catch (_) {
1985
- continue
1986
- }
1987
-
1988
- const commands = []
1989
- collectCommandsWithMarker(parsed, scriptMarker, commands)
1990
-
1991
- const seen = new Set()
1992
- for (const raw of commands) {
1993
- if (seen.has(raw)) continue
1994
- seen.add(raw)
1995
-
1996
- if (!path.isAbsolute(raw)) continue
1997
-
1998
- let scriptContent
1999
- try {
2000
- scriptContent = fs.readFileSync(raw, 'utf8')
2001
- } catch (_) {
2002
- // Existence/readability is validateGuardGlobalHookResolvable's job — do not double-report
2003
- // the same underlying condition under two rule names.
2004
- continue
2005
- }
2006
-
2007
- if (scriptContent === referenceContent) continue
2008
-
2009
- msgs.push(`${raw} (global scope, referenced from ~/${gf.relPath}, ${gf.cli}) content diverges from the template this version of trackfw generates — if you did not edit this file by hand, run \`trackfw update harness\` to regenerate it`)
2010
- }
2331
+ const scriptPath = path.join(home, '.trackfw', 'scripts', scriptFileName)
2332
+ let content
2333
+ try {
2334
+ content = fs.readFileSync(scriptPath, 'utf8')
2335
+ } catch (_) {
2336
+ // Not installed is not a violation — same contract as every other *_script_integrity check
2337
+ // (project-scope and global) in this file.
2338
+ return []
2011
2339
  }
2012
2340
 
2013
- return msgs
2341
+ if (content === referenceContent) return []
2342
+
2343
+ return [`${scriptPath} (global scope) content diverges from the template this version of trackfw generates — if you did not edit this file by hand, run \`trackfw update harness\` to regenerate it`]
2014
2344
  }
2015
2345
 
2016
2346
  // validateCredentialGuardGlobalHookResolvable / validateCredentialGuardGlobalScriptIntegrity /
@@ -2036,6 +2366,10 @@ function validateGitBranchGuardGlobalHookResolvable() {
2036
2366
  function validateGitBranchGuardGlobalScriptIntegrity() {
2037
2367
  return validateGuardGlobalScriptIntegrity(GIT_BRANCH_GUARD_SCRIPT_MARKER, GIT_BRANCH_GUARD_SCRIPT_REFERENCE)
2038
2368
  }
2369
+ // NOTE: CREDENTIAL_GUARD_SCRIPT_MARKER === 'trackfw-credential-guard.sh' and
2370
+ // GIT_BRANCH_GUARD_SCRIPT_MARKER === 'trackfw-git-branch-guard.sh' — both markers are already the
2371
+ // literal script filenames, so they double as scriptFileName above with no extra constant needed
2372
+ // (same reuse the Go port relies on).
2039
2373
 
2040
2374
  // CREDENTIAL_GUARD_MODE_LOOKBEHIND_LINES mirrors the shell script's own resolution of
2041
2375
  // credential_guard.mode (`grep -A 5 '^credential_guard:'`): the mode key is found on the matched