trackfw 7.5.1 → 7.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "7.5.1",
3
+ "version": "7.6.0",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
5
5
  "keywords": [
6
6
  "cli",
@@ -471,73 +471,137 @@ function evalAcceptanceEvidence(mls) {
471
471
  // roadmapTrustForGates determines whether the gates declared in a roadmap can
472
472
  // be trusted for execution without --trust-local-gates.
473
473
  //
474
- // Decision (AC4, AC11): the discriminant is git — a roadmap whose content
475
- // differs from origin/main, or that is absent from origin/main, is untrusted.
474
+ // Posture (AC1): CLOSED by default. Gates execute only when the function can
475
+ // PROVE that the roadmap is present in refs/remotes/origin/main byte-for-byte.
476
+ // Absence of proof is absence of trust — every error path returns
477
+ // { trusted: false } with a named failureMsg (AC2). Exactly one
478
+ // { trusted: true } return, at the very end, after all proofs succeed.
476
479
  //
477
- // Returns { trusted: true } or { trusted: false, failureMsg: '...' }.
478
- // Fail-open when not in a git repo, origin/main not resolvable, or any git
479
- // error other than "path absent from origin/main". See docs/cli-parity.md.
480
- function roadmapTrustForGates(roadmapPath) {
480
+ // AC3: the discriminant never parses git stderr. Steps 4 and 5 use
481
+ // "git rev-parse --verify" and "git cat-file -e" whose exit codes answer
482
+ // the questions directly. The fully-qualified ref refs/remotes/origin/main
483
+ // is used throughout to prevent a local branch named "origin/main" from
484
+ // satisfying the trust anchor.
485
+ //
486
+ // Residual (declared in docs/cli-parity.md): Windows users with
487
+ // core.autocrlf=true may receive "content differs" for an otherwise-identical
488
+ // roadmap (LF vs CRLF). The check fails closed, so the residual is safe.
489
+ // roadmapTrustForGates determines whether the gates declared in a roadmap can
490
+ // be trusted for execution without --trust-local-gates.
491
+ //
492
+ // localContentBuf (Buffer) is the content already read by the caller — the same
493
+ // bytes used to parse gate commands. Comparing it here ensures the proof covers
494
+ // exactly what executes (F1 invariant: what is proved = what executes).
495
+ // The only remaining route from unverified bytes to sh -c is --trust-local-gates,
496
+ // which requires explicit operator consent.
497
+ function roadmapTrustForGates(roadmapPath, localContentBuf) {
481
498
  const roadmapDir = path.dirname(roadmapPath)
482
499
 
483
500
  // Step 1: check if we are inside a git repository.
501
+ // Two distinct failure reasons are separated here (F5):
502
+ // revParse.error → git binary not found in PATH (spawn failure)
503
+ // revParse.status → git ran but not inside a git repository
484
504
  const revParse = spawnSync('git', ['rev-parse', '--git-dir'], {
485
505
  cwd: roadmapDir,
486
506
  encoding: 'utf8',
487
507
  stdio: 'pipe',
488
508
  })
509
+ if (revParse.error) {
510
+ // Spawn failure: git binary is not installed or not in PATH.
511
+ return {
512
+ trusted: false,
513
+ failureMsg: 'gates not evaluated: git not found in PATH — install git to evaluate local gates',
514
+ }
515
+ }
489
516
  if (revParse.status !== 0) {
490
- // Not a git repo → fail-open.
491
- return { trusted: true }
517
+ return {
518
+ trusted: false,
519
+ failureMsg: 'gates not evaluated: not a git repository — pass --trust-local-gates to evaluate local gates',
520
+ }
492
521
  }
493
522
 
494
523
  // Step 2: get the repository toplevel.
495
- const topLevel = spawnSync('git', ['rev-parse', '--show-toplevel'], {
524
+ const topResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
496
525
  cwd: roadmapDir,
497
526
  encoding: 'utf8',
498
527
  stdio: 'pipe',
499
528
  })
500
- if (topLevel.status !== 0) {
501
- return { trusted: true }
529
+ if (topResult.status !== 0 || topResult.error) {
530
+ return {
531
+ trusted: false,
532
+ failureMsg: 'gates not evaluated: cannot resolve git repository root — pass --trust-local-gates to evaluate local gates',
533
+ }
502
534
  }
503
- const repoRoot = topLevel.stdout.trim()
535
+ const repoRoot = topResult.stdout.trim()
504
536
 
505
537
  // Step 3: compute repo-relative path (always forward slashes for git).
506
538
  const absRoadmap = path.resolve(roadmapPath)
507
539
  const relPath = path.relative(repoRoot, absRoadmap).split(path.sep).join('/')
508
540
 
509
- // Step 4: retrieve the file at origin/main.
510
- const show = spawnSync('git', ['show', `origin/main:${relPath}`], {
541
+ // Step 4: verify that refs/remotes/origin/main resolves to a commit (AC3 —
542
+ // exit code only, no stderr parsing). Failure means: no remote named origin,
543
+ // origin/main never fetched, or wrong default branch name. All are untrusted.
544
+ const verify = spawnSync(
545
+ 'git', ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
546
+ { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }
547
+ )
548
+ if (verify.status !== 0 || verify.error) {
549
+ return {
550
+ trusted: false,
551
+ failureMsg: 'gates not evaluated: origin/main ref not available — pass --trust-local-gates to evaluate local gates',
552
+ }
553
+ }
554
+
555
+ // Step 5: check whether the roadmap path exists in refs/remotes/origin/main
556
+ // (AC3 — "git cat-file -e" exits non-zero when the object does not exist).
557
+ const refPath = `refs/remotes/origin/main:${relPath}`
558
+ const catFile = spawnSync('git', ['cat-file', '-e', refPath], {
511
559
  cwd: repoRoot,
512
560
  encoding: 'utf8',
513
561
  stdio: 'pipe',
514
562
  })
515
- if (show.status !== 0) {
516
- // If the path specifically does not exist in origin/main → untrusted.
517
- const stderr = show.stderr || ''
518
- if (stderr.includes('does not exist in') || stderr.includes('exists on disk, but not in')) {
519
- return {
520
- trusted: false,
521
- failureMsg: 'gates not evaluated: roadmap is not committed in origin/main — pass --trust-local-gates to evaluate local gates',
522
- }
563
+ if (catFile.status !== 0 || catFile.error) {
564
+ return {
565
+ trusted: false,
566
+ failureMsg: 'gates not evaluated: roadmap is not committed in origin/main — pass --trust-local-gates to evaluate local gates',
523
567
  }
524
- // Other failures (no remote, not fetched) → fail-open.
525
- return { trusted: true }
526
568
  }
527
569
 
528
- // Step 5: compare content byte-for-byte.
529
- let localContent
530
- try {
531
- localContent = fs.readFileSync(roadmapPath, 'utf8')
532
- } catch (_) {
533
- return { trusted: true }
570
+ // Step 6: retrieve the file content from refs/remotes/origin/main.
571
+ // No encoding option → stdout is a Buffer for true byte-for-byte comparison (F4).
572
+ const show = spawnSync('git', ['show', refPath], {
573
+ cwd: repoRoot,
574
+ stdio: 'pipe',
575
+ })
576
+ if (show.status !== 0 || show.error) {
577
+ // The cat-file check already confirmed the object exists; this is an
578
+ // unexpected failure (e.g. transient I/O). Still fail closed.
579
+ return {
580
+ trusted: false,
581
+ failureMsg: 'gates not evaluated: cannot read roadmap from origin/main — pass --trust-local-gates to evaluate local gates',
582
+ }
534
583
  }
535
- if (show.stdout !== localContent) {
584
+
585
+ // Step 7: compare content byte-for-byte.
586
+ // localContentBuf (the parameter) is the Buffer the caller already read — it
587
+ // is the same slice used to parse gate commands. No second read is performed
588
+ // here; the proof covers exactly what executes (F1 invariant).
589
+ // show.stdout is also a Buffer (no encoding option above) — true binary comparison (F4).
590
+ //
591
+ // Step 5 triple note (F5 — declared unseparable): git cat-file -e exits non-zero
592
+ // for (a) roadmap absent from origin/main, (b) relPath wrong (e.g. macOS symlink,
593
+ // mitigated by WORK_PHYS in check-barrier.sh), (c) unexpected I/O error. All are
594
+ // equally fail-closed; separating them without additional git invocations would
595
+ // expand the attack surface. Declared unseparable by design.
596
+ if (!localContentBuf.equals(show.stdout)) {
536
597
  return {
537
598
  trusted: false,
538
599
  failureMsg: 'gates not evaluated: roadmap content differs from origin/main — pass --trust-local-gates to evaluate local gates',
539
600
  }
540
601
  }
602
+
603
+ // Proven: roadmap is present in refs/remotes/origin/main and byte-identical.
604
+ // The buffer compared is the same one parsed for gate commands.
541
605
  return { trusted: true }
542
606
  }
543
607
 
@@ -548,8 +612,10 @@ function roadmapTrustForGates(roadmapPath) {
548
612
  const SH_MISSING_MSG =
549
613
  'gates not evaluated: sh not found in PATH — install a POSIX shell (e.g. Git Bash, WSL) to evaluate gates'
550
614
 
551
- function evalGates(commands, cwd, trustResult = { trusted: true }) {
615
+ function evalGates(commands, cwd, trustResult = { trusted: false }) {
552
616
  // trustResult: { trusted: true } | { trusted: false, failureMsg: string }
617
+ // Default is { trusted: false } — fail-closed (F2). Every call site must pass
618
+ // an explicit trustResult; omitting it produces not_evaluated, not gate execution.
553
619
  if (!trustResult.trusted) {
554
620
  // Roadmap is not trusted: do not execute gates (AC3, AC14).
555
621
  // Report as not_evaluated — distinct from passed and blocked (AC6).
@@ -669,7 +735,10 @@ async function runBarrier(roadmapArg, waveOption, jsonOutput, trustLocalGates) {
669
735
 
670
736
  const cfg = config.load()
671
737
  const resolved = resolveRoadmapFile(cfg, roadmapArg)
672
- const content = fs.readFileSync(resolved.path, 'utf8')
738
+ // Read as Buffer (no encoding) so the same bytes can be passed to roadmapTrustForGates
739
+ // for the byte-for-byte comparison (F1 invariant: what is proved = what executes).
740
+ const contentBuf = fs.readFileSync(resolved.path)
741
+ const content = contentBuf.toString('utf8')
673
742
  const lines = splitRoadmapLines(content)
674
743
 
675
744
  const startedAt = new Date()
@@ -678,9 +747,10 @@ async function runBarrier(roadmapArg, waveOption, jsonOutput, trustLocalGates) {
678
747
  const gates = parseGates(lines, wave.startLine, wave.endLine)
679
748
 
680
749
  // Determine trust for gate execution (AC11, AC12).
750
+ // Pass contentBuf so the trust check compares the same buffer parsed for gates.
681
751
  const trustResult = trustLocalGates
682
752
  ? { trusted: true }
683
- : roadmapTrustForGates(resolved.path)
753
+ : roadmapTrustForGates(resolved.path, contentBuf)
684
754
 
685
755
  const checks = []
686
756
  checks.push(evalMlsComplete(mls, waveLabel))
@@ -30,8 +30,10 @@ function createBranchCommand() {
30
30
  " 4. With a match (or for chore/docs): runs 'git checkout -b <type>/<slug>', propagating\n" +
31
31
  " Git's own output and exit status literally.\n\n" +
32
32
  'Create the governance artifacts first if this blocks you:\n' +
33
- ' trackfw req new "title"\n' +
34
- ' trackfw roadmap new "title"\n' +
33
+ ' trackfw req new "title" # flat project\n' +
34
+ ' trackfw req new --agent <agent> "title" # by_agent project with 2+ agents\n' +
35
+ ' trackfw roadmap new "title" # flat project\n' +
36
+ ' trackfw roadmap new --agent <agent> "title" # by_agent project with 2+ agents\n' +
35
37
  ' trackfw roadmap move <name> wip'
36
38
  )
37
39
  .argument('<spec>', '<type>/<slug>, type in feat, fix, refactor, chore, docs')
@@ -34,8 +34,10 @@ function createCommitCommand() {
34
34
  'ready-to-use message; review and edit before using it with -m. When \'--suggest\' is set,\n' +
35
35
  "'-m' (if also passed) is ignored and no commit ever happens.\n\n" +
36
36
  'Create the governance artifacts first if this blocks you:\n' +
37
- ' trackfw req new "title"\n' +
38
- ' trackfw roadmap new "title"\n' +
37
+ ' trackfw req new "title" # flat project\n' +
38
+ ' trackfw req new --agent <agent> "title" # by_agent project with 2+ agents\n' +
39
+ ' trackfw roadmap new "title" # flat project\n' +
40
+ ' trackfw roadmap new --agent <agent> "title" # by_agent project with 2+ agents\n' +
39
41
  ' trackfw roadmap move <name> wip'
40
42
  )
41
43
  .option('-m, --message <msg>', 'Commit message (required)')
@@ -11,7 +11,8 @@ cmd.description(t('req.description'))
11
11
 
12
12
  cmd.command('new <title>')
13
13
  .description(t('req.new.description'))
14
- .action(async (title) => {
14
+ .option('--agent <name>', 'Agent namespace to write the REQ into (required in by_agent with multiple namespaces)')
15
+ .action(async (title, opts) => {
15
16
  const { input, select } = require('@inquirer/prompts')
16
17
  const generators = require('../generators/req')
17
18
  const adrGenerators = require('../generators/adr')
@@ -68,7 +69,7 @@ cmd.command('new <title>')
68
69
  content.dependsOnADRs = [...new Set(generatedADRs)]
69
70
  }
70
71
 
71
- await generators.newREQ(content)
72
+ await generators.newREQ(content, opts.agent)
72
73
 
73
74
  if (content.dependsOnADRs.length > 0) {
74
75
  console.log(`\n${t('req.new.adrDraftsCreated')}`)
@@ -12,14 +12,15 @@ cmd.command('new')
12
12
  .option('-t, --title <title>', 'Roadmap title (alias)')
13
13
  .option('-r, --req <path>', 'Path to the linked REQ')
14
14
  .option('--from-req <path>', 'Generate roadmap with ML stubs from REQ acceptance criteria')
15
+ .option('--agent <name>', 'Agent namespace to write the roadmap into (required in by_agent with multiple namespaces)')
15
16
  .action(async (titleArg, opts) => {
16
17
  if (opts.fromReq) {
17
- newRoadmapFromReq(opts.fromReq)
18
+ newRoadmapFromReq(opts.fromReq, opts.agent)
18
19
  return
19
20
  }
20
21
  const title = titleArg || opts.title || 'New Roadmap'
21
22
  const reqPath = opts.req || ''
22
- newRoadmap(title, reqPath)
23
+ newRoadmap(title, reqPath, opts.agent)
23
24
  })
24
25
 
25
26
  cmd.command('list')
@@ -13,6 +13,18 @@ const { handleAttention } = require('../serve/api_attention')
13
13
 
14
14
  const STATIC_DIR = path.join(__dirname, '..', 'serve', 'static')
15
15
 
16
+ // REAL_STATIC_DIR é a versão canônica (symlinks resolvidos) de STATIC_DIR.
17
+ // Calculado uma vez no carregamento do módulo para canonicalizar ambos os lados
18
+ // na verificação de segurança do serveStatic (M-03: prevenção de escape via symlink).
19
+ // Se STATIC_DIR não existir no momento do carregamento, fica null e serveStatic
20
+ // retorna 404 para qualquer requisição de asset estático.
21
+ let REAL_STATIC_DIR
22
+ try {
23
+ REAL_STATIC_DIR = fs.realpathSync.native(STATIC_DIR)
24
+ } catch (_) {
25
+ REAL_STATIC_DIR = null
26
+ }
27
+
16
28
  // Aviso pinado, byte-idêntico entre os 3 runtimes (Go, Node.js, Python) — ver
17
29
  // docs/cli-parity.md "`trackfw serve` — endereço de escuta, `--host` e
18
30
  // aviso de exposição". Emitido quando --host resolve para uma interface
@@ -55,6 +67,73 @@ function displayUrl(host, port) {
55
67
  return `http://${host}:${port}`
56
68
  }
57
69
 
70
+ // isValidHost reports whether host is acceptable as a --host argument.
71
+ // Accepts: 'localhost', valid IPv4, valid IPv6 literal (without zone ID),
72
+ // RFC-1123 hostname. Rejects anything else — in particular strings with shell
73
+ // metacharacters, or IPv6 scoped addresses (zone ID after '%').
74
+ //
75
+ // Zone IDs are rejected even when syntactically clean (e.g. 'fe80::1%eth0')
76
+ // because: (1) Go's net.ParseIP rejects them — parity requires all 3 CLIs to
77
+ // agree; (2) Python's ipaddress.ip_address() accepts any zone ID content,
78
+ // including cmd.exe metacharacters that list2cmdline does not quote; blocking
79
+ // '%' aligns both runtimes and closes the entire attack class rather than
80
+ // enumerating individual metacharacters.
81
+ //
82
+ // Espelha internal/serve/serve.go IsValidHost e _is_valid_host do Python.
83
+ function isValidHost(host) {
84
+ if (host === 'localhost') return true
85
+ // Reject IPv6 scoped addresses (zone ID): '%' in host means a zone
86
+ // identifier that Python's ipaddress accepts with any content (including
87
+ // cmd.exe metacharacters); Go rejects all scoped addresses; rejecting here
88
+ // makes all three runtimes agree on the same contract.
89
+ if (host.includes('%')) return false
90
+ const net = require('net')
91
+ if (net.isIPv4(host) || net.isIPv6(host)) return true
92
+ // RFC 1123 hostname: labels separated by dots, each [a-zA-Z0-9] or hyphens,
93
+ // starting and ending with alphanum. Max label length 63, total max 253.
94
+ if (host.length > 253) return false
95
+ return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(host)
96
+ }
97
+
98
+ // browserArgv returns [cmd, args] for opening url in the default browser
99
+ // without shell interpolation — argv, never a shell string. This is the only
100
+ // safe form: exec(`open "${url}"`) interpolates url into a shell command and
101
+ // allows injection when url contains shell metacharacters.
102
+ //
103
+ // Darwin / Linux: 'open' / 'xdg-open' are invoked directly; no shell involved.
104
+ // Windows: spawn('cmd', ['/c', 'start', '', url]) passes url as a distinct
105
+ // argv element to CreateProcess — safer than exec(), but cmd.exe still
106
+ // re-parses its own metacharacters (& | ^ >) in the url portion. Defense for
107
+ // that residual is AC4 (isValidHost) rejecting metacharacter-bearing hosts
108
+ // before they reach this function.
109
+ //
110
+ // Exported for testing.
111
+ function browserArgv(platform, url) {
112
+ if (platform === 'darwin') return ['open', [url]]
113
+ if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]]
114
+ return ['xdg-open', [url]]
115
+ }
116
+
117
+ // openBrowser opens url in the default browser using spawn (argv, no shell).
118
+ // Exported for testing with a PATH shim.
119
+ //
120
+ // Preserves the original UX: warn when the opener exits non-zero (e.g. when
121
+ // xdg-open finds no handler) as well as when it cannot be launched (ENOENT).
122
+ // The old exec() warned via the callback err; spawn uses 'close' + 'error'.
123
+ function openBrowser(platform, url) {
124
+ const { spawn } = require('child_process')
125
+ const [cmd, args] = browserArgv(platform, url)
126
+ const proc = spawn(cmd, args)
127
+ proc.on('error', (err) => {
128
+ console.warn(`Não foi possível abrir o browser: ${err.message}`)
129
+ })
130
+ proc.on('close', (code) => {
131
+ if (code !== 0 && code !== null) {
132
+ console.warn(`Não foi possível abrir o browser: processo encerrou com código ${code}`)
133
+ }
134
+ })
135
+ }
136
+
58
137
  // Mapa de extensão → Content-Type
59
138
  const MIME = {
60
139
  '.html': 'text/html; charset=utf-8',
@@ -67,7 +146,17 @@ const MIME = {
67
146
 
68
147
  /**
69
148
  * serveStatic serve arquivos do STATIC_DIR.
70
- * Retorna 404 se o arquivo não existe ou se o path tenta escape (path traversal).
149
+ *
150
+ * Modelo de segurança — contenção em dois estágios (M-03):
151
+ *
152
+ * 1. Contenção léxica: path.resolve() normaliza ".." e o resultado deve começar
153
+ * com STATIC_DIR. Caminhos fora recebem 403 antes de tocar o disco.
154
+ *
155
+ * 2. Contenção física: fs.realpathSync.native() é chamado no arquivo pedido e
156
+ * comparado com REAL_STATIC_DIR (pré-computado no carregamento do módulo).
157
+ * Um symlink dentro de STATIC_DIR cujo destino físico aponta para fora → 403.
158
+ * Qualquer falha de canonicalização → 404.
159
+ *
71
160
  * @param {string} urlPath - pathname da URL (ex: '/static/app.js')
72
161
  * @param {http.ServerResponse} res
73
162
  */
@@ -76,25 +165,43 @@ function serveStatic(urlPath, res) {
76
165
  const relative = urlPath.replace(/^\/static/, '') || '/index.html'
77
166
  const resolved = path.resolve(path.join(STATIC_DIR, relative))
78
167
 
79
- // Segurança: path traversal
80
- if (!resolved.startsWith(path.resolve(STATIC_DIR) + path.sep) && resolved !== path.resolve(STATIC_DIR)) {
168
+ // ── Estágio 1: Contenção léxica ────────────────────────────────────────────
169
+ const lexicalStaticDir = path.resolve(STATIC_DIR)
170
+ if (!resolved.startsWith(lexicalStaticDir + path.sep) && resolved !== lexicalStaticDir) {
81
171
  res.writeHead(403, { 'Content-Type': 'text/plain' })
82
172
  res.end('Forbidden')
83
173
  return
84
174
  }
85
175
 
86
- if (!fs.existsSync(resolved)) {
176
+ // ── Estágio 2: Contenção física ────────────────────────────────────────────
177
+ if (!REAL_STATIC_DIR) {
178
+ // STATIC_DIR não existe — nenhum arquivo pode ser servido.
179
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
180
+ res.end('Not Found')
181
+ return
182
+ }
183
+
184
+ let realResolved
185
+ try {
186
+ realResolved = fs.realpathSync.native(resolved)
187
+ } catch (_) {
87
188
  res.writeHead(404, { 'Content-Type': 'text/plain' })
88
189
  res.end('Not Found')
89
190
  return
90
191
  }
91
192
 
92
- const ext = path.extname(resolved).toLowerCase()
193
+ if (!realResolved.startsWith(REAL_STATIC_DIR + path.sep) && realResolved !== REAL_STATIC_DIR) {
194
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
195
+ res.end('Forbidden')
196
+ return
197
+ }
198
+
199
+ const ext = path.extname(realResolved).toLowerCase()
93
200
  const contentType = MIME[ext] || 'application/octet-stream'
94
201
 
95
202
  let content
96
203
  try {
97
- content = fs.readFileSync(resolved)
204
+ content = fs.readFileSync(realResolved)
98
205
  } catch (_) {
99
206
  res.writeHead(500, { 'Content-Type': 'text/plain' })
100
207
  res.end('Internal Server Error')
@@ -190,6 +297,14 @@ function createServeCommand() {
190
297
  const port = parseInt(opts.port, 10) || 8080
191
298
  const host = opts.host
192
299
 
300
+ // AC4 — validate --host before bind. Rejects metacharacter-bearing
301
+ // strings that would reach the browser-open path or the socket.
302
+ if (!isValidHost(host)) {
303
+ console.error(`trackfw serve: invalid --host value: ${host}`)
304
+ console.error('--host must be "localhost", a valid IPv4/IPv6 address, or an RFC-1123 hostname.')
305
+ process.exit(1)
306
+ }
307
+
193
308
  const server = createServer(cfg, port)
194
309
 
195
310
  if (!isLoopbackHost(host)) {
@@ -201,16 +316,8 @@ function createServeCommand() {
201
316
  console.log(`trackfw serve: ${url}`)
202
317
 
203
318
  if (opts.open !== false) {
204
- // Tentar abrir o browser
205
- const { exec } = require('child_process')
206
- const platform = process.platform
207
- let openCmd
208
- if (platform === 'darwin') openCmd = `open "${url}"`
209
- else if (platform === 'win32') openCmd = `start "" "${url}"`
210
- else openCmd = `xdg-open "${url}"`
211
- exec(openCmd, (err) => {
212
- if (err) console.warn(`Não foi possível abrir o browser: ${err.message}`)
213
- })
319
+ // AC1 — use argv (spawn), never shell string interpolation (exec).
320
+ openBrowser(process.platform, url)
214
321
  }
215
322
  })
216
323
 
@@ -235,4 +342,4 @@ function createServeCommand() {
235
342
  return cmd
236
343
  }
237
344
 
238
- module.exports = { createServeCommand, createServer, isLoopbackHost, displayUrl }
345
+ module.exports = { createServeCommand, createServer, isLoopbackHost, displayUrl, isValidHost, browserArgv, openBrowser, serveStatic }
@@ -356,6 +356,26 @@ function readAgentConventions(cwd) {
356
356
  return cfg.update.agentConventions || '';
357
357
  }
358
358
 
359
+ // readNamespacingConfig reads roadmap_namespacing and agents from <cwd>/trackfw.yaml, bypassing
360
+ // the load() singleton — mirrors Go's config.ReadNamespacingConfig. Never throws; returns
361
+ // defaults ('flat', []) on any failure.
362
+ function readNamespacingConfig(cwd) {
363
+ let content;
364
+ try {
365
+ content = fs.readFileSync(path.join(cwd || process.cwd(), 'trackfw.yaml'), 'utf8');
366
+ } catch (_) {
367
+ return { namespacing: 'flat', agents: [] };
368
+ }
369
+ const cfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {}, agentModels: {},
370
+ roadmapNamespacing: 'flat', agents: [] };
371
+ try {
372
+ parse(content, cfg);
373
+ } catch (_) {
374
+ return { namespacing: 'flat', agents: [] };
375
+ }
376
+ return { namespacing: cfg.roadmapNamespacing || 'flat', agents: cfg.agents || [] };
377
+ }
378
+
359
379
  // _cwdAgentModelsSource returns 'project_only' if cwd's trackfw.yaml has agent_models configured,
360
380
  // 'none' otherwise. Used for the AC14 diagnostic in loadGlobalAgentModels.
361
381
  function _cwdAgentModelsSource(cwd) {
@@ -425,6 +445,7 @@ module.exports = {
425
445
  expandPath,
426
446
  parseRulesFromContent,
427
447
  readAgentConventions,
448
+ readNamespacingConfig,
428
449
  loadGlobalAgentModels,
429
450
  resolveAgentModels,
430
451
  NAMESPACING_FLAT,
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const os = require('os')
6
- const { readAgentConventions } = require('../config/index.js')
6
+ const { readAgentConventions, readNamespacingConfig, NAMESPACING_BY_AGENT } = require('../config/index.js')
7
7
  const { homedir } = require('../homedir');
8
8
 
9
9
  // PACKAGE_VERSION — the version of the trackfw binary generating the CI
@@ -499,7 +499,7 @@ const AGENT_HEADERS = {
499
499
  cursor: '---\ndescription: trackfw governance rules\nglob: "**/*"\nalwaysApply: true\n---\n',
500
500
  }
501
501
 
502
- function trackfwRulesBlock(agentConventions) {
502
+ function trackfwRulesBlock(agentConventions, namespacing, agents) {
503
503
  let conventionsSection = ''
504
504
  if (agentConventions && String(agentConventions).trim() !== '') {
505
505
  conventionsSection = `
@@ -513,6 +513,19 @@ ${String(agentConventions).trim()}
513
513
  `
514
514
  }
515
515
 
516
+ // Determine step-1 command form — by_agent + 2+ agents shows --agent variant
517
+ const nonEmpty = (agents || []).filter(a => a)
518
+ const isMulti = namespacing === NAMESPACING_BY_AGENT && nonEmpty.length >= 2
519
+ const step1Req = isMulti
520
+ ? `\`trackfw req new --agent <agent> "title"\``
521
+ : `\`trackfw req new "title"\``
522
+ const step1Roadmap = isMulti
523
+ ? `\`trackfw roadmap new --agent <agent> "title"\``
524
+ : `\`trackfw roadmap new "title"\``
525
+ const step1Note = isMulti
526
+ ? `\n ⚠️ \`--agent\` is required for this project (agents: ${nonEmpty.join(', ')})`
527
+ : ''
528
+
516
529
  return RULES_START + `
517
530
  ## trackfw — Governance Rules
518
531
 
@@ -521,7 +534,7 @@ Chain: \`ADR → REQ → ROADMAP\` · States: \`backlog / analyzing / wip / bloc
521
534
 
522
535
  ### Agent Protocol
523
536
  1. **Before any implementation (mandatory):** create governance artifacts FIRST, then branch:
524
- \`trackfw req new "title"\` → \`trackfw roadmap new "title"\` → \`trackfw roadmap move <name> wip\` → \`git checkout -b feat/<branch>\`
537
+ ${step1Req} → ${step1Roadmap} → \`trackfw roadmap move <name> wip\` → \`git checkout -b feat/<branch>\`${step1Note}
525
538
  ❌ Never create a branch before REQ + ROADMAP are in wip/
526
539
  ❌ Never defer REQ/ROADMAP creation to a future task — they are prerequisites, not deliverables
527
540
  ✓ \`trackfw validate\` enforces this via \`branch_has_wip_roadmap\` rule (v2.7.0+)
@@ -574,7 +587,8 @@ Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
574
587
  function injectOrUpdateRules(filePath, headerIfNew, cwd) {
575
588
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
576
589
 
577
- const block = trackfwRulesBlock(readAgentConventions(cwd))
590
+ const { namespacing, agents: nsAgents } = readNamespacingConfig(cwd)
591
+ const block = trackfwRulesBlock(readAgentConventions(cwd), namespacing, nsAgents)
578
592
 
579
593
  if (!fs.existsSync(filePath)) {
580
594
  let content = headerIfNew || ''
@@ -688,8 +702,17 @@ function generateClaudeMD(cfg) {
688
702
  content += '| Command | When to use |\n'
689
703
  content += '|---|---|\n'
690
704
  content += '| `trackfw adr new "title"` | Create ADR |\n'
691
- content += '| `trackfw req new "title"` | Create REQ |\n'
692
- content += '| `trackfw roadmap new` | Create empty roadmap linked to a REQ |\n'
705
+ {
706
+ const nsNonEmpty = (cfg.agents || []).filter(a => a)
707
+ if (cfg.roadmapNamespacing === NAMESPACING_BY_AGENT && nsNonEmpty.length >= 2) {
708
+ const agentList = nsNonEmpty.join(', ')
709
+ content += `| \`trackfw req new --agent <agent> "title"\` | Create REQ (\`--agent\` required; agents: ${agentList}) |\n`
710
+ content += '| `trackfw roadmap new --agent <agent>` | Create roadmap linked to a REQ (`--agent` required) |\n'
711
+ } else {
712
+ content += '| `trackfw req new "title"` | Create REQ |\n'
713
+ content += '| `trackfw roadmap new` | Create empty roadmap linked to a REQ |\n'
714
+ }
715
+ }
693
716
  content += '| `trackfw roadmap move <name> <state>` | Move roadmap state |\n'
694
717
  content += '| `trackfw validate` | Governance validation gate |\n'
695
718
  content += '| `trackfw status` | Show governance status |\n\n'
@@ -898,6 +921,8 @@ trackfw não está instalado. Instale com uma das opções:
898
921
 
899
922
  'req.md': `Execute o seguinte comando bash: \`trackfw req new "$ARGUMENTS"\`
900
923
 
924
+ ⚠️ Em projetos com \`roadmap_namespacing: by_agent\` e 2+ agentes, use: \`trackfw req new --agent <seu-agente> "$ARGUMENTS"\`
925
+
901
926
  Se o comando falhar com \`trackfw: command not found\` ou similar, informe ao usuário:
902
927
 
903
928
  \`\`\`
@@ -242,13 +242,22 @@ function toSlug(s) {
242
242
  /**
243
243
  * newREQ — cria docs/req/REQ-YYYY-MM-DD-<slug>.md.
244
244
  * @param {{ title: string, motivation?: string, criteria?: string, dependsOnADRs?: string[] }} content
245
+ * @param {string} [agent] - agente explícito via --agent (AC4/AC5/AC10)
245
246
  * @returns {Promise<void>}
246
247
  */
247
- async function newREQ(content) {
248
+ async function newREQ(content, agent) {
248
249
  // Ponto único de decisão de caminho de ESCRITA (ADR-2026-09-03, D2/D4): by_agent grava no
249
250
  // canônico req_dir/<agente>/; flat grava em req_dir/ — o mesmo ponto que alimenta a união de leitura.
251
+ // Em by_agent com múltiplos namespaces sem --agent, reqWriteDir lança erro de ambiguidade (AC5/AC10).
250
252
  const cfg = require('../config').load()
251
- const reqDir = reqWriteDir(cfg) || cfg.reqDir
253
+ let reqDir
254
+ try {
255
+ reqDir = reqWriteDir(cfg, agent) || cfg.reqDir
256
+ } catch (err) {
257
+ console.error(`Error: ${err.message}`)
258
+ process.exitCode = 1
259
+ return
260
+ }
252
261
  fs.mkdirSync(reqDir, { recursive: true })
253
262
 
254
263
  const slug = toSlug(content.title)
@@ -3,7 +3,7 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const config = require('../config')
5
5
  const { localDateISO } = require('./date')
6
- const { resolveReqFiles, resolveAgentNamespaces } = require('../validator/index.js')
6
+ const { resolveReqFiles, resolveAgentNamespaces, resolveAgentForWrite, roadmapNewLine } = require('../validator/index.js')
7
7
  const { normalizeRefSeparator: pathfmtNormalizeRefSeparator } = require('../lib/pathfmt')
8
8
  const { normalizeCRLF } = require('../integrations/render')
9
9
 
@@ -65,13 +65,43 @@ function stateDir(state) {
65
65
  return cfg.roadmapDir + '/' + state
66
66
  }
67
67
 
68
+ // agentFromPath — extrai o nome do agente a partir de um caminho de arquivo dentro de baseDir.
69
+ // Estratégia relative-to-base: relativiza o filePath em relação a baseDir e retorna o primeiro
70
+ // segmento do caminho relativo. Funciona para:
71
+ // req_dir/<agent>/REQ-x.md → agent
72
+ // req_dir/<agent>/wip/REQ-x.md → agent (layout legado)
73
+ // roadmap_dir/<agent>/wip/R.md → agent
74
+ // Usa realpathSync para resolver symlinks antes de computar o relativo — necessário no macOS onde
75
+ // /var é link para /private/var: sem resolução, path.relative produz '../..' em vez do agente.
76
+ // Equivalente ao agentDir/agent inline do moveRoadmap — extraído para função nomeada (AC11).
77
+ function agentFromPath(filePath, baseDir) {
78
+ let abs, base
79
+ try {
80
+ abs = fs.realpathSync(filePath)
81
+ } catch (_) {
82
+ abs = path.resolve(filePath)
83
+ }
84
+ try {
85
+ base = fs.realpathSync(path.resolve(baseDir))
86
+ } catch (_) {
87
+ base = path.resolve(baseDir)
88
+ }
89
+ const rel = path.relative(base, abs)
90
+ const parts = rel.split(path.sep)
91
+ // Arquivo direto em baseDir (1 segmento): não é um namespace, retorna vazio.
92
+ // Segmento inválido ('.' ou '..'): baseDir não contém filePath, retorna vazio.
93
+ if (parts.length < 2) return ''
94
+ if (!parts[0] || parts[0] === '.' || parts[0] === '..') return ''
95
+ return parts[0]
96
+ }
97
+
68
98
  // agentStateDir retorna o diretório para um agente+estado em modo by_agent.
69
- // agent=null usa o primeiro agente configurado (ou "default" se lista vazia).
99
+ // agent=null: delega a resolveAgentForWrite (que lança em multi-agente sem flag — AC5).
70
100
  function agentStateDir(agent, state) {
71
101
  const cfg = config.load()
72
102
  if (!VALID_STATES.includes(state)) return null
73
103
  if (!agent) {
74
- agent = cfg.agents && cfg.agents.length > 0 ? cfg.agents[0] : 'default'
104
+ agent = resolveAgentForWrite(cfg, undefined)
75
105
  }
76
106
  return cfg.roadmapDir + '/' + agent + '/' + state
77
107
  }
@@ -123,7 +153,7 @@ function listRoadmaps() {
123
153
  }
124
154
 
125
155
  if (!found) {
126
- console.log("Nenhum roadmap encontrado. Crie um com 'trackfw roadmap new'.")
156
+ console.log(`Nenhum roadmap encontrado. Crie um com '${roadmapNewLine(cfg)}'.`)
127
157
  }
128
158
  }
129
159
 
@@ -264,8 +294,12 @@ function moveRoadmap(name, state) {
264
294
  let targetDir, fromState, logBasename
265
295
 
266
296
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
267
- const agentDir = path.dirname(path.dirname(src))
268
- const agent = path.basename(agentDir)
297
+ const agent = agentFromPath(src, cfg.roadmapDir)
298
+ if (!agent) {
299
+ console.error(`cannot determine agent namespace for "${src}" — path is outside roadmap directory or resolves via symlink to an external location`)
300
+ process.exitCode = 1
301
+ return
302
+ }
269
303
  fromState = path.basename(path.dirname(src))
270
304
  targetDir = agentStateDir(agent, state)
271
305
  if (!targetDir) {
@@ -492,9 +526,12 @@ function syncReqReferences(movedBasename, newRoadmapPath, cfg) {
492
526
 
493
527
  /**
494
528
  * newRoadmap — cria roadmap em <roadmapDir>/backlog/ROADMAP-YYYY-MM-DD-<slug>.md.
495
- * Em modo by_agent, usa o primeiro agente configurado.
529
+ * Em modo by_agent, usa o agente explícito ou detecta via resolveAgentForWrite (AC4/AC5).
530
+ * @param {string} title
531
+ * @param {string} reqPath - caminho da REQ vinculada (opcional)
532
+ * @param {string} [agent] - agente explícito via --agent (opcional; derivado da REQ se --req fornecido)
496
533
  */
497
- function newRoadmap(title, reqPath) {
534
+ function newRoadmap(title, reqPath, agent) {
498
535
  // AC1/AC2: o título é dado de uma linha — newline e CR são entrada malformada.
499
536
  // Mensagem byte-idêntica nos 3 CLIs (docs/cli-parity.md).
500
537
  if (/[\n\r]/.test(title)) {
@@ -508,8 +545,20 @@ function newRoadmap(title, reqPath) {
508
545
  const slug = toSlug(title)
509
546
 
510
547
  let backlogDir
548
+ let resolvedAgent = ''
511
549
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
512
- backlogDir = agentStateDir(null, 'backlog')
550
+ // AC11: se --req fornecido e sem --agent explícito, deriva o agente da REQ (reusa agentFromPath)
551
+ if (!agent && reqPath) {
552
+ agent = agentFromPath(reqPath, cfg.reqDir || 'docs/req')
553
+ }
554
+ try {
555
+ resolvedAgent = resolveAgentForWrite(cfg, agent)
556
+ } catch (err) {
557
+ console.error(`Error: ${err.message}`)
558
+ process.exitCode = 1
559
+ return
560
+ }
561
+ backlogDir = agentStateDir(resolvedAgent, 'backlog')
513
562
  if (!backlogDir) {
514
563
  console.error('cannot resolve backlog dir in by_agent mode')
515
564
  process.exitCode = 1
@@ -523,12 +572,13 @@ function newRoadmap(title, reqPath) {
523
572
  fs.mkdirSync(backlogDir, { recursive: true })
524
573
 
525
574
  const reqField = reqPath ? `"${reqPath}"` : '""'
575
+ const squadField = resolvedAgent ? `"${resolvedAgent}"` : '""'
526
576
 
527
577
  const body = `---
528
578
  status: backlog
529
579
  date: ${date}
530
580
  req: ${reqField}
531
- squad: ""
581
+ squad: ${squadField}
532
582
  ---
533
583
 
534
584
  # Roadmap: ${title}
@@ -564,8 +614,10 @@ ${WAVE0_BLOCK}## Wave 1 — <name> (parallel MLs)
564
614
  /**
565
615
  * newRoadmapFromReq — lê uma REQ e gera roadmap pré-preenchido com MLs extraídos
566
616
  * dos critérios de aceite.
617
+ * @param {string} reqPath
618
+ * @param {string} [agent] - agente explícito via --agent (AC11: herda do path da REQ se omitido)
567
619
  */
568
- function newRoadmapFromReq(reqPath) {
620
+ function newRoadmapFromReq(reqPath, agent) {
569
621
  let data
570
622
  try {
571
623
  data = fs.readFileSync(reqPath, 'utf8')
@@ -591,8 +643,20 @@ function newRoadmapFromReq(reqPath) {
591
643
  const slug = toSlug(title)
592
644
 
593
645
  let backlogDir
646
+ let resolvedAgent = ''
594
647
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
595
- backlogDir = agentStateDir(null, 'backlog')
648
+ // AC11: herda o agente do caminho da REQ reusando agentFromPath (mesmo mecanismo de moveRoadmap)
649
+ if (!agent) {
650
+ agent = agentFromPath(reqPath, cfg.reqDir || 'docs/req')
651
+ }
652
+ try {
653
+ resolvedAgent = resolveAgentForWrite(cfg, agent)
654
+ } catch (err) {
655
+ console.error(`Error: ${err.message}`)
656
+ process.exitCode = 1
657
+ return
658
+ }
659
+ backlogDir = agentStateDir(resolvedAgent, 'backlog')
596
660
  if (!backlogDir) {
597
661
  console.error('cannot resolve backlog dir in by_agent mode')
598
662
  process.exitCode = 1
@@ -622,12 +686,13 @@ function newRoadmapFromReq(reqPath) {
622
686
  const mlSection = WAVE0_BLOCK + mlLines.join('\n')
623
687
 
624
688
  const adrRef = linkedADR ? `\nADR: ${linkedADR}` : ''
689
+ const squadField = resolvedAgent ? `"${resolvedAgent}"` : '""'
625
690
 
626
691
  const body = `---
627
692
  status: backlog
628
693
  date: ${date}
629
694
  req: "${reqPath}"
630
- squad: ""
695
+ squad: ${squadField}
631
696
  ---
632
697
 
633
698
  # Roadmap: ${title}
@@ -769,6 +834,7 @@ module.exports = {
769
834
  newRoadmapFromReq,
770
835
  stateDir,
771
836
  agentStateDir,
837
+ agentFromPath,
772
838
  VALID_STATES,
773
839
  STATE_ORDER,
774
840
  toSlug,
@@ -9,6 +9,7 @@ const { legacyHashes } = require('./legacy')
9
9
  const identityStore = require('../identity')
10
10
  const { injectRulesForTool } = require('../generators/init')
11
11
  const { applyThirdPartyReferences } = require('../thirdparty/references')
12
+ const { registerAgentInConfig } = require('./register-agent')
12
13
  const { homedir } = require('../homedir')
13
14
 
14
15
  function parseSurfaces(values = []) {
@@ -141,6 +142,27 @@ function execute(kind, operation, options = {}, roots = {}) {
141
142
  if (operation === 'install') {
142
143
  const targetValues = options.targets && options.targets.length ? options.targets : catalog.targets.map(entry => entry.id)
143
144
  for (const targetID of targetValues) injectRulesForTool(targetID, manager.roots.project)
145
+
146
+ // ML-2B: register installed catalog agent IDs in trackfw.yaml `agents:` so
147
+ // that `trackfw req new` / `roadmap new` can route to the agent namespace.
148
+ // Guard: only for `agents` kind (not skills), project scope (global installs
149
+ // do not own the project's trackfw.yaml), and only when install operation
150
+ // succeeds (same call site as injectRulesForTool above).
151
+ // Name written: plan.claim.item (the catalog ID, e.g. "architect") — chosen
152
+ // over the artifact file name ("trackfw-architect") to stay consistent with
153
+ // the namespace format already used in trackfw.yaml's agents: list (bare
154
+ // names, no prefix). Surfaced in handback for parity binding across Go/Python.
155
+ if (kind === 'agents') {
156
+ const projectRoot = options.projectRoot || manager.roots.project
157
+ const installedItems = [...new Set(
158
+ plans
159
+ .filter(p => p.claim.scope === 'project')
160
+ .map(p => p.claim.item)
161
+ )]
162
+ for (const agentName of installedItems) {
163
+ registerAgentInConfig(projectRoot, agentName)
164
+ }
165
+ }
144
166
  }
145
167
 
146
168
  return result(kind, plans, statuses)
@@ -0,0 +1,106 @@
1
+ 'use strict'
2
+
3
+ // registerAgentInConfig — ML-2B (Node)
4
+ //
5
+ // When `trackfw agents install` runs inside a project configured with
6
+ // `roadmap_namespacing: by_agent`, the installed catalog item ID (e.g.
7
+ // "architect") is registered as a namespace entry in the `agents:` list of
8
+ // `trackfw.yaml`, enabling `trackfw req new` / `roadmap new` to route
9
+ // artefacts to that agent's subdirectory.
10
+ //
11
+ // Scope gate: only project-scoped installs write the key. Global installs
12
+ // (scope === "global") install files into the user's home directory and do
13
+ // NOT own any project's trackfw.yaml — we must not write it.
14
+ //
15
+ // Format preservation: we use yaml.parseDocument / doc.toString() which is
16
+ // the documented round-trip that preserves comments, key order and blank
17
+ // lines. We do NOT re-serialise the whole document through yaml.dump so that
18
+ // a diff of trackfw.yaml only shows the added line(s) in the agents: block
19
+ // (AC3). See the AC3 test for the exact assertion.
20
+ //
21
+ // Idempotency: if the agent name is already in the list we return early
22
+ // without touching the file (AC1).
23
+ //
24
+ // flat mode: if roadmap_namespacing is not "by_agent" we do nothing at all —
25
+ // the agents: key MUST NOT be created (AC2).
26
+ //
27
+ // Inline-flow guard: if agents: is written in inline/flow style (e.g.
28
+ // `agents: [alpha, beta]`) we do NOT rewrite the file. Rewriting would change
29
+ // the user's formatting without their consent. Instead we emit a warning to
30
+ // stderr naming the file path and the item, and return — the persona install
31
+ // itself succeeds. Mirrors Go behaviour (the reference runtime).
32
+
33
+ const fs = require('node:fs')
34
+ const path = require('node:path')
35
+ const { parseDocument } = require('yaml')
36
+
37
+ const CONFIG_FILE = 'trackfw.yaml'
38
+
39
+ /**
40
+ * Return true if the raw YAML text has an `agents:` key written in inline/flow
41
+ * style on the same line (e.g. `agents: [alpha, beta]`), ignoring comment lines.
42
+ *
43
+ * We test on the raw text before parsing so that we never mutate a document
44
+ * whose style we cannot faithfully reproduce.
45
+ */
46
+ function isAgentsInlineFlow(raw) {
47
+ for (const line of raw.split('\n')) {
48
+ const trimmed = line.trimStart()
49
+ if (trimmed.startsWith('#')) continue
50
+ // Non-comment line that starts the `agents:` mapping key and has `[` on it
51
+ if (/^agents\s*:.*\[/.test(trimmed)) return true
52
+ }
53
+ return false
54
+ }
55
+
56
+ /**
57
+ * Register `agentName` in the `agents:` list of `<projectRoot>/trackfw.yaml`.
58
+ *
59
+ * No-op when:
60
+ * - trackfw.yaml does not exist in projectRoot (nothing to update)
61
+ * - roadmap_namespacing is absent or not "by_agent" (AC2)
62
+ * - agentName is already present in the list (AC1 idempotency)
63
+ * - agents: is in inline/flow format — warns to stderr, skips write
64
+ *
65
+ * @param {string} projectRoot Absolute path to the project directory.
66
+ * @param {string} agentName Catalog item ID (e.g. "architect").
67
+ */
68
+ function registerAgentInConfig(projectRoot, agentName) {
69
+ const cfgPath = path.join(projectRoot, CONFIG_FILE)
70
+ if (!fs.existsSync(cfgPath)) return
71
+
72
+ const raw = fs.readFileSync(cfgPath, 'utf8')
73
+ const doc = parseDocument(raw)
74
+
75
+ // AC2 gate: only act when roadmap_namespacing is exactly "by_agent"
76
+ const namespacing = doc.get('roadmap_namespacing')
77
+ if (namespacing !== 'by_agent') return
78
+
79
+ // Inline-flow guard: refuse to rewrite a file the user authored in flow style.
80
+ // Mirrors Go: "warning: could not register agent ... trackfw.yaml has agents:
81
+ // in inline-flow format; edit <path> manually to add <item>"
82
+ if (isAgentsInlineFlow(raw)) {
83
+ process.stderr.write(
84
+ `warning: could not register agent "${agentName}" in trackfw.yaml:\n` +
85
+ `trackfw.yaml has agents: in inline-flow format; edit ${cfgPath} manually to add "${agentName}"\n`
86
+ )
87
+ return
88
+ }
89
+
90
+ // Collect current agents list (may be absent)
91
+ const agentsNode = doc.getIn(['agents'], true)
92
+
93
+ if (agentsNode) {
94
+ // List present — check for idempotency (AC1)
95
+ const existing = agentsNode.items.map(item => item.value)
96
+ if (existing.includes(agentName)) return
97
+ agentsNode.items.push(doc.createNode(agentName))
98
+ } else {
99
+ // List absent — create it (AC2 already passed: namespacing === 'by_agent')
100
+ doc.set('agents', doc.createNode([agentName]))
101
+ }
102
+
103
+ fs.writeFileSync(cfgPath, doc.toString(), 'utf8')
104
+ }
105
+
106
+ module.exports = { registerAgentInConfig }
@@ -30,6 +30,8 @@ function defaultCheckGovernance() {
30
30
  return checkShipGovernance()
31
31
  }
32
32
 
33
+ const { load: loadConfig } = require('../config')
34
+ const validator = require('../validator')
33
35
  const { resolve: forgeResolve } = require('../forge/resolve')
34
36
  const { forgeAdapter } = require('../forge/adapter')
35
37
 
@@ -127,8 +129,9 @@ function runPush(opts, deps) {
127
129
  writeln(` ${v}`)
128
130
  }
129
131
  writeln('\nCreate the required artifacts before running push:')
130
- writeln(' trackfw req new "<title>"')
131
- writeln(' trackfw roadmap new "<title>"')
132
+ const pushCfg = loadConfig()
133
+ writeln(` ${validator.reqNewLine(pushCfg)}`)
134
+ writeln(` ${validator.roadmapNewLine(pushCfg)}`)
132
135
  writeln(' trackfw roadmap move <name> wip')
133
136
  writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
134
137
  writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
@@ -4,15 +4,15 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
 
6
6
  /**
7
- * isPathAllowed verifica se o filePath resolvido está dentro de um dos diretórios permitidos.
8
- * @param {string} resolved - path.resolve(filePath)
9
- * @param {string[]} allowedDirs - lista de diretórios permitidos (já resolvidos)
7
+ * isPathAllowed verifica se absPath está dentro de um dos diretórios permitidos.
8
+ * Usa separador no sufixo para evitar que /docs/adr case com /docs/adr2.
9
+ * @param {string} absPath
10
+ * @param {string[]} allowedDirs
10
11
  * @returns {boolean}
11
12
  */
12
- function isPathAllowed(resolved, allowedDirs) {
13
+ function isPathAllowed(absPath, allowedDirs) {
13
14
  for (const dir of allowedDirs) {
14
- // garantir que o path começa com dir + separador
15
- if (resolved === dir || resolved.startsWith(dir + path.sep)) {
15
+ if (absPath === dir || absPath.startsWith(dir + path.sep)) {
16
16
  return true
17
17
  }
18
18
  }
@@ -21,7 +21,22 @@ function isPathAllowed(resolved, allowedDirs) {
21
21
 
22
22
  /**
23
23
  * handleFile responde ao GET /api/file?path=... com o conteúdo do arquivo.
24
- * Retorna 400 se path ausente, 403 se fora dos diretórios permitidos, 404 se não existe.
24
+ *
25
+ * Modelo de segurança — contenção em dois estágios:
26
+ *
27
+ * 1. Contenção léxica (sem acesso ao filesystem): path.resolve() normaliza ".."
28
+ * e o resultado deve começar com uma das raízes autorizadas. Caminhos fora
29
+ * das raízes recebem 403 antes de qualquer acesso ao disco, impedindo que a
30
+ * distinção 403 vs 404 sirva de oracle de existência para caminhos arbitrários.
31
+ *
32
+ * 2. Contenção física (resolução de symlinks): fs.realpathSync.native() é chamado
33
+ * no arquivo pedido e em cada raiz autorizada. Um symlink cujo nome está
34
+ * dentro de uma raiz autorizada mas cujo destino físico não está → 403.
35
+ * Qualquer falha de canonicalização (ENOENT, symlink pendente, …) → 404.
36
+ *
37
+ * Um symlink legítimo cujo destino também está dentro de uma raiz autorizada
38
+ * continua sendo servido normalmente.
39
+ *
25
40
  * @param {object} cfg
26
41
  * @param {http.IncomingMessage} req
27
42
  * @param {http.ServerResponse} res
@@ -43,10 +58,10 @@ function handleFile(cfg, req, res) {
43
58
  return
44
59
  }
45
60
 
46
- // Resolver o path absoluto para verificar segurança
61
+ // ── Estágio 1: Contenção léxica ────────────────────────────────────────────
62
+ // path.resolve() resolve '..' mas NÃO segue symlinks (operação puramente léxica).
47
63
  const resolved = path.resolve(filePath)
48
64
 
49
- // Montar lista de diretórios permitidos a partir da config
50
65
  const adrDirs = (cfg.adrDirs || ['docs/adr']).map(d => path.resolve(d))
51
66
  const reqDir = path.resolve(cfg.reqDir || 'docs/req')
52
67
  const roadmapDir = path.resolve(cfg.roadmapDir || 'docs/roadmaps')
@@ -58,15 +73,37 @@ function handleFile(cfg, req, res) {
58
73
  return
59
74
  }
60
75
 
61
- if (!fs.existsSync(resolved)) {
76
+ // ── Estágio 2: Contenção física ────────────────────────────────────────────
77
+ // Canonicalizar o arquivo pedido. Qualquer erro (ENOENT, symlink pendente,
78
+ // loop, permissão) → 404. O arquivo está nominalmente dentro de uma raiz
79
+ // autorizada (estágio 1 passou), portanto 404 é o resultado correto quando
80
+ // ele simplesmente não existe.
81
+ let realResolved
82
+ try {
83
+ realResolved = fs.realpathSync.native(resolved)
84
+ } catch (_) {
62
85
  res.writeHead(404, { 'Content-Type': 'text/plain' })
63
86
  res.end('Not Found')
64
87
  return
65
88
  }
66
89
 
90
+ // Canonicalizar as raízes autorizadas. Raízes inexistentes são ignoradas —
91
+ // nenhum arquivo pode residir em um diretório que não existe.
92
+ const realAllowedDirs = allowedDirs.flatMap(d => {
93
+ try { return [fs.realpathSync.native(d)] } catch (_) { return [] }
94
+ })
95
+
96
+ // Contenção física: o destino canônico deve estar dentro de uma raiz canônica.
97
+ if (!isPathAllowed(realResolved, realAllowedDirs)) {
98
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
99
+ res.end('Forbidden')
100
+ return
101
+ }
102
+
103
+ // O arquivo existe (realpathSync não falhou) — ler da rota canônica.
67
104
  let content
68
105
  try {
69
- content = fs.readFileSync(resolved, 'utf8')
106
+ content = fs.readFileSync(realResolved, 'utf8')
70
107
  } catch (err) {
71
108
  res.writeHead(500, { 'Content-Type': 'text/plain' })
72
109
  res.end('Internal Server Error')
@@ -499,8 +499,9 @@ function runShip(opts, deps = {}) {
499
499
  writeln(` ${v}`)
500
500
  }
501
501
  writeln('\nCreate the required artifacts before running ship:')
502
- writeln(' trackfw req new "<title>"')
503
- writeln(' trackfw roadmap new "<title>"')
502
+ const shipCfg = loadConfig()
503
+ writeln(` ${validator.reqNewLine(shipCfg)}`)
504
+ writeln(` ${validator.roadmapNewLine(shipCfg)}`)
504
505
  writeln(' trackfw roadmap move <name> wip')
505
506
  writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
506
507
  writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
@@ -414,18 +414,33 @@ function listReqMdFiles(dir) {
414
414
  } catch (_) { return [] }
415
415
  }
416
416
 
417
+ // resolveAgentForWrite — ponto único de resolução do agente de escrita em modo by_agent.
418
+ // Regra (AC5, AC10, KG 2026-08-29):
419
+ // - explicitAgent fornecido → usa-o (AC4/AC5b: valor fora de agents: é permitido, produz violação de namespace)
420
+ // - agents: com UM namespace → usa aquele, sem erro (AC14: projeto single-agent nunca vê o erro)
421
+ // - agents: vazio → usa "default"
422
+ // - agents: com VÁRIOS → lança erro nomeando todas as opções (AC5: silêncio de agents[0] deixa de existir)
423
+ // Nomes vazios em agents: não contam (agents: ["", "zeus"] é UM namespace) — filter antes de contar.
424
+ function resolveAgentForWrite(cfg, explicitAgent) {
425
+ if (explicitAgent) return explicitAgent
426
+ const agents = (cfg.agents || []).filter(a => a)
427
+ if (agents.length === 0) return 'default'
428
+ if (agents.length === 1) return agents[0]
429
+ throw new Error(`by_agent project has multiple agent namespaces (${agents.join(', ')}): use --agent to specify one`)
430
+ }
431
+
417
432
  // reqWriteDir é o PONTO ÚNICO que decide ONDE uma REQ nova é gravada (ADR-2026-09-03, D2/D4):
418
433
  // flat → req_dir/
419
- // by_agent → req_dir/<agente>/ (primeiro de agents:, ou "default" se a lista é vazia)
434
+ // by_agent → req_dir/<agente>/ (agente resolvido via resolveAgentForWrite — AC4/AC5/AC10)
420
435
  // Consumido pelo gerador (newREQ); a união de leitura abaixo contém este diretório por construção.
421
- function reqWriteDir(cfg) {
436
+ // Lança Error em by_agent com múltiplos namespaces e sem agente explícito (AC5).
437
+ function reqWriteDir(cfg, agent) {
422
438
  const reqDir = cfg.reqDir || cfg.req_dir || ''
423
439
  if (!reqDir) return ''
424
440
  const namespacing = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
425
441
  if (namespacing === 'by_agent') {
426
- const agents = (cfg.agents || []).filter(a => a)
427
- const agent = agents.length > 0 ? agents[0] : 'default'
428
- return path.join(reqDir, agent)
442
+ const resolvedAgent = resolveAgentForWrite(cfg, agent)
443
+ return path.join(reqDir, resolvedAgent)
429
444
  }
430
445
  return reqDir
431
446
  }
@@ -1500,12 +1515,37 @@ function branchSlugMatchesRoadmap(branchSlug, wipDirs, doneDirs) {
1500
1515
  return { matched, candidates }
1501
1516
  }
1502
1517
 
1518
+ // isMultiAgentByAgent returns {multi: bool, agents: string[]} — parity with Go's IsMultiAgentByAgent.
1519
+ // multi is true only when roadmap_namespacing is "by_agent" AND there are 2+ non-empty agent names.
1520
+ function isMultiAgentByAgent(cfg) {
1521
+ const ns = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
1522
+ if (ns !== 'by_agent') return { multi: false, agents: [] }
1523
+ const nonEmpty = (cfg.agents || []).filter(a => a)
1524
+ if (nonEmpty.length < 2) return { multi: false, agents: nonEmpty }
1525
+ return { multi: true, agents: nonEmpty }
1526
+ }
1527
+
1528
+ // reqNewLine returns the correct `trackfw req new` command for the project — parity with Go's ReqNewLine.
1529
+ function reqNewLine(cfg) {
1530
+ const { multi, agents } = isMultiAgentByAgent(cfg)
1531
+ if (multi) return `trackfw req new --agent <agent> "title" # agents: ${agents.join(', ')}`
1532
+ return 'trackfw req new "title"'
1533
+ }
1534
+
1535
+ // roadmapNewLine returns the correct `trackfw roadmap new` command — parity with Go's RoadmapNewLine.
1536
+ function roadmapNewLine(cfg) {
1537
+ const { multi } = isMultiAgentByAgent(cfg)
1538
+ if (multi) return 'trackfw roadmap new --agent <agent> "title"'
1539
+ return 'trackfw roadmap new "title"'
1540
+ }
1541
+
1503
1542
  // branchGovernanceOrientation is the guidance message printed when a feat/fix/refactor branch has
1504
1543
  // no roadmap in wip/ nor done/ at all (candidates is empty). Shared by validateBranchHasWIPRoadmap
1505
1544
  // and `trackfw branch new` — never duplicate this string. Byte-identical to Go's
1506
1545
  // BranchGovernanceOrientation.
1507
- function branchGovernanceOrientation(branch) {
1508
- return `branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ nor done/ — create governance artifacts first:\n trackfw req new "title"\n trackfw roadmap new "title"\n trackfw roadmap move <name> wip`
1546
+ function branchGovernanceOrientation(branch, cfg) {
1547
+ const cfgObj = cfg || {}
1548
+ return `branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ nor done/ — create governance artifacts first:\n ${reqNewLine(cfgObj)}\n ${roadmapNewLine(cfgObj)}\n trackfw roadmap move <name> wip`
1509
1549
  }
1510
1550
 
1511
1551
  // branchNoMatchingRoadmapMessage is the guidance message printed when roadmaps exist in wip/ or
@@ -1548,7 +1588,7 @@ function validateBranchHasWIPRoadmap() {
1548
1588
  if (matched) return []
1549
1589
 
1550
1590
  if (candidates.length === 0) {
1551
- return [branchGovernanceOrientation(branch)]
1591
+ return [branchGovernanceOrientation(branch, cfg)]
1552
1592
  }
1553
1593
  return [branchNoMatchingRoadmapMessage(branch, candidates)]
1554
1594
  }
@@ -3962,6 +4002,7 @@ module.exports = {
3962
4002
  tryListDir,
3963
4003
  resolveReqFiles,
3964
4004
  reqWriteDir,
4005
+ resolveAgentForWrite,
3965
4006
  resolveStateDirs,
3966
4007
  resolveWIPDirs,
3967
4008
  resolveDoneDirs,
@@ -3980,6 +4021,9 @@ module.exports = {
3980
4021
  validateBranchHasWIPRoadmap,
3981
4022
  // novas funções — trackfw branch new (extraídas do gate branch_has_wip_roadmap)
3982
4023
  branchSlugMatchesRoadmap,
4024
+ isMultiAgentByAgent,
4025
+ reqNewLine,
4026
+ roadmapNewLine,
3983
4027
  branchGovernanceOrientation,
3984
4028
  branchNoMatchingRoadmapMessage,
3985
4029
  normalizeBranchSlug,