trackfw 6.10.0 → 7.1.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.
Files changed (38) hide show
  1. package/README.md +0 -3
  2. package/bin/trackfw +38 -1
  3. package/package.json +1 -1
  4. package/src/branch/prune.js +450 -0
  5. package/src/branch/runner.js +33 -20
  6. package/src/changelog/index.js +114 -0
  7. package/src/commands/branch.js +59 -9
  8. package/src/commands/changelog.js +29 -0
  9. package/src/commands/commit.js +60 -0
  10. package/src/commands/discover.js +75 -0
  11. package/src/commands/doctor.js +32 -0
  12. package/src/commands/index.js +23 -4
  13. package/src/commands/integrations.js +23 -1
  14. package/src/commands/serve.js +52 -4
  15. package/src/commands/ship.js +4 -3
  16. package/src/commands/thirdparty.js +384 -0
  17. package/src/commands/update-harness.js +320 -12
  18. package/src/commands/update.js +3 -1
  19. package/src/commit/runner.js +352 -0
  20. package/src/config/index.js +27 -0
  21. package/src/generators/hooks.js +925 -6
  22. package/src/generators/init.js +28 -6
  23. package/src/i18n/locales/en-US.json +0 -21
  24. package/src/i18n/locales/es-ES.json +0 -21
  25. package/src/i18n/locales/pt-BR.json +0 -21
  26. package/src/integrations/doctor.js +0 -0
  27. package/src/integrations/index.js +19 -2
  28. package/src/integrations/manager.js +133 -19
  29. package/src/lib/fatal-error.js +64 -0
  30. package/src/lib/unknown-command.js +82 -0
  31. package/src/ship/runner.js +259 -95
  32. package/src/thirdparty/fetch.js +140 -0
  33. package/src/thirdparty/markers.js +207 -0
  34. package/src/thirdparty/provenance.js +114 -0
  35. package/src/thirdparty/quarantine.js +113 -0
  36. package/src/thirdparty/references.js +194 -0
  37. package/src/validator/index.js +1060 -27
  38. package/src/commands/plugins.js +0 -184
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ // changelog — parsing e extração de seções do CHANGELOG.md no formato
4
+ // Keep a Changelog (https://keepachangelog.com/en/1.1.0/).
5
+ // Port 1:1 de internal/changelog/changelog.go — manter em sincronia.
6
+
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+
10
+ // sectionHeaderRE casa cabeçalhos de seção no formato "## [x.y.z] - YYYY-MM-DD"
11
+ // ou "## [Unreleased]" (sem data).
12
+ const sectionHeaderRE = /^## \[([^\]]+)\](?: - (\d{4}-\d{2}-\d{2}))?/
13
+
14
+ /**
15
+ * parseSections — separa o conteúdo de um CHANGELOG.md em seções, uma por
16
+ * cabeçalho "## [...]" encontrado. Texto antes da primeira seção (título do
17
+ * arquivo, preâmbulo) é descartado.
18
+ * @param {string} content
19
+ * @returns {Array<{version: string, date: string, body: string}>}
20
+ */
21
+ function parseSections(content) {
22
+ const lines = content.split('\n')
23
+
24
+ const sections = []
25
+ let current = null
26
+ let bodyLines = []
27
+
28
+ const flush = () => {
29
+ if (current !== null) {
30
+ current.body = bodyLines.join('\n')
31
+ sections.push(current)
32
+ }
33
+ }
34
+
35
+ for (const line of lines) {
36
+ const m = sectionHeaderRE.exec(line)
37
+ if (m !== null) {
38
+ flush()
39
+ current = { version: m[1], date: m[2] || '', body: '' }
40
+ bodyLines = []
41
+ continue
42
+ }
43
+ if (current !== null) {
44
+ bodyLines.push(line)
45
+ }
46
+ }
47
+ flush()
48
+
49
+ return sections
50
+ }
51
+
52
+ /**
53
+ * firstSection — retorna a primeira seção da lista.
54
+ * Erro se a lista vier vazia.
55
+ * @param {Array<{version: string, date: string, body: string}>} sections
56
+ * @returns {{version: string, date: string, body: string}}
57
+ */
58
+ function firstSection(sections) {
59
+ if (sections.length === 0) {
60
+ throw new Error('CHANGELOG.md has no version sections')
61
+ }
62
+ return sections[0]
63
+ }
64
+
65
+ /**
66
+ * findVersion — busca a seção com version igual ao argumento, normalizando um
67
+ * prefixo "v"/"V" opcional no argumento do usuário antes de comparar.
68
+ * @param {Array<{version: string, date: string, body: string}>} sections
69
+ * @param {string} version
70
+ * @returns {{version: string, date: string, body: string}}
71
+ */
72
+ function findVersion(sections, version) {
73
+ let normalized = version
74
+ if (normalized.length > 0 && (normalized[0] === 'v' || normalized[0] === 'V')) {
75
+ normalized = normalized.slice(1)
76
+ }
77
+ for (const s of sections) {
78
+ if (s.version === normalized || s.version === version) {
79
+ return s
80
+ }
81
+ }
82
+ throw new Error(`version "${version}" not found in CHANGELOG.md`)
83
+ }
84
+
85
+ /**
86
+ * formatSection — reconstrói o texto formatado de uma seção, reproduzindo o
87
+ * cabeçalho original.
88
+ * @param {{version: string, date: string, body: string}} section
89
+ * @returns {string}
90
+ */
91
+ function formatSection(section) {
92
+ const dateSuffix = section.date !== '' ? ` - ${section.date}` : ''
93
+ const body = section.body.replace(/^\n+/, '')
94
+ return `## [${section.version}]${dateSuffix}\n\n${body.replace(/\n+$/, '') + '\n'}`
95
+ }
96
+
97
+ /**
98
+ * read — lê o CHANGELOG.md na raiz informada.
99
+ * @param {string} root
100
+ * @returns {string}
101
+ */
102
+ function read(root) {
103
+ const filePath = path.join(root, 'CHANGELOG.md')
104
+ try {
105
+ return fs.readFileSync(filePath, 'utf8')
106
+ } catch (err) {
107
+ if (err.code === 'ENOENT') {
108
+ throw new Error('CHANGELOG.md not found — nothing to show')
109
+ }
110
+ throw err
111
+ }
112
+ }
113
+
114
+ module.exports = { parseSections, firstSection, findVersion, formatSection, read }
@@ -7,6 +7,7 @@
7
7
 
8
8
  const { Command } = require('commander')
9
9
  const { runBranchNew } = require('../branch/runner')
10
+ const { runBranchPrune } = require('../branch/prune')
10
11
 
11
12
  function createBranchCommand() {
12
13
  const cmd = new Command('branch')
@@ -15,22 +16,25 @@ function createBranchCommand() {
15
16
  const newCmd = new Command('new')
16
17
  newCmd
17
18
  .description(
18
- 'Create a feat/fix/refactor branch, gated on a matching REQ + roadmap already in wip/ or done/\n\n' +
19
+ 'Create a feat/fix/refactor/chore/docs branch; feat/fix/refactor gated on a matching REQ + roadmap already in wip/ or done/\n\n' +
19
20
  'trackfw branch new moves the branch_has_wip_roadmap governance gate (already enforced\n' +
20
21
  "by 'trackfw validate' and 'trackfw ship') to before branch creation, instead of after:\n\n" +
21
- ' 1. Validates <type> is one of feat, fix, refactor and <slug> is non-empty.\n' +
22
- ' 2. Checks whether a roadmap in wip/ or done/ matches the given slug — the exact matching\n' +
23
- " logic 'trackfw validate' already uses (normalized slug, filename contains match).\n" +
24
- " 3. Without a match: blocks — 'git checkout -b' is never executed — and prints the same\n" +
25
- " governance orientation message 'trackfw validate' already prints for this rule.\n" +
26
- " 4. With a match: runs 'git checkout -b <type>/<slug>', propagating Git's own output and\n" +
27
- ' exit status literally.\n\n' +
22
+ ' 1. Validates <type> is one of feat, fix, refactor, chore, docs and <slug> is non-empty.\n' +
23
+ ' 2. For feat, fix, refactor: checks whether a roadmap in wip/ or done/ matches the given\n' +
24
+ " slug — the exact matching logic 'trackfw validate' already uses (normalized slug,\n" +
25
+ " filename contains match). Without a match: blocks — 'git checkout -b' is never\n" +
26
+ " executed — and prints the same governance orientation message 'trackfw validate'\n" +
27
+ " already prints for this rule.\n" +
28
+ " 3. For chore, docs: housekeeping types already treated as roadmap-exempt by 'trackfw ship'\n" +
29
+ " and 'trackfw commit' — the branch is created without the roadmap gate.\n" +
30
+ " 4. With a match (or for chore/docs): runs 'git checkout -b <type>/<slug>', propagating\n" +
31
+ " Git's own output and exit status literally.\n\n" +
28
32
  'Create the governance artifacts first if this blocks you:\n' +
29
33
  ' trackfw req new "title"\n' +
30
34
  ' trackfw roadmap new "title"\n' +
31
35
  ' trackfw roadmap move <name> wip'
32
36
  )
33
- .argument('<spec>', '<type>/<slug>, type in feat, fix, refactor')
37
+ .argument('<spec>', '<type>/<slug>, type in feat, fix, refactor, chore, docs')
34
38
  .option('--dry-run', 'Report whether the branch would be created or blocked, without executing git', false)
35
39
  .action((spec, options) => {
36
40
  const exitCode = runBranchNew(spec, !!options.dryRun, {})
@@ -40,6 +44,52 @@ function createBranchCommand() {
40
44
  })
41
45
 
42
46
  cmd.addCommand(newCmd)
47
+
48
+ const pruneCmd = new Command('prune')
49
+ pruneCmd
50
+ .description('Report (and, with --apply, delete) local branches already integrated into origin/main')
51
+ .addHelpText(
52
+ 'after',
53
+ '\n' +
54
+ 'trackfw branch prune automates the "one active branch at a time" check documented in\n' +
55
+ 'CLAUDE.md §1 — it does not remove human judgment from every case: a branch whose only\n' +
56
+ 'remaining divergence is doc/config files is flagged for manual review, never deleted\n' +
57
+ 'automatically.\n\n' +
58
+ "A best-effort 'git fetch origin --prune' runs first. Failure (offline, no remote) is\n" +
59
+ 'non-blocking: a warning is printed and evaluation proceeds against the local origin/main\n' +
60
+ 'ref, whatever its state. A stale origin/main only ever makes the result MORE conservative —\n' +
61
+ 'it can miss a branch that was in fact integrated since the last fetch (reporting it kept\n' +
62
+ 'when a fresh fetch would show it deletable), but it never reports one as deletable that a\n' +
63
+ 'fresh fetch would show as pending.\n\n' +
64
+ "Decides integration with the touched-files heuristic, NOT git's own ancestry check (which\n" +
65
+ 'always refuses squash-merged branches) and NOT a naive bidirectional diff against origin/main\n' +
66
+ '(which false-positives on a branch that is merged but stale, once main has advanced further):\n\n' +
67
+ ' mb = git merge-base origin/main <branch>\n' +
68
+ ' touched = git diff --name-only mb <branch> (what the branch touched)\n' +
69
+ ' diverg = git diff --name-only origin/main <branch> -- touched (what still differs there)\n\n' +
70
+ 'touched empty -> integrated (safe to delete)\n' +
71
+ 'diverg empty -> integrated (safe to delete) -- squash-merged, stale, main advanced since\n' +
72
+ 'diverg doc/config only -> flagged for review (kept; probable housekeeping, confirm and delete manually)\n' +
73
+ 'otherwise -> kept, with the diverging files named\n\n' +
74
+ 'Every local branch is reported, always, with its decision and reason. The current branch, any\n' +
75
+ 'branch checked out in another worktree, and the default branch (main) are always kept and\n' +
76
+ 'never evaluated for deletion. Without origin/main resolvable at all (offline with no prior\n' +
77
+ 'fetch ever having run, or no remote configured), the whole command refuses and deletes\n' +
78
+ 'nothing.\n\n' +
79
+ '--dry-run is the default: without --apply, nothing is ever deleted, even the clearly\n' +
80
+ "integrated. Deletion tries 'git branch -d' first — confirming the integration via git's own\n" +
81
+ "ancestry check too, when possible — and falls back to 'git branch -D' only when -d refuses,\n" +
82
+ 'the expected case for squash-merged branches, which never have fast-forward ancestry with main.'
83
+ )
84
+ .option('--apply', 'Actually delete branches decided as integrated (default: report only, delete nothing)', false)
85
+ .action((options) => {
86
+ const exitCode = runBranchPrune(!!options.apply, {})
87
+ if (exitCode !== 0) {
88
+ process.exit(exitCode)
89
+ }
90
+ })
91
+
92
+ cmd.addCommand(pruneCmd)
43
93
  return cmd
44
94
  }
45
95
 
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+ const { Command } = require('commander')
3
+ const { read, parseSections, firstSection, findVersion, formatSection } = require('../changelog')
4
+
5
+ const cmd = new Command('changelog')
6
+ cmd.description('Show entries from CHANGELOG.md')
7
+ // enablePositionalOptions escopa a flag local "--version" a este subcomando,
8
+ // evitando que o root a intercepte como a flag global "trackfw --version".
9
+ cmd.enablePositionalOptions()
10
+ cmd.option('--version <x.y.z>', 'Show a specific version section')
11
+ cmd.option('--all', 'Show the entire CHANGELOG.md')
12
+ cmd.action(async (opts) => {
13
+ try {
14
+ const root = process.cwd()
15
+ const content = read(root)
16
+ if (opts.all) {
17
+ process.stdout.write(content)
18
+ return
19
+ }
20
+ const sections = parseSections(content)
21
+ const section = opts.version ? findVersion(sections, opts.version) : firstSection(sections)
22
+ process.stdout.write(formatSection(section))
23
+ } catch (err) {
24
+ console.error(`Error: ${err.message}`)
25
+ process.exitCode = 1
26
+ }
27
+ })
28
+
29
+ module.exports = cmd
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+
3
+ // trackfw commit -m "<message>"
4
+ //
5
+ // CLI wiring for the `trackfw commit` command. The testable logic lives in
6
+ // ../commit/runner.js (mirrors the branch.js / branch/runner.js split already used in this CLI).
7
+
8
+ const { Command } = require('commander')
9
+ const { runCommit, buildSuggestedMessage } = require('../commit/runner')
10
+
11
+ function createCommitCommand() {
12
+ const cmd = new Command('commit')
13
+ cmd
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' +
18
+ " 1. On 'main'/'master': always blocked — commit directly on the default branch is never\n" +
19
+ ' permitted.\n' +
20
+ ' 2. On a feat/fix/refactor branch: requires a roadmap matching the branch slug already in\n' +
21
+ " wip/ or done/ — the exact matching logic 'trackfw branch new' and 'trackfw validate'\n" +
22
+ ' already use. Without a match, blocks with the same governance orientation message.\n' +
23
+ ' 3. On any other branch (e.g. doc/housekeeping branches): allowed without requiring a\n' +
24
+ ' roadmap — a warning is logged, but the commit proceeds.\n' +
25
+ " 4. When allowed: runs 'git commit -m <message>', propagating Git's own output and exit\n" +
26
+ ' status literally.\n\n' +
27
+ "'--suggest' takes a completely separate path: it prints a heuristic Conventional Commits\n" +
28
+ "skeleton built from 'git diff --cached --name-status' (type + staged file list) and exits\n" +
29
+ 'without ever committing — no LLM call, just a structural heuristic. It is not a\n' +
30
+ 'ready-to-use message; review and edit before using it with -m. When \'--suggest\' is set,\n' +
31
+ "'-m' (if also passed) is ignored and no commit ever happens.\n\n" +
32
+ 'Create the governance artifacts first if this blocks you:\n' +
33
+ ' trackfw req new "title"\n' +
34
+ ' trackfw roadmap new "title"\n' +
35
+ ' trackfw roadmap move <name> wip'
36
+ )
37
+ .option('-m, --message <msg>', 'Commit message (required)')
38
+ .option('--suggest', 'Print a heuristic Conventional Commits message skeleton from staged files and exit without committing (ignores -m)')
39
+ .action((options) => {
40
+ if (options.suggest) {
41
+ try {
42
+ const suggestion = buildSuggestedMessage({})
43
+ process.stdout.write(suggestion + '\n')
44
+ return
45
+ } catch (err) {
46
+ process.stderr.write(err.message + '\n')
47
+ process.exit(1)
48
+ }
49
+ }
50
+
51
+ const exitCode = runCommit(options.message || '', {})
52
+ if (exitCode !== 0) {
53
+ process.exit(exitCode)
54
+ }
55
+ })
56
+
57
+ return cmd
58
+ }
59
+
60
+ module.exports = createCommitCommand()
@@ -22,6 +22,11 @@ function scan(rootDir) {
22
22
  hookFramework: 'none',
23
23
  ciSystem: 'none',
24
24
  forge: '',
25
+ // suggestedTestFramework é uma sugestão best-effort (nunca erro, '' quando nada bate) baseada
26
+ // em arquivos de configuração presentes na raiz do projeto. É apenas impressa como sugestão
27
+ // pelo comando `discover` — nunca escrita automaticamente em trackfw.yaml (a convenção de
28
+ // agent_conventions deve ser sempre declarada pelo time, não inferida).
29
+ suggestedTestFramework: '',
25
30
  };
26
31
 
27
32
  // trackfw.yaml
@@ -110,6 +115,9 @@ function scan(rootDir) {
110
115
  r.ciSystem = 'none';
111
116
  }
112
117
 
118
+ // Suggested test framework — best-effort heuristic, never an error.
119
+ r.suggestedTestFramework = detectTestFramework(rootDir);
120
+
113
121
  // Forge detection — reuse forge/resolve.js (no duplicate parse).
114
122
  // gitRemoteURL returns '' on any error; CI detection is filesystem-based.
115
123
  try {
@@ -338,6 +346,60 @@ function countMD(dir) {
338
346
  return n;
339
347
  }
340
348
 
349
+ // detectTestFramework é uma heurística best-effort para sugerir um framework de teste com base
350
+ // em arquivos de configuração presentes na raiz do projeto. Nunca retorna erro — retorna '' quando
351
+ // nenhum arquivo-gatilho é encontrado. Ordem de precedência: jest, vitest, pytest, go test.
352
+ function detectTestFramework(rootDir) {
353
+ if (isFile(path.join(rootDir, 'jest.config.js')) || isFile(path.join(rootDir, 'jest.config.ts'))) {
354
+ return 'jest';
355
+ }
356
+ if (isFile(path.join(rootDir, 'vitest.config.js')) || isFile(path.join(rootDir, 'vitest.config.ts'))) {
357
+ return 'vitest';
358
+ }
359
+ if (isFile(path.join(rootDir, 'pytest.ini'))) {
360
+ return 'pytest';
361
+ }
362
+ if (hasFileWithSubstring(path.join(rootDir, 'pyproject.toml'), '[tool.pytest')) {
363
+ return 'pytest';
364
+ }
365
+ if (hasFileWithSubstring(path.join(rootDir, 'setup.cfg'), '[tool:pytest]')) {
366
+ return 'pytest';
367
+ }
368
+ if (isFile(path.join(rootDir, 'go.mod')) && hasGoTestFile(rootDir)) {
369
+ return 'go test';
370
+ }
371
+ return '';
372
+ }
373
+
374
+ // hasFileWithSubstring lê path e retorna true se seu conteúdo contém sub. Retorna false
375
+ // silenciosamente se o arquivo não existir ou não puder ser lido (best-effort).
376
+ function hasFileWithSubstring(p, sub) {
377
+ let content;
378
+ try { content = fs.readFileSync(p, 'utf8'); } catch { return false; }
379
+ return content.includes(sub);
380
+ }
381
+
382
+ // hasGoTestFile percorre rootDir recursivamente procurando qualquer arquivo *_test.go.
383
+ function hasGoTestFile(rootDir) {
384
+ let found = false;
385
+ function walk(d) {
386
+ if (found) return;
387
+ let entries;
388
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
389
+ for (const e of entries) {
390
+ if (found) return;
391
+ if (e.isDirectory()) {
392
+ walk(path.join(d, e.name));
393
+ } else if (e.name.endsWith('_test.go')) {
394
+ found = true;
395
+ return;
396
+ }
397
+ }
398
+ }
399
+ walk(rootDir);
400
+ return found;
401
+ }
402
+
341
403
  function listSubDirs(dir) {
342
404
  try {
343
405
  return fs.readdirSync(dir).filter(f => {
@@ -412,6 +474,12 @@ cmd.action((opts) => {
412
474
  console.log('⚠ No CI system detected');
413
475
  }
414
476
 
477
+ // suggested test framework — printed only, never written to trackfw.yaml automatically
478
+ // (agent_conventions must always be declared by the team).
479
+ if (r.suggestedTestFramework) {
480
+ console.log(`Suggested test framework: ${r.suggestedTestFramework} (add to trackfw.yaml as agent_conventions: if correct)`);
481
+ }
482
+
415
483
  console.log(`\nGovernance Score: ${r.governanceScore}/100`);
416
484
 
417
485
  if (opts.init) {
@@ -447,6 +515,12 @@ cmd.action((opts) => {
447
515
  } catch (e) {
448
516
  console.warn(`⚠ credential guard script: ${e.message}`);
449
517
  }
518
+ try {
519
+ const { generateGitBranchGuardScript } = require('../generators/hooks');
520
+ generateGitBranchGuardScript(cwd);
521
+ } catch (e) {
522
+ console.warn(`⚠ git branch guard script: ${e.message}`);
523
+ }
450
524
  try {
451
525
  const { injectHooksDetected } = require('../generators/hooks');
452
526
  injectHooksDetected(cwd);
@@ -500,3 +574,4 @@ module.exports.generateBootstrapLog = generateBootstrapLog;
500
574
  module.exports.writeValidateScript = writeValidateScript;
501
575
  module.exports.writeCIWorkflow = writeCIWorkflow;
502
576
  module.exports.writeCIWorkflowForce = writeCIWorkflowForce;
577
+ module.exports.detectTestFramework = detectTestFramework;
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { runDoctor, UNREGISTERED_WRITE, HAND_MODIFIED, UNKNOWN_CONTENT } = require('../integrations/doctor')
5
+
6
+ function printReport(findings) {
7
+ if (findings.length === 0) {
8
+ return 'trackfw doctor: no mismatches found -- disk matches the manifest for every catalog-managed artifact.'
9
+ }
10
+ const unregistered = findings.filter(finding => finding.finding === UNREGISTERED_WRITE).length
11
+ const handModified = findings.filter(finding => finding.finding === HAND_MODIFIED).length
12
+ 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`, '']
14
+ for (const finding of findings) {
15
+ lines.push(`[${finding.finding}] ${finding.destination}`, ` remedy: ${finding.remedy}`, '')
16
+ }
17
+ return lines.join('\n').replace(/\n$/, '')
18
+ }
19
+
20
+ const cmd = new Command('doctor')
21
+ cmd.description('Detect artifacts on disk missing from the manifest, distinguishing hand-modified artifacts from unknown content')
22
+ cmd.option('--json', 'Emit findings as a JSON array instead of the text report')
23
+ cmd.action(async options => {
24
+ const findings = runDoctor({})
25
+ if (options.json) {
26
+ process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`)
27
+ return
28
+ }
29
+ process.stdout.write(`${printReport(findings)}\n`)
30
+ })
31
+
32
+ module.exports = cmd
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { Command } = require('commander')
4
4
  const { version } = require('../../package.json')
5
+ const { formatUnknownCommandError } = require('../lib/unknown-command')
5
6
 
6
7
  function createProgram() {
7
8
  const program = new Command()
@@ -9,6 +10,25 @@ function createProgram() {
9
10
  .name('trackfw')
10
11
  .description('trackfw — governed software delivery framework\nADR → REQ → ROADMAP → kanban')
11
12
  .version(`trackfw ${version}`)
13
+ // enablePositionalOptions: garante que uma flag de subcomando (ex.: o
14
+ // "changelog --version <x.y.z>") não seja capturada pela flag global
15
+ // "--version" do root — sem isto, commander casa "--version" contra o
16
+ // primeiro registro conhecido em toda a árvore de comandos.
17
+ program.enablePositionalOptions()
18
+
19
+ // Comando desconhecido — mensagem canônica compartilhada pelos 3 CLIs
20
+ // (ADR-2026-08-15-remocao-do-subsistema-de-plugins-em-vez-de-gate-de-binario-
21
+ // de-terceiro.md D3). Registrar um listener 'command:*' intercepta ANTES do
22
+ // commander chamar seu próprio Command.unknownCommand() — evita reformatar
23
+ // uma mensagem já impressa por ele (o padrão que causava dupla-impressão no
24
+ // Go, ver internal/commands/root.go) e evita depender do texto/threshold
25
+ // próprio de commander.suggestSimilar, que diverge de cobra/argparse.
26
+ program.on('command:*', (operands) => {
27
+ const typed = operands[0]
28
+ const candidates = program.commands.map((cmd) => cmd.name())
29
+ process.stderr.write(formatUnknownCommandError(typed, candidates, program.name()) + '\n')
30
+ process.exit(1)
31
+ })
12
32
 
13
33
  program.addCommand(require('./init'))
14
34
  program.addCommand(require('./adr'))
@@ -16,8 +36,8 @@ function createProgram() {
16
36
  program.addCommand(require('./roadmap'))
17
37
  program.addCommand(require('./validate'))
18
38
  program.addCommand(require('./status'))
39
+ program.addCommand(require('./changelog'))
19
40
  program.addCommand(require('./log'))
20
- program.addCommand(require('./plugins'))
21
41
  program.addCommand(require('./discover'))
22
42
  program.addCommand(require('./update'))
23
43
  program.addCommand(require('./metrics'))
@@ -33,13 +53,12 @@ function createProgram() {
33
53
  program.addCommand(require('./ship'))
34
54
  program.addCommand(require('./barrier'))
35
55
  program.addCommand(require('./branch'))
56
+ program.addCommand(require('./commit'))
57
+ program.addCommand(require('./doctor'))
36
58
 
37
59
  const { createServeCommand } = require('./serve')
38
60
  program.addCommand(createServeCommand())
39
61
 
40
- // plugin dispatch — comandos desconhecidos tentam executar plugin
41
- program.hook('preSubcommand', () => {})
42
-
43
62
  return program
44
63
  }
45
64
 
@@ -6,10 +6,26 @@ const { catalog, execute, parseSurfaces, buildPlans } = require('../integrations
6
6
  const identityStore = require('../identity')
7
7
  const identityWizard = require('./identity-wizard')
8
8
  const { t } = require('../i18n')
9
+ const { createThirdPartyCommand } = require('./thirdparty')
9
10
 
10
11
  const csv = value => String(value).split(',').map(entry => entry.trim()).filter(Boolean)
11
12
  const collect = (value, previous) => previous.concat(value)
12
13
 
14
+ // forceHelp returns the --force help text for a mutation subcommand. The
15
+ // three operations grant --force different powers, and a single shared
16
+ // string previously overstated update/uninstall's reach while never
17
+ // mentioning install's ability to adopt unmanaged bytes — that ambiguity is
18
+ // what sent a user straight into the "Unmanaged artifact does not match a
19
+ // trackfw template" error on `update --force` (see unmanagedArtifactError in
20
+ // src/integrations/manager.js for the matching remediation). Mirrors
21
+ // internal/commands/integrations_flags.go:forceHelp and
22
+ // pypi/trackfw/integrations/command.py.
23
+ function forceHelp(operation) {
24
+ if (operation === 'install') return 'Replace a modified managed artifact, or adopt/overwrite an unmanaged file already on disk'
25
+ if (operation === 'uninstall') return 'Remove a modified managed artifact'
26
+ return "Replace a modified managed artifact; never adopts unmanaged bytes — use 'install --force' for that"
27
+ }
28
+
13
29
  function human(result) {
14
30
  const lines = [`Available ${result.kind} (catalog ${result.catalog_version}):`]
15
31
  for (const item of result.items) lines.push(` ${item.id.padEnd(14)} ${item.name} — ${item.description}`)
@@ -102,6 +118,12 @@ async function promptAmbiguousSurfaces(kind, options, prompts = require('@inquir
102
118
 
103
119
  function createLifecycleCommand(kind) {
104
120
  const root = new Command(kind).description(`Manage trackfw ${kind}`)
121
+ // third-party fetch/install (D1): reachable from both `trackfw agents
122
+ // third-party` and `trackfw skills third-party`, under the same
123
+ // two-phase quarantine gate. Mirrors
124
+ // internal/commands/integrations_flags.go registering `third-party` in
125
+ // newIntegrationsLifecycleCmd.
126
+ root.addCommand(createThirdPartyCommand(kind))
105
127
  for (const operation of ['list', 'install', 'uninstall', 'update']) {
106
128
  const mutation = operation !== 'list'
107
129
  const command = new Command(operation)
@@ -110,7 +132,7 @@ function createLifecycleCommand(kind) {
110
132
  .option('--scope <scope>', 'Installation scope: project or global (default: global; asks interactively)')
111
133
  .option('--surface <target=surface>', 'Surface selection (repeatable)', collect, [])
112
134
  .option('--json', 'Print deterministic JSON')
113
- .option('--force', 'Replace or remove modified artifacts')
135
+ .option('--force', forceHelp(operation))
114
136
 
115
137
  // Flags de identidade são exclusivas de agents (ADR D5): skills não têm
116
138
  // identidade, e createLifecycleCommand é compartilhado entre `agents` e
@@ -13,6 +13,48 @@ const { handleAttention } = require('../serve/api_attention')
13
13
 
14
14
  const STATIC_DIR = path.join(__dirname, '..', 'serve', 'static')
15
15
 
16
+ // Aviso pinado, byte-idêntico entre os 3 runtimes (Go, Node.js, Python) — ver
17
+ // docs/cli-parity.md "`trackfw serve` — endereço de escuta, `--host` e
18
+ // aviso de exposição". Emitido quando --host resolve para uma interface
19
+ // diferente de loopback.
20
+ function exposureWarning(host, port) {
21
+ return `WARNING: trackfw serve is binding to ${host}:${port} — the governance chain (ADRs, REQs, roadmaps) will be readable without authentication by any device that can reach it.`
22
+ }
23
+
24
+ // Texto de --help pinado, byte-idêntico entre os 3 runtimes.
25
+ const SERVE_HOST_FLAG_HELP = 'Host to bind to (loopback only by default; use 0.0.0.0 to expose on the network)'
26
+
27
+ // Espelha internal/serve/serve.go IsLoopbackHost — 'localhost' ou IP loopback
28
+ // (IPv4 127.0.0.0/8 inteiro, ou IPv6 ::1), igual a net.IP.IsLoopback() do Go
29
+ // e ipaddress.ip_address(...).is_loopback do Python.
30
+ function isLoopbackHost(host) {
31
+ const net = require('net')
32
+ if (host === 'localhost') return true
33
+ if (net.isIPv4(host)) {
34
+ return host.split('.')[0] === '127'
35
+ }
36
+ if (net.isIPv6(host)) {
37
+ return host === '::1' || host === '0:0:0:0:0:0:0:1'
38
+ }
39
+ return false
40
+ }
41
+
42
+ // Espelha internal/serve/serve.go DisplayURL — URL a imprimir e a abrir no
43
+ // browser. 'localhost' é mantido só para 'localhost' ou IPv4 loopback
44
+ // (127.0.0.0/8), para não mudar a saída do caso comum; hosts IPv6 usam
45
+ // colchetes; qualquer outro host é impresso como está.
46
+ function displayUrl(host, port) {
47
+ const net = require('net')
48
+ if (host === 'localhost') return `http://localhost:${port}`
49
+ if (net.isIPv4(host)) {
50
+ return host.split('.')[0] === '127' ? `http://localhost:${port}` : `http://${host}:${port}`
51
+ }
52
+ if (net.isIPv6(host)) {
53
+ return `http://[${host}]:${port}`
54
+ }
55
+ return `http://${host}:${port}`
56
+ }
57
+
16
58
  // Mapa de extensão → Content-Type
17
59
  const MIME = {
18
60
  '.html': 'text/html; charset=utf-8',
@@ -141,19 +183,25 @@ function createServeCommand() {
141
183
  cmd
142
184
  .description('Inicia o servidor HTTP do trackfw dashboard (kanban + chain + metrics)')
143
185
  .option('--port <port>', 'Porta do servidor', '8080')
186
+ .option('--host <host>', SERVE_HOST_FLAG_HELP, '127.0.0.1')
144
187
  .option('--no-open', 'Não abrir o browser automaticamente')
145
188
  .action((opts) => {
146
189
  const cfg = config.load()
147
190
  const port = parseInt(opts.port, 10) || 8080
191
+ const host = opts.host
148
192
 
149
193
  const server = createServer(cfg, port)
150
194
 
151
- server.listen(port, '127.0.0.1', () => {
152
- console.log(`trackfw serve: http://localhost:${port}`)
195
+ if (!isLoopbackHost(host)) {
196
+ console.error(exposureWarning(host, port))
197
+ }
198
+
199
+ server.listen(port, host, () => {
200
+ const url = displayUrl(host, port)
201
+ console.log(`trackfw serve: ${url}`)
153
202
 
154
203
  if (opts.open !== false) {
155
204
  // Tentar abrir o browser
156
- const url = `http://localhost:${port}`
157
205
  const { exec } = require('child_process')
158
206
  const platform = process.platform
159
207
  let openCmd
@@ -187,4 +235,4 @@ function createServeCommand() {
187
235
  return cmd
188
236
  }
189
237
 
190
- module.exports = { createServeCommand, createServer }
238
+ module.exports = { createServeCommand, createServer, isLoopbackHost, displayUrl }
@@ -9,9 +9,10 @@ function createShipCommand() {
9
9
  cmd
10
10
  .description(
11
11
  'trackfw ship runs a governed delivery sequence:\n\n' +
12
- ' 1. Validates branch name — must match feat|fix|refactor/<slug>\n' +
13
- ' 2. Validates governance — REQ + roadmap in wip/ must exist\n' +
14
- ' (hard gate: not affected by lenient mode or per-rule severity)\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" +
15
16
  ' 3. Detects pending squash-merges in other branches (advisory only)\n' +
16
17
  ' 4. Reviews what is staged (git status --short + git diff --cached --stat)\n' +
17
18
  ' 5. Commits with Conventional Commits format (-m is required)\n' +