trackfw 7.2.0 → 7.4.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,7 +4,7 @@ const { Command } = require('commander')
4
4
  const fs = require('fs')
5
5
  const path = require('path')
6
6
  const config = require('../config')
7
- const { validate } = require('../validator')
7
+ const { validate, resolveAgentNamespaces, resolveReqFiles } = require('../validator')
8
8
 
9
9
  /**
10
10
  * extractFrontmatterField — extrai valor de campo YAML dentro de bloco --- ... ---.
@@ -85,11 +85,37 @@ function collectEntries(dir, type, state) {
85
85
  return entries
86
86
  }
87
87
 
88
+ /**
89
+ * collectReqEntries — entradas de REQ para o `context`, derivadas do ponto único de leitura
90
+ * (resolveReqFiles). O `state` sai do nome da pasta-pai quando ela é um estado legado; em layout
91
+ * flat ou no canônico req_dir/<agente>/*.md não há estado (invariante D1: REQ não tem estado).
92
+ * @param {object} cfg
93
+ * @returns {Array<{type:string,file:string,status:string,state?:string}>}
94
+ */
95
+ function collectReqEntries(cfg) {
96
+ const STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
97
+ const entries = []
98
+ for (const full of resolveReqFiles(cfg)) {
99
+ let content = ''
100
+ try { content = fs.readFileSync(full, 'utf8') } catch (_) {}
101
+ const parent = path.basename(path.dirname(full))
102
+ const state = STATES.includes(parent) ? parent : ''
103
+ let status = extractFrontmatterField(content, 'status')
104
+ if (!status) status = extractInlineStatus(content)
105
+ if (!status) status = state || 'unknown'
106
+ const entry = { type: 'REQ', file: path.basename(full), status }
107
+ if (state) entry.state = state
108
+ entries.push(entry)
109
+ }
110
+ return entries
111
+ }
112
+
88
113
  /**
89
114
  * getContext — coleta governança e imprime em md ou json.
90
115
  * @param {string} format - 'md' | 'json'
116
+ * @returns {Promise<void>}
91
117
  */
92
- function getContext(format) {
118
+ async function getContext(format) {
93
119
  const cfg = config.load()
94
120
 
95
121
  // ADRs
@@ -98,41 +124,15 @@ function getContext(format) {
98
124
  adrs.push(...collectEntries(adrDir, 'ADR'))
99
125
  }
100
126
 
101
- // REQs
102
- const reqs = []
103
- const reqDir = cfg.reqDir || cfg.req_dir || 'docs/req'
104
- const reqNamespacing = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
105
- if (reqNamespacing === 'by_agent') {
106
- const STATES = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
107
- let agents = cfg.agents || []
108
- if (!agents.length) {
109
- try {
110
- agents = fs.readdirSync(reqDir).filter(f => {
111
- try { return fs.statSync(path.join(reqDir, f)).isDirectory() } catch (_) { return false }
112
- })
113
- } catch (_) {}
114
- }
115
- for (const agent of agents) {
116
- for (const state of STATES) {
117
- reqs.push(...collectEntries(path.join(reqDir, agent, state), 'REQ', state))
118
- }
119
- }
120
- } else {
121
- reqs.push(...collectEntries(reqDir, 'REQ'))
122
- }
127
+ // REQs — pelo PONTO ÚNICO de leitura (ADR-2026-09-03, D3/D4). Antes, `context` montava a própria
128
+ // árvore (flat, ou <agente>/<estado>/ em by_agent) e não enxergava o layout canônico <agente>/*.md.
129
+ const reqs = collectReqEntries(cfg)
123
130
 
124
131
  // Roadmaps
125
132
  const roadmaps = []
126
133
  const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
127
134
  if (cfg.roadmapNamespacing === 'by_agent') {
128
- let agents = cfg.agents || []
129
- if (agents.length === 0) {
130
- try {
131
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
132
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
133
- })
134
- } catch (_) { agents = [] }
135
- }
135
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
136
136
  for (const agent of agents) {
137
137
  for (const state of states) {
138
138
  const dir = path.join(cfg.roadmapDir, agent, state)
@@ -147,7 +147,7 @@ function getContext(format) {
147
147
  }
148
148
 
149
149
  // Validate
150
- const { violations, warnings } = validate()
150
+ const { violations, warnings } = await validate()
151
151
 
152
152
  // Score
153
153
  let score = 0
@@ -202,8 +202,14 @@ module.exports = (function () {
202
202
  cmd
203
203
  .description('Print governance context for LLM consumption')
204
204
  .option('--format <fmt>', 'Output format: md or json', 'md')
205
- .action((opts) => {
206
- getContext(opts.format)
207
- })
205
+ // Retorna a Promise: o bin roda parseAsync() e só assim uma rejeição chega
206
+ // ao .catch(reportFatalError) com exit code 1 em vez de virar unhandled
207
+ // rejection (ML-1A, ROADMAP-2026-09-02-context-do-cli-node-aguarda-validate).
208
+ .action((opts) => getContext(opts.format))
208
209
  return cmd
209
210
  })()
211
+
212
+ // Exposto para teste: o teste de context em by_agent duplicava a lógica de coleta e, por isso,
213
+ // continuava verde mesmo quando a produção mudava (asserção sobre uma CÓPIA). Anexar a função ao
214
+ // Command exportado é o menor caminho para o teste exercitar o código de produção de fato.
215
+ module.exports.collectReqEntries = collectReqEntries
@@ -302,24 +302,72 @@ function installLefthook(rootDir) {
302
302
  }
303
303
  }
304
304
 
305
- function writeCIWorkflow(rootDir) {
306
- const workflowsDir = path.join(rootDir, '.github', 'workflows');
307
- if (!isDir(workflowsDir)) fs.mkdirSync(workflowsDir, { recursive: true });
308
- const dest = path.join(workflowsDir, 'trackfw-validate.yml');
309
- if (isFile(dest)) return; // idempotente
310
- const content = `name: trackfw validate
305
+ // DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH is the canonical relative path of the second,
306
+ // independent CI workflow trackfw writes — the one `trackfw discover --init` generates
307
+ // via installGates, distinct from GITHUB_ACTIONS_WORKFLOW_PATH (trackfw-gate.yml,
308
+ // written by init/update). Both files can coexist in the same project
309
+ // (ADR-2026-08-28). Exported so scaffold_doctor.js can compare against it by path.
310
+ const DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH = '.github/workflows/trackfw-validate.yml';
311
+
312
+ // buildDiscoverGitHubActionsWorkflowContent returns the template content trackfw writes
313
+ // to DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH. Mirrors
314
+ // generators.BuildDiscoverGitHubActionsWorkflowContent in
315
+ // internal/generators/scaffold_doctor.go (Go, canonical source of truth) byte-for-byte
316
+ // for the same version.
317
+ //
318
+ // NOT version-independent (ADR-2026-08-28, REQ-2026-08-28 AC6/AC7): the `go install
319
+ // .../cmd/trackfw@vX.Y.Z` step pins the second install mechanism (`go install ...@latest`)
320
+ // to the version of npm/package.json — the version of the CLI that generated/updated the
321
+ // project — mirroring the install.sh pin already applied to
322
+ // buildGitHubActionsWorkflowContent (trackfw-gate.yml) in generators/init.js. Never
323
+ // hardcoded: read lazily to avoid a circular require at module load time.
324
+ //
325
+ // Job id is `governance-go-install` (ML-1A, ROADMAP-2026-09-01) — was `governance`,
326
+ // colliding with buildGitHubActionsWorkflowContent's job id (trackfw-gate.yml,
327
+ // generators/init.js). See that function's doc comment for the full rationale; named
328
+ // after the install mechanism (`go install`), not the workflow file.
329
+ function buildDiscoverGitHubActionsWorkflowContent() {
330
+ const { version } = require('../../package.json');
331
+ return `name: trackfw validate
311
332
  on: [push, pull_request]
312
333
  jobs:
313
- governance:
334
+ governance-go-install:
314
335
  runs-on: ubuntu-latest
315
336
  steps:
316
337
  - uses: actions/checkout@v4
317
338
  - uses: actions/setup-go@v5
318
339
  with:
319
340
  go-version: "1.22"
320
- - run: go install github.com/kgsaran/trackfw/cmd/trackfw@latest
341
+ - run: go install github.com/kgsaran/trackfw/cmd/trackfw@v${version}
321
342
  - run: trackfw validate
322
343
  `;
344
+ }
345
+
346
+ function writeCIWorkflow(rootDir) {
347
+ const workflowsDir = path.join(rootDir, '.github', 'workflows');
348
+ if (!isDir(workflowsDir)) fs.mkdirSync(workflowsDir, { recursive: true });
349
+ const dest = path.join(workflowsDir, 'trackfw-validate.yml');
350
+ // Uses fs.lstatSync directly, NOT the isFile helper (fs.statSync, follows
351
+ // symlinks): a DANGLING symlink at dest resolves to "does not exist" under
352
+ // fs.statSync, so the idempotency guard below would not fire, and
353
+ // fs.writeFileSync would then follow the link and CREATE the workflow
354
+ // template at whatever path outside the project the symlink points to. A
355
+ // symlink here — live or dangling — is treated as "already present" so
356
+ // this function never writes through it; it refuses loudly instead of
357
+ // silently creating a file somewhere the caller never asked for.
358
+ let info
359
+ try {
360
+ info = fs.lstatSync(dest)
361
+ } catch (e) {
362
+ info = null
363
+ }
364
+ if (info) {
365
+ if (info.isSymbolicLink()) {
366
+ console.error(`aviso: ${DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH} é um symlink; trackfw discover não escreve através de symlinks — arquivo não foi tocado`)
367
+ }
368
+ return; // idempotente — não sobrescreve (nem segue o link)
369
+ }
370
+ const content = buildDiscoverGitHubActionsWorkflowContent();
323
371
  fs.writeFileSync(dest, content, 'utf8');
324
372
  }
325
373
 
@@ -551,19 +599,20 @@ function writeCIWorkflowForce(rootDir) {
551
599
  const workflowsDir = path.join(rootDir, '.github', 'workflows');
552
600
  if (!isDir(workflowsDir)) fs.mkdirSync(workflowsDir, { recursive: true });
553
601
  const dest = path.join(workflowsDir, 'trackfw-validate.yml');
554
- const content = `name: trackfw validate
555
- on: [push, pull_request]
556
- jobs:
557
- governance:
558
- runs-on: ubuntu-latest
559
- steps:
560
- - uses: actions/checkout@v4
561
- - uses: actions/setup-go@v5
562
- with:
563
- go-version: "1.22"
564
- - run: go install github.com/kgsaran/trackfw/cmd/trackfw@latest
565
- - run: trackfw validate
566
- `;
602
+ // Same symlink guard as writeCIWorkflow above: "force" means "overwrite
603
+ // an existing REGULAR file unconditionally", not "follow whatever this
604
+ // path resolves to". A symlink here — live or dangling — is refused, not
605
+ // followed.
606
+ try {
607
+ const info = fs.lstatSync(dest)
608
+ if (info.isSymbolicLink()) {
609
+ console.error(`aviso: ${DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH} é um symlink; trackfw discover não escreve através de symlinks — arquivo não foi tocado`)
610
+ return;
611
+ }
612
+ } catch (e) {
613
+ // does not exist — fine, this is the "force create" case
614
+ }
615
+ const content = buildDiscoverGitHubActionsWorkflowContent();
567
616
  fs.writeFileSync(dest, content, 'utf8');
568
617
  }
569
618
 
@@ -575,3 +624,5 @@ module.exports.writeValidateScript = writeValidateScript;
575
624
  module.exports.writeCIWorkflow = writeCIWorkflow;
576
625
  module.exports.writeCIWorkflowForce = writeCIWorkflowForce;
577
626
  module.exports.detectTestFramework = detectTestFramework;
627
+ module.exports.buildDiscoverGitHubActionsWorkflowContent = buildDiscoverGitHubActionsWorkflowContent;
628
+ module.exports.DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH = DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH;
@@ -1,17 +1,38 @@
1
1
  'use strict'
2
2
 
3
3
  const { Command } = require('commander')
4
- const { runDoctor, UNREGISTERED_WRITE, HAND_MODIFIED, UNKNOWN_CONTENT } = require('../integrations/doctor')
4
+ const {
5
+ runDoctor,
6
+ UNREGISTERED_WRITE,
7
+ HAND_MODIFIED,
8
+ UNKNOWN_CONTENT,
9
+ REQUIRED_STATUS_CHECKS_MISSING,
10
+ ENFORCE_ADMINS_DISABLED,
11
+ HOOKS_PATH_NEUTRALIZED,
12
+ NOT_EVALUATED,
13
+ } = require('../integrations/doctor')
14
+ const { runScaffoldDoctor, SCAFFOLD_DIVERGENT, SCAFFOLD_MISSING, SCAFFOLD_WRONG_MODE } = require('../integrations/scaffold_doctor')
15
+ const { runDoctorRemote } = require('../integrations/doctor_remote')
5
16
  const projectConfig = require('../config')
6
17
 
7
18
  function printReport(findings) {
8
19
  if (findings.length === 0) {
9
- return 'trackfw doctor: no mismatches found -- disk matches the manifest for every catalog-managed artifact.'
20
+ return 'trackfw doctor: no mismatches found -- disk matches the manifest for every catalog-managed artifact and all scaffold templates.'
10
21
  }
11
22
  const unregistered = findings.filter(finding => finding.finding === UNREGISTERED_WRITE).length
12
23
  const handModified = findings.filter(finding => finding.finding === HAND_MODIFIED).length
13
24
  const unknownContent = findings.filter(finding => finding.finding === UNKNOWN_CONTENT).length
14
- const lines = [`trackfw doctor: ${findings.length} finding(s) -- ${unregistered} unregistered-write, ${handModified} hand-modified, ${unknownContent} unknown-content`, '']
25
+ const scaffoldDivergent = findings.filter(finding => finding.finding === SCAFFOLD_DIVERGENT).length
26
+ const scaffoldMissing = findings.filter(finding => finding.finding === SCAFFOLD_MISSING).length
27
+ const scaffoldWrongMode = findings.filter(finding => finding.finding === SCAFFOLD_WRONG_MODE).length
28
+ const requiredChecksMissing = findings.filter(finding => finding.finding === REQUIRED_STATUS_CHECKS_MISSING).length
29
+ const enforceAdminsDisabled = findings.filter(finding => finding.finding === ENFORCE_ADMINS_DISABLED).length
30
+ const hooksPathNeutralized = findings.filter(finding => finding.finding === HOOKS_PATH_NEUTRALIZED).length
31
+ const notEvaluated = findings.filter(finding => finding.finding === NOT_EVALUATED).length
32
+ const lines = [
33
+ `trackfw doctor: ${findings.length} finding(s) -- ${unregistered} unregistered-write, ${handModified} hand-modified, ${unknownContent} unknown-content, ${scaffoldDivergent} scaffold-divergent, ${scaffoldMissing} scaffold-missing, ${scaffoldWrongMode} scaffold-wrong-mode, ${requiredChecksMissing} required-status-checks-missing, ${enforceAdminsDisabled} enforce-admins-disabled, ${hooksPathNeutralized} hooks-path-neutralized, ${notEvaluated} not-evaluated`,
34
+ '',
35
+ ]
15
36
  for (const finding of findings) {
16
37
  lines.push(`[${finding.finding}] ${finding.destination}`, ` remedy: ${finding.remedy}`, '')
17
38
  }
@@ -21,8 +42,20 @@ function printReport(findings) {
21
42
  const cmd = new Command('doctor')
22
43
  cmd.description('Detect artifacts on disk missing from the manifest, distinguishing hand-modified artifacts from unknown content')
23
44
  cmd.option('--json', 'Emit findings as a JSON array instead of the text report')
45
+ cmd.option('--remote', 'Also check GitHub branch protection (required_status_checks, enforce_admins) and local core.hooksPath neutralization. Requires network + a GitHub credential with admin access; absence of either is reported as not-evaluated, never as a pass (ADR-2026-09-02).')
24
46
  cmd.action(async options => {
25
- const findings = runDoctor({ agentModels: projectConfig.load().agentModels || {} })
47
+ const projectRoot = process.cwd()
48
+ const catalogFindings = runDoctor({ agentModels: projectConfig.load().agentModels || {} })
49
+ // Scaffold coverage (ADR-2026-08-27): compare scaffold artifacts on disk against
50
+ // the templates the current binary would generate, using the project's own
51
+ // trackfw.yaml. No manifest entry is written or read (AC3).
52
+ const scaffoldFindings = runScaffoldDoctor(projectRoot)
53
+ let findings = [...catalogFindings, ...scaffoldFindings]
54
+ // Remote modality (ADR-2026-09-02, ML-3A): opt-in only — never runs without --remote, so
55
+ // doctor's offline default is unchanged.
56
+ if (options.remote) {
57
+ findings = [...findings, ...runDoctorRemote({ configForge: projectConfig.load().forge || '', repoDir: projectRoot })]
58
+ }
26
59
  if (options.json) {
27
60
  process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`)
28
61
  return
@@ -57,6 +57,7 @@ function createProgram() {
57
57
  program.addCommand(require('./commit'))
58
58
  program.addCommand(require('./push'))
59
59
  program.addCommand(require('./doctor'))
60
+ program.addCommand(require('./audit-surface'))
60
61
 
61
62
  const { createServeCommand } = require('./serve')
62
63
  program.addCommand(createServeCommand())
@@ -5,6 +5,7 @@ const identityStore = require('../identity')
5
5
  const identityWizard = require('./identity-wizard')
6
6
  const { resolveIdentityPreset, identityFileExists } = identityWizard
7
7
  const { resolveScope } = require('./integrations')
8
+ const { homedir } = require('../homedir')
8
9
 
9
10
  const cmd = new Command('init')
10
11
  cmd.description(t('init.description'))
@@ -16,7 +17,7 @@ cmd.action(async (options, command) => {
16
17
  const path = require('path')
17
18
  const generators = require('../generators/init')
18
19
 
19
- const home = os.homedir()
20
+ const home = homedir()
20
21
 
21
22
  // A validação e persistência da flag acontecem incondicionalmente, acima
22
23
  // do early-return não-TTY abaixo — é isso que faz um --identity-preset
@@ -10,6 +10,7 @@ const identityWizard = require('./identity-wizard')
10
10
  const { t } = require('../i18n')
11
11
  const { createThirdPartyCommand } = require('./thirdparty')
12
12
  const configModule = require('../config')
13
+ const { homedir } = require('../homedir')
13
14
 
14
15
  const csv = value => String(value).split(',').map(entry => entry.trim()).filter(Boolean)
15
16
  const collect = (value, previous) => previous.concat(value)
@@ -179,7 +180,7 @@ function createLifecycleCommand(kind) {
179
180
  let presetChanged = false
180
181
  if (mutation && kind === 'agents') {
181
182
  presetChanged = cmd.getOptionValueSource('identityPreset') === 'cli'
182
- if (presetChanged) identityWizard.applyIdentityPresetFlag(os.homedir(), options.identityPreset, operation)
183
+ if (presetChanged) identityWizard.applyIdentityPresetFlag(homedir(), options.identityPreset, operation)
183
184
  }
184
185
 
185
186
  if (mutation && (!options.targets || !options.targets.length)) {
@@ -197,7 +198,7 @@ function createLifecycleCommand(kind) {
197
198
  // planos abaixo. Espelha
198
199
  // internal/commands/integrations_flags.go:executeIntegrationMutation.
199
200
  if (mutation && kind === 'agents' && !presetChanged) {
200
- const homeRoot = os.homedir()
201
+ const homeRoot = homedir()
201
202
  const identityExists = identityWizard.identityFileExists(homeRoot)
202
203
  const isTTY = Boolean(process.stdin.isTTY)
203
204
  if (identityWizard.shouldPromptIdentity(kind, isTTY, identityExists, forceIdentity)) {
@@ -221,7 +222,9 @@ function createLifecycleCommand(kind) {
221
222
  }
222
223
  }
223
224
 
224
- options.agentModels = configModule.load().agentModels || {}
225
+ const { models: resolvedModels, warning: modelsWarning } = configModule.resolveAgentModels(options.scope, homedir(), process.cwd())
226
+ if (modelsWarning) process.stderr.write(modelsWarning + '\n')
227
+ options.agentModels = resolvedModels
225
228
  const output = execute(kind, operation, options)
226
229
  console.log(options.json ? JSON.stringify(output) : human(output))
227
230
  })
@@ -250,7 +253,22 @@ function createAgentModelsCommand() {
250
253
  const cmd = new Command('models')
251
254
  .description('Show the resolved model each agent uses per target')
252
255
  cmd.action(() => {
253
- const agentModels = configModule.load().agentModels || {}
256
+ // AC5 + AC11: read agent_models from the global config (~/.trackfw/trackfw.yaml),
257
+ // not from the cwd singleton. Show origin before the table.
258
+ const { models: agentModels, source: modelsSource } = configModule.loadGlobalAgentModels(homedir(), process.cwd())
259
+
260
+ // Source line (AC5): show origin before the table; advisory to stderr when not resolved.
261
+ const sourceLines = {
262
+ global: 'source: ~/.trackfw/trackfw.yaml',
263
+ none: 'source: não configurado',
264
+ project_only: 'source: trackfw.yaml do projeto (não vale para escopo global)',
265
+ global_malformed: 'source: arquivo global malformado',
266
+ }
267
+ const sourceLine = sourceLines[modelsSource] || sourceLines['none']
268
+ process.stdout.write(sourceLine + '\n')
269
+ if (modelsSource === 'none') process.stderr.write(configModule.GLOBAL_AGENT_MODELS_NONE_MESSAGE + '\n')
270
+ else if (modelsSource === 'project_only') process.stderr.write(configModule.GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE + '\n')
271
+ else if (modelsSource === 'global_malformed') process.stderr.write(configModule.MALFORMED_GLOBAL_CONFIG_MESSAGE + '\n')
254
272
 
255
273
  // Warnings: emit once per suspect tier, sorted, to stderr.
256
274
  const suspectTiers = Object.keys(agentModels)
@@ -4,6 +4,7 @@ const os = require('os')
4
4
  const { Command } = require('commander')
5
5
  const { listREQs, moveREQ } = require('../generators/req')
6
6
  const { t } = require('../i18n')
7
+ const { homedir } = require('../homedir')
7
8
 
8
9
  const cmd = new Command('req')
9
10
  cmd.description(t('req.description'))
@@ -38,7 +39,7 @@ cmd.command('new <title>')
38
39
  default: 'local',
39
40
  })
40
41
  const adrDir = adrScope === 'global'
41
- ? path.join(os.homedir(), '.trackfw', 'adr')
42
+ ? path.join(homedir(), '.trackfw', 'adr')
42
43
  : require('../config').load().adrDirs[0]
43
44
 
44
45
  // Perguntas dinâmicas por probe
@@ -6,8 +6,11 @@
6
6
  // `trackfw skills third-party` (D1). See:
7
7
  // docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
8
8
 
9
+ const os = require('os')
10
+
9
11
  const { Command } = require('commander')
10
12
 
13
+ const configModule = require('../config')
11
14
  const identityStore = require('../identity')
12
15
  const { items: catalogItems } = require('../integrations/catalog')
13
16
  const { buildPlans, IntegrationManager } = require('../integrations')
@@ -16,6 +19,7 @@ const { checkMarkers, checksum: sha256hex } = require('../thirdparty/markers')
16
19
  const { quarantinePath, newQuarantineEntry, writeQuarantine, readQuarantine, decodeContent } = require('../thirdparty/quarantine')
17
20
  const { loadProvenance, verifyApproval, upsertProvenanceEntry } = require('../thirdparty/provenance')
18
21
  const { upsertThirdPartyReference, normalizeThirdPartyContent, resolveThirdPartySkillDestination } = require('../thirdparty/references')
22
+ const { homedir } = require('../homedir')
19
23
 
20
24
  // api is the module.exports object itself, referenced internally via
21
25
  // `api.thirdPartyFetch(...)` so tests can substitute the network fetch by
@@ -186,13 +190,15 @@ async function executeThirdPartyInstall(kind, options) {
186
190
  // write happens (including the skill file below) — failing everything up
187
191
  // front avoids leaving a partial state (skill file written, no reference
188
192
  // injected) on a precondition failure.
193
+ const { models: resolvedAgentModels, warning: agentModelsWarning } = configModule.resolveAgentModels(scope, homedir(), projectRoot)
194
+ if (agentModelsWarning) process.stderr.write(agentModelsWarning + '\n')
189
195
  let identityConfig
190
196
  if (options.applyTo && options.applyTo.length) {
191
197
  identityConfig = identityStore.load(manager.roots.global)
192
198
  for (const agentID of options.applyTo) {
193
199
  if (!catalogItems('agents').some(item => item.id === agentID)) throw new Error(`unknown agent item "${agentID}"`)
194
200
  for (const rt of resolvedTargets) {
195
- const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot })
201
+ const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot, agentModels: resolvedAgentModels })
196
202
  if (!agentPlans.length) throw new Error(`target ${rt.targetID} has no supported agents surface for item "${agentID}"`)
197
203
  const inspection = manager.inspect([agentPlans[0]])[0]
198
204
  // ADR imprecision found and resolved here (reported, not silently
@@ -333,7 +339,7 @@ async function executeThirdPartyInstall(kind, options) {
333
339
  // immediately — not only on the next `agents update`. buildPlans
334
340
  // picks up the registry entry just written via
335
341
  // applyThirdPartyReferences (../integrations/index.js).
336
- const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot })
342
+ const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot, agentModels: resolvedAgentModels })
337
343
  manager.update(agentPlans, {})
338
344
  }
339
345
  }
@@ -8,6 +8,7 @@ const projectConfig = require('../config')
8
8
  const { catalog, buildPlans, IntegrationManager, globalGroupPath } = require('../integrations')
9
9
  const { tildeify, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine')
10
10
  const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, generateGlobalCredentialGuardScript, generateGlobalGitBranchGuardScript } = require('../generators/hooks')
11
+ const { homedir } = require('../homedir')
11
12
 
12
13
  // `trackfw update harness` is the global counterpart to `trackfw update` —
13
14
  // see docs/cli-parity.md, "`trackfw update` vs `trackfw update harness`".
@@ -40,7 +41,7 @@ const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, ge
40
41
 
41
42
  // claudeSkillContent — mirrors generators/init.js:installSkillsForce
42
43
  // (npm/src/generators/init.js:1242) byte-for-byte. Duplicated here, not
43
- // imported, because installSkillsForce() bakes os.homedir() internally with
44
+ // imported, because installSkillsForce() bakes homedir() internally with
44
45
  // an unconditional overwrite and no dry-run mode — it cannot be reused
45
46
  // without either mutating on --dry-run or forking its signature. Reported
46
47
  // as a follow-up cleanup opportunity in the ML-6C handoff report.
@@ -758,7 +759,9 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
758
759
  let displayPath = `~/.${toolId}`
759
760
  try {
760
761
  displayPath = globalGroupPath(toolId, kind)
761
- const plans = buildPlans(kind, { targets: [toolId], scope: 'global', identity: identityConfig, agentModels: projectConfig.load().agentModels || {} })
762
+ const { models: harnessAgentModels, warning: harnessWarnMsg } = projectConfig.resolveAgentModels('global', homeRoot, process.cwd())
763
+ if (harnessWarnMsg) process.stderr.write(harnessWarnMsg + '\n')
764
+ const plans = buildPlans(kind, { targets: [toolId], scope: 'global', identity: identityConfig, agentModels: harnessAgentModels })
762
765
  if (!plans.length) return { id, state: 'missing', path: displayPath }
763
766
 
764
767
  const manager = new IntegrationManager({ homeRoot })
@@ -878,7 +881,7 @@ function run(options) {
878
881
  process.exit(1)
879
882
  }
880
883
 
881
- const homeRoot = os.homedir()
884
+ const homeRoot = homedir()
882
885
  const dryRun = Boolean(options.dryRun)
883
886
  const installMissing = Boolean(options.installMissing)
884
887