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.
@@ -472,15 +472,63 @@ const GLOBAL_CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_DETECTION_CORE + CG_GLOBAL
472
472
  const GBG_BACKTICK = '\\`'
473
473
 
474
474
  const GIT_BRANCH_GUARD_SCRIPT = `#!/usr/bin/env bash
475
- # trackfw git branch guard — bloqueia git commit/push/checkout -b brutos por subagente
475
+ # trackfw git branch guard — bloqueia git commit/push/checkout -b/branch/worktree add -b
476
+ # brutos por subagente
477
+ #
478
+ # TRIPWIRE, NÃO FRONTEIRA DE SEGURANÇA: detecta o caso óbvio — comando git literal, sem
479
+ # indireção de shell — não é defesa contra um agente adversário competente. Evasões que
480
+ # exigem tokenizar como o bash (ex.: git\${IFS}push, {git,push}, g""it push) permanecem
481
+ # abertas por decisão: ver docs/adr/ADR-2026-08-12-nao-ha-prevencao-contra-agente-induzido-
482
+ # com-escrita-irrestrita-a-resposta-e-deteccao-ancorada-no-git.md. O stripping de
483
+ # env/command abaixo reconhece as formas SEM argumentos antes de git (env git ...,
484
+ # command git ...) e o env seguido de uma sequência de atribuições CHAVE=valor
485
+ # (env FOO=bar git ..., env FOO=bar BAZ=qux git ...) — env com FLAGS (env -i git ...,
486
+ # env --ignore-environment git ...) e command com flags (command -p git ...) continuam
487
+ # evadindo; declarado, não fechado (ver AC5 do ML que adicionou esse stripping). A
488
+ # segmentação abaixo
489
+ # (quote_aware_split) evita falso-positivo em texto citado — não deve ser lida como imune a
490
+ # evasão por citação/tokenização do shell.
476
491
  set -euo pipefail
477
492
  set -f
478
493
 
494
+ # --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
495
+ # global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
496
+ # quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
497
+ # — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. Só drena
498
+ # se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
499
+ # esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
500
+ # leitura.
501
+ _TRACKFW_STDIN=""
502
+ [ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
503
+
504
+ # --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
505
+ # projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
506
+ # achar trackfw.yaml na raiz do projeto. Sem trackfw.yaml em nenhum ancestral, o guard não se
507
+ # aplica — fora de projeto trackfw não há trackfw ship como alternativa, e bloquear ali é custo
508
+ # sem contrapartida. Custo medido: só parameter expansion e test -f por nível, nenhum fork de
509
+ # processo; limitado pela profundidade do caminho.
510
+ _TRACKFW_ROOT_DIR=$(pwd -P)
511
+ _TRACKFW_FOUND=0
512
+ while :; do
513
+ if [ -f "$_TRACKFW_ROOT_DIR/trackfw.yaml" ]; then
514
+ _TRACKFW_FOUND=1
515
+ break
516
+ fi
517
+ if [ "$_TRACKFW_ROOT_DIR" = "/" ]; then
518
+ break
519
+ fi
520
+ _TRACKFW_ROOT_DIR="\${_TRACKFW_ROOT_DIR%/*}"
521
+ if [ -z "$_TRACKFW_ROOT_DIR" ]; then
522
+ _TRACKFW_ROOT_DIR="/"
523
+ fi
524
+ done
525
+ [ "$_TRACKFW_FOUND" -eq 1 ] || exit 0
526
+
479
527
  # --- 1. Obter o comando git bruto ------------------------------------------------------------
480
528
  if [ "$#" -gt 0 ]; then
481
529
  CMD_RAW="$*"
482
530
  else
483
- INPUT=$(cat 2>/dev/null || true)
531
+ INPUT="$_TRACKFW_STDIN"
484
532
  TRIMMED=$(printf '%s' "$INPUT" | sed -e 's/^[[:space:]]*//')
485
533
  case "$TRIMMED" in
486
534
  \\{*)
@@ -513,15 +561,143 @@ fi
513
561
 
514
562
  [ -n "$CMD_RAW" ] || exit 0
515
563
 
516
- # --- 2. Casar contra "git (commit|push|checkout -b)", segmento por segmento -----------------
517
- # Cada segmento é um comando real (dividido por ; && || | e por quebra de linha). "git" só
518
- # conta se for o PRIMEIRO token do segmento (por basename, então /usr/bin/git também casa) —
519
- # nunca uma ocorrência solta em qualquer posição da string inteira. Isso evita: (a) o segundo
520
- # comando de uma cadeia escapar da checagem, (b) um path absoluto para o git escapar por
521
- # comparação de igualdade exata, e (c) texto de prosa (ex.: mensagem de commit mencionando
522
- # "git commit" no meio de uma frase) ser tratado como comando.
564
+ # --- 2. Pré-processamento anti-falso-positivo: neutraliza separadores reais (';', '&&',
565
+ # '||', '|', quebra de linha) que estão DENTRO de aspas ou de corpo de heredoc, para que
566
+ # conteúdo de mensagem (ex.: ` + "`" + `-m "linha 1\\nlinha 2"` + "`" + `) nunca seja fatiado em pseudo-segmentos
567
+ # e lido como comando -------------------------------------------------------------------
568
+ #
569
+ # strip_heredoc_bodies: remove o CORPO de blocos heredoc (<<DELIM ... DELIM), preservando a
570
+ # linha de abertura e a linha terminadora — cobre o padrão ` + "`" + `git commit -F- <<'EOF' ... EOF` + "`" + `
571
+ # (heredoc não citado, fora do escopo de quote_aware_split abaixo). Heurística por linha, não
572
+ # sintaxe completa de shell: só remove o corpo quando encontra a linha terminadora
573
+ # correspondente. Se o heredoc nunca fecha (terminador ausente ou não localizado), devolve o
574
+ # texto ORIGINAL sem qualquer alteração — lado seguro: mais restritivo é preferível a esconder
575
+ # um comando real atrás de um heredoc mal-formado.
576
+ strip_heredoc_bodies() {
577
+ printf '%s' "$1" | awk '
578
+ BEGIN {
579
+ dq = sprintf("%c", 34)
580
+ sq = sprintf("%c", 39)
581
+ in_heredoc = 0
582
+ delim = ""
583
+ ok = 1
584
+ }
585
+ {
586
+ raw = raw $0 "\\n"
587
+ if (in_heredoc) {
588
+ trimmed = $0
589
+ sub(/^[ \\t]+/, "", trimmed)
590
+ sub(/[ \\t]+$/, "", trimmed)
591
+ if (trimmed == delim) {
592
+ in_heredoc = 0
593
+ out = out $0 "\\n"
594
+ }
595
+ next
596
+ }
597
+ if (match($0, /<<-?[ \\t]*[^ \\t]+/)) {
598
+ d = substr($0, RSTART, RLENGTH)
599
+ sub(/^<<-?[ \\t]*/, "", d)
600
+ gsub(dq, "", d)
601
+ gsub(sq, "", d)
602
+ if (d != "") {
603
+ delim = d
604
+ in_heredoc = 1
605
+ }
606
+ }
607
+ out = out $0 "\\n"
608
+ }
609
+ END {
610
+ if (in_heredoc) ok = 0
611
+ if (ok) { printf "%s", out } else { printf "%s", raw }
612
+ }
613
+ '
614
+ }
615
+
616
+ # quote_aware_split: emite o texto com ';' isolado, '&&', '||' e '|' isolado convertidos em
617
+ # quebra de linha — EXCETO quando ocorrem dentro de uma string entre aspas simples ou duplas,
618
+ # caso em que são preservados como texto e uma quebra de linha real dentro das aspas vira
619
+ # espaço (nunca gera um novo pseudo-segmento). Substitui o antigo ` + "`" + `sed` + "`" + ` cego, que não
620
+ # distinguia texto citado de sintaxe de comando — a causa raiz do falso-positivo de linha de
621
+ # mensagem de commit iniciada por "git ...". Aspas não fechadas até o fim da entrada
622
+ # permanecem "abertas" até o fim — mesma semântica do shell real: uma aspa não fechada nunca
623
+ # deixa o texto seguinte executar como comando novo, só torna o restante parte da mesma
624
+ # string.
625
+ quote_aware_split() {
626
+ printf '%s' "$1" | awk '
627
+ BEGIN {
628
+ dq = sprintf("%c", 34)
629
+ sq = sprintf("%c", 39)
630
+ bs = sprintf("%c", 92)
631
+ nl = sprintf("%c", 10)
632
+ }
633
+ { s = (NR == 1) ? $0 : s nl $0 }
634
+ END {
635
+ n = length(s)
636
+ q = ""
637
+ out = ""
638
+ i = 1
639
+ while (i <= n) {
640
+ c = substr(s, i, 1)
641
+ if (q != "") {
642
+ if (q == dq && c == bs && i < n) {
643
+ nx = substr(s, i + 1, 1)
644
+ out = out c (nx == nl ? " " : nx)
645
+ i += 2
646
+ continue
647
+ }
648
+ if (c == q) {
649
+ q = ""
650
+ out = out c
651
+ i++
652
+ continue
653
+ }
654
+ out = out (c == nl ? " " : c)
655
+ i++
656
+ continue
657
+ }
658
+ if (c == dq || c == sq) {
659
+ q = c
660
+ out = out c
661
+ i++
662
+ continue
663
+ }
664
+ if (substr(s, i, 2) == "&&" || substr(s, i, 2) == "||") {
665
+ out = out nl
666
+ i += 2
667
+ continue
668
+ }
669
+ if (c == ";" || c == "|") {
670
+ out = out nl
671
+ i++
672
+ continue
673
+ }
674
+ out = out c
675
+ i++
676
+ }
677
+ printf "%s", out
678
+ }
679
+ '
680
+ }
681
+
682
+ # match_subcommand — casa contra "git (commit|push|checkout -b|switch -c)", segmento por
683
+ # segmento. Cada segmento é um comando real, obtido depois do pré-processamento acima
684
+ # (strip_heredoc_bodies + quote_aware_split), que converte ';', '&&', '||', '|' fora de aspas
685
+ # em quebra de linha e neutraliza os mesmos separadores quando aparecem dentro de
686
+ # aspas/heredoc. "git" só conta se for o PRIMEIRO token do segmento (por basename, então
687
+ # /usr/bin/git também casa) — nunca uma ocorrência solta em qualquer posição da string
688
+ # inteira. Isso evita: (a) o segundo comando de uma cadeia escapar da checagem, (b) um path
689
+ # absoluto para o git escapar por comparação de igualdade exata, e (c) texto de prosa —
690
+ # inclusive linha de mensagem de commit que COMEÇA com "git <sub>" (ex.: uma tabela
691
+ # documentando comandos bloqueados) — ser tratado como comando, porque esse texto agora nunca
692
+ # produz um novo segmento. ` + "`" + `git switch -c/-C/--create` + "`" + ` (forma alternativa a ` + "`" + `checkout -b` + "`" + `
693
+ # para criar branch) é reconhecido varrendo TODOS os tokens após o subcomando, não só o
694
+ # primeiro — cobre ` + "`" + `git switch --track -c feat/x` + "`" + ` (flag antes de -c).
695
+ # checkout -b é reconhecido do mesmo jeito: varre TODOS os tokens até achar -b/-B/--orphan,
696
+ # não só o primeiro. Prefixos env e command antes de git são descartados antes da checagem do
697
+ # basename — cobre env git push/command git push sem exigir tokenizar como o bash.
523
698
  match_subcommand() {
524
- normalized=$(printf '%s' "$1" | sed -e 's/&&/\\n/g' -e 's/||/\\n/g' -e 's/[;|]/\\n/g')
699
+ normalized=$(strip_heredoc_bodies "$1")
700
+ normalized=$(quote_aware_split "$normalized")
525
701
  while IFS= read -r seg; do
526
702
  seg_trimmed=$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//')
527
703
  [ -n "$seg_trimmed" ] || continue
@@ -529,6 +705,29 @@ match_subcommand() {
529
705
  set -- $seg_trimmed
530
706
  first="$1"
531
707
  base="\${first##*/}"
708
+ while [ "$base" = "env" ] || [ "$base" = "command" ]; do
709
+ is_env="$base"
710
+ shift
711
+ [ "$#" -gt 0 ] || break
712
+ if [ "$is_env" = "env" ]; then
713
+ while [ "$#" -gt 0 ]; do
714
+ case "$1" in
715
+ -*)
716
+ break
717
+ ;;
718
+ *=*)
719
+ shift
720
+ ;;
721
+ *)
722
+ break
723
+ ;;
724
+ esac
725
+ done
726
+ [ "$#" -gt 0 ] || break
727
+ fi
728
+ first="$1"
729
+ base="\${first##*/}"
730
+ done
532
731
  [ "$base" = "git" ] || continue
533
732
  shift
534
733
 
@@ -562,9 +761,80 @@ match_subcommand() {
562
761
  return 0
563
762
  ;;
564
763
  checkout)
565
- if [ "\${1:-}" = "-b" ]; then
566
- echo "checkout-b"
567
- return 0
764
+ for tok2 in "$@"; do
765
+ case "$tok2" in
766
+ -b|-B|--orphan|--orphan=*)
767
+ echo "checkout-b"
768
+ return 0
769
+ ;;
770
+ esac
771
+ done
772
+ ;;
773
+ switch)
774
+ for tok2 in "$@"; do
775
+ case "$tok2" in
776
+ -c|-C|--create|--create=*|--force-create|--force-create=*)
777
+ echo "switch-c"
778
+ return 0
779
+ ;;
780
+ esac
781
+ done
782
+ ;;
783
+ branch)
784
+ # git branch é majoritariamente leitura (sem args, -a, -r, -l, --list, -v/-vv,
785
+ # --show-current, --contains, --no-contains, --merged, --no-merged, --sort=,
786
+ # --format=, --points-at, -d/-D/--delete) — bloquear leitura seria pior que a
787
+ # brecha. Só bloqueia: (a) -c/-C/-m/-M/--copy/--move (cria/renomeia branch,
788
+ # qualquer posição de token) ou (b) um argumento posicional puro (nome da branch a
789
+ # criar), a menos que -d/-D/--delete também esteja presente (delete tem
790
+ # posicional legítimo — o nome a apagar). Flags de valor conhecidas (--contains,
791
+ # --no-contains, --sort, --format, --points-at, --merged, --no-merged) têm seu
792
+ # valor seguinte pulado quando vem em token separado, para não ser lido como
793
+ # posicional de criação.
794
+ branch_action=0
795
+ has_delete=0
796
+ saw_positional=0
797
+ skip_next=0
798
+ for tok2 in "$@"; do
799
+ if [ "$skip_next" = "1" ]; then
800
+ skip_next=0
801
+ continue
802
+ fi
803
+ case "$tok2" in
804
+ -c|-C|-m|-M|--copy|--copy=*|--move|--move=*)
805
+ branch_action=1
806
+ ;;
807
+ -d|-D|--delete|--delete=*)
808
+ has_delete=1
809
+ ;;
810
+ --contains|--no-contains|--sort|--format|--points-at|--merged|--no-merged)
811
+ skip_next=1
812
+ ;;
813
+ -*)
814
+ ;;
815
+ *)
816
+ saw_positional=1
817
+ ;;
818
+ esac
819
+ done
820
+ if [ "$has_delete" != "1" ]; then
821
+ if [ "$branch_action" = "1" ] || [ "$saw_positional" = "1" ]; then
822
+ echo "branch-create"
823
+ return 0
824
+ fi
825
+ fi
826
+ ;;
827
+ worktree)
828
+ if [ "\${1:-}" = "add" ]; then
829
+ shift
830
+ for tok2 in "$@"; do
831
+ case "$tok2" in
832
+ -b|-B)
833
+ echo "worktree-add-b"
834
+ return 0
835
+ ;;
836
+ esac
837
+ done
568
838
  fi
569
839
  ;;
570
840
  esac
@@ -580,6 +850,15 @@ case "$SUBCOMMAND" in
580
850
  checkout-b)
581
851
  REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
582
852
  ;;
853
+ switch-c)
854
+ REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
855
+ ;;
856
+ branch-create)
857
+ REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
858
+ ;;
859
+ worktree-add-b)
860
+ REASON="trackfw: git worktree add -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
861
+ ;;
583
862
  commit)
584
863
  REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
585
864
  ;;
@@ -832,17 +1111,95 @@ function readGlobalHookJSON(...relParts) {
832
1111
  }
833
1112
  }
834
1113
 
835
- /** Read-only counterpart of mergeClaudeHookArray. */
1114
+ /**
1115
+ * Collapses runs of consecutive slashes ("//" -> "/", any position,
1116
+ * including leading) and strips a trailing slash, so two on-disk forms of
1117
+ * the SAME script path compare equal regardless of incidental formatting
1118
+ * (e.g. $HOME resolving with a trailing slash, as happens with macOS's
1119
+ * $TMPDIR, or a hand-edited config file). Does NOT resolve "." / ".."
1120
+ * segments or symlinks -- those transforms would let unrelated paths
1121
+ * compare equal (silently disarming the dedup, the more dangerous failure
1122
+ * mode here) and symlink resolution errors on a path that does not exist
1123
+ * yet, which every caller here must fail OPEN on. Hand-rolled instead of
1124
+ * path.normalize because it disagrees with Go's filepath.Clean and Python's
1125
+ * os.path.normpath on leading "//" and trailing "/" handling (measured) --
1126
+ * mirrored byte-for-byte in internal/generators/agentfiles.go
1127
+ * (normalizeGuardPath) and pypi/trackfw/generators/hooks.py
1128
+ * (_normalize_guard_path). Never call with anything other than a script
1129
+ * path -- it is not a general string normalizer.
1130
+ */
1131
+ function normalizeGuardPath(p) {
1132
+ if (!p) return p
1133
+ let out = ''
1134
+ let prevSlash = false
1135
+ for (const ch of p) {
1136
+ if (ch === '/') {
1137
+ if (prevSlash) continue
1138
+ prevSlash = true
1139
+ } else {
1140
+ prevSlash = false
1141
+ }
1142
+ out += ch
1143
+ }
1144
+ if (out.length > 1 && out.endsWith('/')) {
1145
+ out = out.replace(/\/+$/, '') || '/'
1146
+ }
1147
+ return out
1148
+ }
1149
+
1150
+ /** Reports whether a and b denote the same script command path after normalizeGuardPath. */
1151
+ function samePathCommand(a, b) {
1152
+ return normalizeGuardPath(a) === normalizeGuardPath(b)
1153
+ }
1154
+
1155
+ /**
1156
+ * Read-only counterpart of mergeClaudeHookArray. Compares command paths via
1157
+ * samePathCommand (normalized), not raw string equality.
1158
+ *
1159
+ * ROADMAP-2026-08-17 ML-4B: also requires the sibling `type` field to equal
1160
+ * "command" -- mergeClaudeHookArray always writes
1161
+ * {type:'command',command:...}, and Claude/Codex/Gemini all silently ignore
1162
+ * a hook entry missing "type":"command" (measured, hades-tf ML-4A barrier
1163
+ * finding: a global entry with the correct command but no `type` field
1164
+ * looked "installed" to the dedup, so the project-scope entry was skipped
1165
+ * in favor of a global entry that never actually executes -- leaving BOTH
1166
+ * scopes silently unprotected while `trackfw validate` stayed green).
1167
+ * Requiring "type":"command" here closes that gap: a malformed global entry
1168
+ * is now treated as "not installed", so the project-scope entry gets
1169
+ * re-wired instead of being skipped.
1170
+ */
836
1171
  function hookArrayHasCommand(existing, matcher, command) {
837
1172
  const arr = Array.isArray(existing) ? existing : []
838
1173
  for (const item of arr) {
839
1174
  if (!item || item.matcher !== matcher) continue
840
1175
  const inner = Array.isArray(item.hooks) ? item.hooks : []
841
- if (inner.some(h => h && h.command === command)) return true
1176
+ if (inner.some(h => h && h.type === 'command' && typeof h.command === 'string' && samePathCommand(h.command, command))) return true
842
1177
  }
843
1178
  return false
844
1179
  }
845
1180
 
1181
+ /**
1182
+ * Read-only, path-normalized counterpart of hasEntry -- used ONLY by the
1183
+ * global-dedup read paths (globalXInstalledCursor/Copilot below), never by
1184
+ * the write-side merge/idempotency helpers (mergeSimpleCommandArray,
1185
+ * injectCursorHooks), which must keep comparing raw strings so their
1186
+ * idempotency behavior does not drift from Go/Python. See samePathCommand's
1187
+ * doc comment for why the value must always be a script path.
1188
+ *
1189
+ * ROADMAP-2026-08-17 ML-4B: requireCommandType mirrors Go's
1190
+ * simpleArrayHasValue -- Copilot entries (mergeCredentialGuardCopilotHooks)
1191
+ * always carry "type":"command" and Copilot ignores an entry without it
1192
+ * (same hades-tf ML-4A finding as hookArrayHasCommand above), so Copilot
1193
+ * callers pass true. Cursor entries (mergeCredentialGuardCursorHooks,
1194
+ * {command:...}) never carry a `type` field -- not part of Cursor's schema
1195
+ * -- so requiring it there would make this always return false for a
1196
+ * perfectly valid, executing Cursor entry; Cursor callers pass false. Do
1197
+ * NOT uniformize this across CLIs.
1198
+ */
1199
+ function hasEntryPath(arr, field, value, requireCommandType) {
1200
+ return Array.isArray(arr) && arr.some(e => e && (!requireCommandType || e.type === 'command') && typeof e[field] === 'string' && samePathCommand(e[field], value))
1201
+ }
1202
+
846
1203
  function globalCredentialGuardInstalledClaude() {
847
1204
  const scriptPath = globalCredentialGuardScriptPath()
848
1205
  if (!scriptPath) return false
@@ -872,7 +1229,7 @@ function globalCredentialGuardInstalledCursor() {
872
1229
  if (!scriptPath) return false
873
1230
  const root = readGlobalHookJSON('.cursor', 'hooks.json')
874
1231
  if (!root || !root.hooks) return false
875
- return hasEntry(root.hooks.beforeShellExecution, 'command', scriptPath)
1232
+ return hasEntryPath(root.hooks.beforeShellExecution, 'command', scriptPath, false)
876
1233
  }
877
1234
 
878
1235
  function globalCredentialGuardInstalledCopilot() {
@@ -880,7 +1237,7 @@ function globalCredentialGuardInstalledCopilot() {
880
1237
  if (!scriptPath) return false
881
1238
  const root = readGlobalHookJSON('.copilot', 'settings.json')
882
1239
  if (!root || !root.hooks) return false
883
- return hasEntry(root.hooks.preToolUse, 'bash', scriptPath)
1240
+ return hasEntryPath(root.hooks.preToolUse, 'bash', scriptPath, true)
884
1241
  }
885
1242
 
886
1243
  /**
@@ -900,6 +1257,68 @@ function globalCredentialGuardInstalledKiro() {
900
1257
  }
901
1258
  }
902
1259
 
1260
+ // ---------------------------------------------------------------------------
1261
+ // git-branch-guard global-installed dedup (ROADMAP-2026-08-17 Wave 2/ML-2B)
1262
+ //
1263
+ // Mirrors the globalCredentialGuardInstalled<Tool> family above exactly,
1264
+ // pointed at ~/.trackfw/scripts/trackfw-git-branch-guard.sh instead of
1265
+ // trackfw-credential-guard.sh. Only 5 of the 6 credential-guard dedup
1266
+ // targets have a git-branch-guard counterpart: Kiro's project-scope
1267
+ // injector never wires git-branch-guard at all (see injectKiroHooks'
1268
+ // git-branch-guard doc comment), so there is no
1269
+ // globalGitBranchGuardInstalledKiro. Windsurf/AmazonQ wire git-branch-guard
1270
+ // at project scope but have no global-scope target (ML-2A only added
1271
+ // targets for the 6 CLIs credential-guard already covers) and no
1272
+ // credential-guard dedup precedent either -- consistent, not a gap.
1273
+ // ---------------------------------------------------------------------------
1274
+
1275
+ /** Mirrors Go's globalGitBranchGuardScriptPath. */
1276
+ function globalGitBranchGuardScriptPath() {
1277
+ const home = os.homedir()
1278
+ if (!home) return null
1279
+ return path.join(home, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
1280
+ }
1281
+
1282
+ function globalGitBranchGuardInstalledClaude() {
1283
+ const scriptPath = globalGitBranchGuardScriptPath()
1284
+ if (!scriptPath) return false
1285
+ const root = readGlobalHookJSON('.claude', 'settings.json')
1286
+ if (!root || !root.hooks) return false
1287
+ return hookArrayHasCommand(root.hooks.PreToolUse, 'Bash', scriptPath)
1288
+ }
1289
+
1290
+ function globalGitBranchGuardInstalledCodex() {
1291
+ const scriptPath = globalGitBranchGuardScriptPath()
1292
+ if (!scriptPath) return false
1293
+ const root = readGlobalHookJSON('.codex', 'hooks.json')
1294
+ if (!root || !root.hooks) return false
1295
+ return hookArrayHasCommand(root.hooks.PreToolUse, 'Bash', scriptPath)
1296
+ }
1297
+
1298
+ function globalGitBranchGuardInstalledGemini() {
1299
+ const scriptPath = globalGitBranchGuardScriptPath()
1300
+ if (!scriptPath) return false
1301
+ const root = readGlobalHookJSON('.gemini', 'settings.json')
1302
+ if (!root || !root.hooks) return false
1303
+ return hookArrayHasCommand(root.hooks.BeforeTool, 'run_shell_command', scriptPath)
1304
+ }
1305
+
1306
+ function globalGitBranchGuardInstalledCursor() {
1307
+ const scriptPath = globalGitBranchGuardScriptPath()
1308
+ if (!scriptPath) return false
1309
+ const root = readGlobalHookJSON('.cursor', 'hooks.json')
1310
+ if (!root || !root.hooks) return false
1311
+ return hasEntryPath(root.hooks.beforeShellExecution, 'command', scriptPath, false)
1312
+ }
1313
+
1314
+ function globalGitBranchGuardInstalledCopilot() {
1315
+ const scriptPath = globalGitBranchGuardScriptPath()
1316
+ if (!scriptPath) return false
1317
+ const root = readGlobalHookJSON('.copilot', 'settings.json')
1318
+ if (!root || !root.hooks) return false
1319
+ return hasEntryPath(root.hooks.preToolUse, 'bash', scriptPath, true)
1320
+ }
1321
+
903
1322
  // ---------------------------------------------------------------------------
904
1323
  // generateAttentionScripts — writes the two shell scripts to scripts/
905
1324
  // ---------------------------------------------------------------------------
@@ -966,11 +1385,13 @@ function injectClaudeHooks(cwd) {
966
1385
  }
967
1386
 
968
1387
  // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 1): PreToolUse-only, matcher
969
- // "Bash" — blocks raw `git commit`/`git push`/`git checkout -b` before they execute. No
970
- // global-install dedup here (unlike GUARD_CMD_CLAUDE above): there is no per-CLI global
971
- // git-branch-guard harness target yet (only the plain script-generation call at
972
- // GenerateGlobalGitBranchGuardScript/generateGlobalGitBranchGuardScript exists so far).
973
- data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GBG_CMD_CLAUDE)
1388
+ // "Bash" — blocks raw `git commit`/`git push`/`git checkout -b` before they execute.
1389
+ // Dedup (ROADMAP-2026-08-17 Wave 2/ML-2B): skip the project-scope entry when the global
1390
+ // one is already installed (`trackfw update harness --targets claude-git-branch-guard`),
1391
+ // so the guard doesn't fire (and print its block message) twice per Bash call.
1392
+ if (!globalGitBranchGuardInstalledClaude()) {
1393
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GBG_CMD_CLAUDE)
1394
+ }
974
1395
 
975
1396
  writeJSON(filePath, data)
976
1397
  }
@@ -1026,9 +1447,12 @@ function injectCodexHooks(cwd) {
1026
1447
  }
1027
1448
 
1028
1449
  // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 2): PreToolUse-only, matcher
1029
- // "Bash" -- git commands only ever run via the Bash tool, never apply_patch. No
1030
- // global-install dedup (see GBG_CMD_CLAUDE comment).
1031
- data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GBG_CMD_CODEX)
1450
+ // "Bash" -- git commands only ever run via the Bash tool, never apply_patch.
1451
+ // Dedup (ROADMAP-2026-08-17 Wave 2/ML-2B): skip project-scope when the global one is
1452
+ // already installed (`trackfw update harness --targets codex-git-branch-guard`).
1453
+ if (!globalGitBranchGuardInstalledCodex()) {
1454
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GBG_CMD_CODEX)
1455
+ }
1032
1456
 
1033
1457
  writeJSON(filePath, data)
1034
1458
  }
@@ -1097,9 +1521,12 @@ function injectGeminiHooks(cwd) {
1097
1521
  }
1098
1522
 
1099
1523
  // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 3): BeforeTool-only, matcher
1100
- // "run_shell_command" -- git commands only ever run via the shell tool. No global-install
1101
- // dedup (see GBG_CMD_CLAUDE comment).
1102
- data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command', GBG_CMD_GEMINI)
1524
+ // "run_shell_command" -- git commands only ever run via the shell tool.
1525
+ // Dedup (ROADMAP-2026-08-17 Wave 2/ML-2B): skip project-scope when the global one is
1526
+ // already installed (`trackfw update harness --targets gemini-git-branch-guard`).
1527
+ if (!globalGitBranchGuardInstalledGemini()) {
1528
+ data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command', GBG_CMD_GEMINI)
1529
+ }
1103
1530
 
1104
1531
  writeJSON(filePath, data)
1105
1532
  }
@@ -1259,9 +1686,14 @@ function injectCopilotHooks(cwd) {
1259
1686
  // Go's InjectCopilotHooks (internal/generators/agentfiles.go), which made the same
1260
1687
  // choice for the same reason; the CLI-flag/permissions-config.json approach is a
1261
1688
  // documented, deliberate divergence from the roadmap's literal wording. This file is
1262
- // overwritten wholesale every run, so no dedup-against-global check is needed --
1263
- // added unconditionally, matching the always-on attention-signal/cleanup entries.
1264
- preToolUse.push({ type: 'command', matcher: 'bash', bash: GBG_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
1689
+ // overwritten wholesale every run, but that only means there is no migration concern
1690
+ // -- it does not exempt this entry from the dedup-against-global check
1691
+ // (ROADMAP-2026-08-17 Wave 2/ML-2B): skip project-scope when the global one is
1692
+ // already installed (`trackfw update harness --targets copilot-git-branch-guard`),
1693
+ // same reasoning as the credential-guard dedup above.
1694
+ if (!globalGitBranchGuardInstalledCopilot()) {
1695
+ preToolUse.push({ type: 'command', matcher: 'bash', bash: GBG_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
1696
+ }
1265
1697
 
1266
1698
  const data = {
1267
1699
  version: 1,
@@ -1396,11 +1828,19 @@ function injectCursorHooks(cwd) {
1396
1828
  }
1397
1829
 
1398
1830
  // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 5): beforeShellExecution-only --
1399
- // Cursor's dedicated pre-execution Bash event; git commands only ever run there. No
1400
- // global-install dedup (see GBG_CMD_CLAUDE comment).
1401
- if (!Array.isArray(data.hooks.beforeShellExecution)) data.hooks.beforeShellExecution = []
1402
- if (!hasEntry(data.hooks.beforeShellExecution, 'command', GBG_CMD_CURSOR)) {
1403
- data.hooks.beforeShellExecution.push({ command: GBG_CMD_CURSOR })
1831
+ // Cursor's dedicated pre-execution Bash event; git commands only ever run there.
1832
+ // Dedup (ROADMAP-2026-08-17 Wave 2/ML-2B): skip project-scope when the global one is
1833
+ // already installed (`trackfw update harness --targets cursor-git-branch-guard`). The
1834
+ // key is intentionally only touched inside this conditional (never created outside it)
1835
+ // so that when BOTH credential-guard and git-branch-guard are deduped away, the key
1836
+ // stays absent from the emitted JSON rather than becoming a present-but-empty array --
1837
+ // matches Go's InjectCursorHooks, which check-agent-hooks-parity.sh's structural
1838
+ // comparator treats as significant (absent key vs empty array is drift, not noise).
1839
+ if (!globalGitBranchGuardInstalledCursor()) {
1840
+ if (!Array.isArray(data.hooks.beforeShellExecution)) data.hooks.beforeShellExecution = []
1841
+ if (!hasEntry(data.hooks.beforeShellExecution, 'command', GBG_CMD_CURSOR)) {
1842
+ data.hooks.beforeShellExecution.push({ command: GBG_CMD_CURSOR })
1843
+ }
1404
1844
  }
1405
1845
 
1406
1846
  writeJSON(filePath, data)
@@ -1650,4 +2090,6 @@ module.exports = {
1650
2090
  mergeClaudeHookArray,
1651
2091
  mergeSimpleCommandArray,
1652
2092
  mergeCopilotHookArray,
2093
+ normalizeGuardPath,
2094
+ samePathCommand,
1653
2095
  }
@@ -120,8 +120,5 @@
120
120
  "no_data": "No transitions recorded yet.",
121
121
  "since": "Filter by period (e.g. 7d, 30d, 90d)",
122
122
  "export": "Export metrics to CSV file"
123
- },
124
- "errors": {
125
- "notFound": "Not found: {{path}}"
126
123
  }
127
124
  }
@@ -120,8 +120,5 @@
120
120
  "no_data": "No hay transiciones registradas aún.",
121
121
  "since": "Filtrar por período (ej: 7d, 30d, 90d)",
122
122
  "export": "Exportar métricas a archivo CSV"
123
- },
124
- "errors": {
125
- "notFound": "No encontrado: {{path}}"
126
123
  }
127
124
  }
@@ -120,8 +120,5 @@
120
120
  "no_data": "Nenhuma transição registrada ainda.",
121
121
  "since": "Filtrar por período (ex: 7d, 30d, 90d)",
122
122
  "export": "Exportar métricas para arquivo CSV"
123
- },
124
- "errors": {
125
- "notFound": "Não encontrado: {{path}}"
126
123
  }
127
124
  }
Binary file