trackfw 7.3.0 → 7.5.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.
@@ -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,8 +1,18 @@
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')
5
14
  const { runScaffoldDoctor, SCAFFOLD_DIVERGENT, SCAFFOLD_MISSING, SCAFFOLD_WRONG_MODE } = require('../integrations/scaffold_doctor')
15
+ const { runDoctorRemote } = require('../integrations/doctor_remote')
6
16
  const projectConfig = require('../config')
7
17
 
8
18
  function printReport(findings) {
@@ -15,8 +25,12 @@ function printReport(findings) {
15
25
  const scaffoldDivergent = findings.filter(finding => finding.finding === SCAFFOLD_DIVERGENT).length
16
26
  const scaffoldMissing = findings.filter(finding => finding.finding === SCAFFOLD_MISSING).length
17
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
18
32
  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`,
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`,
20
34
  '',
21
35
  ]
22
36
  for (const finding of findings) {
@@ -28,6 +42,7 @@ function printReport(findings) {
28
42
  const cmd = new Command('doctor')
29
43
  cmd.description('Detect artifacts on disk missing from the manifest, distinguishing hand-modified artifacts from unknown content')
30
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).')
31
46
  cmd.action(async options => {
32
47
  const projectRoot = process.cwd()
33
48
  const catalogFindings = runDoctor({ agentModels: projectConfig.load().agentModels || {} })
@@ -35,7 +50,12 @@ cmd.action(async options => {
35
50
  // the templates the current binary would generate, using the project's own
36
51
  // trackfw.yaml. No manifest entry is written or read (AC3).
37
52
  const scaffoldFindings = runScaffoldDoctor(projectRoot)
38
- const findings = [...catalogFindings, ...scaffoldFindings]
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
+ }
39
59
  if (options.json) {
40
60
  process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`)
41
61
  return
@@ -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,7 @@ function createLifecycleCommand(kind) {
221
222
  }
222
223
  }
223
224
 
224
- const { models: resolvedModels, warning: modelsWarning } = configModule.resolveAgentModels(options.scope, os.homedir(), process.cwd())
225
+ const { models: resolvedModels, warning: modelsWarning } = configModule.resolveAgentModels(options.scope, homedir(), process.cwd())
225
226
  if (modelsWarning) process.stderr.write(modelsWarning + '\n')
226
227
  options.agentModels = resolvedModels
227
228
  const output = execute(kind, operation, options)
@@ -254,7 +255,7 @@ function createAgentModelsCommand() {
254
255
  cmd.action(() => {
255
256
  // AC5 + AC11: read agent_models from the global config (~/.trackfw/trackfw.yaml),
256
257
  // not from the cwd singleton. Show origin before the table.
257
- const { models: agentModels, source: modelsSource } = configModule.loadGlobalAgentModels(os.homedir(), process.cwd())
258
+ const { models: agentModels, source: modelsSource } = configModule.loadGlobalAgentModels(homedir(), process.cwd())
258
259
 
259
260
  // Source line (AC5): show origin before the table; advisory to stderr when not resolved.
260
261
  const sourceLines = {
@@ -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
@@ -19,6 +19,7 @@ const { checkMarkers, checksum: sha256hex } = require('../thirdparty/markers')
19
19
  const { quarantinePath, newQuarantineEntry, writeQuarantine, readQuarantine, decodeContent } = require('../thirdparty/quarantine')
20
20
  const { loadProvenance, verifyApproval, upsertProvenanceEntry } = require('../thirdparty/provenance')
21
21
  const { upsertThirdPartyReference, normalizeThirdPartyContent, resolveThirdPartySkillDestination } = require('../thirdparty/references')
22
+ const { homedir } = require('../homedir')
22
23
 
23
24
  // api is the module.exports object itself, referenced internally via
24
25
  // `api.thirdPartyFetch(...)` so tests can substitute the network fetch by
@@ -189,7 +190,7 @@ async function executeThirdPartyInstall(kind, options) {
189
190
  // write happens (including the skill file below) — failing everything up
190
191
  // front avoids leaving a partial state (skill file written, no reference
191
192
  // injected) on a precondition failure.
192
- const { models: resolvedAgentModels, warning: agentModelsWarning } = configModule.resolveAgentModels(scope, os.homedir(), projectRoot)
193
+ const { models: resolvedAgentModels, warning: agentModelsWarning } = configModule.resolveAgentModels(scope, homedir(), projectRoot)
193
194
  if (agentModelsWarning) process.stderr.write(agentModelsWarning + '\n')
194
195
  let identityConfig
195
196
  if (options.applyTo && options.applyTo.length) {
@@ -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.
@@ -880,7 +881,7 @@ function run(options) {
880
881
  process.exit(1)
881
882
  }
882
883
 
883
- const homeRoot = os.homedir()
884
+ const homeRoot = homedir()
884
885
  const dryRun = Boolean(options.dryRun)
885
886
  const installMissing = Boolean(options.installMissing)
886
887
 
@@ -7,6 +7,7 @@ const path = require('path');
7
7
  const identityStore = require('../identity');
8
8
  const projectConfig = require('../config');
9
9
  const { runFileTarget, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine');
10
+ const { homedir } = require('../homedir')
10
11
 
11
12
  // `trackfw update` is project-scoped only — see docs/cli-parity.md,
12
13
  // "`trackfw update` vs `trackfw update harness`". It must NEVER touch global
@@ -37,7 +38,7 @@ function loadUpdateConfig(rootDir) {
37
38
  // (line-based text splice, not a YAML parse+reserialize) to preserve 100% of
38
39
  // the user's existing trackfw.yaml content (comments, key order, formatting).
39
40
  function ensureGlobalAdrDirRegistered(cwd) {
40
- const home = os.homedir();
41
+ const home = homedir();
41
42
  const globalDir = path.join(home, '.trackfw', 'adr');
42
43
  if (!fs.existsSync(globalDir)) return;
43
44
 
@@ -168,10 +169,74 @@ function codexProjectAgentsTarget(cwd, identityConfig, { dryRun, installMissing
168
169
  }
169
170
  }
170
171
 
172
+ // discoverWorkflowPresent — reports whether
173
+ // .github/workflows/trackfw-validate.yml (written by `trackfw discover
174
+ // --init`, DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH in discover.js) already
175
+ // exists under cwd AS A REGULAR FILE. Used both to decide whether
176
+ // `ci-workflow` is declared (AC17(c), REQ-2026-08-28) and, inside its
177
+ // apply(), to decide whether to refresh it — existence is checked against
178
+ // the REAL cwd, never the --dry-run sandbox, mirroring how cfg itself is
179
+ // read before the sandbox is built.
180
+ //
181
+ // Uses fs.lstatSync, NOT fs.existsSync: fs.existsSync follows symlinks, so a
182
+ // symlink at this path — even a live one pointing outside the project —
183
+ // would report "present" purely because its target resolves, pulling
184
+ // `ci-workflow` into the declared target set on the strength of a link this
185
+ // command does not own. Symlinks are therefore treated as NOT present here:
186
+ // `update` will not declare/manage a target on their account, and
187
+ // refreshDiscoverGitHubActionsWorkflowIfPresent below refuses to write
188
+ // through them regardless.
189
+ function discoverWorkflowPresent(cwd) {
190
+ const { DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH } = require('./discover')
191
+ const dest = path.join(cwd, DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH)
192
+ let info
193
+ try {
194
+ info = fs.lstatSync(dest)
195
+ } catch (e) {
196
+ return false
197
+ }
198
+ return !info.isSymbolicLink()
199
+ }
200
+
201
+ // refreshDiscoverGitHubActionsWorkflowIfPresent — refreshes
202
+ // .github/workflows/trackfw-validate.yml ONLY when it already exists under
203
+ // root as a REGULAR FILE — `update` never creates this file (AC17(b)):
204
+ // ownership of the install decision belongs to `trackfw discover --init`,
205
+ // not `update`. Writes the SAME builder scaffold doctor compares against
206
+ // (buildDiscoverGitHubActionsWorkflowContent, discover.js) so what `update`
207
+ // writes and what `doctor` expects can never drift apart by construction.
208
+ //
209
+ // Uses fs.lstatSync to decide presence, not fs.existsSync: this path is the
210
+ // most sensitive one `update` can write to (it controls what runs in CI for
211
+ // anyone who checks the project out), so if it is a symlink — live or
212
+ // dangling — this function refuses to write through it. Refusing is loud
213
+ // (stderr), never silent, so "update didn't refresh my workflow" stays
214
+ // diagnosable instead of a silent no-op.
215
+ function refreshDiscoverGitHubActionsWorkflowIfPresent(root) {
216
+ const { DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH, buildDiscoverGitHubActionsWorkflowContent } = require('./discover')
217
+ const dest = path.join(root, DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH)
218
+ let info
219
+ try {
220
+ info = fs.lstatSync(dest)
221
+ } catch (e) {
222
+ return // not installed — update never creates it (AC17(b))
223
+ }
224
+ if (info.isSymbolicLink()) {
225
+ console.error(`aviso: ${DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH} é um symlink; trackfw update não escreve através de symlinks — arquivo não foi tocado`)
226
+ return
227
+ }
228
+ fs.writeFileSync(dest, buildDiscoverGitHubActionsWorkflowContent(), 'utf8')
229
+ }
230
+
171
231
  // PROJECT_TARGET_IDS — the fixed declared order for `trackfw update`
172
232
  // targets. `ci-workflow` and `git-hooks` only appear when the project's
173
- // trackfw.yaml opted into a CI system / hook framework — see ambiguity
174
- // note in the ML-6C handoff report about config-conditional target lists.
233
+ // trackfw.yaml opted into a CI system / hook framework, OR — for
234
+ // `ci-workflow` only — when trackfw-validate.yml already exists on disk
235
+ // (AC17(c)) — see ambiguity note in the ML-6C handoff report about
236
+ // config-conditional target lists. This constant is the full declared
237
+ // surface (used to validate --targets); the config/disk-conditional
238
+ // inclusion itself lives in buildProjectTargets' `include('ci-workflow')`
239
+ // call below.
175
240
  const PROJECT_TARGET_IDS = [
176
241
  'agent-rules',
177
242
  'agent-hooks',
@@ -189,7 +254,6 @@ const PROJECT_TARGET_IDS = [
189
254
  // filtering afterwards would still have written every unrequested target.
190
255
  function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing }, wanted) {
191
256
  const generators = require('../generators/init');
192
- const discover = require('./discover');
193
257
  const hooksGen = require('../generators/hooks');
194
258
  const include = (id) => !wanted || wanted.includes(id);
195
259
 
@@ -274,16 +338,70 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
274
338
  installMissing,
275
339
  }));
276
340
 
277
- if (include('ci-workflow') && (cfg.ci === 'github-actions' || cfg.ci === 'github_actions')) {
341
+ // ci-workflow mirrors Go's internal/generators/update.go:1925 target: it manages
342
+ // the SAME pair of files Go manages — .github/workflows/trackfw-gate.yml (the
343
+ // generated, version-pinned governance gate, ADR-2026-08-28) and
344
+ // .gitlab-ci-trackfw.yml — AND, since ML-2G (AC17), ALSO
345
+ // .github/workflows/trackfw-validate.yml, the different file written by
346
+ // discover.js (own template, `go install …@latest`, version-pinned to the
347
+ // generating binary). That third file is refreshed-only-if-present
348
+ // (AC17(a)/(b)): `update` never installs it, it only keeps an
349
+ // already-discover-installed copy in sync with what the current binary
350
+ // would generate, using discover.js's own builder so `update` and `doctor`
351
+ // can never drift apart by construction.
352
+ // Writing directly with the byte-identical builders (instead of calling
353
+ // generateCIWorkflow/generateGitHubActionsWorkflow from generators/init.js) is
354
+ // deliberate: those functions hardcode paths relative to process.cwd() with no
355
+ // root parameter, which would silently escape the dry-run tmp sandbox that
356
+ // runFileTarget builds for every other target here.
357
+ //
358
+ // AC17(c): the target is included when cfg.ci opts into github-actions/
359
+ // gitlab-ci (as before) OR when trackfw-validate.yml already exists on
360
+ // disk — otherwise a `ci: none` project that ran `discover` would have
361
+ // that file outside any command's management.
362
+ const ciWorkflowConfigOptIn = cfg.ci === 'github-actions' || cfg.ci === 'github_actions' || cfg.ci === 'gitlab-ci';
363
+ if (include('ci-workflow') && (ciWorkflowConfigOptIn || discoverWorkflowPresent(cwd))) {
364
+ const isGitLab = cfg.ci === 'gitlab-ci';
365
+ const { DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH } = require('./discover');
278
366
  targets.push(runFileTarget({
279
367
  id: 'ci-workflow',
280
- path: '.github/workflows/trackfw-validate.yml',
368
+ path: `.github/workflows/trackfw-gate.yml, .gitlab-ci-trackfw.yml, ${DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH}`,
281
369
  root: cwd,
282
- relPaths: ['.github/workflows/trackfw-validate.yml'],
283
- apply: (root) => discover.writeCIWorkflowForce(root),
370
+ relPaths: ['.github/workflows/trackfw-gate.yml', '.gitlab-ci-trackfw.yml', DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH],
371
+ apply: (root) => {
372
+ if (isGitLab) {
373
+ fs.writeFileSync(path.join(root, '.gitlab-ci-trackfw.yml'), generators.buildGitLabCIWorkflowContent(cfg), 'utf8');
374
+ } else if (cfg.ci === 'github-actions' || cfg.ci === 'github_actions') {
375
+ fs.mkdirSync(path.join(root, '.github/workflows'), { recursive: true });
376
+ fs.writeFileSync(path.join(root, '.github/workflows/trackfw-gate.yml'), generators.buildGitHubActionsWorkflowContent(cfg), 'utf8');
377
+ }
378
+ refreshDiscoverGitHubActionsWorkflowIfPresent(root);
379
+ },
284
380
  dryRun,
285
381
  installMissing,
286
382
  }));
383
+ } else if (include('ci-workflow')) {
384
+ // ML-3E (audit of ML-3D): the branch above is skipped exactly when
385
+ // `ci-workflow` isn't config-opted-in AND discoverWorkflowPresent(cwd)
386
+ // is false — which is also true when the file at
387
+ // DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH is a symlink (live or dangling),
388
+ // since discoverWorkflowPresent() deliberately treats symlinks as NOT
389
+ // present (AC17(c) comment above). Without this call, a `ci: none`
390
+ // project with a discover-installed symlink went completely silent:
391
+ // no target declared, no write, no warning — the exact failure mode
392
+ // this REQ exists to close (ML-3D's own stderr warning only fired from
393
+ // inside the branch above, i.e. only in cases where it was already not
394
+ // needed). refreshDiscoverGitHubActionsWorkflowIfPresent(cwd) re-checks
395
+ // the real cwd independently: it is a no-op when the file is absent
396
+ // (AC17(b) — update never creates it) and prints the SAME stderr
397
+ // warning Go/Python emit — byte-identical, see
398
+ // internal/generators/update.go:1899 and
399
+ // pypi/trackfw/commands/update.py:221-225 — when it is a symlink. No
400
+ // regular file ever reaches this branch (that case is always declared
401
+ // above via discoverWorkflowPresent(cwd) === true), so this can never
402
+ // duplicate the write/warning already performed by the branch above,
403
+ // and never writes through a symlink itself.
404
+ refreshDiscoverGitHubActionsWorkflowIfPresent(cwd);
287
405
  }
288
406
 
289
407
  if (include('git-hooks') && (cfg.hooks === 'husky' || cfg.hooks === 'lefthook')) {
@@ -359,7 +477,7 @@ cmd.action((mode, options) => {
359
477
  // A identidade é carregada uma única vez, fora de qualquer try/catch —
360
478
  // um identity.json corrompido deve abortar o comando inteiro, nunca cair
361
479
  // silenciosamente para os nomes neutros default.
362
- const identityConfig = identityStore.load(os.homedir());
480
+ const identityConfig = identityStore.load(homedir());
363
481
 
364
482
  // With --json, stdout must carry only the result document — apply()
365
483
  // functions log human progress lines as a side effect; silence them so a
@@ -4,14 +4,15 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { parseDocument, isScalar, isAlias, isSeq, isMap } = require('yaml');
7
+ const { homedir } = require('../homedir')
7
8
 
8
9
  function expandPath(filePath) {
9
10
  if (!filePath || typeof filePath !== 'string') return filePath;
10
11
  if (filePath === '~') {
11
- return os.homedir();
12
+ return homedir();
12
13
  }
13
14
  if (filePath.startsWith('~/') || filePath.startsWith('~\\')) {
14
- return path.join(os.homedir(), filePath.slice(2));
15
+ return path.join(homedir(), filePath.slice(2));
15
16
  }
16
17
  return filePath;
17
18
  }
@@ -369,7 +370,7 @@ function _cwdAgentModelsSource(cwd) {
369
370
  }
370
371
 
371
372
  // 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
+ // singleton. homeDir is homedir(); cwd is process.cwd() (used only for AC14 diagnostic).
373
374
  // Returns { models: {}, source } where source is one of:
374
375
  // 'global' — agent_models resolved from ~/.trackfw/trackfw.yaml
375
376
  // 'none' — global config absent or has no agent_models; cwd also has none