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.
- package/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/commands/branch.js +47 -0
- package/src/commands/discover.js +6 -0
- package/src/commands/doctor.js +32 -0
- package/src/commands/index.js +1 -0
- package/src/commands/integrations.js +16 -1
- package/src/commands/serve.js +52 -4
- package/src/commands/update-harness.js +317 -11
- package/src/generators/hooks.js +478 -36
- package/src/i18n/locales/en-US.json +0 -3
- package/src/i18n/locales/es-ES.json +0 -3
- package/src/i18n/locales/pt-BR.json +0 -3
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/manager.js +118 -17
- package/src/lib/fatal-error.js +64 -0
- package/src/ship/runner.js +52 -75
- package/src/validator/index.js +426 -92
package/src/generators/hooks.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
517
|
-
#
|
|
518
|
-
#
|
|
519
|
-
#
|
|
520
|
-
#
|
|
521
|
-
#
|
|
522
|
-
# "git commit
|
|
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=$(
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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.
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
|
|
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.
|
|
1030
|
-
//
|
|
1031
|
-
|
|
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.
|
|
1101
|
-
//
|
|
1102
|
-
|
|
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,
|
|
1263
|
-
//
|
|
1264
|
-
|
|
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.
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
}
|
|
Binary file
|