trackfw 7.1.0 → 7.3.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.
@@ -65,6 +65,10 @@ function defaults() {
65
65
  credentialGuard: {
66
66
  mode: 'warn',
67
67
  },
68
+ // agent_models field — see ADR-2026-08-21-versao-do-modelo-por-tier-com-composicao-por-alvo.md.
69
+ // Maps tier name (e.g. "opus", "sonnet") to version string (e.g. "5", "4.6").
70
+ // Absent or empty map → behavior identical to today (tier alias used verbatim).
71
+ agentModels: {},
68
72
  rules: {
69
73
  wip_has_req: 'error',
70
74
  wip_acceptance: 'error',
@@ -82,6 +86,21 @@ function defaults() {
82
86
 
83
87
  let _instance = null;
84
88
 
89
+ // MALFORMED_GLOBAL_CONFIG_MESSAGE is written to stderr (non-fatal) when ~/.trackfw/trackfw.yaml
90
+ // exists but fails to parse as valid YAML. Must be byte-identical to Go's MalformedGlobalConfigMessage
91
+ // and Python's MALFORMED_GLOBAL_CONFIG_MESSAGE — ADR-2026-08-23.
92
+ const MALFORMED_GLOBAL_CONFIG_MESSAGE = 'trackfw: aviso: "~/.trackfw/trackfw.yaml" tem YAML malformado — config global de modelo ignorada; usando tier canônico.';
93
+
94
+ // GLOBAL_AGENT_MODELS_NONE_MESSAGE is emitted to stderr when agent_models is not configured
95
+ // in the global config and not found in cwd's trackfw.yaml either.
96
+ // Must be byte-identical to Go's GlobalAgentModelsNoneMessage and Python's equivalent.
97
+ const GLOBAL_AGENT_MODELS_NONE_MESSAGE = 'trackfw: agents global: agent_models não configurado em ~/.trackfw/trackfw.yaml — usando tier canônico. Configure em ~/.trackfw/trackfw.yaml para pinar versões.';
98
+
99
+ // GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE is emitted when agent_models is found in the
100
+ // project's trackfw.yaml but not in the global config (AC4/AC14). Value NOT applied.
101
+ // Must be byte-identical to Go's GlobalAgentModelsProjectOnlyMessage and Python's equivalent.
102
+ const GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE = 'trackfw: agents global: agent_models configurado em trackfw.yaml do projeto mas não vale para escopo global. Mova a chave para ~/.trackfw/trackfw.yaml.';
103
+
85
104
  // MALFORMED_CONFIG_MESSAGE is written to stderr, verbatim, when trackfw.yaml exists but fails to
86
105
  // parse as YAML. Kept identical, character-for-character, to Go's MalformedConfigMessage and
87
106
  // Python's MALFORMED_CONFIG_MESSAGE — see the comment on parse() below for why the text is
@@ -278,6 +297,17 @@ function parse(content, cfg) {
278
297
  if (mode === 'warn' || mode === 'block') cfg.credentialGuard.mode = mode;
279
298
  }
280
299
 
300
+ // agent_models field — flat mapping from tier name to version string.
301
+ // See ADR-2026-08-21-versao-do-modelo-por-tier-com-composicao-por-alvo.md.
302
+ // An absent, malformed, or empty block leaves agentModels as the empty object from defaults(),
303
+ // preserving identical behavior to today. A key with an empty string value is stored as-is
304
+ // (the render layer treats empty string as "no pin" — fall back to tier alias).
305
+ if (m.agent_models !== undefined && typeof m.agent_models === 'object' && !Array.isArray(m.agent_models)) {
306
+ for (const [k, v] of Object.entries(m.agent_models)) {
307
+ if (typeof v === 'string') cfg.agentModels[k] = v;
308
+ }
309
+ }
310
+
281
311
  return false;
282
312
  }
283
313
 
@@ -298,7 +328,7 @@ function parseRulesFromContent(content) {
298
328
  // never creates them) — so this cannot be a bare { rules: {} } literal or parse() throws on any
299
329
  // content that sets one of those nested keys. rules starts empty (not seeded from defaults()),
300
330
  // deliberately: the caller wants exactly what `rules:` in content declares, nothing else.
301
- const cfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {} };
331
+ const cfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {}, agentModels: {} };
302
332
  parse(content, cfg);
303
333
  return cfg.rules;
304
334
  }
@@ -316,7 +346,7 @@ function readAgentConventions(cwd) {
316
346
  } catch (_) {
317
347
  return '';
318
348
  }
319
- const cfg = { rules: {}, credentialGuard: {}, update: { agentConventions: '' }, sync: {}, linkFields: {} };
349
+ const cfg = { rules: {}, credentialGuard: {}, update: { agentConventions: '' }, sync: {}, linkFields: {}, agentModels: {} };
320
350
  try {
321
351
  parse(content, cfg);
322
352
  } catch (_) {
@@ -325,6 +355,65 @@ function readAgentConventions(cwd) {
325
355
  return cfg.update.agentConventions || '';
326
356
  }
327
357
 
358
+ // _cwdAgentModelsSource returns 'project_only' if cwd's trackfw.yaml has agent_models configured,
359
+ // 'none' otherwise. Used for the AC14 diagnostic in loadGlobalAgentModels.
360
+ function _cwdAgentModelsSource(cwd) {
361
+ if (!cwd) return 'none';
362
+ try {
363
+ const cwdContent = fs.readFileSync(path.join(cwd, 'trackfw.yaml'), 'utf8');
364
+ const cwdCfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {}, agentModels: {} };
365
+ parse(cwdContent, cwdCfg);
366
+ if (cwdCfg.agentModels && Object.keys(cwdCfg.agentModels).length > 0) return 'project_only';
367
+ } catch (_) {}
368
+ return 'none';
369
+ }
370
+
371
+ // loadGlobalAgentModels reads agent_models from ~/.trackfw/trackfw.yaml, bypassing the load()
372
+ // singleton. homeDir is os.homedir(); cwd is process.cwd() (used only for AC14 diagnostic).
373
+ // Returns { models: {}, source } where source is one of:
374
+ // 'global' — agent_models resolved from ~/.trackfw/trackfw.yaml
375
+ // 'none' — global config absent or has no agent_models; cwd also has none
376
+ // 'project_only' — agent_models in cwd's trackfw.yaml but not global (AC14 trigger)
377
+ // 'global_malformed' — ~/.trackfw/trackfw.yaml exists but has invalid YAML (AC12 trigger)
378
+ // Never calls process.exit. Pattern mirrors readAgentConventions above.
379
+ function loadGlobalAgentModels(homeDir, cwd) {
380
+ const globalPath = path.join(homeDir, '.trackfw', 'trackfw.yaml');
381
+ let data;
382
+ try {
383
+ data = fs.readFileSync(globalPath, 'utf8');
384
+ } catch (_) {
385
+ return { models: {}, source: _cwdAgentModelsSource(cwd) };
386
+ }
387
+ // Validate YAML (non-fatal for global config, per AC12).
388
+ const globalCfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {}, agentModels: {} };
389
+ const malformed = parse(data, globalCfg);
390
+ if (malformed) {
391
+ return { models: {}, source: 'global_malformed' };
392
+ }
393
+ if (globalCfg.agentModels && Object.keys(globalCfg.agentModels).length > 0) {
394
+ return { models: globalCfg.agentModels, source: 'global' };
395
+ }
396
+ // Global file exists but has no agent_models → check cwd (AC14).
397
+ return { models: {}, source: _cwdAgentModelsSource(cwd) };
398
+ }
399
+
400
+ // resolveAgentModels returns { models, warning } for the given scope.
401
+ // For global scope, reads from ~/.trackfw/trackfw.yaml via loadGlobalAgentModels.
402
+ // For project scope, reads from the cwd's trackfw.yaml via load().
403
+ // warning is a non-empty string when the caller should emit an advisory to stderr.
404
+ // resolveAgentModels never writes to stderr itself.
405
+ function resolveAgentModels(scope, homeDir, cwd) {
406
+ if (scope !== 'global') {
407
+ return { models: load().agentModels || {}, warning: '' };
408
+ }
409
+ const { models, source } = loadGlobalAgentModels(homeDir, cwd);
410
+ let warning = '';
411
+ if (source === 'none') warning = GLOBAL_AGENT_MODELS_NONE_MESSAGE;
412
+ else if (source === 'project_only') warning = GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE;
413
+ else if (source === 'global_malformed') warning = MALFORMED_GLOBAL_CONFIG_MESSAGE;
414
+ return { models, warning };
415
+ }
416
+
328
417
  const NAMESPACING_FLAT = 'flat';
329
418
  const NAMESPACING_BY_AGENT = 'by_agent';
330
419
 
@@ -335,7 +424,12 @@ module.exports = {
335
424
  expandPath,
336
425
  parseRulesFromContent,
337
426
  readAgentConventions,
427
+ loadGlobalAgentModels,
428
+ resolveAgentModels,
338
429
  NAMESPACING_FLAT,
339
430
  NAMESPACING_BY_AGENT,
340
431
  MALFORMED_CONFIG_MESSAGE,
432
+ MALFORMED_GLOBAL_CONFIG_MESSAGE,
433
+ GLOBAL_AGENT_MODELS_NONE_MESSAGE,
434
+ GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE,
341
435
  };
@@ -769,6 +769,24 @@ match_subcommand() {
769
769
  ;;
770
770
  esac
771
771
  done
772
+ # git checkout -- <path> | git checkout . descarta alterações não commitadas do
773
+ # caminho indicado, de forma irreversível, no worktree compartilhado — bloqueia
774
+ # quando '--' aparece em qualquer posição (forma explícita de pathspec) ou quando
775
+ # '.' aparece como token isolado. 'git checkout <branch>' sem nenhum dos dois
776
+ # segue liberado por decisão (distinguir branch de caminho sem '--' é ambíguo, e
777
+ # adivinhar produziria falso-positivo).
778
+ checkout_path=0
779
+ for tok2 in "$@"; do
780
+ case "$tok2" in
781
+ --|.)
782
+ checkout_path=1
783
+ ;;
784
+ esac
785
+ done
786
+ if [ "$checkout_path" = "1" ]; then
787
+ echo "checkout-path"
788
+ return 0
789
+ fi
772
790
  ;;
773
791
  switch)
774
792
  for tok2 in "$@"; do
@@ -780,6 +798,87 @@ match_subcommand() {
780
798
  esac
781
799
  done
782
800
  ;;
801
+ stash)
802
+ # git stash: liberado só para leitura (list/show) — bloqueia a forma bare
803
+ # (equivale a "push"), push, save, clear e drop. Decisão de KG: bloquear a
804
+ # classe inteira, não só os literais medidos (ver REQ). Repositório com um único
805
+ # worktree compartilhado entre subagentes paralelos — um stash de um agente
806
+ # remove as alterações não commitadas de todos os outros.
807
+ stash_sub="\${1:-}"
808
+ case "$stash_sub" in
809
+ list|show)
810
+ ;;
811
+ *)
812
+ echo "stash"
813
+ return 0
814
+ ;;
815
+ esac
816
+ ;;
817
+ reset)
818
+ # Só --hard bloqueia, em qualquer posição de token — --soft/--mixed (inclusive
819
+ # sem flag, que é --mixed implícito) seguem liberados: --soft é o contorno
820
+ # padrão para reempurrar trabalho staged via ` + "`" + `trackfw ship -m "..."` + "`" + ` (ainda falta commitar após --soft).
821
+ for tok2 in "$@"; do
822
+ case "$tok2" in
823
+ --hard)
824
+ echo "reset-hard"
825
+ return 0
826
+ ;;
827
+ esac
828
+ done
829
+ ;;
830
+ clean)
831
+ # Bloqueia qualquer forma com force (-f, -fd, -fx, --force) ou -x/-X, EXCETO
832
+ # quando -n/--dry-run também está presente (dry-run nunca apaga nada).
833
+ clean_dry=0
834
+ clean_force=0
835
+ for tok2 in "$@"; do
836
+ case "$tok2" in
837
+ -n|--dry-run)
838
+ clean_dry=1
839
+ ;;
840
+ -f*|--force|--force=*|-x|-X)
841
+ clean_force=1
842
+ ;;
843
+ esac
844
+ done
845
+ if [ "$clean_dry" != "1" ] && [ "$clean_force" = "1" ]; then
846
+ echo "clean-force"
847
+ return 0
848
+ fi
849
+ ;;
850
+ restore)
851
+ # git restore --staged SOZINHO nunca toca o working tree (mexe só no
852
+ # index), então segue liberado mesmo com path. Mas --worktree/-W (com ou
853
+ # sem --staged junto) SEMPRE afeta o working tree — inclusive
854
+ # "--staged --worktree", que restaura os dois — então bloqueia sempre que
855
+ # --worktree/-W aparecer, e também no caso padrão (sem --staged em
856
+ # nenhuma forma) com um argumento posicional (o path).
857
+ restore_staged=0
858
+ restore_worktree=0
859
+ restore_positional=0
860
+ for tok2 in "$@"; do
861
+ case "$tok2" in
862
+ --staged)
863
+ restore_staged=1
864
+ ;;
865
+ --worktree|-W)
866
+ restore_worktree=1
867
+ ;;
868
+ -*)
869
+ ;;
870
+ *)
871
+ restore_positional=1
872
+ ;;
873
+ esac
874
+ done
875
+ if [ "$restore_positional" = "1" ]; then
876
+ if [ "$restore_worktree" = "1" ] || [ "$restore_staged" != "1" ]; then
877
+ echo "restore-path"
878
+ return 0
879
+ fi
880
+ fi
881
+ ;;
783
882
  branch)
784
883
  # git branch é majoritariamente leitura (sem args, -a, -r, -l, --list, -v/-vv,
785
884
  # --show-current, --contains, --no-contains, --merged, --no-merged, --sort=,
@@ -835,8 +934,44 @@ match_subcommand() {
835
934
  ;;
836
935
  esac
837
936
  done
937
+ elif [ "\${1:-}" = "remove" ]; then
938
+ # git worktree remove SEM -f/--force já recusa sozinho quando há alteração não
939
+ # commitada no worktree indicado — só a forma com force é irreversível o bastante
940
+ # para bloquear aqui.
941
+ shift
942
+ for tok2 in "$@"; do
943
+ case "$tok2" in
944
+ -f|--force)
945
+ echo "worktree-remove-force"
946
+ return 0
947
+ ;;
948
+ esac
949
+ done
838
950
  fi
839
951
  ;;
952
+ update-ref)
953
+ # git update-ref reescreve um ref (inclusive refs/remotes/origin/*) sem tocar o
954
+ # objeto apontado nem exigir push — foi o mecanismo que tornou alcançável o exploit
955
+ # descrito no ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md
956
+ # (Emenda 1): forjar origin/<base> localmente para desviar o commit-alvo de trackfw
957
+ # release tag. Sem forma de leitura equivalente a bloquear seletivamente — a
958
+ # subcommand inteira é escrita — bloqueia sempre, sem exceção de token.
959
+ echo "update-ref"
960
+ return 0
961
+ ;;
962
+ rm)
963
+ # git rm -f/--force apaga do working tree e do index de forma irreversível, mesma
964
+ # classe de git clean -f/git reset --hard já bloqueados acima — sem exceção para
965
+ # --cached (destrancar do index sem -f já segue liberado por não precisar de force).
966
+ for tok2 in "$@"; do
967
+ case "$tok2" in
968
+ -f*|--force|--force=*)
969
+ echo "rm-force"
970
+ return 0
971
+ ;;
972
+ esac
973
+ done
974
+ ;;
840
975
  esac
841
976
  done <<EOF
842
977
  $normalized
@@ -848,22 +983,46 @@ SUBCOMMAND=$(match_subcommand "$CMD_RAW") || exit 0
848
983
 
849
984
  case "$SUBCOMMAND" in
850
985
  checkout-b)
851
- REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
986
+ REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
852
987
  ;;
853
988
  switch-c)
854
- REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
989
+ REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
855
990
  ;;
856
991
  branch-create)
857
- REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
992
+ REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
858
993
  ;;
859
994
  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."
995
+ REASON="trackfw: git worktree add -b bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
861
996
  ;;
862
997
  commit)
863
- REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
998
+ REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
864
999
  ;;
865
1000
  push)
866
- REASON="trackfw: git push bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw ship` + GBG_BACKTICK + `. Ver CLAUDE.md §1."
1001
+ REASON="trackfw: git push bruto bloqueado. Use ` + GBG_BACKTICK + `trackfw push` + GBG_BACKTICK + ` (para empurrar commits já criados), ` + GBG_BACKTICK + `trackfw ship` + GBG_BACKTICK + ` (para commit+push+PR em uma etapa) ou ` + GBG_BACKTICK + `trackfw release tag` + GBG_BACKTICK + ` (para publicar uma tag de release). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1002
+ ;;
1003
+ stash)
1004
+ REASON="trackfw: git stash bruto bloqueado — worktree compartilhado entre subagentes, um stash remove as alterações não commitadas de todos os outros. ` + GBG_BACKTICK + `git stash list` + GBG_BACKTICK + `/` + GBG_BACKTICK + `git stash show` + GBG_BACKTICK + ` seguem liberados; para guardar trabalho em progresso, use uma branch própria via ` + GBG_BACKTICK + `trackfw branch new` + GBG_BACKTICK + ` e commit nela. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1005
+ ;;
1006
+ reset-hard)
1007
+ REASON="trackfw: git reset --hard bruto bloqueado — descarta de forma irreversível as alterações não commitadas de todo o worktree compartilhado. ` + GBG_BACKTICK + `git reset --soft` + GBG_BACKTICK + `/` + GBG_BACKTICK + `--mixed` + GBG_BACKTICK + ` seguem liberados (ex.: ` + GBG_BACKTICK + `git reset --soft HEAD~1` + GBG_BACKTICK + ` é o caminho padrão; use ` + GBG_BACKTICK + `trackfw ship -m "..."` + GBG_BACKTICK + ` para commitar e empurrar). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1008
+ ;;
1009
+ clean-force)
1010
+ REASON="trackfw: git clean -f/-x bruto bloqueado — apaga arquivos não rastreados do worktree compartilhado, de forma irreversível. ` + GBG_BACKTICK + `git clean -n` + GBG_BACKTICK + `/` + GBG_BACKTICK + `--dry-run` + GBG_BACKTICK + ` segue liberado para revisar antes o que seria apagado. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1011
+ ;;
1012
+ restore-path)
1013
+ REASON="trackfw: git restore <path> bruto bloqueado — descarta de forma irreversível as alterações não commitadas do caminho indicado. ` + GBG_BACKTICK + `git restore --staged` + GBG_BACKTICK + ` (não toca o working tree) segue liberado; para descartar de fato, confirme antes com o usuário. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1014
+ ;;
1015
+ checkout-path)
1016
+ REASON="trackfw: git checkout -- <path>/git checkout . bruto bloqueado — descarta de forma irreversível as alterações não commitadas do caminho indicado. ` + GBG_BACKTICK + `git checkout <branch>` + GBG_BACKTICK + `/` + GBG_BACKTICK + `git switch <branch>` + GBG_BACKTICK + ` seguem liberados; para descartar de fato, confirme antes com o usuário. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1017
+ ;;
1018
+ update-ref)
1019
+ REASON="trackfw: git update-ref bruto bloqueado — reescreve um ref (inclusive refs/remotes/origin/*) sem tocar o objeto apontado nem exigir push, o que permite forjar o commit-alvo que ` + GBG_BACKTICK + `trackfw release tag` + GBG_BACKTICK + ` publicaria. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1020
+ ;;
1021
+ worktree-remove-force)
1022
+ REASON="trackfw: git worktree remove -f/--force bruto bloqueado — remove um worktree e descarta de forma irreversível qualquer alteração não commitada nele. ` + GBG_BACKTICK + `git worktree remove` + GBG_BACKTICK + ` sem force segue liberado (recusa sozinho quando há algo não commitado). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1023
+ ;;
1024
+ rm-force)
1025
+ REASON="trackfw: git rm -f/--force bruto bloqueado — apaga arquivos do working tree e do index de forma irreversível, mesma classe de ` + GBG_BACKTICK + `git clean -f` + GBG_BACKTICK + `/` + GBG_BACKTICK + `git reset --hard` + GBG_BACKTICK + ` já bloqueados. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
867
1026
  ;;
868
1027
  *)
869
1028
  exit 0
@@ -888,6 +1047,8 @@ function generateGitBranchGuardScript(cwd) {
888
1047
 
889
1048
  const scriptPath = path.join(scriptsDir, 'trackfw-git-branch-guard.sh')
890
1049
  fs.writeFileSync(scriptPath, GIT_BRANCH_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
1050
+ // AC9: chmodSync restores execute bit on existing files.
1051
+ fs.chmodSync(scriptPath, 0o755)
891
1052
 
892
1053
  console.log(' ✓ scripts/trackfw-git-branch-guard.sh')
893
1054
  }
@@ -925,6 +1086,8 @@ function generateCredentialGuardScript(cwd) {
925
1086
 
926
1087
  const scriptPath = path.join(scriptsDir, 'trackfw-credential-guard.sh')
927
1088
  fs.writeFileSync(scriptPath, CREDENTIAL_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
1089
+ // AC9: chmodSync restores execute bit on existing files.
1090
+ fs.chmodSync(scriptPath, 0o755)
928
1091
 
929
1092
  console.log(' ✓ scripts/trackfw-credential-guard.sh')
930
1093
  }
@@ -1330,9 +1493,14 @@ function generateAttentionScripts(cfg, cwd) {
1330
1493
 
1331
1494
  const signalPath = path.join(scriptsDir, 'trackfw-attention-signal.sh')
1332
1495
  fs.writeFileSync(signalPath, SIGNAL_SCRIPT, { encoding: 'utf8', mode: 0o755 })
1496
+ // AC9 (REQ-2026-08-28): chmodSync restores the execute bit on existing files
1497
+ // where writeFileSync's {mode} option has no effect (applies only on O_CREAT).
1498
+ fs.chmodSync(signalPath, 0o755)
1333
1499
 
1334
1500
  const cleanupPath = path.join(scriptsDir, 'trackfw-attention-cleanup.sh')
1335
1501
  fs.writeFileSync(cleanupPath, CLEANUP_SCRIPT, { encoding: 'utf8', mode: 0o755 })
1502
+ // AC9: same rationale as signalPath above.
1503
+ fs.chmodSync(cleanupPath, 0o755)
1336
1504
 
1337
1505
  console.log(' ✓ scripts/trackfw-attention-signal.sh')
1338
1506
  console.log(' ✓ scripts/trackfw-attention-cleanup.sh')
@@ -1965,7 +2133,17 @@ function injectWindsurfHooks(cwd) {
1965
2133
  // written on first creation so the default agent keeps today's unrestricted
1966
2134
  // tool access (this fix does not narrow what any agent can do, only where
1967
2135
  // the deny wiring lives). Mirrors Go's InjectAmazonQHooks
1968
- // (internal/generators/agentfiles.go) field-for-field.
2136
+ // (internal/generators/agentfiles.go) field-for-field -- Go is the
2137
+ // canonical set (ROADMAP-2026-08-20, ML-1A-bis): only `name`, `description`
2138
+ // and `tools` are written on first creation. `prompt`/`mcpServers`/
2139
+ // `toolAliases`/`allowedTools`/`resources`/`useLegacyMcpJson` were written
2140
+ // here until ML-1A-bis and are now deliberately NOT written -- an extra
2141
+ // field the real schema doesn't expect risks failing validation, whereas an
2142
+ // absent optional field usually doesn't (asymmetry-of-risk decision, not a
2143
+ // verification against the live Amazon Q schema -- see docs/cli-parity.md
2144
+ // for the recorded limit). Pre-existing occurrences of the dropped fields
2145
+ // in a user's file are left untouched (never removed, only no longer
2146
+ // created).
1969
2147
  //
1970
2148
  // Native custom-agent toolset restriction (REQ acceptance criterion): still
1971
2149
  // NOT implemented here -- this ML only wires the guard/deny fields on the
@@ -1982,13 +2160,7 @@ function injectAmazonQHooks(cwd) {
1982
2160
  const defaults = {
1983
2161
  name: 'q_cli_default',
1984
2162
  description: 'trackfw-managed default agent — wires the git branch guard hook/denylist. See docs/cli-parity.md.',
1985
- prompt: null,
1986
- mcpServers: {},
1987
2163
  tools: ['*'],
1988
- toolAliases: {},
1989
- allowedTools: [],
1990
- resources: [],
1991
- useLegacyMcpJson: false,
1992
2164
  }
1993
2165
  for (const [k, v] of Object.entries(defaults)) {
1994
2166
  if (!Object.prototype.hasOwnProperty.call(data, k)) data[k] = v
@@ -2092,4 +2264,13 @@ module.exports = {
2092
2264
  mergeCopilotHookArray,
2093
2265
  normalizeGuardPath,
2094
2266
  samePathCommand,
2267
+ // Template constants — exported for scaffold doctor (ADR-2026-08-27).
2268
+ // These are the single source of truth for each script's content; the
2269
+ // scaffold doctor compares on-disk files against these exact bytes, using
2270
+ // the same constants the write path uses, so drift between the comparison
2271
+ // and the generator is structurally impossible.
2272
+ SIGNAL_SCRIPT,
2273
+ CLEANUP_SCRIPT,
2274
+ CREDENTIAL_GUARD_SCRIPT,
2275
+ GIT_BRANCH_GUARD_SCRIPT,
2095
2276
  }
@@ -123,6 +123,10 @@ function generateValidateScript(cfg, cwd) {
123
123
  const script = buildValidateScript(cfg)
124
124
  const scriptPath = path.join(root, 'scripts', 'trackfw-validate.sh')
125
125
  fs.writeFileSync(scriptPath, script, { encoding: 'utf8', mode: 0o755 })
126
+ // AC9 (REQ-2026-08-28): writeFileSync's {mode} option applies only on O_CREAT;
127
+ // for an existing file the mode is not changed. fs.chmodSync restores 0o755
128
+ // unconditionally, matching Python's os.chmod behavior (which was already correct).
129
+ fs.chmodSync(scriptPath, 0o755)
126
130
  console.log(` ✓ ${path.join('scripts', 'trackfw-validate.sh')}`)
127
131
  }
128
132
 
@@ -201,10 +205,13 @@ function generateCIWorkflow(cfg) {
201
205
  }
202
206
  }
203
207
 
204
- function generateGitHubActionsWorkflow() {
205
- fs.mkdirSync('.github/workflows', { recursive: true })
206
-
207
- const content = `name: trackfw-gate
208
+ // buildGitHubActionsWorkflowContent returns the byte-identical content the GitHub Actions
209
+ // workflow generator would write. Exported for scaffold doctor (ADR-2026-08-27): the
210
+ // comparison uses the same function the write path uses, so drift is structurally impossible.
211
+ // The cfg parameter is accepted for API symmetry with Go's buildGitHubActionsWorkflowContent
212
+ // (which also ignores cfg for now — the template is not cfg-dependent).
213
+ function buildGitHubActionsWorkflowContent(_cfg) {
214
+ return `name: trackfw-gate
208
215
  on:
209
216
  pull_request:
210
217
  branches: [main]
@@ -222,14 +229,11 @@ jobs:
222
229
  - name: Governance gate
223
230
  run: trackfw validate
224
231
  `
225
-
226
- const filePath = '.github/workflows/trackfw-gate.yml'
227
- fs.writeFileSync(filePath, content, 'utf8')
228
- console.log(` ✓ ${filePath}`)
229
232
  }
230
233
 
231
- function generateGitLabCIWorkflow() {
232
- const content = `# trackfw governance gate
234
+ // buildGitLabCIWorkflowContent mirrors buildGitHubActionsWorkflowContent for GitLab CI.
235
+ function buildGitLabCIWorkflowContent(_cfg) {
236
+ return `# trackfw governance gate
233
237
  trackfw-gate:
234
238
  stage: test
235
239
  image: alpine:latest
@@ -241,8 +245,17 @@ trackfw-gate:
241
245
  only:
242
246
  - merge_requests
243
247
  `
248
+ }
244
249
 
245
- fs.writeFileSync('.gitlab-ci-trackfw.yml', content, 'utf8')
250
+ function generateGitHubActionsWorkflow() {
251
+ fs.mkdirSync('.github/workflows', { recursive: true })
252
+ const filePath = '.github/workflows/trackfw-gate.yml'
253
+ fs.writeFileSync(filePath, buildGitHubActionsWorkflowContent(null), 'utf8')
254
+ console.log(` ✓ ${filePath}`)
255
+ }
256
+
257
+ function generateGitLabCIWorkflow() {
258
+ fs.writeFileSync('.gitlab-ci-trackfw.yml', buildGitLabCIWorkflowContent(null), 'utf8')
246
259
  console.log(' ✓ .gitlab-ci-trackfw.yml')
247
260
  }
248
261
 
@@ -452,7 +465,7 @@ Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
452
465
  - **Docker + .env from day 1:** containerize early; all config via env vars
453
466
  - **2-layer validation:** frontend (UX) + backend (security) — never only one
454
467
  - **API-first:** define OpenAPI contract before coding frontend/backend integration
455
- - **Security wave:** include a red-team review wave in every feature roadmap
468
+ - **Threat model waves:** every feature roadmap opens with a Wave 0 threat model (before implementation) and closes with a red-team review wave (before release)
456
469
  - **Test coverage:** TDD for critical logic; min 60% (prototype) / 80% (production)
457
470
  - Use \`/trackfw:architect\` to define stack before the first REQ
458
471
  ` + conventionsSection + `
@@ -723,6 +736,18 @@ function generateClaudeMD(cfg) {
723
736
  content += 'Decide low-risk details autonomously following existing project conventions, '
724
737
  content += 'and record autonomous decisions in the commit message.\n'
725
738
 
739
+ content += '\n## Architect responses\n\n'
740
+ content += 'Default: what changed · what was decided · what is needed from you. Three to five lines.\n\n'
741
+ content += 'Scale up only on these three triggers, and only on them: a **blocker** that stops the next wave; '
742
+ content += 'a **pending user decision** that cannot be inferred from context; '
743
+ content += 'an **error the architect made** that cannot be self-corrected.\n\n'
744
+ content += 'Never cut, even when short: measured evidence (command and result), barrier verdict, decision taken and why. '
745
+ content += 'A response that buries a blocker in paragraph seven produced the same effect as not reporting it.\n\n'
746
+ content += 'Cut: restating what an executor already reported, re-explaining reasoning already given, '
747
+ content += 'recapping state that has not changed, closing praise. '
748
+ content += 'Tables and code blocks only when they replace prose, never when they add to it.\n\n'
749
+ content += 'Depth is on demand from the user.\n'
750
+
726
751
  injectOrUpdateRules('CLAUDE.md', content, '.')
727
752
  console.log(' ✓ CLAUDE.md')
728
753
  }
@@ -843,7 +868,7 @@ trackfw não está instalado. Instale com:
843
868
  3. **Gerar o roadmap**
844
869
  Com base no conteúdo da REQ, gere um roadmap seguindo **estritamente** este formato:
845
870
 
846
- \`\`\`markdown
871
+ \`\`\`\`markdown
847
872
  ---
848
873
  status: backlog
849
874
  date: <YYYY-MM-DD>
@@ -858,6 +883,28 @@ trackfw não está instalado. Instale com:
858
883
  ## Diagnóstico / Contexto
859
884
  <resumo do problema, motivação e escopo extraídos da REQ>
860
885
 
886
+ ## Wave 0 — Threat Model
887
+ > Dependencies: none. Blocks all implementation.
888
+
889
+ ### ML-0A — Threat model for this roadmap
890
+ **Status:** pending
891
+ **Files affected:**
892
+ **Actions:**
893
+ 1. Enumeration completeness — is the list of surfaces in this roadmap complete? Name what is missing, or show the list is closed. Do not limit the search to the files already named by the REQ — before declaring the list closed, search the repository for other places that emit the same artifact or the same pattern (for example, grep for the literal the final artifact contains).
894
+ 2. Threat model — who empties this Wave 0 without breaking any written rule, and how?
895
+ 3. Falsification targets in both directions — for each surface, what breaks when the behavior regresses, and what breaks when it regresses the opposite way?
896
+ 4. Declared residual — what this design accepts not covering.
897
+ **Acceptance criteria:**
898
+ - [ ] The four sections above answered with evidence, not a one-line assertion
899
+ - [ ] No implementation line written for this ML
900
+
901
+ **Gates da wave:**
902
+ \`\`\`bash
903
+ # Wave 0 gate — replace this placeholder with a project-specific check before
904
+ # marking ML-0A done. Do not remove the gate; replace its command (AC13).
905
+ exit 1 # placeholder gate fails closed until ML-0A replaces it — see docs/cli-parity.md
906
+ \`\`\`
907
+
861
908
  ## Wave 1 — <nome descritivo> (<N> MLs em paralelo)
862
909
  > Dependências: Independente
863
910
 
@@ -878,7 +925,7 @@ trackfw não está instalado. Instale com:
878
925
  ## Wave 2 — <nome> (depende de Wave 1)
879
926
  > Dependências: Wave 1 completa
880
927
  ...
881
- \`\`\`
928
+ \`\`\`\`
882
929
 
883
930
  **Princípios obrigatórios:**
884
931
  - MLs dentro da mesma Wave são **independentes** (arquivos distintos, sem conflito)
@@ -1020,9 +1067,19 @@ Próximo passo: abrir PR com gh pr create
1020
1067
 
1021
1068
  Execute primeiro:
1022
1069
  \`\`\`bash
1023
- trackfw barrier <roadmap> --wave <n> --json
1070
+ trackfw barrier <roadmap> --wave <n> --trust-local-gates --json
1024
1071
  \`\`\`
1025
1072
 
1073
+ \`--trust-local-gates\` é obrigatório aqui: roadmaps WIP (modificados localmente, ainda não commitados em
1074
+ \`origin/main\`) são marcados como não confiáveis pela CLI direta por padrão, como proteção contra a
1075
+ execução de gates de roadmaps chegados por PR de terceiro. O slash command aplica esse flag porque
1076
+ ele representa o fluxo legítimo do arquiteto operando no próprio repositório — não porque os gates
1077
+ são inspecionados previamente (o diff ainda é responsabilidade do checklist abaixo).
1078
+
1079
+ ⚠️ **Não use \`--trust-local-gates\` ao revisar um roadmap chegado por PR de terceiro** — use a CLI
1080
+ direta sem o flag (\`trackfw barrier <roadmap> --wave <n> --json\`) para que os gates sejam marcados
1081
+ como \`not_evaluated\` e não executados.
1082
+
1026
1083
  Este comando é **necessário mas não suficiente**. Ele verifica MLs concluídos, evidências e \`trackfw validate\`, mas não substitui as inspeções especializadas nem a auditoria de diff abaixo — nenhuma delas é avaliada pelo binário. Consulte a seção \`trackfw barrier\` em \`docs/cli-parity.md\` para o contrato completo (estados, exit codes, saída JSON).
1027
1084
 
1028
1085
  Se o comando retornar exit code não-zero (\`blocked\` ou erro de resolução): pare, reporte a falha ao usuário e não prossiga no checklist até que a wave passe.
@@ -1246,8 +1303,11 @@ async function installAmazonQ(cwd = process.cwd()) {
1246
1303
  */
1247
1304
  async function installIntegrationTarget(target, cwd = process.cwd(), scope = 'project', { onSkip } = {}) {
1248
1305
  const { execute, buildPlans } = require('../integrations')
1306
+ const { resolveAgentModels } = require('../config')
1307
+ const { models: agentModels, warning: agentModelsWarning } = resolveAgentModels(scope, os.homedir(), cwd)
1308
+ if (agentModelsWarning) process.stderr.write(agentModelsWarning + '\n')
1249
1309
  const roots = { projectRoot: cwd }
1250
- const options = { targets: [target], scope, onSkip }
1310
+ const options = { targets: [target], scope, onSkip, agentModels }
1251
1311
  // D5 — transparency: print resolved destinations before writing anything.
1252
1312
  // buildPlans has no side effects, so it is safe to call here purely to
1253
1313
  // enumerate destinations; the actual write happens below via execute().
@@ -1371,4 +1431,12 @@ module.exports = {
1371
1431
  injectHooksDetected,
1372
1432
  trackfwRulesBlock,
1373
1433
  printArchitectNextSteps,
1434
+ // Template constants and builders — exported for scaffold doctor (ADR-2026-08-27).
1435
+ // Same single-source-of-truth principle as hooks.js exports: comparing against the
1436
+ // same object the write path uses makes drift structurally impossible.
1437
+ CLAUDE_COMMANDS,
1438
+ buildValidateScript,
1439
+ // CI workflow content builders (inline content from generateGitHub/GitLabCIWorkflow)
1440
+ buildGitHubActionsWorkflowContent,
1441
+ buildGitLabCIWorkflowContent,
1374
1442
  }