trackfw 7.1.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -12,9 +12,13 @@ function createCommitCommand() {
12
12
  const cmd = new Command('commit')
13
13
  cmd
14
14
  .description(
15
- "trackfw commit is the missing intermediate step between raw 'git commit' and\n" +
16
- "'trackfw ship': it commits staged changes directly, but blocks the commit before it happens\n" +
17
- 'when governance is missing, instead of letting it land and only catching it later:\n\n' +
15
+ "trackfw commit commits staged changes directly, but blocks the commit before it\n" +
16
+ 'happens when governance is missing, instead of letting it land and only catching it later.\n\n' +
17
+ 'Compositional vocabulary:\n' +
18
+ ' trackfw commit -m "..." commits\n' +
19
+ ' trackfw push pushes\n' +
20
+ ' trackfw ship -m "..." commit + push + PR (composition)\n\n' +
21
+ 'Behavioral steps:\n\n' +
18
22
  " 1. On 'main'/'master': always blocked — commit directly on the default branch is never\n" +
19
23
  ' permitted.\n' +
20
24
  ' 2. On a feat/fix/refactor branch: requires a roadmap matching the branch slug already in\n' +
@@ -2,15 +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')
6
+ const projectConfig = require('../config')
5
7
 
6
8
  function printReport(findings) {
7
9
  if (findings.length === 0) {
8
- 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.'
9
11
  }
10
12
  const unregistered = findings.filter(finding => finding.finding === UNREGISTERED_WRITE).length
11
13
  const handModified = findings.filter(finding => finding.finding === HAND_MODIFIED).length
12
14
  const unknownContent = findings.filter(finding => finding.finding === UNKNOWN_CONTENT).length
13
- 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
+ ]
14
22
  for (const finding of findings) {
15
23
  lines.push(`[${finding.finding}] ${finding.destination}`, ` remedy: ${finding.remedy}`, '')
16
24
  }
@@ -21,7 +29,13 @@ const cmd = new Command('doctor')
21
29
  cmd.description('Detect artifacts on disk missing from the manifest, distinguishing hand-modified artifacts from unknown content')
22
30
  cmd.option('--json', 'Emit findings as a JSON array instead of the text report')
23
31
  cmd.action(async options => {
24
- const findings = runDoctor({})
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]
25
39
  if (options.json) {
26
40
  process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`)
27
41
  return
@@ -51,10 +51,13 @@ function createProgram() {
51
51
  program.addCommand(require('./skills'))
52
52
  program.addCommand(require('./note'))
53
53
  program.addCommand(require('./ship'))
54
+ program.addCommand(require('./release'))
54
55
  program.addCommand(require('./barrier'))
55
56
  program.addCommand(require('./branch'))
56
57
  program.addCommand(require('./commit'))
58
+ program.addCommand(require('./push'))
57
59
  program.addCommand(require('./doctor'))
60
+ program.addCommand(require('./audit-surface'))
58
61
 
59
62
  const { createServeCommand } = require('./serve')
60
63
  program.addCommand(createServeCommand())
@@ -3,10 +3,13 @@
3
3
  const os = require('node:os')
4
4
  const { Command } = require('commander')
5
5
  const { catalog, execute, parseSurfaces, buildPlans } = require('../integrations')
6
+ const { surfaceFor, readAsset } = require('../integrations/catalog')
7
+ const { markdownParts, resolveAgentModel, looksLikeSuspectModelValue } = require('../integrations/render')
6
8
  const identityStore = require('../identity')
7
9
  const identityWizard = require('./identity-wizard')
8
10
  const { t } = require('../i18n')
9
11
  const { createThirdPartyCommand } = require('./thirdparty')
12
+ const configModule = require('../config')
10
13
 
11
14
  const csv = value => String(value).split(',').map(entry => entry.trim()).filter(Boolean)
12
15
  const collect = (value, previous) => previous.concat(value)
@@ -124,6 +127,10 @@ function createLifecycleCommand(kind) {
124
127
  // internal/commands/integrations_flags.go registering `third-party` in
125
128
  // newIntegrationsLifecycleCmd.
126
129
  root.addCommand(createThirdPartyCommand(kind))
130
+ // "models" is agents-only: skills have no model field, and surfacing this
131
+ // under `trackfw skills models` would mislead users. Mirrors the identity
132
+ // flag gate and the Go/Python implementations.
133
+ if (kind === 'agents') root.addCommand(createAgentModelsCommand())
127
134
  for (const operation of ['list', 'install', 'uninstall', 'update']) {
128
135
  const mutation = operation !== 'list'
129
136
  const command = new Command(operation)
@@ -214,6 +221,9 @@ function createLifecycleCommand(kind) {
214
221
  }
215
222
  }
216
223
 
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
217
227
  const output = execute(kind, operation, options)
218
228
  console.log(options.json ? JSON.stringify(output) : human(output))
219
229
  })
@@ -222,4 +232,73 @@ function createLifecycleCommand(kind) {
222
232
  return root
223
233
  }
224
234
 
225
- module.exports = { createLifecycleCommand, csv, human, promptSelection, promptAmbiguousSurfaces, resolveScope }
235
+ // createAgentModelsCommand returns the "models" subcommand of "trackfw agents".
236
+ // Lists, for each catalog agent × target pair, the model identifier that
237
+ // install/update would write to the generated artifact. Mirrors
238
+ // internal/commands/agents_models.go:newAgentModelsCmd and
239
+ // pypi/trackfw/integrations/command.py:_run_models.
240
+ //
241
+ // Column widths must match Go and Python for byte-identical output.
242
+ const MODELS_AGENT_WIDTH = 14
243
+ const MODELS_TIER_WIDTH = 8
244
+ const MODELS_TARGET_WIDTH = 12
245
+ const MODELS_NA = '—'
246
+
247
+ function pad(s, width) {
248
+ return String(s).padEnd(width)
249
+ }
250
+
251
+ function createAgentModelsCommand() {
252
+ const cmd = new Command('models')
253
+ .description('Show the resolved model each agent uses per target')
254
+ cmd.action(() => {
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')
271
+
272
+ // Warnings: emit once per suspect tier, sorted, to stderr.
273
+ const suspectTiers = Object.keys(agentModels)
274
+ .filter(tier => looksLikeSuspectModelValue(agentModels[tier]))
275
+ .sort()
276
+ for (const tier of suspectTiers) {
277
+ process.stderr.write(
278
+ `WARN: agent_models.${tier} = ${JSON.stringify(agentModels[tier])} — not a version string and not a claude- model ID; will be written literally and may produce an invalid model identifier\n`
279
+ )
280
+ }
281
+
282
+ const lines = []
283
+ lines.push(`${pad('AGENT', MODELS_AGENT_WIDTH)} ${pad('TIER', MODELS_TIER_WIDTH)} ${pad('TARGET', MODELS_TARGET_WIDTH)} RESOLVED`)
284
+
285
+ for (const agent of catalog.agents) {
286
+ const source = readAsset(agent)
287
+ const parts = markdownParts(source)
288
+ const tier = parts.model || 'sonnet'
289
+
290
+ for (const target of catalog.targets) {
291
+ const surface = surfaceFor(target, null, 'agents')
292
+ const representation = surface.capabilities.agents.representation
293
+ const { resolved, present } = resolveAgentModel(tier, representation, target.id, agentModels)
294
+ const display = present ? resolved : MODELS_NA
295
+ lines.push(`${pad(agent.id, MODELS_AGENT_WIDTH)} ${pad(tier, MODELS_TIER_WIDTH)} ${pad(target.id, MODELS_TARGET_WIDTH)} ${display}`)
296
+ }
297
+ }
298
+
299
+ console.log(lines.join('\n'))
300
+ })
301
+ return cmd
302
+ }
303
+
304
+ module.exports = { createLifecycleCommand, createAgentModelsCommand, csv, human, promptSelection, promptAmbiguousSurfaces, resolveScope }
@@ -0,0 +1,51 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { runPush } = require('../push/runner')
5
+ const config = require('../config')
6
+
7
+ function createPushCommand() {
8
+ const cmd = new Command('push')
9
+ cmd
10
+ .description(
11
+ 'trackfw push pushes already-created commits without committing and without opening a PR/MR.\n\n' +
12
+ ' 1. Validates branch name — must match feat|fix|refactor|chore|docs/<slug>\n' +
13
+ ' 2. Validates governance — REQ + roadmap in wip/ must exist for feat/fix/refactor branches\n' +
14
+ ' (hard gate: not affected by lenient mode or per-rule severity); chore/docs branches skip\n' +
15
+ ' this check, mirroring \'trackfw commit\'\n' +
16
+ ' 3. Detects pending squash-merges in other branches (advisory only)\n' +
17
+ ' 4. Pushes to origin (adds -u if no upstream is configured yet)\n\n' +
18
+ 'push never commits and never opens a PR/MR.\n' +
19
+ 'Does not accept -m. If you have not committed yet, run \'trackfw commit -m "..."\' first.\n\n' +
20
+ 'Compositional vocabulary:\n' +
21
+ ' trackfw commit -m "..." commits\n' +
22
+ ' trackfw push pushes\n' +
23
+ ' trackfw ship -m "..." commit + push + PR (composition)\n\n' +
24
+ "--force-with-lease pushes with 'git push --force-with-lease' instead of a plain push — for\n" +
25
+ 'the post-rebase case, where a plain push is rejected. It only runs when the branch already\n' +
26
+ "has an open PR/MR (verified via the resolved forge CLI): the safe path is always to open the\n" +
27
+ 'PR first.'
28
+ )
29
+ .option('--dry-run', 'Print what would be done without executing write commands', false)
30
+ .option('--force-with-lease', 'Governed force-push (git push --force-with-lease); requires an open PR/MR on this branch', false)
31
+ .action((options) => {
32
+ const cfg = config.load()
33
+ const exitCode = runPush(
34
+ {
35
+ dryRun: options.dryRun || false,
36
+ forceWithLease: options.forceWithLease || false,
37
+ },
38
+ {
39
+ configForge: cfg.forge || '',
40
+ repoDir: '.',
41
+ }
42
+ )
43
+ if (exitCode !== 0) {
44
+ process.exit(exitCode)
45
+ }
46
+ })
47
+
48
+ return cmd
49
+ }
50
+
51
+ module.exports = createPushCommand()
@@ -0,0 +1,47 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { runReleaseTag } = require('../release/runner')
5
+ const config = require('../config')
6
+
7
+ function createReleaseCommand() {
8
+ const cmd = new Command('release')
9
+ cmd.description('Governed release operations')
10
+
11
+ const tagCmd = new Command('tag')
12
+ tagCmd
13
+ .description(
14
+ 'Create and publish an annotated release tag.\n\n' +
15
+ "It exists because 'trackfw ship' only pushes branches — tag is not a branch operation,\n" +
16
+ "and ship's governance gate (\"REQ + roadmap in wip/\") does not apply to release. See\n" +
17
+ 'ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md.\n\n' +
18
+ 'Every precondition below refuses with a message naming what to fix — this command\n' +
19
+ 'never guesses:\n' +
20
+ ' 1. Working tree must be clean.\n' +
21
+ ' 2. The default branch (main/master), if checked out locally, must be up to date with origin.\n' +
22
+ ' 3. The 4 version files must all match <version> exactly.\n' +
23
+ ' 4. CHANGELOG.md must have a "## [<version>] - YYYY-MM-DD" section.\n' +
24
+ ' 5. The tag must not already exist, locally or on origin.\n' +
25
+ ' 6. The GitHub CLI (gh) must be available and authenticated — release tag currently\n' +
26
+ ' only supports GitHub; other forges are refused with instructions to push the tag\n' +
27
+ ' manually.\n\n' +
28
+ 'On success, it publishes the tag via two GitHub API calls (POST git/tags then POST\n' +
29
+ 'git/refs), preserving the annotation.'
30
+ )
31
+ .argument('<version>', 'Version to tag (with or without a leading "v")')
32
+ .action((version) => {
33
+ const cfg = config.load()
34
+ const exitCode = runReleaseTag(version, {
35
+ configForge: cfg.forge || '',
36
+ repoDir: '.',
37
+ })
38
+ if (exitCode !== 0) {
39
+ process.exit(exitCode)
40
+ }
41
+ })
42
+
43
+ cmd.addCommand(tagCmd)
44
+ return cmd
45
+ }
46
+
47
+ module.exports = createReleaseCommand()
@@ -15,16 +15,23 @@ function createShipCommand() {
15
15
  " this check, mirroring 'trackfw commit'\n" +
16
16
  ' 3. Detects pending squash-merges in other branches (advisory only)\n' +
17
17
  ' 4. Reviews what is staged (git status --short + git diff --cached --stat)\n' +
18
- ' 5. Commits with Conventional Commits format (-m is required)\n' +
18
+ ' 5. Commits with Conventional Commits format (-m is required, unless nothing is staged\n' +
19
+ ' and --force-with-lease is set — see below)\n' +
19
20
  ' 6. Pushes to origin (adds -u if no upstream is configured yet)\n' +
20
21
  ' 7. Opens PR/MR via the resolved forge CLI (or prints URL if CLI is absent)\n\n' +
21
22
  "Stage your files explicitly before running ship.\n" +
22
- "This command never executes 'git add .' or 'git add -A'."
23
+ "This command never executes 'git add .' or 'git add -A'.\n\n" +
24
+ "--force-with-lease pushes with 'git push --force-with-lease' instead of a plain push —\n" +
25
+ "for the post-rebase case, where a plain push is rejected. It only runs when the branch\n" +
26
+ "already has an open PR/MR (verified via the resolved forge CLI): the safe path is\n" +
27
+ "always to open the PR first. When nothing is staged, it pushes existing commits\n" +
28
+ "without requiring -m."
23
29
  )
24
30
  .option('-m, --message <msg>', 'Commit message (Conventional Commits format required)')
25
31
  .option('--dry-run', 'Print what would be done without executing write commands', false)
26
32
  .option('--no-pr', 'Skip PR/MR creation after push')
27
33
  .option('--forge <forge>', 'Override forge detection (github, gitlab, bitbucket, azure)', '')
34
+ .option('--force-with-lease', 'Governed force-push (git push --force-with-lease); requires an open PR/MR on this branch', false)
28
35
  .action((options) => {
29
36
  const cfg = config.load()
30
37
  const exitCode = runShip(
@@ -33,6 +40,7 @@ function createShipCommand() {
33
40
  dryRun: options.dryRun || false,
34
41
  noPR: options.pr === false,
35
42
  forge: options.forge || '',
43
+ forceWithLease: options.forceWithLease || false,
36
44
  },
37
45
  {
38
46
  configForge: cfg.forge || '',
@@ -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
  }
@@ -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')
@@ -757,7 +758,9 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
757
758
  let displayPath = `~/.${toolId}`
758
759
  try {
759
760
  displayPath = globalGroupPath(toolId, kind)
760
- const plans = buildPlans(kind, { targets: [toolId], scope: 'global', identity: identityConfig })
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 })
761
764
  if (!plans.length) return { id, state: 'missing', path: displayPath }
762
765
 
763
766
  const manager = new IntegrationManager({ homeRoot })
@@ -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;
@@ -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);