trackfw 7.5.0 → 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.0",
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 }
@@ -7,7 +7,7 @@ const identityStore = require('../identity')
7
7
  const projectConfig = require('../config')
8
8
  const { catalog, buildPlans, IntegrationManager, globalGroupPath } = require('../integrations')
9
9
  const { tildeify, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine')
10
- const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, generateGlobalCredentialGuardScript, generateGlobalGitBranchGuardScript } = require('../generators/hooks')
10
+ const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, GIT_BRANCH_GUARD_SCRIPT, GLOBAL_CREDENTIAL_GUARD_SCRIPT } = require('../generators/hooks')
11
11
  const { homedir } = require('../homedir')
12
12
 
13
13
  // `trackfw update harness` is the global counterpart to `trackfw update` —
@@ -73,6 +73,72 @@ Cadeia: **ADR → REQ → ROADMAP** · Estados: \`backlog / wip / blocked / done
73
73
  `
74
74
  }
75
75
 
76
+ // gitBranchGuardScriptTarget — evaluates (and, unless --dry-run, applies) the
77
+ // global git-branch-guard shell script at
78
+ // ~/.trackfw/scripts/trackfw-git-branch-guard.sh.
79
+ //
80
+ // This target OWNS the script file. It compares content before writing so that
81
+ // an already up-to-date script reports "skipped" and its mtime is left unchanged
82
+ // (REQ-2026-09-09 AC3). Creation does not require --install-missing because
83
+ // ~/.trackfw/scripts/ is 100% trackfw-owned.
84
+ function gitBranchGuardScriptTarget(homeRoot, { dryRun }) {
85
+ const id = 'git-branch-guard-script'
86
+ const filePath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
87
+ const displayPath = tildeify(homeRoot, filePath)
88
+ const desired = GIT_BRANCH_GUARD_SCRIPT
89
+
90
+ try {
91
+ const exists = fs.existsSync(filePath)
92
+ const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
93
+
94
+ if (exists && actual === desired) {
95
+ // Content identical — skip write, repair execute bit without touching mtime.
96
+ try { fs.chmodSync(filePath, 0o755) } catch (_) { /* best-effort */ }
97
+ return { id, state: 'skipped', path: displayPath }
98
+ }
99
+
100
+ if (dryRun) return { id, state: 'updated', path: displayPath }
101
+
102
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
103
+ fs.writeFileSync(filePath, desired, { encoding: 'utf8', mode: 0o755 })
104
+ return { id, state: 'updated', path: displayPath }
105
+ } catch (e) {
106
+ return { id, state: 'failed', path: displayPath, message: e.message }
107
+ }
108
+ }
109
+
110
+ // credentialGuardScriptTarget — evaluates (and, unless --dry-run, applies) the
111
+ // global credential-guard shell script at
112
+ // ~/.trackfw/scripts/trackfw-credential-guard.sh.
113
+ //
114
+ // Mirrors gitBranchGuardScriptTarget — same ownership model, same idempotency
115
+ // contract, same absence of --install-missing gate.
116
+ function credentialGuardScriptTarget(homeRoot, { dryRun }) {
117
+ const id = 'credential-guard-script'
118
+ const filePath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
119
+ const displayPath = tildeify(homeRoot, filePath)
120
+ const desired = GLOBAL_CREDENTIAL_GUARD_SCRIPT
121
+
122
+ try {
123
+ const exists = fs.existsSync(filePath)
124
+ const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
125
+
126
+ if (exists && actual === desired) {
127
+ // Content identical — skip write, repair execute bit without touching mtime.
128
+ try { fs.chmodSync(filePath, 0o755) } catch (_) { /* best-effort */ }
129
+ return { id, state: 'skipped', path: displayPath }
130
+ }
131
+
132
+ if (dryRun) return { id, state: 'updated', path: displayPath }
133
+
134
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
135
+ fs.writeFileSync(filePath, desired, { encoding: 'utf8', mode: 0o755 })
136
+ return { id, state: 'updated', path: displayPath }
137
+ } catch (e) {
138
+ return { id, state: 'failed', path: displayPath, message: e.message }
139
+ }
140
+ }
141
+
76
142
  function claudeSkillTarget(homeRoot, { dryRun, installMissing }) {
77
143
  const id = 'claude-skill'
78
144
  const filePath = path.join(homeRoot, '.claude', 'skills', 'trackfw', 'SKILL.md')
@@ -799,7 +865,10 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
799
865
  // which precede claude-agents/claude-skills), with credential-guard always
800
866
  // preceding git-branch-guard within a tool — see buildHarnessTargetIDs's
801
867
  // comment in update.go for the full rationale.
802
- const HARNESS_TARGET_IDS = ['claude-skill', 'claude-credential-guard', 'claude-git-branch-guard']
868
+ // Script targets precede every wiring target that references them so that even
869
+ // a --targets-filtered run that only requests a wiring target still finds the
870
+ // script already in place from a prior full run.
871
+ const HARNESS_TARGET_IDS = ['claude-skill', 'git-branch-guard-script', 'credential-guard-script', 'claude-credential-guard', 'claude-git-branch-guard']
803
872
  for (const target of catalog.targets) {
804
873
  if (target.id === 'codex') HARNESS_TARGET_IDS.push('codex-credential-guard', 'codex-git-branch-guard')
805
874
  if (target.id === 'gemini') HARNESS_TARGET_IDS.push('gemini-credential-guard', 'gemini-git-branch-guard')
@@ -818,6 +887,8 @@ function buildHarnessTargets(homeRoot, identityConfig, { dryRun, installMissing
818
887
  const include = (id) => !wanted || wanted.includes(id)
819
888
  const targets = []
820
889
  if (include('claude-skill')) targets.push(claudeSkillTarget(homeRoot, { dryRun, installMissing }))
890
+ if (include('git-branch-guard-script')) targets.push(gitBranchGuardScriptTarget(homeRoot, { dryRun }))
891
+ if (include('credential-guard-script')) targets.push(credentialGuardScriptTarget(homeRoot, { dryRun }))
821
892
  if (include('claude-credential-guard')) targets.push(credentialGuardTargetClaude(homeRoot, { dryRun, installMissing }))
822
893
  if (include('claude-git-branch-guard')) targets.push(gitBranchGuardTargetClaude(homeRoot, { dryRun, installMissing }))
823
894
  for (const target of catalog.targets) {
@@ -885,20 +956,11 @@ function run(options) {
885
956
  const dryRun = Boolean(options.dryRun)
886
957
  const installMissing = Boolean(options.installMissing)
887
958
 
888
- // The per-CLI *-credential-guard targets below only wire hook entries that
889
- // point at ~/.trackfw/scripts/trackfw-credential-guard.sh — none of them
890
- // write the script itself (ADR-2026-08-06, decision #2/#3). Without this
891
- // call the wiring is installed but every hook invocation fails with
892
- // "No such file or directory" because the script never exists.
893
- if (!dryRun) {
894
- if (options.json) {
895
- silenceConsole(() => generateGlobalCredentialGuardScript(homeRoot))
896
- silenceConsole(() => generateGlobalGitBranchGuardScript(homeRoot))
897
- } else {
898
- generateGlobalCredentialGuardScript(homeRoot)
899
- generateGlobalGitBranchGuardScript(homeRoot)
900
- }
901
- }
959
+ // NOTE: the script files are now first-class targets ("git-branch-guard-script",
960
+ // "credential-guard-script") positioned before the wiring targets in
961
+ // HARNESS_TARGET_IDS. The pre-loop unconditional writes that used to live here
962
+ // have been removed — the target functions handle creation and idempotency.
963
+ // (REQ-2026-09-09, ML-1A)
902
964
 
903
965
  // Identidade resolvida do disco antes de buildPlans — pular esta etapa
904
966
  // reverteria silenciosamente os nomes customizados para os defaults
@@ -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,
@@ -2409,5 +2409,6 @@ module.exports = {
2409
2409
  SIGNAL_SCRIPT,
2410
2410
  CLEANUP_SCRIPT,
2411
2411
  CREDENTIAL_GUARD_SCRIPT,
2412
+ GLOBAL_CREDENTIAL_GUARD_SCRIPT,
2412
2413
  GIT_BRANCH_GUARD_SCRIPT,
2413
2414
  }