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 +1 -1
- package/src/commands/barrier.js +104 -34
- package/src/commands/branch.js +4 -2
- package/src/commands/commit.js +4 -2
- package/src/commands/req.js +3 -2
- package/src/commands/roadmap.js +3 -2
- package/src/commands/serve.js +124 -17
- package/src/commands/update-harness.js +78 -16
- package/src/config/index.js +21 -0
- package/src/generators/hooks.js +1 -0
- package/src/generators/init.js +31 -6
- package/src/generators/req.js +11 -2
- package/src/generators/roadmap.js +79 -13
- package/src/integrations/index.js +22 -0
- package/src/integrations/register-agent.js +106 -0
- package/src/push/runner.js +5 -2
- package/src/serve/api_file.js +48 -11
- package/src/ship/runner.js +3 -2
- package/src/validator/index.js +52 -8
package/src/generators/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
692
|
-
|
|
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
|
\`\`\`
|
package/src/generators/req.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
268
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
package/src/push/runner.js
CHANGED
|
@@ -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
|
-
|
|
131
|
-
writeln(
|
|
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'")
|
package/src/serve/api_file.js
CHANGED
|
@@ -4,15 +4,15 @@ const fs = require('fs')
|
|
|
4
4
|
const path = require('path')
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* isPathAllowed verifica se
|
|
8
|
-
*
|
|
9
|
-
* @param {string
|
|
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(
|
|
13
|
+
function isPathAllowed(absPath, allowedDirs) {
|
|
13
14
|
for (const dir of allowedDirs) {
|
|
14
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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')
|
package/src/ship/runner.js
CHANGED
|
@@ -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
|
-
|
|
503
|
-
writeln(
|
|
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'")
|