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 +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/config/index.js +21 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "7.
|
|
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",
|
package/src/commands/barrier.js
CHANGED
|
@@ -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
|
-
//
|
|
475
|
-
//
|
|
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
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
|
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 (
|
|
501
|
-
return {
|
|
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 =
|
|
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:
|
|
510
|
-
|
|
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 (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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))
|
package/src/commands/branch.js
CHANGED
|
@@ -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
|
|
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')
|
package/src/commands/commit.js
CHANGED
|
@@ -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
|
|
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)')
|
package/src/commands/req.js
CHANGED
|
@@ -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
|
-
.
|
|
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')}`)
|
package/src/commands/roadmap.js
CHANGED
|
@@ -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')
|
package/src/commands/serve.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
205
|
-
|
|
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 }
|
package/src/config/index.js
CHANGED
|
@@ -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,
|
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'")
|
package/src/validator/index.js
CHANGED
|
@@ -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>/ (
|
|
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
|
-
|
|
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
|
|
427
|
-
|
|
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
|
-
|
|
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,
|