trackfw 7.2.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.
@@ -48,17 +48,20 @@ function resolveRoadmapFile(cfg, roadmapArg) {
48
48
  // recognised as a wave heading (neither valid nor malformed).
49
49
  const WAVE_SCAN_RE = /^## Wave (\S+) /
50
50
 
51
- // Grammar: <integer>[-<suffix>] where integer >= 1 and suffix is [a-z0-9]+ (lowercase).
52
- // Valid: 1, 2, 2-bis, 2-hotfix, 10-a2.
53
- // Invalid: X, 2-BIS, -bis, 2-, 2-bis-ter, 0.
51
+ // Grammar: <integer>[-<suffix>] where integer >= 0 and suffix is [a-z0-9]+ (lowercase).
52
+ // Valid: 0, 1, 2, 2-bis, 2-hotfix, 10-a2. 0 is the Wave 0 threat-model convention
53
+ // (docs/cli-parity.md § "Wave label grammar").
54
+ // Invalid: X, 2-BIS, -bis, 2-, 2-bis-ter.
54
55
  const WAVE_LABEL_RE = /^(\d+)(?:-([a-z0-9]+))?$/
55
56
 
56
57
  // isValidWaveLabel returns true iff token matches the grammar above.
57
58
  // Exported so unit tests can assert the full table of valid/invalid examples.
59
+ // Shared by both guard points (the --wave flag validation and the heading
60
+ // pre-pass in findWave) — a single function, unlike Go's two duplicated checks.
58
61
  function isValidWaveLabel(token) {
59
62
  const m = WAVE_LABEL_RE.exec(String(token))
60
63
  if (!m) return false
61
- return parseInt(m[1], 10) >= 1
64
+ return parseInt(m[1], 10) >= 0
62
65
  }
63
66
 
64
67
  // ────────────────────────────────────────────────────────────────────────────
@@ -224,7 +227,96 @@ function evalAcceptanceEvidence(mls) {
224
227
  return { name: 'acceptance_evidence', status, evidence, failures }
225
228
  }
226
229
 
227
- function evalGates(commands, cwd) {
230
+ // ────────────────────────────────────────────────────────────────────────────
231
+ // Roadmap trust check (AC11, AC12 — docs/cli-parity.md § Trust and --trust-local-gates)
232
+ // ────────────────────────────────────────────────────────────────────────────
233
+
234
+ // roadmapTrustForGates determines whether the gates declared in a roadmap can
235
+ // be trusted for execution without --trust-local-gates.
236
+ //
237
+ // Decision (AC4, AC11): the discriminant is git — a roadmap whose content
238
+ // differs from origin/main, or that is absent from origin/main, is untrusted.
239
+ //
240
+ // Returns { trusted: true } or { trusted: false, failureMsg: '...' }.
241
+ // Fail-open when not in a git repo, origin/main not resolvable, or any git
242
+ // error other than "path absent from origin/main". See docs/cli-parity.md.
243
+ function roadmapTrustForGates(roadmapPath) {
244
+ const roadmapDir = path.dirname(roadmapPath)
245
+
246
+ // Step 1: check if we are inside a git repository.
247
+ const revParse = spawnSync('git', ['rev-parse', '--git-dir'], {
248
+ cwd: roadmapDir,
249
+ encoding: 'utf8',
250
+ stdio: 'pipe',
251
+ })
252
+ if (revParse.status !== 0) {
253
+ // Not a git repo → fail-open.
254
+ return { trusted: true }
255
+ }
256
+
257
+ // Step 2: get the repository toplevel.
258
+ const topLevel = spawnSync('git', ['rev-parse', '--show-toplevel'], {
259
+ cwd: roadmapDir,
260
+ encoding: 'utf8',
261
+ stdio: 'pipe',
262
+ })
263
+ if (topLevel.status !== 0) {
264
+ return { trusted: true }
265
+ }
266
+ const repoRoot = topLevel.stdout.trim()
267
+
268
+ // Step 3: compute repo-relative path (always forward slashes for git).
269
+ const absRoadmap = path.resolve(roadmapPath)
270
+ const relPath = path.relative(repoRoot, absRoadmap).split(path.sep).join('/')
271
+
272
+ // Step 4: retrieve the file at origin/main.
273
+ const show = spawnSync('git', ['show', `origin/main:${relPath}`], {
274
+ cwd: repoRoot,
275
+ encoding: 'utf8',
276
+ stdio: 'pipe',
277
+ })
278
+ if (show.status !== 0) {
279
+ // If the path specifically does not exist in origin/main → untrusted.
280
+ const stderr = show.stderr || ''
281
+ if (stderr.includes('does not exist in') || stderr.includes('exists on disk, but not in')) {
282
+ return {
283
+ trusted: false,
284
+ failureMsg: 'gates not evaluated: roadmap is not committed in origin/main — pass --trust-local-gates to evaluate local gates',
285
+ }
286
+ }
287
+ // Other failures (no remote, not fetched) → fail-open.
288
+ return { trusted: true }
289
+ }
290
+
291
+ // Step 5: compare content byte-for-byte.
292
+ let localContent
293
+ try {
294
+ localContent = fs.readFileSync(roadmapPath, 'utf8')
295
+ } catch (_) {
296
+ return { trusted: true }
297
+ }
298
+ if (show.stdout !== localContent) {
299
+ return {
300
+ trusted: false,
301
+ failureMsg: 'gates not evaluated: roadmap content differs from origin/main — pass --trust-local-gates to evaluate local gates',
302
+ }
303
+ }
304
+ return { trusted: true }
305
+ }
306
+
307
+ function evalGates(commands, cwd, trustResult = { trusted: true }) {
308
+ // trustResult: { trusted: true } | { trusted: false, failureMsg: string }
309
+ if (!trustResult.trusted) {
310
+ // Roadmap is not trusted: do not execute gates (AC3, AC14).
311
+ // Report as not_evaluated — distinct from passed and blocked (AC6).
312
+ return {
313
+ name: 'gates',
314
+ status: 'not_evaluated',
315
+ commands,
316
+ evidence: [],
317
+ failures: [trustResult.failureMsg],
318
+ }
319
+ }
228
320
  const evidence = []
229
321
  const failures = []
230
322
  for (const command of commands) {
@@ -303,7 +395,7 @@ function printTextReport(doc) {
303
395
  // Command
304
396
  // ────────────────────────────────────────────────────────────────────────────
305
397
 
306
- async function runBarrier(roadmapArg, waveOption, jsonOutput) {
398
+ async function runBarrier(roadmapArg, waveOption, jsonOutput, trustLocalGates) {
307
399
  if (waveOption === undefined || waveOption === null || String(waveOption).trim() === '') {
308
400
  throw new UsageError('--wave is required')
309
401
  }
@@ -322,10 +414,15 @@ async function runBarrier(roadmapArg, waveOption, jsonOutput) {
322
414
  const mls = findMLs(lines, wave.startLine, wave.endLine)
323
415
  const gates = parseGates(lines, wave.startLine, wave.endLine)
324
416
 
417
+ // Determine trust for gate execution (AC11, AC12).
418
+ const trustResult = trustLocalGates
419
+ ? { trusted: true }
420
+ : roadmapTrustForGates(resolved.path)
421
+
325
422
  const checks = []
326
423
  checks.push(evalMlsComplete(mls, waveLabel))
327
424
  checks.push(evalAcceptanceEvidence(mls))
328
- checks.push(evalGates(gates.commands, process.cwd()))
425
+ checks.push(evalGates(gates.commands, process.cwd(), trustResult))
329
426
  checks.push(await evalValidate())
330
427
  const finishedAt = new Date()
331
428
 
@@ -351,9 +448,10 @@ function createBarrierCommand() {
351
448
  .argument('<roadmap>', 'Roadmap basename, with or without .md')
352
449
  .option('--wave <label>', 'Wave label to evaluate (e.g. 1, 2-bis, 2-hotfix)')
353
450
  .option('--json', 'Emit the result document as JSON instead of a text report')
451
+ .option('--trust-local-gates', 'Trust the local roadmap content for gate execution without comparing to origin/main (used by the /trackfw:barrier slash command for WIP roadmaps)')
354
452
  .action(async (roadmapArg, options) => {
355
453
  try {
356
- const exitCode = await runBarrier(roadmapArg, options.wave, !!options.json)
454
+ const exitCode = await runBarrier(roadmapArg, options.wave, !!options.json, !!options.trustLocalGates)
357
455
  process.exit(exitCode)
358
456
  } catch (err) {
359
457
  if (err instanceof UsageError) {
@@ -378,6 +476,7 @@ module.exports.findMLs = findMLs
378
476
  module.exports.mlCompletionStatus = mlCompletionStatus
379
477
  module.exports.mlAcceptanceEvidence = mlAcceptanceEvidence
380
478
  module.exports.parseGates = parseGates
479
+ module.exports.roadmapTrustForGates = roadmapTrustForGates
381
480
  module.exports.evalMlsComplete = evalMlsComplete
382
481
  module.exports.evalAcceptanceEvidence = evalAcceptanceEvidence
383
482
  module.exports.evalGates = evalGates
@@ -2,16 +2,23 @@
2
2
 
3
3
  const { Command } = require('commander')
4
4
  const { runDoctor, UNREGISTERED_WRITE, HAND_MODIFIED, UNKNOWN_CONTENT } = require('../integrations/doctor')
5
+ const { runScaffoldDoctor, SCAFFOLD_DIVERGENT, SCAFFOLD_MISSING, SCAFFOLD_WRONG_MODE } = require('../integrations/scaffold_doctor')
5
6
  const projectConfig = require('../config')
6
7
 
7
8
  function printReport(findings) {
8
9
  if (findings.length === 0) {
9
- return 'trackfw doctor: no mismatches found -- disk matches the manifest for every catalog-managed artifact.'
10
+ return 'trackfw doctor: no mismatches found -- disk matches the manifest for every catalog-managed artifact and all scaffold templates.'
10
11
  }
11
12
  const unregistered = findings.filter(finding => finding.finding === UNREGISTERED_WRITE).length
12
13
  const handModified = findings.filter(finding => finding.finding === HAND_MODIFIED).length
13
14
  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`, '']
15
+ const scaffoldDivergent = findings.filter(finding => finding.finding === SCAFFOLD_DIVERGENT).length
16
+ const scaffoldMissing = findings.filter(finding => finding.finding === SCAFFOLD_MISSING).length
17
+ const scaffoldWrongMode = findings.filter(finding => finding.finding === SCAFFOLD_WRONG_MODE).length
18
+ const lines = [
19
+ `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`,
20
+ '',
21
+ ]
15
22
  for (const finding of findings) {
16
23
  lines.push(`[${finding.finding}] ${finding.destination}`, ` remedy: ${finding.remedy}`, '')
17
24
  }
@@ -22,7 +29,13 @@ const cmd = new Command('doctor')
22
29
  cmd.description('Detect artifacts on disk missing from the manifest, distinguishing hand-modified artifacts from unknown content')
23
30
  cmd.option('--json', 'Emit findings as a JSON array instead of the text report')
24
31
  cmd.action(async options => {
25
- const findings = runDoctor({ agentModels: projectConfig.load().agentModels || {} })
32
+ const projectRoot = process.cwd()
33
+ const catalogFindings = runDoctor({ agentModels: projectConfig.load().agentModels || {} })
34
+ // Scaffold coverage (ADR-2026-08-27): compare scaffold artifacts on disk against
35
+ // the templates the current binary would generate, using the project's own
36
+ // trackfw.yaml. No manifest entry is written or read (AC3).
37
+ const scaffoldFindings = runScaffoldDoctor(projectRoot)
38
+ const findings = [...catalogFindings, ...scaffoldFindings]
26
39
  if (options.json) {
27
40
  process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`)
28
41
  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())
@@ -221,7 +221,9 @@ function createLifecycleCommand(kind) {
221
221
  }
222
222
  }
223
223
 
224
- options.agentModels = configModule.load().agentModels || {}
224
+ const { models: resolvedModels, warning: modelsWarning } = configModule.resolveAgentModels(options.scope, os.homedir(), process.cwd())
225
+ if (modelsWarning) process.stderr.write(modelsWarning + '\n')
226
+ options.agentModels = resolvedModels
225
227
  const output = execute(kind, operation, options)
226
228
  console.log(options.json ? JSON.stringify(output) : human(output))
227
229
  })
@@ -250,7 +252,22 @@ function createAgentModelsCommand() {
250
252
  const cmd = new Command('models')
251
253
  .description('Show the resolved model each agent uses per target')
252
254
  cmd.action(() => {
253
- const agentModels = configModule.load().agentModels || {}
255
+ // AC5 + AC11: read agent_models from the global config (~/.trackfw/trackfw.yaml),
256
+ // not from the cwd singleton. Show origin before the table.
257
+ const { models: agentModels, source: modelsSource } = configModule.loadGlobalAgentModels(os.homedir(), process.cwd())
258
+
259
+ // Source line (AC5): show origin before the table; advisory to stderr when not resolved.
260
+ const sourceLines = {
261
+ global: 'source: ~/.trackfw/trackfw.yaml',
262
+ none: 'source: não configurado',
263
+ project_only: 'source: trackfw.yaml do projeto (não vale para escopo global)',
264
+ global_malformed: 'source: arquivo global malformado',
265
+ }
266
+ const sourceLine = sourceLines[modelsSource] || sourceLines['none']
267
+ process.stdout.write(sourceLine + '\n')
268
+ if (modelsSource === 'none') process.stderr.write(configModule.GLOBAL_AGENT_MODELS_NONE_MESSAGE + '\n')
269
+ else if (modelsSource === 'project_only') process.stderr.write(configModule.GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE + '\n')
270
+ else if (modelsSource === 'global_malformed') process.stderr.write(configModule.MALFORMED_GLOBAL_CONFIG_MESSAGE + '\n')
254
271
 
255
272
  // Warnings: emit once per suspect tier, sorted, to stderr.
256
273
  const suspectTiers = Object.keys(agentModels)
@@ -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')
@@ -186,13 +189,15 @@ async function executeThirdPartyInstall(kind, options) {
186
189
  // write happens (including the skill file below) — failing everything up
187
190
  // front avoids leaving a partial state (skill file written, no reference
188
191
  // injected) on a precondition failure.
192
+ const { models: resolvedAgentModels, warning: agentModelsWarning } = configModule.resolveAgentModels(scope, os.homedir(), projectRoot)
193
+ if (agentModelsWarning) process.stderr.write(agentModelsWarning + '\n')
189
194
  let identityConfig
190
195
  if (options.applyTo && options.applyTo.length) {
191
196
  identityConfig = identityStore.load(manager.roots.global)
192
197
  for (const agentID of options.applyTo) {
193
198
  if (!catalogItems('agents').some(item => item.id === agentID)) throw new Error(`unknown agent item "${agentID}"`)
194
199
  for (const rt of resolvedTargets) {
195
- const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot })
200
+ const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot, agentModels: resolvedAgentModels })
196
201
  if (!agentPlans.length) throw new Error(`target ${rt.targetID} has no supported agents surface for item "${agentID}"`)
197
202
  const inspection = manager.inspect([agentPlans[0]])[0]
198
203
  // ADR imprecision found and resolved here (reported, not silently
@@ -333,7 +338,7 @@ async function executeThirdPartyInstall(kind, options) {
333
338
  // immediately — not only on the next `agents update`. buildPlans
334
339
  // picks up the registry entry just written via
335
340
  // applyThirdPartyReferences (../integrations/index.js).
336
- const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot })
341
+ const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot, agentModels: resolvedAgentModels })
337
342
  manager.update(agentPlans, {})
338
343
  }
339
344
  }
@@ -758,7 +758,9 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
758
758
  let displayPath = `~/.${toolId}`
759
759
  try {
760
760
  displayPath = globalGroupPath(toolId, kind)
761
- const plans = buildPlans(kind, { targets: [toolId], scope: 'global', identity: identityConfig, agentModels: projectConfig.load().agentModels || {} })
761
+ const { models: harnessAgentModels, warning: harnessWarnMsg } = projectConfig.resolveAgentModels('global', homeRoot, process.cwd())
762
+ if (harnessWarnMsg) process.stderr.write(harnessWarnMsg + '\n')
763
+ const plans = buildPlans(kind, { targets: [toolId], scope: 'global', identity: identityConfig, agentModels: harnessAgentModels })
762
764
  if (!plans.length) return { id, state: 'missing', path: displayPath }
763
765
 
764
766
  const manager = new IntegrationManager({ homeRoot })
@@ -200,6 +200,10 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
200
200
  path: 'CLAUDE.md, AGENTS.md, GEMINI.md, .github/copilot-instructions.md, .windsurfrules, .amazonq/developer/guidelines.md, .cursor/rules/trackfw.mdc',
201
201
  root: cwd,
202
202
  relPaths: ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.github/copilot-instructions.md', '.windsurfrules', '.amazonq/developer/guidelines.md', '.cursor/rules/trackfw.mdc'],
203
+ // Gap E: injectRulesDetected → readAgentConventions reads trackfw.yaml from root;
204
+ // without it in the dry-run sandbox, agent_conventions is silently omitted from
205
+ // CLAUDE.md, producing a hash that diverges from the real run.
206
+ seeds: ['trackfw.yaml'],
203
207
  apply: (root) => generators.injectRulesDetected(root),
204
208
  dryRun,
205
209
  installMissing,
@@ -207,7 +211,7 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
207
211
 
208
212
  if (include('agent-hooks')) targets.push(runFileTarget({
209
213
  id: 'agent-hooks',
210
- path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh, scripts/trackfw-credential-guard.sh, scripts/trackfw-git-branch-guard.sh',
214
+ path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh, scripts/trackfw-credential-guard.sh, scripts/trackfw-git-branch-guard.sh, .windsurf/hooks.json, .amazonq/cli-agents/q_cli_default.json',
211
215
  root: cwd,
212
216
  relPaths: [
213
217
  '.claude/settings.json',
@@ -220,6 +224,21 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
220
224
  'scripts/trackfw-attention-cleanup.sh',
221
225
  'scripts/trackfw-credential-guard.sh',
222
226
  'scripts/trackfw-git-branch-guard.sh',
227
+ '.windsurf/hooks.json', // Gap A: injectWindsurfHooks writes this
228
+ '.amazonq/cli-agents/q_cli_default.json', // Gap B: injectAmazonQHooks writes this
229
+ ],
230
+ // Gap C: injectHooksDetected checks these files to decide which hooks to
231
+ // inject. Without them in the dry-run sandbox, hooks for installed agents
232
+ // (windsurf, copilot, etc.) are silently omitted — dry-run lies by omission.
233
+ // Gap E: trackfw.yaml may be read by hooks generators for config.
234
+ seeds: [
235
+ 'trackfw.yaml',
236
+ 'CLAUDE.md',
237
+ 'AGENTS.md',
238
+ 'GEMINI.md',
239
+ '.github/copilot-instructions.md',
240
+ '.windsurfrules',
241
+ '.amazonq/developer/guidelines.md',
223
242
  ],
224
243
  apply: (root) => {
225
244
  hooksGen.injectHooksDetected(root);
@@ -86,6 +86,21 @@ function defaults() {
86
86
 
87
87
  let _instance = null;
88
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
+
89
104
  // MALFORMED_CONFIG_MESSAGE is written to stderr, verbatim, when trackfw.yaml exists but fails to
90
105
  // parse as YAML. Kept identical, character-for-character, to Go's MalformedConfigMessage and
91
106
  // Python's MALFORMED_CONFIG_MESSAGE — see the comment on parse() below for why the text is
@@ -340,6 +355,65 @@ function readAgentConventions(cwd) {
340
355
  return cfg.update.agentConventions || '';
341
356
  }
342
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
+
343
417
  const NAMESPACING_FLAT = 'flat';
344
418
  const NAMESPACING_BY_AGENT = 'by_agent';
345
419
 
@@ -350,7 +424,12 @@ module.exports = {
350
424
  expandPath,
351
425
  parseRulesFromContent,
352
426
  readAgentConventions,
427
+ loadGlobalAgentModels,
428
+ resolveAgentModels,
353
429
  NAMESPACING_FLAT,
354
430
  NAMESPACING_BY_AGENT,
355
431
  MALFORMED_CONFIG_MESSAGE,
432
+ MALFORMED_GLOBAL_CONFIG_MESSAGE,
433
+ GLOBAL_AGENT_MODELS_NONE_MESSAGE,
434
+ GLOBAL_AGENT_MODELS_PROJECT_ONLY_MESSAGE,
356
435
  };
@@ -1047,6 +1047,8 @@ function generateGitBranchGuardScript(cwd) {
1047
1047
 
1048
1048
  const scriptPath = path.join(scriptsDir, 'trackfw-git-branch-guard.sh')
1049
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)
1050
1052
 
1051
1053
  console.log(' ✓ scripts/trackfw-git-branch-guard.sh')
1052
1054
  }
@@ -1084,6 +1086,8 @@ function generateCredentialGuardScript(cwd) {
1084
1086
 
1085
1087
  const scriptPath = path.join(scriptsDir, 'trackfw-credential-guard.sh')
1086
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)
1087
1091
 
1088
1092
  console.log(' ✓ scripts/trackfw-credential-guard.sh')
1089
1093
  }
@@ -1489,9 +1493,14 @@ function generateAttentionScripts(cfg, cwd) {
1489
1493
 
1490
1494
  const signalPath = path.join(scriptsDir, 'trackfw-attention-signal.sh')
1491
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)
1492
1499
 
1493
1500
  const cleanupPath = path.join(scriptsDir, 'trackfw-attention-cleanup.sh')
1494
1501
  fs.writeFileSync(cleanupPath, CLEANUP_SCRIPT, { encoding: 'utf8', mode: 0o755 })
1502
+ // AC9: same rationale as signalPath above.
1503
+ fs.chmodSync(cleanupPath, 0o755)
1495
1504
 
1496
1505
  console.log(' ✓ scripts/trackfw-attention-signal.sh')
1497
1506
  console.log(' ✓ scripts/trackfw-attention-cleanup.sh')
@@ -2255,4 +2264,13 @@ module.exports = {
2255
2264
  mergeCopilotHookArray,
2256
2265
  normalizeGuardPath,
2257
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,
2258
2276
  }