trackfw 6.10.0 → 7.0.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.
@@ -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,218 @@ 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 brutos por subagente
476
+ set -euo pipefail
477
+ set -f
478
+
479
+ # --- 1. Obter o comando git bruto ------------------------------------------------------------
480
+ if [ "$#" -gt 0 ]; then
481
+ CMD_RAW="$*"
482
+ else
483
+ INPUT=$(cat 2>/dev/null || true)
484
+ TRIMMED=$(printf '%s' "$INPUT" | sed -e 's/^[[:space:]]*//')
485
+ case "$TRIMMED" in
486
+ \\{*)
487
+ CMD_RAW=""
488
+ if command -v jq >/dev/null 2>&1; then
489
+ CMD_RAW=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // .command // .tool_info.command_line // .hook_input.command // empty' 2>/dev/null || true)
490
+ fi
491
+ if [ -z "$CMD_RAW" ] || [ "$CMD_RAW" = "null" ]; then
492
+ CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"tool_input"[[:space:]]*:[[:space:]]*{[^}]*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
493
+ fi
494
+ if [ -z "$CMD_RAW" ]; then
495
+ CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"tool_info"[[:space:]]*:[[:space:]]*{[^}]*"command_line"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
496
+ fi
497
+ if [ -z "$CMD_RAW" ]; then
498
+ CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"hook_input"[[:space:]]*:[[:space:]]*{[^}]*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
499
+ fi
500
+ if [ -z "$CMD_RAW" ]; then
501
+ CMD_RAW=$(printf '%s' "$INPUT" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)
502
+ fi
503
+ ;;
504
+ *)
505
+ CMD_RAW="$INPUT"
506
+ ;;
507
+ esac
508
+ fi
509
+
510
+ if [ -z "$CMD_RAW" ]; then
511
+ CMD_RAW="\${TRACKFW_GIT_COMMAND:-}"
512
+ fi
513
+
514
+ [ -n "$CMD_RAW" ] || exit 0
515
+
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.
523
+ match_subcommand() {
524
+ normalized=$(printf '%s' "$1" | sed -e 's/&&/\\n/g' -e 's/||/\\n/g' -e 's/[;|]/\\n/g')
525
+ while IFS= read -r seg; do
526
+ seg_trimmed=$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//')
527
+ [ -n "$seg_trimmed" ] || continue
528
+
529
+ set -- $seg_trimmed
530
+ first="$1"
531
+ base="\${first##*/}"
532
+ [ "$base" = "git" ] || continue
533
+ shift
534
+
535
+ sub=""
536
+ while [ "$#" -gt 0 ]; do
537
+ tok="$1"
538
+ case "$tok" in
539
+ -C|-c|--work-tree|--git-dir|--namespace)
540
+ if [ "$#" -ge 2 ]; then shift 2; else shift; fi
541
+ continue
542
+ ;;
543
+ -*)
544
+ shift
545
+ continue
546
+ ;;
547
+ *)
548
+ sub="$tok"
549
+ shift
550
+ break
551
+ ;;
552
+ esac
553
+ done
554
+
555
+ case "$sub" in
556
+ commit)
557
+ echo "commit"
558
+ return 0
559
+ ;;
560
+ push)
561
+ echo "push"
562
+ return 0
563
+ ;;
564
+ checkout)
565
+ if [ "\${1:-}" = "-b" ]; then
566
+ echo "checkout-b"
567
+ return 0
568
+ fi
569
+ ;;
570
+ esac
571
+ done <<EOF
572
+ $normalized
573
+ EOF
574
+ return 1
575
+ }
576
+
577
+ SUBCOMMAND=$(match_subcommand "$CMD_RAW") || exit 0
578
+
579
+ case "$SUBCOMMAND" in
580
+ checkout-b)
581
+ REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
582
+ ;;
583
+ commit)
584
+ REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
585
+ ;;
586
+ push)
587
+ REASON="trackfw: git push bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw ship` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
588
+ ;;
589
+ *)
590
+ exit 0
591
+ ;;
592
+ esac
593
+
594
+ printf '{"decision":"block","reason":"%s"}\\n' "$REASON"
595
+ echo "$REASON" >&2
596
+ exit 2
597
+ `
598
+
599
+ // ---------------------------------------------------------------------------
600
+ // generateGitBranchGuardScript — writes scripts/trackfw-git-branch-guard.sh
601
+ // ---------------------------------------------------------------------------
602
+ // This ML only creates the script — it is NOT wired into any hooks.json/
603
+ // settings.json here (that is this roadmap's Wave 3 scope). Mirrors
604
+ // generateCredentialGuardScript exactly (mkdirSync + writeFileSync, mode 0o755).
605
+ function generateGitBranchGuardScript(cwd) {
606
+ const root = cwd || process.cwd()
607
+ const scriptsDir = path.join(root, 'scripts')
608
+ fs.mkdirSync(scriptsDir, { recursive: true })
609
+
610
+ const scriptPath = path.join(scriptsDir, 'trackfw-git-branch-guard.sh')
611
+ fs.writeFileSync(scriptPath, GIT_BRANCH_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
612
+
613
+ console.log(' ✓ scripts/trackfw-git-branch-guard.sh')
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // generateGlobalGitBranchGuardScript — writes <home>/.trackfw/scripts/trackfw-git-branch-guard.sh
618
+ // ---------------------------------------------------------------------------
619
+ // Destinado a ser referenciado por hooks globais de CLI, instalados via
620
+ // `trackfw update harness` -- não é chamado por `trackfw init`/`trackfw
621
+ // update` (escopo de projeto), que continuam usando
622
+ // generateGitBranchGuardScript. Mirrors generateGlobalCredentialGuardScript.
623
+ function generateGlobalGitBranchGuardScript(home) {
624
+ if (!home) {
625
+ throw new Error('home directory vazio')
626
+ }
627
+ const scriptsDir = path.join(home, '.trackfw', 'scripts')
628
+ fs.mkdirSync(scriptsDir, { recursive: true })
629
+
630
+ const scriptPath = path.join(scriptsDir, 'trackfw-git-branch-guard.sh')
631
+ fs.writeFileSync(scriptPath, GIT_BRANCH_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
632
+
633
+ console.log(' ✓ .trackfw/scripts/trackfw-git-branch-guard.sh')
634
+ }
635
+
407
636
  // ---------------------------------------------------------------------------
408
637
  // generateCredentialGuardScript — writes scripts/trackfw-credential-guard.sh
409
638
  // ---------------------------------------------------------------------------
@@ -521,6 +750,53 @@ const GUARD_CMD_CURSOR = 'scripts/trackfw-credential-guard.sh'
521
750
  // successfully in practice.
522
751
  const GUARD_CMD_CLAUDE = '$CLAUDE_PROJECT_DIR/scripts/trackfw-credential-guard.sh'
523
752
 
753
+ // ---------------------------------------------------------------------------
754
+ // GBG_CMD_* — git-branch-guard command per CLI (ROADMAP-2026-08-14, ML-3B,
755
+ // Wave 3). Same path-resolution mechanism per runtime already established
756
+ // above for GUARD_CMD_*/SIGNAL_CMD_* (Claude's $CLAUDE_PROJECT_DIR pin,
757
+ // Codex's `$(git rev-parse --show-toplevel)` substitution, Gemini's
758
+ // $GEMINI_PROJECT_DIR, Copilot/Cursor's project-root-relative path) — the
759
+ // git branch guard has no per-CLI variance of its own, it just needs the
760
+ // same "always resolve to the project root regardless of cwd drift" fix
761
+ // each of those constants already encodes for the credential guard.
762
+ //
763
+ // Wiring scope of this ML (Claude/Codex/Gemini/Cursor): registers the guard
764
+ // against the SAME hook events/matchers already used for the credential
765
+ // guard in each of these 4 CLIs' existing merge-based hooks.json/
766
+ // settings.json contract — a `Bash`/`run_shell_command`/`beforeShellExecution`
767
+ // PreToolUse-equivalent entry is sufficient to intercept `git commit`/`push`/
768
+ // `checkout -b` before they execute; no PostToolUse entry is added (unlike
769
+ // the credential guard) because blocking after the git command already ran
770
+ // is too late to be useful.
771
+ //
772
+ // NOT implemented in this ML (documented gap, mirrors the Go side of this
773
+ // same roadmap wave, which also has not built these yet): GitHub Copilot's
774
+ // `--deny-tool='shell(git commit)'`-style entries in a dedicated
775
+ // permissions-config.json/settings.json (a different mechanism than the
776
+ // existing `.github/hooks/trackfw-attention.json` hooks file this module
777
+ // already generates for Copilot); Windsurf's `pre_run_command` hook +
778
+ // `windsurf.cascadeCommandsAllowList` deny entry (injectWindsurfHooks today
779
+ // only injects textual rules, same as Go's InjectWindsurfHooks); and Amazon Q
780
+ // Developer's `preToolUse`/`execute_bash` hook + `deniedCommands` regex +
781
+ // restricted custom-agent toolset (no Amazon Q hook generator exists in this
782
+ // module today — only the textual `.amazonq/developer/guidelines.md` rules
783
+ // generator in generators/init.js — matching Go, which also has no
784
+ // InjectAmazonQHooks yet). Each of these requires a NEW file-format decision
785
+ // this roadmap wave has not yet settled on either stack; building one from
786
+ // scratch here, ahead of that decision, risks diverging from whatever the Go
787
+ // side lands on. Flagged for a follow-up ML once that decision is made.
788
+ const GBG_CMD_CLAUDE = '$CLAUDE_PROJECT_DIR/scripts/trackfw-git-branch-guard.sh'
789
+ const GBG_CMD_CODEX = CODEX_ROOT + '/scripts/trackfw-git-branch-guard.sh"'
790
+ const GBG_CMD_GEMINI = '$GEMINI_PROJECT_DIR/scripts/trackfw-git-branch-guard.sh'
791
+ const GBG_CMD_CURSOR = 'scripts/trackfw-git-branch-guard.sh'
792
+ const GBG_CMD_COPILOT = 'scripts/trackfw-git-branch-guard.sh'
793
+ const GBG_CMD_PLAIN = 'scripts/trackfw-git-branch-guard.sh'
794
+ // GBG_CMD_WINDSURF -- Windsurf invokes hooks.pre_run_command entries via a shell (confirmed
795
+ // against https://docs.devin.ai/desktop/cascade/hooks), so the guard script is wrapped in
796
+ // `bash <path>` rather than invoked directly, unlike every other CLI above. Mirrors Go's
797
+ // windsurfGitGuardCmd (internal/generators/agentfiles.go).
798
+ const GBG_CMD_WINDSURF = 'bash scripts/trackfw-git-branch-guard.sh'
799
+
524
800
  // ---------------------------------------------------------------------------
525
801
  // Global credential-guard dedup (ROADMAP-2026-08-06 Wave 3/ML-3A)
526
802
  // ---------------------------------------------------------------------------
@@ -689,6 +965,13 @@ function injectClaudeHooks(cwd) {
689
965
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Write|Edit', GUARD_CMD_CLAUDE)
690
966
  }
691
967
 
968
+ // 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)
974
+
692
975
  writeJSON(filePath, data)
693
976
  }
694
977
 
@@ -742,6 +1025,11 @@ function injectCodexHooks(cwd) {
742
1025
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'apply_patch', GUARD_CMD_CODEX)
743
1026
  }
744
1027
 
1028
+ // 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)
1032
+
745
1033
  writeJSON(filePath, data)
746
1034
  }
747
1035
 
@@ -808,6 +1096,11 @@ function injectGeminiHooks(cwd) {
808
1096
  data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'write_file|replace', GUARD_CMD_GEMINI)
809
1097
  }
810
1098
 
1099
+ // 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)
1103
+
811
1104
  writeJSON(filePath, data)
812
1105
  }
813
1106
 
@@ -958,6 +1251,18 @@ function injectCopilotHooks(cwd) {
958
1251
  postToolUse.push({ type: 'command', matcher: 'create|edit', bash: GUARD_CMD_COPILOT, cwd: '.', timeoutSec: 10 })
959
1252
  }
960
1253
 
1254
+ // git branch guard (ROADMAP-2026-08-14, ML-3B/Wave 3, step 4): the roadmap describes
1255
+ // `--deny-tool='shell(git commit)'`-style CLI flags in a permissions-config.json/
1256
+ // settings.json file, but no such file/flag mechanism exists anywhere else in this
1257
+ // module -- Copilot's only established deny-adjacent mechanism here is this same
1258
+ // preToolUse/postToolUse hooks file already used for credential-guard above. Mirrors
1259
+ // Go's InjectCopilotHooks (internal/generators/agentfiles.go), which made the same
1260
+ // choice for the same reason; the CLI-flag/permissions-config.json approach is a
1261
+ // 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 })
1265
+
961
1266
  const data = {
962
1267
  version: 1,
963
1268
  hooks: { preToolUse, postToolUse },
@@ -1090,16 +1395,178 @@ function injectCursorHooks(cwd) {
1090
1395
  }
1091
1396
  }
1092
1397
 
1398
+ // 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 })
1404
+ }
1405
+
1093
1406
  writeJSON(filePath, data)
1094
1407
  }
1095
1408
 
1096
1409
  // ---------------------------------------------------------------------------
1097
- // Windsurf — update .windsurfrules with attention instruction
1410
+ // Windsurf — update .windsurfrules with attention instruction, and register
1411
+ // the `pre_run_command` git-branch-guard hook in Windsurf's real hooks file.
1098
1412
  // ---------------------------------------------------------------------------
1413
+ // Path/schema correction (apolo-tf, 2026-08-14, post-ML-3A audit): the
1414
+ // original ML-3A implementation wrote a dedicated, wholly-owned file at
1415
+ // `.windsurf/hooks/trackfw-git-branch-guard.json` with an invented payload
1416
+ // shape (`{"version":1,"hooks":[{"name":...,"trigger":"pre_run_command",
1417
+ // "action":{...}}]}`) that was flagged in its own doc comment as UNCONFIRMED
1418
+ // against official documentation. A verification pass against
1419
+ // https://docs.devin.ai/desktop/cascade/hooks confirmed both the path and
1420
+ // the shape were wrong:
1421
+ // - Windsurf reads hooks from a single fixed-name file, `.windsurf/hooks.json`
1422
+ // -- NOT a directory of per-hook files under `.windsurf/hooks/`.
1423
+ // - The schema is `{"hooks": {"<event>": [{"command": "...", "show_output":
1424
+ // bool}]}}` -- an object keyed by event name (e.g. "pre_run_command",
1425
+ // "post_run_command"), each mapping to an ARRAY of hook defs. There is no
1426
+ // "name"/"trigger"/"action" envelope.
1427
+ // - The hook script receives its context via stdin as JSON, including
1428
+ // `tool_info.command_line` -- GIT_BRANCH_GUARD_SCRIPT above now tries
1429
+ // this field explicitly (in addition to the generic `.command`/
1430
+ // `.tool_input.command`/`.hook_input.command` fields it already
1431
+ // handled). This is a byte-shared script across all 3 CLIs -- do not
1432
+ // edit it again without mirroring Go's gitBranchGuardScript.
1433
+ //
1434
+ // Merge is idempotent and shaped like every other multi-tool settings file in
1435
+ // this module: existing `pre_run_command` entries from other tools (or a
1436
+ // prior trackfw run) are preserved; only an entry with our exact command
1437
+ // string is deduped via mergeWindsurfHookArray. Other events already present
1438
+ // (e.g. a user- or third-party-authored `post_run_command`) are left
1439
+ // untouched.
1440
+ //
1441
+ // Migration: if the stale `.windsurf/hooks/trackfw-git-branch-guard.json`
1442
+ // file from the incorrect ML-3A version exists on disk, it is removed here
1443
+ // (never left orphaned), and the now-possibly-empty legacy `.windsurf/hooks`
1444
+ // directory is best-effort cleaned up too -- mirrors Go's InjectWindsurfHooks
1445
+ // migration step exactly (os.Remove + best-effort rmdir).
1446
+ //
1447
+ // `windsurf.cascadeCommandsAllowList` (an IDE *user settings* key, not a
1448
+ // project-local file) remains out of scope -- same reasoning as before this
1449
+ // fix: trackfw has no established, confirmed mechanism for rewriting IDE user
1450
+ // settings safely, and inventing one on a guess repeats the exact mistake
1451
+ // this fix corrects. Documented as an open gap in docs/cli-parity.md.
1452
+ const LEGACY_WINDSURF_HOOKS_FILE = 'trackfw-git-branch-guard.json'
1099
1453
 
1100
1454
  function injectWindsurfHooks(cwd) {
1101
1455
  const { injectRulesForTool } = require('./init')
1102
- return injectRulesForTool('windsurf', cwd)
1456
+ injectRulesForTool('windsurf', cwd)
1457
+
1458
+ // Migration: remove the incorrect, previously-written dedicated hook file
1459
+ // from an older (buggy) trackfw run, so it doesn't linger as a dead,
1460
+ // never-consumed artifact once the correct .windsurf/hooks.json exists.
1461
+ const legacyDir = path.join(cwd, '.windsurf', 'hooks')
1462
+ const legacyPath = path.join(legacyDir, LEGACY_WINDSURF_HOOKS_FILE)
1463
+ try {
1464
+ fs.unlinkSync(legacyPath)
1465
+ } catch (e) {
1466
+ if (e.code !== 'ENOENT') throw e
1467
+ }
1468
+ // Best-effort cleanup of the now-possibly-empty legacy directory; ignore
1469
+ // failure (non-empty dir, e.g. holding unrelated user files, or already
1470
+ // gone) -- never fatal.
1471
+ try {
1472
+ fs.rmdirSync(legacyDir)
1473
+ } catch (_) {
1474
+ // ignore
1475
+ }
1476
+
1477
+ const dir = path.join(cwd, '.windsurf')
1478
+ fs.mkdirSync(dir, { recursive: true })
1479
+ const filePath = path.join(dir, 'hooks.json')
1480
+ const data = readJSON(filePath)
1481
+
1482
+ if (typeof data.hooks !== 'object' || data.hooks === null || Array.isArray(data.hooks)) {
1483
+ data.hooks = {}
1484
+ }
1485
+
1486
+ data.hooks.pre_run_command = mergeWindsurfHookArray(data.hooks.pre_run_command, GBG_CMD_WINDSURF, true)
1487
+
1488
+ writeJSON(filePath, data)
1489
+ }
1490
+
1491
+ // ---------------------------------------------------------------------------
1492
+ // Amazon Q Developer CLI — .amazonq/cli-agents/q_cli_default.json
1493
+ // (ROADMAP-2026-08-14, ML-3B/Wave 3, step 7)
1494
+ // ---------------------------------------------------------------------------
1495
+ // Path/schema correction (apolo-tf, 2026-08-14, post-ML-3A audit): the
1496
+ // original ML-3A implementation wrote to `.amazonq/settings.json`, which was
1497
+ // flagged in its own doc comment as NOT independently confirmed against
1498
+ // official Amazon Q documentation. Verification against
1499
+ // https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-custom-agents-configuration.html
1500
+ // and https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-agents-default-behavior.html
1501
+ // confirmed the real mechanism is a *custom agent* file, not a settings
1502
+ // file: `.amazonq/cli-agents/q_cli_default.json` -- `q_cli_default.json` is
1503
+ // the documented convention closest to activating automatically without
1504
+ // requiring a manual `--agent` flag, though AWS has an open bug where this
1505
+ // default-name override is not always honored
1506
+ // (github.com/aws/amazon-q-developer-cli#2922) -- a custom agent named
1507
+ // `q_cli_default.json` is not guaranteed to always be picked up
1508
+ // automatically depending on CLI version/config. Documented rather than
1509
+ // worked around.
1510
+ //
1511
+ // Two guard mechanisms are wired, unchanged from the original ML-3A
1512
+ // implementation -- only the target file (and the minimal-but-valid custom
1513
+ // agent envelope around it) moved:
1514
+ // - hooks.preToolUse[matcher:"execute_bash"] -> the guard script, same
1515
+ // matcher+hooks[].command shape already used by Claude/Codex/Gemini
1516
+ // above (reuses mergeClaudeHookArray for idempotent merge).
1517
+ // - toolsSettings.execute_bash.deniedCommands -> a regex denylist
1518
+ // evaluated before allow, independent of and in addition to the hook.
1519
+ //
1520
+ // Minimal-but-valid custom agent schema (per command-line-custom-agents-
1521
+ // configuration.html): only set defaults for fields not already present, so
1522
+ // re-running against a hand-edited or previously-generated file never
1523
+ // clobbers user customization -- same "preserve existing settings" contract
1524
+ // as every other merge-based injector in this module. `tools: ["*"]` is
1525
+ // written on first creation so the default agent keeps today's unrestricted
1526
+ // tool access (this fix does not narrow what any agent can do, only where
1527
+ // the deny wiring lives). Mirrors Go's InjectAmazonQHooks
1528
+ // (internal/generators/agentfiles.go) field-for-field.
1529
+ //
1530
+ // Native custom-agent toolset restriction (REQ acceptance criterion): still
1531
+ // NOT implemented here -- this ML only wires the guard/deny fields on the
1532
+ // one default agent file; per-specialist-agent toolset restriction is out of
1533
+ // scope, same limitation already accepted for Gemini above.
1534
+ const AMAZONQ_CLI_AGENTS_DIR = 'cli-agents'
1535
+ const AMAZONQ_DEFAULT_AGENT_FILE = 'q_cli_default.json'
1536
+ const GBG_DENIED_COMMANDS_PATTERN = '^git (commit|push|checkout -b)'
1537
+
1538
+ function injectAmazonQHooks(cwd) {
1539
+ const filePath = path.join(cwd, '.amazonq', AMAZONQ_CLI_AGENTS_DIR, AMAZONQ_DEFAULT_AGENT_FILE)
1540
+ const data = readJSON(filePath)
1541
+
1542
+ const defaults = {
1543
+ name: 'q_cli_default',
1544
+ description: 'trackfw-managed default agent — wires the git branch guard hook/denylist. See docs/cli-parity.md.',
1545
+ prompt: null,
1546
+ mcpServers: {},
1547
+ tools: ['*'],
1548
+ toolAliases: {},
1549
+ allowedTools: [],
1550
+ resources: [],
1551
+ useLegacyMcpJson: false,
1552
+ }
1553
+ for (const [k, v] of Object.entries(defaults)) {
1554
+ if (!Object.prototype.hasOwnProperty.call(data, k)) data[k] = v
1555
+ }
1556
+
1557
+ if (!data.hooks) data.hooks = {}
1558
+ data.hooks.preToolUse = mergeClaudeHookArray(data.hooks.preToolUse, 'execute_bash', GBG_CMD_PLAIN)
1559
+
1560
+ if (!data.toolsSettings) data.toolsSettings = {}
1561
+ if (!data.toolsSettings.execute_bash) data.toolsSettings.execute_bash = {}
1562
+ if (!Array.isArray(data.toolsSettings.execute_bash.deniedCommands)) {
1563
+ data.toolsSettings.execute_bash.deniedCommands = []
1564
+ }
1565
+ if (!data.toolsSettings.execute_bash.deniedCommands.includes(GBG_DENIED_COMMANDS_PATTERN)) {
1566
+ data.toolsSettings.execute_bash.deniedCommands.push(GBG_DENIED_COMMANDS_PATTERN)
1567
+ }
1568
+
1569
+ writeJSON(filePath, data)
1103
1570
  }
1104
1571
 
1105
1572
  // ---------------------------------------------------------------------------
@@ -1146,6 +1613,13 @@ function injectHooksDetected(cwd) {
1146
1613
  check: () => fs.existsSync(path.join(root, '.windsurfrules')),
1147
1614
  fn: injectWindsurfHooks,
1148
1615
  },
1616
+ // amazonq (ROADMAP-2026-08-14, ML-3B/Wave 3, step 7 dispatch): mirrors Go's
1617
+ // hooks.go InjectHooksDetected entry -- dispatches injectAmazonQHooks (git branch
1618
+ // guard, .amazonq/settings.json) whenever the .amazonq directory is present.
1619
+ amazonq: {
1620
+ check: () => fs.existsSync(path.join(root, '.amazonq')),
1621
+ fn: injectAmazonQHooks,
1622
+ },
1149
1623
  }
1150
1624
 
1151
1625
  for (const [name, { check, fn }] of Object.entries(detections)) {
@@ -1162,6 +1636,8 @@ module.exports = {
1162
1636
  generateAttentionScripts,
1163
1637
  generateCredentialGuardScript,
1164
1638
  generateGlobalCredentialGuardScript,
1639
+ generateGitBranchGuardScript,
1640
+ generateGlobalGitBranchGuardScript,
1165
1641
  injectClaudeHooks,
1166
1642
  injectCodexHooks,
1167
1643
  injectGeminiHooks,
@@ -1169,6 +1645,7 @@ module.exports = {
1169
1645
  injectCopilotHooks,
1170
1646
  injectCursorHooks,
1171
1647
  injectWindsurfHooks,
1648
+ injectAmazonQHooks,
1172
1649
  injectHooksDetected,
1173
1650
  mergeClaudeHookArray,
1174
1651
  mergeSimpleCommandArray,
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const os = require('os')
6
+ const { readAgentConventions } = require('../config/index.js')
6
7
 
7
8
  const GOV_DIRS = [
8
9
  'docs/adr',
@@ -34,6 +35,7 @@ async function scaffold(cfg, cwd) {
34
35
  generateValidateScript(cfg, root)
35
36
  generateAttentionScripts(cfg, root)
36
37
  generateCredentialGuardScript(root)
38
+ generateGitBranchGuardScript(root)
37
39
  generateCIWorkflow(cfg, root)
38
40
  generateGitHooks(cfg, root)
39
41
  generateCommitMsgHook(cfg, root)
@@ -174,6 +176,11 @@ function generateCredentialGuardScript(cwd) {
174
176
  gen(cwd || process.cwd())
175
177
  }
176
178
 
179
+ function generateGitBranchGuardScript(cwd) {
180
+ const { generateGitBranchGuardScript: gen } = require('./hooks')
181
+ gen(cwd || process.cwd())
182
+ }
183
+
177
184
  function injectHooksDetected(cwd) {
178
185
  const { injectHooksDetected: inject } = require('./hooks')
179
186
  inject(cwd || process.cwd())
@@ -392,7 +399,20 @@ const AGENT_HEADERS = {
392
399
  cursor: '---\ndescription: trackfw governance rules\nglob: "**/*"\nalwaysApply: true\n---\n',
393
400
  }
394
401
 
395
- function trackfwRulesBlock() {
402
+ function trackfwRulesBlock(agentConventions) {
403
+ let conventionsSection = ''
404
+ if (agentConventions && String(agentConventions).trim() !== '') {
405
+ conventionsSection = `
406
+
407
+ ### Project Conventions
408
+ > Declared by the team in \`trackfw.yaml\`'s \`agent_conventions\` field — NOT
409
+ > inferred automatically. trackfw does not impose an architectural standard; it only
410
+ > propagates what the project has already decided.
411
+
412
+ ${String(agentConventions).trim()}
413
+ `
414
+ }
415
+
396
416
  return RULES_START + `
397
417
  ## trackfw — Governance Rules
398
418
 
@@ -435,7 +455,7 @@ Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
435
455
  - **Security wave:** include a red-team review wave in every feature roadmap
436
456
  - **Test coverage:** TDD for critical logic; min 60% (prototype) / 80% (production)
437
457
  - Use \`/trackfw:architect\` to define stack before the first REQ
438
-
458
+ ` + conventionsSection + `
439
459
  ### Key Commands
440
460
  - \`trackfw context\` — current governance state (always run first)
441
461
  - \`trackfw status\` — all artifacts and states
@@ -451,10 +471,10 @@ Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
451
471
  * - Arquivo existe, sem marcador: appenda o bloco no final
452
472
  * - Arquivo existe, com marcador: substitui conteúdo entre marcadores
453
473
  */
454
- function injectOrUpdateRules(filePath, headerIfNew) {
474
+ function injectOrUpdateRules(filePath, headerIfNew, cwd) {
455
475
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
456
476
 
457
- const block = trackfwRulesBlock()
477
+ const block = trackfwRulesBlock(readAgentConventions(cwd))
458
478
 
459
479
  if (!fs.existsSync(filePath)) {
460
480
  let content = headerIfNew || ''
@@ -497,7 +517,8 @@ function injectRulesForTool(tool, cwd) {
497
517
  const relPath = AGENT_FILES[tool]
498
518
  if (!relPath) return
499
519
  const header = AGENT_HEADERS[tool] || ''
500
- injectOrUpdateRules(path.join(cwd || process.cwd(), relPath), header)
520
+ const root = cwd || process.cwd()
521
+ injectOrUpdateRules(path.join(root, relPath), header, root)
501
522
  }
502
523
 
503
524
  /**
@@ -702,7 +723,7 @@ function generateClaudeMD(cfg) {
702
723
  content += 'Decide low-risk details autonomously following existing project conventions, '
703
724
  content += 'and record autonomous decisions in the commit message.\n'
704
725
 
705
- injectOrUpdateRules('CLAUDE.md', content)
726
+ injectOrUpdateRules('CLAUDE.md', content, '.')
706
727
  console.log(' ✓ CLAUDE.md')
707
728
  }
708
729
 
@@ -1329,6 +1350,7 @@ module.exports = {
1329
1350
  generateValidateScript,
1330
1351
  generateAttentionScripts,
1331
1352
  generateCredentialGuardScript,
1353
+ generateGitBranchGuardScript,
1332
1354
  generateCIWorkflow,
1333
1355
  generateGitHooks,
1334
1356
  generateCommitMsgHook,