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.
@@ -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'")