trackfw 6.10.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.
Files changed (38) hide show
  1. package/README.md +0 -3
  2. package/bin/trackfw +38 -1
  3. package/package.json +1 -1
  4. package/src/branch/prune.js +450 -0
  5. package/src/branch/runner.js +33 -20
  6. package/src/changelog/index.js +114 -0
  7. package/src/commands/branch.js +59 -9
  8. package/src/commands/changelog.js +29 -0
  9. package/src/commands/commit.js +60 -0
  10. package/src/commands/discover.js +75 -0
  11. package/src/commands/doctor.js +32 -0
  12. package/src/commands/index.js +23 -4
  13. package/src/commands/integrations.js +23 -1
  14. package/src/commands/serve.js +52 -4
  15. package/src/commands/ship.js +4 -3
  16. package/src/commands/thirdparty.js +384 -0
  17. package/src/commands/update-harness.js +320 -12
  18. package/src/commands/update.js +3 -1
  19. package/src/commit/runner.js +352 -0
  20. package/src/config/index.js +27 -0
  21. package/src/generators/hooks.js +925 -6
  22. package/src/generators/init.js +28 -6
  23. package/src/i18n/locales/en-US.json +0 -21
  24. package/src/i18n/locales/es-ES.json +0 -21
  25. package/src/i18n/locales/pt-BR.json +0 -21
  26. package/src/integrations/doctor.js +0 -0
  27. package/src/integrations/index.js +19 -2
  28. package/src/integrations/manager.js +133 -19
  29. package/src/lib/fatal-error.js +64 -0
  30. package/src/lib/unknown-command.js +82 -0
  31. package/src/ship/runner.js +259 -95
  32. package/src/thirdparty/fetch.js +140 -0
  33. package/src/thirdparty/markers.js +207 -0
  34. package/src/thirdparty/provenance.js +114 -0
  35. package/src/thirdparty/quarantine.js +113 -0
  36. package/src/thirdparty/references.js +194 -0
  37. package/src/validator/index.js +1060 -27
  38. package/src/commands/plugins.js +0 -184
@@ -43,6 +43,23 @@ function mergeSimpleCommandArray(existing, command) {
43
43
  return arr
44
44
  }
45
45
 
46
+ /**
47
+ * Merge helper for Windsurf's `.windsurf/hooks.json` `hooks.<event>` arrays
48
+ * (e.g. `hooks.pre_run_command`): each entry is `{"command": "...",
49
+ * "show_output": bool}` -- one field richer than mergeSimpleCommandArray's
50
+ * plain `{"command"}` shape (Cursor), so it needs its own merge helper.
51
+ * Mirrors Go's mergeSimpleCommandArray call in InjectWindsurfHooks
52
+ * (internal/generators/agentfiles.go), which is generic over the entry
53
+ * shape via makeEntry/getCmd callbacks -- Node has no equivalent generic
54
+ * variant, so this is a dedicated function instead.
55
+ */
56
+ function mergeWindsurfHookArray(existing, command, showOutput) {
57
+ const arr = Array.isArray(existing) ? existing.slice() : []
58
+ if (hasEntry(arr, 'command', command)) return arr
59
+ arr.push({ command, show_output: showOutput })
60
+ return arr
61
+ }
62
+
46
63
  /**
47
64
  * Merge helper para arrays de hooks tipo GitHub Copilot
48
65
  * (hooks.preToolUse/postToolUse): cada entry é
@@ -404,6 +421,497 @@ const CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_PROJECT_GUARD + CG_DETECTION_CORE
404
421
 
405
422
  const GLOBAL_CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_DETECTION_CORE + CG_GLOBAL_TAIL
406
423
 
424
+ // ---------------------------------------------------------------------------
425
+ // GIT_BRANCH_GUARD_SCRIPT — trackfw-git-branch-guard.sh (ML-1A, ROADMAP-2026-08-14)
426
+ // ---------------------------------------------------------------------------
427
+ // Byte-identical port of internal/generators/scaffold.go:gitBranchGuardScript
428
+ // (Go's canonical reference). Blocks raw `git commit`/`git push`/
429
+ // `git checkout -b` by a subagent, regardless of runtime contract: emits BOTH
430
+ // `{"decision":"block","reason":"..."}` on stdout (Claude/Gemini JSON-stdout
431
+ // style) AND `exit 2` (Codex/Windsurf/Cursor exit-code style) simultaneously,
432
+ // same simplification decision as the Go const's doc comment. Unlike
433
+ // CREDENTIAL_GUARD_SCRIPT, this script is IDENTICAL between project and
434
+ // global scope (no trackfw.yaml dependency, no mode/roadmap_dir resolution)
435
+ // — a single constant is written by both generateGitBranchGuardScript and
436
+ // generateGlobalGitBranchGuardScript below, mirroring Go's single
437
+ // `gitBranchGuardScript` const reused by both Generate*/GenerateGlobal*
438
+ // functions.
439
+ //
440
+ // KNOWN LIMITATION (fix for 3 real bugs found by ML-4A manual E2E testing, 2026-08-14 —
441
+ // chained command `git status; git push ...` escaping detection, absolute path
442
+ // `/usr/bin/git commit` escaping via exact-string compare, and prose mentioning "git commit"
443
+ // inside a quoted string being read as a real command): the script's `match_subcommand()`
444
+ // now splits the raw command into segments on `;`/`&&`/`||`/`|`/newline and only treats a
445
+ // segment as a real git invocation if `git` (by basename) is that segment's FIRST token —
446
+ // this fixes all 3 bugs, but the splitter is NOT shell-quote-aware. It splits on those
447
+ // delimiter characters wherever they occur in the raw string, even inside single/double
448
+ // quotes. Practical consequence proven live in this session: a line inside a multi-line
449
+ // heredoc body that itself STARTS with the token `git` still blocks (newline is treated as
450
+ // a command boundary regardless of quoting context), and testing this script by embedding a
451
+ // literal `; git push`/`; git commit` substring inside a quoted JSON payload passed as a raw
452
+ // Bash tool command can trip the SAME guard when it's wired as this session's own
453
+ // PreToolUse/Bash hook — write such payloads to a file and pipe `< file.json` instead of
454
+ // inlining them in the shell command text. This limitation applies equally to the generated
455
+ // shell script itself (Go-generated and Node-generated content is byte-identical), even
456
+ // though internal/generators/scaffold.go's gitBranchGuardScript doc comment does not yet
457
+ // call it out explicitly — flagging for trackfw_architect to consider adding there too.
458
+ // GBG_BACKTICK — literal 2-char sequence "\\`" (backslash + backtick), used to embed a
459
+ // shell-escaped backtick (so REASON's `` `cmd` `` doesn't trigger bash command substitution
460
+ // inside the surrounding double-quoted string) without breaking the enclosing JS template
461
+ // literal. Mirrors the exact same problem Go's `gitBranchGuardScript` const solves by
462
+ // breaking out of its raw string and concatenating `"`"` — see scaffold.go's REASON lines
463
+ // (`\` + "`" + `trackfw ...`). A plain, unescaped backtick inside a JS template literal
464
+ // would either be interpreted as JS template-literal syntax (illegal placement) or, if
465
+ // naively escaped as just `` \` ``, would defeat the same regex-based source extraction the
466
+ // credential-guard parity test already documents as unable to handle string concatenation
467
+ // (see the CG_PROJECT_TAIL/CG_GLOBAL_TAIL comment above) — an embedded, un-terminated
468
+ // `` \` `` inside a single backtick-delimited block would make a naive
469
+ // `` const NAME = \`...\` `` regex stop at the first inner backtick instead of the real
470
+ // closing one. Splitting at every backtick insertion point, exactly like Go's own
471
+ // workaround, sidesteps both problems.
472
+ const GBG_BACKTICK = '\\`'
473
+
474
+ const GIT_BRANCH_GUARD_SCRIPT = `#!/usr/bin/env bash
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.
491
+ set -euo pipefail
492
+ set -f
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
+
527
+ # --- 1. Obter o comando git bruto ------------------------------------------------------------
528
+ if [ "$#" -gt 0 ]; then
529
+ CMD_RAW="$*"
530
+ else
531
+ INPUT="$_TRACKFW_STDIN"
532
+ TRIMMED=$(printf '%s' "$INPUT" | sed -e 's/^[[:space:]]*//')
533
+ case "$TRIMMED" in
534
+ \\{*)
535
+ CMD_RAW=""
536
+ if command -v jq >/dev/null 2>&1; then
537
+ CMD_RAW=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // .command // .tool_info.command_line // .hook_input.command // empty' 2>/dev/null || true)
538
+ fi
539
+ if [ -z "$CMD_RAW" ] || [ "$CMD_RAW" = "null" ]; then
540
+ CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"tool_input"[[:space:]]*:[[:space:]]*{[^}]*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
541
+ fi
542
+ if [ -z "$CMD_RAW" ]; then
543
+ CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"tool_info"[[:space:]]*:[[:space:]]*{[^}]*"command_line"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
544
+ fi
545
+ if [ -z "$CMD_RAW" ]; then
546
+ CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"hook_input"[[:space:]]*:[[:space:]]*{[^}]*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
547
+ fi
548
+ if [ -z "$CMD_RAW" ]; then
549
+ CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
550
+ fi
551
+ ;;
552
+ *)
553
+ CMD_RAW="$INPUT"
554
+ ;;
555
+ esac
556
+ fi
557
+
558
+ if [ -z "$CMD_RAW" ]; then
559
+ CMD_RAW="\${TRACKFW_GIT_COMMAND:-}"
560
+ fi
561
+
562
+ [ -n "$CMD_RAW" ] || exit 0
563
+
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.
698
+ match_subcommand() {
699
+ normalized=$(strip_heredoc_bodies "$1")
700
+ normalized=$(quote_aware_split "$normalized")
701
+ while IFS= read -r seg; do
702
+ seg_trimmed=$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//')
703
+ [ -n "$seg_trimmed" ] || continue
704
+
705
+ set -- $seg_trimmed
706
+ first="$1"
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
731
+ [ "$base" = "git" ] || continue
732
+ shift
733
+
734
+ sub=""
735
+ while [ "$#" -gt 0 ]; do
736
+ tok="$1"
737
+ case "$tok" in
738
+ -C|-c|--work-tree|--git-dir|--namespace)
739
+ if [ "$#" -ge 2 ]; then shift 2; else shift; fi
740
+ continue
741
+ ;;
742
+ -*)
743
+ shift
744
+ continue
745
+ ;;
746
+ *)
747
+ sub="$tok"
748
+ shift
749
+ break
750
+ ;;
751
+ esac
752
+ done
753
+
754
+ case "$sub" in
755
+ commit)
756
+ echo "commit"
757
+ return 0
758
+ ;;
759
+ push)
760
+ echo "push"
761
+ return 0
762
+ ;;
763
+ checkout)
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
838
+ fi
839
+ ;;
840
+ esac
841
+ done <<EOF
842
+ $normalized
843
+ EOF
844
+ return 1
845
+ }
846
+
847
+ SUBCOMMAND=$(match_subcommand "$CMD_RAW") || exit 0
848
+
849
+ case "$SUBCOMMAND" in
850
+ checkout-b)
851
+ REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
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
+ ;;
862
+ commit)
863
+ REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
864
+ ;;
865
+ push)
866
+ REASON="trackfw: git push bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw ship` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
867
+ ;;
868
+ *)
869
+ exit 0
870
+ ;;
871
+ esac
872
+
873
+ printf '{"decision":"block","reason":"%s"}\\n' "$REASON"
874
+ echo "$REASON" >&2
875
+ exit 2
876
+ `
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // generateGitBranchGuardScript — writes scripts/trackfw-git-branch-guard.sh
880
+ // ---------------------------------------------------------------------------
881
+ // This ML only creates the script — it is NOT wired into any hooks.json/
882
+ // settings.json here (that is this roadmap's Wave 3 scope). Mirrors
883
+ // generateCredentialGuardScript exactly (mkdirSync + writeFileSync, mode 0o755).
884
+ function generateGitBranchGuardScript(cwd) {
885
+ const root = cwd || process.cwd()
886
+ const scriptsDir = path.join(root, 'scripts')
887
+ fs.mkdirSync(scriptsDir, { recursive: true })
888
+
889
+ const scriptPath = path.join(scriptsDir, 'trackfw-git-branch-guard.sh')
890
+ fs.writeFileSync(scriptPath, GIT_BRANCH_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
891
+
892
+ console.log(' ✓ scripts/trackfw-git-branch-guard.sh')
893
+ }
894
+
895
+ // ---------------------------------------------------------------------------
896
+ // generateGlobalGitBranchGuardScript — writes <home>/.trackfw/scripts/trackfw-git-branch-guard.sh
897
+ // ---------------------------------------------------------------------------
898
+ // Destinado a ser referenciado por hooks globais de CLI, instalados via
899
+ // `trackfw update harness` -- não é chamado por `trackfw init`/`trackfw
900
+ // update` (escopo de projeto), que continuam usando
901
+ // generateGitBranchGuardScript. Mirrors generateGlobalCredentialGuardScript.
902
+ function generateGlobalGitBranchGuardScript(home) {
903
+ if (!home) {
904
+ throw new Error('home directory vazio')
905
+ }
906
+ const scriptsDir = path.join(home, '.trackfw', 'scripts')
907
+ fs.mkdirSync(scriptsDir, { recursive: true })
908
+
909
+ const scriptPath = path.join(scriptsDir, 'trackfw-git-branch-guard.sh')
910
+ fs.writeFileSync(scriptPath, GIT_BRANCH_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
911
+
912
+ console.log(' ✓ .trackfw/scripts/trackfw-git-branch-guard.sh')
913
+ }
914
+
407
915
  // ---------------------------------------------------------------------------
408
916
  // generateCredentialGuardScript — writes scripts/trackfw-credential-guard.sh
409
917
  // ---------------------------------------------------------------------------
@@ -521,6 +1029,53 @@ const GUARD_CMD_CURSOR = 'scripts/trackfw-credential-guard.sh'
521
1029
  // successfully in practice.
522
1030
  const GUARD_CMD_CLAUDE = '$CLAUDE_PROJECT_DIR/scripts/trackfw-credential-guard.sh'
523
1031
 
1032
+ // ---------------------------------------------------------------------------
1033
+ // GBG_CMD_* — git-branch-guard command per CLI (ROADMAP-2026-08-14, ML-3B,
1034
+ // Wave 3). Same path-resolution mechanism per runtime already established
1035
+ // above for GUARD_CMD_*/SIGNAL_CMD_* (Claude's $CLAUDE_PROJECT_DIR pin,
1036
+ // Codex's `$(git rev-parse --show-toplevel)` substitution, Gemini's
1037
+ // $GEMINI_PROJECT_DIR, Copilot/Cursor's project-root-relative path) — the
1038
+ // git branch guard has no per-CLI variance of its own, it just needs the
1039
+ // same "always resolve to the project root regardless of cwd drift" fix
1040
+ // each of those constants already encodes for the credential guard.
1041
+ //
1042
+ // Wiring scope of this ML (Claude/Codex/Gemini/Cursor): registers the guard
1043
+ // against the SAME hook events/matchers already used for the credential
1044
+ // guard in each of these 4 CLIs' existing merge-based hooks.json/
1045
+ // settings.json contract — a `Bash`/`run_shell_command`/`beforeShellExecution`
1046
+ // PreToolUse-equivalent entry is sufficient to intercept `git commit`/`push`/
1047
+ // `checkout -b` before they execute; no PostToolUse entry is added (unlike
1048
+ // the credential guard) because blocking after the git command already ran
1049
+ // is too late to be useful.
1050
+ //
1051
+ // NOT implemented in this ML (documented gap, mirrors the Go side of this
1052
+ // same roadmap wave, which also has not built these yet): GitHub Copilot's
1053
+ // `--deny-tool='shell(git commit)'`-style entries in a dedicated
1054
+ // permissions-config.json/settings.json (a different mechanism than the
1055
+ // existing `.github/hooks/trackfw-attention.json` hooks file this module
1056
+ // already generates for Copilot); Windsurf's `pre_run_command` hook +
1057
+ // `windsurf.cascadeCommandsAllowList` deny entry (injectWindsurfHooks today
1058
+ // only injects textual rules, same as Go's InjectWindsurfHooks); and Amazon Q
1059
+ // Developer's `preToolUse`/`execute_bash` hook + `deniedCommands` regex +
1060
+ // restricted custom-agent toolset (no Amazon Q hook generator exists in this
1061
+ // module today — only the textual `.amazonq/developer/guidelines.md` rules
1062
+ // generator in generators/init.js — matching Go, which also has no
1063
+ // InjectAmazonQHooks yet). Each of these requires a NEW file-format decision
1064
+ // this roadmap wave has not yet settled on either stack; building one from
1065
+ // scratch here, ahead of that decision, risks diverging from whatever the Go
1066
+ // side lands on. Flagged for a follow-up ML once that decision is made.
1067
+ const GBG_CMD_CLAUDE = '$CLAUDE_PROJECT_DIR/scripts/trackfw-git-branch-guard.sh'
1068
+ const GBG_CMD_CODEX = CODEX_ROOT + '/scripts/trackfw-git-branch-guard.sh"'
1069
+ const GBG_CMD_GEMINI = '$GEMINI_PROJECT_DIR/scripts/trackfw-git-branch-guard.sh'
1070
+ const GBG_CMD_CURSOR = 'scripts/trackfw-git-branch-guard.sh'
1071
+ const GBG_CMD_COPILOT = 'scripts/trackfw-git-branch-guard.sh'
1072
+ const GBG_CMD_PLAIN = 'scripts/trackfw-git-branch-guard.sh'
1073
+ // GBG_CMD_WINDSURF -- Windsurf invokes hooks.pre_run_command entries via a shell (confirmed
1074
+ // against https://docs.devin.ai/desktop/cascade/hooks), so the guard script is wrapped in
1075
+ // `bash <path>` rather than invoked directly, unlike every other CLI above. Mirrors Go's
1076
+ // windsurfGitGuardCmd (internal/generators/agentfiles.go).
1077
+ const GBG_CMD_WINDSURF = 'bash scripts/trackfw-git-branch-guard.sh'
1078
+
524
1079
  // ---------------------------------------------------------------------------
525
1080
  // Global credential-guard dedup (ROADMAP-2026-08-06 Wave 3/ML-3A)
526
1081
  // ---------------------------------------------------------------------------
@@ -556,17 +1111,95 @@ function readGlobalHookJSON(...relParts) {
556
1111
  }
557
1112
  }
558
1113
 
559
- /** 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
+ */
560
1171
  function hookArrayHasCommand(existing, matcher, command) {
561
1172
  const arr = Array.isArray(existing) ? existing : []
562
1173
  for (const item of arr) {
563
1174
  if (!item || item.matcher !== matcher) continue
564
1175
  const inner = Array.isArray(item.hooks) ? item.hooks : []
565
- 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
566
1177
  }
567
1178
  return false
568
1179
  }
569
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
+
570
1203
  function globalCredentialGuardInstalledClaude() {
571
1204
  const scriptPath = globalCredentialGuardScriptPath()
572
1205
  if (!scriptPath) return false
@@ -596,7 +1229,7 @@ function globalCredentialGuardInstalledCursor() {
596
1229
  if (!scriptPath) return false
597
1230
  const root = readGlobalHookJSON('.cursor', 'hooks.json')
598
1231
  if (!root || !root.hooks) return false
599
- return hasEntry(root.hooks.beforeShellExecution, 'command', scriptPath)
1232
+ return hasEntryPath(root.hooks.beforeShellExecution, 'command', scriptPath, false)
600
1233
  }
601
1234
 
602
1235
  function globalCredentialGuardInstalledCopilot() {
@@ -604,7 +1237,7 @@ function globalCredentialGuardInstalledCopilot() {
604
1237
  if (!scriptPath) return false
605
1238
  const root = readGlobalHookJSON('.copilot', 'settings.json')
606
1239
  if (!root || !root.hooks) return false
607
- return hasEntry(root.hooks.preToolUse, 'bash', scriptPath)
1240
+ return hasEntryPath(root.hooks.preToolUse, 'bash', scriptPath, true)
608
1241
  }
609
1242
 
610
1243
  /**
@@ -624,6 +1257,68 @@ function globalCredentialGuardInstalledKiro() {
624
1257
  }
625
1258
  }
626
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
+
627
1322
  // ---------------------------------------------------------------------------
628
1323
  // generateAttentionScripts — writes the two shell scripts to scripts/
629
1324
  // ---------------------------------------------------------------------------
@@ -689,6 +1384,15 @@ function injectClaudeHooks(cwd) {
689
1384
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Write|Edit', GUARD_CMD_CLAUDE)
690
1385
  }
691
1386
 
1387
+ // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 1): PreToolUse-only, matcher
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
+ }
1395
+
692
1396
  writeJSON(filePath, data)
693
1397
  }
694
1398
 
@@ -742,6 +1446,14 @@ function injectCodexHooks(cwd) {
742
1446
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'apply_patch', GUARD_CMD_CODEX)
743
1447
  }
744
1448
 
1449
+ // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 2): PreToolUse-only, matcher
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
+ }
1456
+
745
1457
  writeJSON(filePath, data)
746
1458
  }
747
1459
 
@@ -808,6 +1520,14 @@ function injectGeminiHooks(cwd) {
808
1520
  data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'write_file|replace', GUARD_CMD_GEMINI)
809
1521
  }
810
1522
 
1523
+ // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 3): BeforeTool-only, matcher
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
+ }
1530
+
811
1531
  writeJSON(filePath, data)
812
1532
  }
813
1533
 
@@ -958,6 +1678,23 @@ function injectCopilotHooks(cwd) {
958
1678
  postToolUse.push({ type: 'command', matcher: 'create|edit', bash: GUARD_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
959
1679
  }
960
1680
 
1681
+ // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 4): the roadmap describes
1682
+ // `--deny-tool='shell(git commit)'`-style CLI flags in a permissions-config.json/
1683
+ // settings.json file, but no such file/flag mechanism exists anywhere else in this
1684
+ // module -- Copilot's only established deny-adjacent mechanism here is this same
1685
+ // preToolUse/postToolUse hooks file already used for credential-guard above. Mirrors
1686
+ // Go's InjectCopilotHooks (internal/generators/agentfiles.go), which made the same
1687
+ // choice for the same reason; the CLI-flag/permissions-config.json approach is a
1688
+ // documented, deliberate divergence from the roadmap's literal wording. This file is
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
+ }
1697
+
961
1698
  const data = {
962
1699
  version: 1,
963
1700
  hooks: { preToolUse, postToolUse },
@@ -1090,16 +1827,186 @@ function injectCursorHooks(cwd) {
1090
1827
  }
1091
1828
  }
1092
1829
 
1830
+ // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 5): beforeShellExecution-only --
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
+ }
1844
+ }
1845
+
1093
1846
  writeJSON(filePath, data)
1094
1847
  }
1095
1848
 
1096
1849
  // ---------------------------------------------------------------------------
1097
- // Windsurf — update .windsurfrules with attention instruction
1850
+ // Windsurf — update .windsurfrules with attention instruction, and register
1851
+ // the `pre_run_command` git-branch-guard hook in Windsurf's real hooks file.
1098
1852
  // ---------------------------------------------------------------------------
1853
+ // Path/schema correction (apolo-tf, 2026-08-14, post-ML-3A audit): the
1854
+ // original ML-3A implementation wrote a dedicated, wholly-owned file at
1855
+ // `.windsurf/hooks/trackfw-git-branch-guard.json` with an invented payload
1856
+ // shape (`{"version":1,"hooks":[{"name":...,"trigger":"pre_run_command",
1857
+ // "action":{...}}]}`) that was flagged in its own doc comment as UNCONFIRMED
1858
+ // against official documentation. A verification pass against
1859
+ // https://docs.devin.ai/desktop/cascade/hooks confirmed both the path and
1860
+ // the shape were wrong:
1861
+ // - Windsurf reads hooks from a single fixed-name file, `.windsurf/hooks.json`
1862
+ // -- NOT a directory of per-hook files under `.windsurf/hooks/`.
1863
+ // - The schema is `{"hooks": {"<event>": [{"command": "...", "show_output":
1864
+ // bool}]}}` -- an object keyed by event name (e.g. "pre_run_command",
1865
+ // "post_run_command"), each mapping to an ARRAY of hook defs. There is no
1866
+ // "name"/"trigger"/"action" envelope.
1867
+ // - The hook script receives its context via stdin as JSON, including
1868
+ // `tool_info.command_line` -- GIT_BRANCH_GUARD_SCRIPT above now tries
1869
+ // this field explicitly (in addition to the generic `.command`/
1870
+ // `.tool_input.command`/`.hook_input.command` fields it already
1871
+ // handled). This is a byte-shared script across all 3 CLIs -- do not
1872
+ // edit it again without mirroring Go's gitBranchGuardScript.
1873
+ //
1874
+ // Merge is idempotent and shaped like every other multi-tool settings file in
1875
+ // this module: existing `pre_run_command` entries from other tools (or a
1876
+ // prior trackfw run) are preserved; only an entry with our exact command
1877
+ // string is deduped via mergeWindsurfHookArray. Other events already present
1878
+ // (e.g. a user- or third-party-authored `post_run_command`) are left
1879
+ // untouched.
1880
+ //
1881
+ // Migration: if the stale `.windsurf/hooks/trackfw-git-branch-guard.json`
1882
+ // file from the incorrect ML-3A version exists on disk, it is removed here
1883
+ // (never left orphaned), and the now-possibly-empty legacy `.windsurf/hooks`
1884
+ // directory is best-effort cleaned up too -- mirrors Go's InjectWindsurfHooks
1885
+ // migration step exactly (os.Remove + best-effort rmdir).
1886
+ //
1887
+ // `windsurf.cascadeCommandsAllowList` (an IDE *user settings* key, not a
1888
+ // project-local file) remains out of scope -- same reasoning as before this
1889
+ // fix: trackfw has no established, confirmed mechanism for rewriting IDE user
1890
+ // settings safely, and inventing one on a guess repeats the exact mistake
1891
+ // this fix corrects. Documented as an open gap in docs/cli-parity.md.
1892
+ const LEGACY_WINDSURF_HOOKS_FILE = 'trackfw-git-branch-guard.json'
1099
1893
 
1100
1894
  function injectWindsurfHooks(cwd) {
1101
1895
  const { injectRulesForTool } = require('./init')
1102
- return injectRulesForTool('windsurf', cwd)
1896
+ injectRulesForTool('windsurf', cwd)
1897
+
1898
+ // Migration: remove the incorrect, previously-written dedicated hook file
1899
+ // from an older (buggy) trackfw run, so it doesn't linger as a dead,
1900
+ // never-consumed artifact once the correct .windsurf/hooks.json exists.
1901
+ const legacyDir = path.join(cwd, '.windsurf', 'hooks')
1902
+ const legacyPath = path.join(legacyDir, LEGACY_WINDSURF_HOOKS_FILE)
1903
+ try {
1904
+ fs.unlinkSync(legacyPath)
1905
+ } catch (e) {
1906
+ if (e.code !== 'ENOENT') throw e
1907
+ }
1908
+ // Best-effort cleanup of the now-possibly-empty legacy directory; ignore
1909
+ // failure (non-empty dir, e.g. holding unrelated user files, or already
1910
+ // gone) -- never fatal.
1911
+ try {
1912
+ fs.rmdirSync(legacyDir)
1913
+ } catch (_) {
1914
+ // ignore
1915
+ }
1916
+
1917
+ const dir = path.join(cwd, '.windsurf')
1918
+ fs.mkdirSync(dir, { recursive: true })
1919
+ const filePath = path.join(dir, 'hooks.json')
1920
+ const data = readJSON(filePath)
1921
+
1922
+ if (typeof data.hooks !== 'object' || data.hooks === null || Array.isArray(data.hooks)) {
1923
+ data.hooks = {}
1924
+ }
1925
+
1926
+ data.hooks.pre_run_command = mergeWindsurfHookArray(data.hooks.pre_run_command, GBG_CMD_WINDSURF, true)
1927
+
1928
+ writeJSON(filePath, data)
1929
+ }
1930
+
1931
+ // ---------------------------------------------------------------------------
1932
+ // Amazon Q Developer CLI — .amazonq/cli-agents/q_cli_default.json
1933
+ // (ROADMAP-2026-08-14, ML-3B/Wave 3, step 7)
1934
+ // ---------------------------------------------------------------------------
1935
+ // Path/schema correction (apolo-tf, 2026-08-14, post-ML-3A audit): the
1936
+ // original ML-3A implementation wrote to `.amazonq/settings.json`, which was
1937
+ // flagged in its own doc comment as NOT independently confirmed against
1938
+ // official Amazon Q documentation. Verification against
1939
+ // https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-custom-agents-configuration.html
1940
+ // and https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-agents-default-behavior.html
1941
+ // confirmed the real mechanism is a *custom agent* file, not a settings
1942
+ // file: `.amazonq/cli-agents/q_cli_default.json` -- `q_cli_default.json` is
1943
+ // the documented convention closest to activating automatically without
1944
+ // requiring a manual `--agent` flag, though AWS has an open bug where this
1945
+ // default-name override is not always honored
1946
+ // (github.com/aws/amazon-q-developer-cli#2922) -- a custom agent named
1947
+ // `q_cli_default.json` is not guaranteed to always be picked up
1948
+ // automatically depending on CLI version/config. Documented rather than
1949
+ // worked around.
1950
+ //
1951
+ // Two guard mechanisms are wired, unchanged from the original ML-3A
1952
+ // implementation -- only the target file (and the minimal-but-valid custom
1953
+ // agent envelope around it) moved:
1954
+ // - hooks.preToolUse[matcher:"execute_bash"] -> the guard script, same
1955
+ // matcher+hooks[].command shape already used by Claude/Codex/Gemini
1956
+ // above (reuses mergeClaudeHookArray for idempotent merge).
1957
+ // - toolsSettings.execute_bash.deniedCommands -> a regex denylist
1958
+ // evaluated before allow, independent of and in addition to the hook.
1959
+ //
1960
+ // Minimal-but-valid custom agent schema (per command-line-custom-agents-
1961
+ // configuration.html): only set defaults for fields not already present, so
1962
+ // re-running against a hand-edited or previously-generated file never
1963
+ // clobbers user customization -- same "preserve existing settings" contract
1964
+ // as every other merge-based injector in this module. `tools: ["*"]` is
1965
+ // written on first creation so the default agent keeps today's unrestricted
1966
+ // tool access (this fix does not narrow what any agent can do, only where
1967
+ // the deny wiring lives). Mirrors Go's InjectAmazonQHooks
1968
+ // (internal/generators/agentfiles.go) field-for-field.
1969
+ //
1970
+ // Native custom-agent toolset restriction (REQ acceptance criterion): still
1971
+ // NOT implemented here -- this ML only wires the guard/deny fields on the
1972
+ // one default agent file; per-specialist-agent toolset restriction is out of
1973
+ // scope, same limitation already accepted for Gemini above.
1974
+ const AMAZONQ_CLI_AGENTS_DIR = 'cli-agents'
1975
+ const AMAZONQ_DEFAULT_AGENT_FILE = 'q_cli_default.json'
1976
+ const GBG_DENIED_COMMANDS_PATTERN = '^git (commit|push|checkout -b)'
1977
+
1978
+ function injectAmazonQHooks(cwd) {
1979
+ const filePath = path.join(cwd, '.amazonq', AMAZONQ_CLI_AGENTS_DIR, AMAZONQ_DEFAULT_AGENT_FILE)
1980
+ const data = readJSON(filePath)
1981
+
1982
+ const defaults = {
1983
+ name: 'q_cli_default',
1984
+ description: 'trackfw-managed default agent — wires the git branch guard hook/denylist. See docs/cli-parity.md.',
1985
+ prompt: null,
1986
+ mcpServers: {},
1987
+ tools: ['*'],
1988
+ toolAliases: {},
1989
+ allowedTools: [],
1990
+ resources: [],
1991
+ useLegacyMcpJson: false,
1992
+ }
1993
+ for (const [k, v] of Object.entries(defaults)) {
1994
+ if (!Object.prototype.hasOwnProperty.call(data, k)) data[k] = v
1995
+ }
1996
+
1997
+ if (!data.hooks) data.hooks = {}
1998
+ data.hooks.preToolUse = mergeClaudeHookArray(data.hooks.preToolUse, 'execute_bash', GBG_CMD_PLAIN)
1999
+
2000
+ if (!data.toolsSettings) data.toolsSettings = {}
2001
+ if (!data.toolsSettings.execute_bash) data.toolsSettings.execute_bash = {}
2002
+ if (!Array.isArray(data.toolsSettings.execute_bash.deniedCommands)) {
2003
+ data.toolsSettings.execute_bash.deniedCommands = []
2004
+ }
2005
+ if (!data.toolsSettings.execute_bash.deniedCommands.includes(GBG_DENIED_COMMANDS_PATTERN)) {
2006
+ data.toolsSettings.execute_bash.deniedCommands.push(GBG_DENIED_COMMANDS_PATTERN)
2007
+ }
2008
+
2009
+ writeJSON(filePath, data)
1103
2010
  }
1104
2011
 
1105
2012
  // ---------------------------------------------------------------------------
@@ -1146,6 +2053,13 @@ function injectHooksDetected(cwd) {
1146
2053
  check: () => fs.existsSync(path.join(root, '.windsurfrules')),
1147
2054
  fn: injectWindsurfHooks,
1148
2055
  },
2056
+ // amazonq (ROADMAP-2026-08-14, ML-3B/Wave 3, step 7 dispatch): mirrors Go's
2057
+ // hooks.go InjectHooksDetected entry -- dispatches injectAmazonQHooks (git branch
2058
+ // guard, .amazonq/settings.json) whenever the .amazonq directory is present.
2059
+ amazonq: {
2060
+ check: () => fs.existsSync(path.join(root, '.amazonq')),
2061
+ fn: injectAmazonQHooks,
2062
+ },
1149
2063
  }
1150
2064
 
1151
2065
  for (const [name, { check, fn }] of Object.entries(detections)) {
@@ -1162,6 +2076,8 @@ module.exports = {
1162
2076
  generateAttentionScripts,
1163
2077
  generateCredentialGuardScript,
1164
2078
  generateGlobalCredentialGuardScript,
2079
+ generateGitBranchGuardScript,
2080
+ generateGlobalGitBranchGuardScript,
1165
2081
  injectClaudeHooks,
1166
2082
  injectCodexHooks,
1167
2083
  injectGeminiHooks,
@@ -1169,8 +2085,11 @@ module.exports = {
1169
2085
  injectCopilotHooks,
1170
2086
  injectCursorHooks,
1171
2087
  injectWindsurfHooks,
2088
+ injectAmazonQHooks,
1172
2089
  injectHooksDetected,
1173
2090
  mergeClaudeHookArray,
1174
2091
  mergeSimpleCommandArray,
1175
2092
  mergeCopilotHookArray,
2093
+ normalizeGuardPath,
2094
+ samePathCommand,
1176
2095
  }