trackfw 7.0.0 → 7.2.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.
@@ -4,6 +4,7 @@ const fs = require('fs')
4
4
  const os = require('os')
5
5
  const path = require('path')
6
6
  const identityStore = require('../identity')
7
+ const projectConfig = require('../config')
7
8
  const { catalog, buildPlans, IntegrationManager, globalGroupPath } = require('../integrations')
8
9
  const { tildeify, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine')
9
10
  const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, generateGlobalCredentialGuardScript, generateGlobalGitBranchGuardScript } = require('../generators/hooks')
@@ -462,6 +463,288 @@ function credentialGuardTargetKiro(homeRoot, { dryRun, installMissing }) {
462
463
  }
463
464
  }
464
465
 
466
+ // ---------------------------------------------------------------------------
467
+ // git-branch-guard global-scope wiring (ROADMAP-2026-08-17 Wave 2/ML-2A).
468
+ // Mirrors the six credentialGuardTarget<Tool> functions above, entry-for-
469
+ // entry — same 4-state contract, same displayPath per tool, same reuse of
470
+ // mergeClaudeHookArray/mergeSimpleCommandArray/mergeCopilotHookArray (those
471
+ // helpers only need a scriptPath, which here is trackfw-git-branch-guard.sh
472
+ // instead of trackfw-credential-guard.sh). Mirrors
473
+ // internal/generators/update.go:harnessGitBranchGuardTarget<Tool>.
474
+ //
475
+ // Kiro is the one structural exception: credentialGuardTargetKiro rewrites
476
+ // ~/.kiro/hooks/trackfw-credential-guard.json WHOLESALE every run (never
477
+ // merges), so sharing that file with a second wholesale writer would make
478
+ // the two targets flap between each other's desired 2-entry document forever
479
+ // (idempotency failure). gitBranchGuardTargetKiro therefore writes its OWN
480
+ // dedicated file, ~/.kiro/hooks/trackfw-git-branch-guard.json, same
481
+ // {"version":"v1","hooks":[pre,post]} schema, hook names
482
+ // "trackfw-git-branch-guard-global-pre"/"-global-post".
483
+ // ---------------------------------------------------------------------------
484
+
485
+ function gitBranchGuardTargetClaude(homeRoot, { dryRun, installMissing }) {
486
+ const id = 'claude-git-branch-guard'
487
+ const filePath = path.join(homeRoot, '.claude', 'settings.json')
488
+ const displayPath = tildeify(homeRoot, filePath)
489
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
490
+
491
+ let root
492
+ let raw = null
493
+ try {
494
+ if (fs.existsSync(filePath)) {
495
+ raw = fs.readFileSync(filePath, 'utf8')
496
+ root = raw.length ? JSON.parse(raw) : {}
497
+ } else {
498
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
499
+ if (dryRun) return { id, state: 'updated', path: displayPath }
500
+ root = {}
501
+ if (!root.hooks) root.hooks = {}
502
+ root.hooks.PreToolUse = mergeClaudeHookArray(root.hooks.PreToolUse, 'Bash', scriptPath)
503
+ root.hooks.PostToolUse = mergeClaudeHookArray(root.hooks.PostToolUse, 'Bash', scriptPath)
504
+ const desired = JSON.stringify(root, null, 2) + '\n'
505
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
506
+ fs.writeFileSync(filePath, desired, 'utf8')
507
+ return { id, state: 'updated', path: displayPath }
508
+ }
509
+ } catch (e) {
510
+ return { id, state: 'failed', path: displayPath, message: e.message }
511
+ }
512
+
513
+ try {
514
+ if (!root.hooks) root.hooks = {}
515
+ root.hooks.PreToolUse = mergeClaudeHookArray(root.hooks.PreToolUse, 'Bash', scriptPath)
516
+ root.hooks.PostToolUse = mergeClaudeHookArray(root.hooks.PostToolUse, 'Bash', scriptPath)
517
+ const desired = JSON.stringify(root, null, 2) + '\n'
518
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
519
+ if (dryRun) return { id, state: 'updated', path: displayPath }
520
+ fs.writeFileSync(filePath, desired, 'utf8')
521
+ return { id, state: 'updated', path: displayPath }
522
+ } catch (e) {
523
+ return { id, state: 'failed', path: displayPath, message: e.message }
524
+ }
525
+ }
526
+
527
+ function gitBranchGuardTargetCodex(homeRoot, { dryRun, installMissing }) {
528
+ const id = 'codex-git-branch-guard'
529
+ const filePath = path.join(homeRoot, '.codex', 'hooks.json')
530
+ const displayPath = tildeify(homeRoot, filePath)
531
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
532
+
533
+ let root
534
+ let raw = null
535
+ try {
536
+ if (fs.existsSync(filePath)) {
537
+ raw = fs.readFileSync(filePath, 'utf8')
538
+ root = raw.length ? JSON.parse(raw) : {}
539
+ } else {
540
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
541
+ if (dryRun) return { id, state: 'updated', path: displayPath }
542
+ root = {}
543
+ if (!root.hooks) root.hooks = {}
544
+ root.hooks.PreToolUse = mergeClaudeHookArray(root.hooks.PreToolUse, 'Bash', scriptPath)
545
+ root.hooks.PostToolUse = mergeClaudeHookArray(root.hooks.PostToolUse, 'Bash', scriptPath)
546
+ const desired = JSON.stringify(root, null, 2) + '\n'
547
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
548
+ fs.writeFileSync(filePath, desired, 'utf8')
549
+ return { id, state: 'updated', path: displayPath }
550
+ }
551
+ } catch (e) {
552
+ return { id, state: 'failed', path: displayPath, message: e.message }
553
+ }
554
+
555
+ try {
556
+ if (!root.hooks) root.hooks = {}
557
+ root.hooks.PreToolUse = mergeClaudeHookArray(root.hooks.PreToolUse, 'Bash', scriptPath)
558
+ root.hooks.PostToolUse = mergeClaudeHookArray(root.hooks.PostToolUse, 'Bash', scriptPath)
559
+ const desired = JSON.stringify(root, null, 2) + '\n'
560
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
561
+ if (dryRun) return { id, state: 'updated', path: displayPath }
562
+ fs.writeFileSync(filePath, desired, 'utf8')
563
+ return { id, state: 'updated', path: displayPath }
564
+ } catch (e) {
565
+ return { id, state: 'failed', path: displayPath, message: e.message }
566
+ }
567
+ }
568
+
569
+ function gitBranchGuardTargetGemini(homeRoot, { dryRun, installMissing }) {
570
+ const id = 'gemini-git-branch-guard'
571
+ const filePath = path.join(homeRoot, '.gemini', 'settings.json')
572
+ const displayPath = tildeify(homeRoot, filePath)
573
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
574
+
575
+ let root
576
+ let raw = null
577
+ try {
578
+ if (fs.existsSync(filePath)) {
579
+ raw = fs.readFileSync(filePath, 'utf8')
580
+ root = raw.length ? JSON.parse(raw) : {}
581
+ } else {
582
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
583
+ if (dryRun) return { id, state: 'updated', path: displayPath }
584
+ root = {}
585
+ if (!root.hooks) root.hooks = {}
586
+ root.hooks.BeforeTool = mergeClaudeHookArray(root.hooks.BeforeTool, 'run_shell_command', scriptPath)
587
+ root.hooks.AfterTool = mergeClaudeHookArray(root.hooks.AfterTool, 'run_shell_command', scriptPath)
588
+ const desired = JSON.stringify(root, null, 2) + '\n'
589
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
590
+ fs.writeFileSync(filePath, desired, 'utf8')
591
+ return { id, state: 'updated', path: displayPath }
592
+ }
593
+ } catch (e) {
594
+ return { id, state: 'failed', path: displayPath, message: e.message }
595
+ }
596
+
597
+ try {
598
+ if (!root.hooks) root.hooks = {}
599
+ root.hooks.BeforeTool = mergeClaudeHookArray(root.hooks.BeforeTool, 'run_shell_command', scriptPath)
600
+ root.hooks.AfterTool = mergeClaudeHookArray(root.hooks.AfterTool, 'run_shell_command', scriptPath)
601
+ const desired = JSON.stringify(root, null, 2) + '\n'
602
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
603
+ if (dryRun) return { id, state: 'updated', path: displayPath }
604
+ fs.writeFileSync(filePath, desired, 'utf8')
605
+ return { id, state: 'updated', path: displayPath }
606
+ } catch (e) {
607
+ return { id, state: 'failed', path: displayPath, message: e.message }
608
+ }
609
+ }
610
+
611
+ function gitBranchGuardTargetCursor(homeRoot, { dryRun, installMissing }) {
612
+ const id = 'cursor-git-branch-guard'
613
+ const filePath = path.join(homeRoot, '.cursor', 'hooks.json')
614
+ const displayPath = tildeify(homeRoot, filePath)
615
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
616
+
617
+ let root
618
+ let raw = null
619
+ try {
620
+ if (fs.existsSync(filePath)) {
621
+ raw = fs.readFileSync(filePath, 'utf8')
622
+ root = raw.length ? JSON.parse(raw) : {}
623
+ } else {
624
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
625
+ if (dryRun) return { id, state: 'updated', path: displayPath }
626
+ root = {}
627
+ if (typeof root.version === 'undefined') root.version = 1
628
+ if (!root.hooks) root.hooks = {}
629
+ root.hooks.beforeShellExecution = mergeSimpleCommandArray(root.hooks.beforeShellExecution, scriptPath)
630
+ root.hooks.afterShellExecution = mergeSimpleCommandArray(root.hooks.afterShellExecution, scriptPath)
631
+ const desired = JSON.stringify(root, null, 2) + '\n'
632
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
633
+ fs.writeFileSync(filePath, desired, 'utf8')
634
+ return { id, state: 'updated', path: displayPath }
635
+ }
636
+ } catch (e) {
637
+ return { id, state: 'failed', path: displayPath, message: e.message }
638
+ }
639
+
640
+ try {
641
+ if (typeof root.version === 'undefined') root.version = 1
642
+ if (!root.hooks) root.hooks = {}
643
+ root.hooks.beforeShellExecution = mergeSimpleCommandArray(root.hooks.beforeShellExecution, scriptPath)
644
+ root.hooks.afterShellExecution = mergeSimpleCommandArray(root.hooks.afterShellExecution, scriptPath)
645
+ const desired = JSON.stringify(root, null, 2) + '\n'
646
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
647
+ if (dryRun) return { id, state: 'updated', path: displayPath }
648
+ fs.writeFileSync(filePath, desired, 'utf8')
649
+ return { id, state: 'updated', path: displayPath }
650
+ } catch (e) {
651
+ return { id, state: 'failed', path: displayPath, message: e.message }
652
+ }
653
+ }
654
+
655
+ function gitBranchGuardTargetCopilot(homeRoot, { dryRun, installMissing }) {
656
+ const id = 'copilot-git-branch-guard'
657
+ const filePath = path.join(homeRoot, '.copilot', 'settings.json')
658
+ const displayPath = tildeify(homeRoot, filePath)
659
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
660
+
661
+ let root
662
+ let raw = null
663
+ try {
664
+ if (fs.existsSync(filePath)) {
665
+ raw = fs.readFileSync(filePath, 'utf8')
666
+ root = raw.length ? JSON.parse(raw) : {}
667
+ } else {
668
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
669
+ if (dryRun) return { id, state: 'updated', path: displayPath }
670
+ root = {}
671
+ if (!root.hooks) root.hooks = {}
672
+ root.hooks.preToolUse = mergeCopilotHookArray(root.hooks.preToolUse, scriptPath)
673
+ root.hooks.postToolUse = mergeCopilotHookArray(root.hooks.postToolUse, scriptPath)
674
+ const desired = JSON.stringify(root, null, 2) + '\n'
675
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
676
+ fs.writeFileSync(filePath, desired, 'utf8')
677
+ return { id, state: 'updated', path: displayPath }
678
+ }
679
+ } catch (e) {
680
+ return { id, state: 'failed', path: displayPath, message: e.message }
681
+ }
682
+
683
+ try {
684
+ if (!root.hooks) root.hooks = {}
685
+ root.hooks.preToolUse = mergeCopilotHookArray(root.hooks.preToolUse, scriptPath)
686
+ root.hooks.postToolUse = mergeCopilotHookArray(root.hooks.postToolUse, scriptPath)
687
+ const desired = JSON.stringify(root, null, 2) + '\n'
688
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
689
+ if (dryRun) return { id, state: 'updated', path: displayPath }
690
+ fs.writeFileSync(filePath, desired, 'utf8')
691
+ return { id, state: 'updated', path: displayPath }
692
+ } catch (e) {
693
+ return { id, state: 'failed', path: displayPath, message: e.message }
694
+ }
695
+ }
696
+
697
+ // gitBranchGuardTargetKiro writes a DEDICATED file,
698
+ // ~/.kiro/hooks/trackfw-git-branch-guard.json — deliberately NOT
699
+ // credentialGuardTargetKiro's file. See this section's header comment for
700
+ // why sharing would break idempotency.
701
+ function gitBranchGuardTargetKiro(homeRoot, { dryRun, installMissing }) {
702
+ const id = 'kiro-git-branch-guard'
703
+ const filePath = path.join(homeRoot, '.kiro', 'hooks', 'trackfw-git-branch-guard.json')
704
+ const displayPath = tildeify(homeRoot, filePath)
705
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
706
+
707
+ const desired = JSON.stringify(
708
+ {
709
+ version: 'v1',
710
+ hooks: [
711
+ {
712
+ name: 'trackfw-git-branch-guard-global-pre',
713
+ description: 'Blocks branch-creation git subcommands issued outside trackfw branch new (global, all trackfw projects)',
714
+ trigger: 'PreToolUse',
715
+ matcher: 'shell',
716
+ action: { type: 'command', command: scriptPath },
717
+ },
718
+ {
719
+ name: 'trackfw-git-branch-guard-global-post',
720
+ description: 'Warns on branch-creation git subcommands issued outside trackfw branch new (global, all trackfw projects)',
721
+ trigger: 'PostToolUse',
722
+ matcher: 'shell',
723
+ action: { type: 'command', command: scriptPath },
724
+ },
725
+ ],
726
+ },
727
+ null,
728
+ 2
729
+ ) + '\n'
730
+
731
+ try {
732
+ const exists = fs.existsSync(filePath)
733
+ const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
734
+
735
+ if (!exists && !installMissing) return { id, state: 'missing', path: displayPath }
736
+ if (exists && actual === desired) return { id, state: 'skipped', path: displayPath }
737
+
738
+ if (dryRun) return { id, state: 'updated', path: displayPath }
739
+
740
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
741
+ fs.writeFileSync(filePath, desired, 'utf8')
742
+ return { id, state: 'updated', path: displayPath }
743
+ } catch (e) {
744
+ return { id, state: 'failed', path: displayPath, message: e.message }
745
+ }
746
+ }
747
+
465
748
  // catalogBundleTarget — one target per (tool, kind) pair at global scope.
466
749
  // Uses IntegrationManager.inspect (read-only) to classify every catalog
467
750
  // item under that pair, then only calls manager.update() for the subset
@@ -475,7 +758,7 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
475
758
  let displayPath = `~/.${toolId}`
476
759
  try {
477
760
  displayPath = globalGroupPath(toolId, kind)
478
- const plans = buildPlans(kind, { targets: [toolId], scope: 'global', identity: identityConfig })
761
+ const plans = buildPlans(kind, { targets: [toolId], scope: 'global', identity: identityConfig, agentModels: projectConfig.load().agentModels || {} })
479
762
  if (!plans.length) return { id, state: 'missing', path: displayPath }
480
763
 
481
764
  const manager = new IntegrationManager({ homeRoot })
@@ -506,18 +789,20 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
506
789
  }
507
790
 
508
791
  // HARNESS_TARGET_IDS — mirrors internal/generators/update.go:HarnessTargetIDs.
509
- // "codex-credential-guard"/"gemini-credential-guard" are each inserted
510
- // immediately BEFORE their tool's "-agents"/"-skills" pair (same relative
511
- // position as claude-credential-guard, which precedes claude-agents/
512
- // claude-skills) — see buildHarnessTargetIDs's comment in update.go for the
513
- // full rationale.
514
- const HARNESS_TARGET_IDS = ['claude-skill', 'claude-credential-guard']
792
+ // "codex-credential-guard"/"codex-git-branch-guard",
793
+ // "gemini-credential-guard"/"gemini-git-branch-guard", etc. are each
794
+ // inserted immediately BEFORE their tool's "-agents"/"-skills" pair (same
795
+ // relative position as claude-credential-guard/claude-git-branch-guard,
796
+ // which precede claude-agents/claude-skills), with credential-guard always
797
+ // preceding git-branch-guard within a tool — see buildHarnessTargetIDs's
798
+ // comment in update.go for the full rationale.
799
+ const HARNESS_TARGET_IDS = ['claude-skill', 'claude-credential-guard', 'claude-git-branch-guard']
515
800
  for (const target of catalog.targets) {
516
- if (target.id === 'codex') HARNESS_TARGET_IDS.push('codex-credential-guard')
517
- if (target.id === 'gemini') HARNESS_TARGET_IDS.push('gemini-credential-guard')
518
- if (target.id === 'cursor') HARNESS_TARGET_IDS.push('cursor-credential-guard')
519
- if (target.id === 'copilot') HARNESS_TARGET_IDS.push('copilot-credential-guard')
520
- if (target.id === 'kiro') HARNESS_TARGET_IDS.push('kiro-credential-guard')
801
+ if (target.id === 'codex') HARNESS_TARGET_IDS.push('codex-credential-guard', 'codex-git-branch-guard')
802
+ if (target.id === 'gemini') HARNESS_TARGET_IDS.push('gemini-credential-guard', 'gemini-git-branch-guard')
803
+ if (target.id === 'cursor') HARNESS_TARGET_IDS.push('cursor-credential-guard', 'cursor-git-branch-guard')
804
+ if (target.id === 'copilot') HARNESS_TARGET_IDS.push('copilot-credential-guard', 'copilot-git-branch-guard')
805
+ if (target.id === 'kiro') HARNESS_TARGET_IDS.push('kiro-credential-guard', 'kiro-git-branch-guard')
521
806
  HARNESS_TARGET_IDS.push(`${target.id}-agents`, `${target.id}-skills`)
522
807
  }
523
808
 
@@ -531,22 +816,38 @@ function buildHarnessTargets(homeRoot, identityConfig, { dryRun, installMissing
531
816
  const targets = []
532
817
  if (include('claude-skill')) targets.push(claudeSkillTarget(homeRoot, { dryRun, installMissing }))
533
818
  if (include('claude-credential-guard')) targets.push(credentialGuardTargetClaude(homeRoot, { dryRun, installMissing }))
819
+ if (include('claude-git-branch-guard')) targets.push(gitBranchGuardTargetClaude(homeRoot, { dryRun, installMissing }))
534
820
  for (const target of catalog.targets) {
535
821
  if (target.id === 'codex' && include('codex-credential-guard')) {
536
822
  targets.push(credentialGuardTargetCodex(homeRoot, { dryRun, installMissing }))
537
823
  }
824
+ if (target.id === 'codex' && include('codex-git-branch-guard')) {
825
+ targets.push(gitBranchGuardTargetCodex(homeRoot, { dryRun, installMissing }))
826
+ }
538
827
  if (target.id === 'gemini' && include('gemini-credential-guard')) {
539
828
  targets.push(credentialGuardTargetGemini(homeRoot, { dryRun, installMissing }))
540
829
  }
830
+ if (target.id === 'gemini' && include('gemini-git-branch-guard')) {
831
+ targets.push(gitBranchGuardTargetGemini(homeRoot, { dryRun, installMissing }))
832
+ }
541
833
  if (target.id === 'cursor' && include('cursor-credential-guard')) {
542
834
  targets.push(credentialGuardTargetCursor(homeRoot, { dryRun, installMissing }))
543
835
  }
836
+ if (target.id === 'cursor' && include('cursor-git-branch-guard')) {
837
+ targets.push(gitBranchGuardTargetCursor(homeRoot, { dryRun, installMissing }))
838
+ }
544
839
  if (target.id === 'copilot' && include('copilot-credential-guard')) {
545
840
  targets.push(credentialGuardTargetCopilot(homeRoot, { dryRun, installMissing }))
546
841
  }
842
+ if (target.id === 'copilot' && include('copilot-git-branch-guard')) {
843
+ targets.push(gitBranchGuardTargetCopilot(homeRoot, { dryRun, installMissing }))
844
+ }
547
845
  if (target.id === 'kiro' && include('kiro-credential-guard')) {
548
846
  targets.push(credentialGuardTargetKiro(homeRoot, { dryRun, installMissing }))
549
847
  }
848
+ if (target.id === 'kiro' && include('kiro-git-branch-guard')) {
849
+ targets.push(gitBranchGuardTargetKiro(homeRoot, { dryRun, installMissing }))
850
+ }
550
851
  const agentsId = `${target.id}-agents`
551
852
  const skillsId = `${target.id}-skills`
552
853
  if (include(agentsId)) targets.push(catalogBundleTarget(target.id, 'agents', homeRoot, identityConfig, { dryRun, installMissing }))
@@ -627,4 +928,10 @@ module.exports = {
627
928
  credentialGuardTargetCursor,
628
929
  credentialGuardTargetCopilot,
629
930
  credentialGuardTargetKiro,
931
+ gitBranchGuardTargetClaude,
932
+ gitBranchGuardTargetCodex,
933
+ gitBranchGuardTargetGemini,
934
+ gitBranchGuardTargetCursor,
935
+ gitBranchGuardTargetCopilot,
936
+ gitBranchGuardTargetKiro,
630
937
  }
@@ -151,7 +151,7 @@ function codexProjectAgentsTarget(cwd, identityConfig, { dryRun, installMissing
151
151
  const manager = new IntegrationManager({ projectRoot: cwd });
152
152
  let wroteAny = false;
153
153
  for (const kind of ['agents', 'skills']) {
154
- const plans = buildPlans(kind, { targets: ['codex'], scope: 'project', identity: identityConfig });
154
+ const plans = buildPlans(kind, { targets: ['codex'], scope: 'project', identity: identityConfig, agentModels: projectConfig.load(cwd).agentModels || {} });
155
155
  const statuses = manager.inspect(plans);
156
156
  const toWrite = plans.filter((_, index) => {
157
157
  const state = statuses[index].state;
@@ -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',
@@ -278,6 +282,17 @@ function parse(content, cfg) {
278
282
  if (mode === 'warn' || mode === 'block') cfg.credentialGuard.mode = mode;
279
283
  }
280
284
 
285
+ // agent_models field — flat mapping from tier name to version string.
286
+ // See ADR-2026-08-21-versao-do-modelo-por-tier-com-composicao-por-alvo.md.
287
+ // An absent, malformed, or empty block leaves agentModels as the empty object from defaults(),
288
+ // preserving identical behavior to today. A key with an empty string value is stored as-is
289
+ // (the render layer treats empty string as "no pin" — fall back to tier alias).
290
+ if (m.agent_models !== undefined && typeof m.agent_models === 'object' && !Array.isArray(m.agent_models)) {
291
+ for (const [k, v] of Object.entries(m.agent_models)) {
292
+ if (typeof v === 'string') cfg.agentModels[k] = v;
293
+ }
294
+ }
295
+
281
296
  return false;
282
297
  }
283
298
 
@@ -298,7 +313,7 @@ function parseRulesFromContent(content) {
298
313
  // never creates them) — so this cannot be a bare { rules: {} } literal or parse() throws on any
299
314
  // content that sets one of those nested keys. rules starts empty (not seeded from defaults()),
300
315
  // deliberately: the caller wants exactly what `rules:` in content declares, nothing else.
301
- const cfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {} };
316
+ const cfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {}, agentModels: {} };
302
317
  parse(content, cfg);
303
318
  return cfg.rules;
304
319
  }
@@ -316,7 +331,7 @@ function readAgentConventions(cwd) {
316
331
  } catch (_) {
317
332
  return '';
318
333
  }
319
- const cfg = { rules: {}, credentialGuard: {}, update: { agentConventions: '' }, sync: {}, linkFields: {} };
334
+ const cfg = { rules: {}, credentialGuard: {}, update: { agentConventions: '' }, sync: {}, linkFields: {}, agentModels: {} };
320
335
  try {
321
336
  parse(content, cfg);
322
337
  } catch (_) {