trackfw 7.3.0 → 7.5.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/README.md +67 -1
- package/package.json +1 -1
- package/src/commands/adr.js +2 -1
- package/src/commands/barrier.js +293 -26
- package/src/commands/context.js +42 -36
- package/src/commands/discover.js +72 -21
- package/src/commands/doctor.js +23 -3
- package/src/commands/init.js +2 -1
- package/src/commands/integrations.js +5 -4
- package/src/commands/req.js +2 -1
- package/src/commands/thirdparty.js +2 -1
- package/src/commands/update-harness.js +3 -2
- package/src/commands/update.js +127 -9
- package/src/config/index.js +4 -3
- package/src/generators/hooks.js +152 -15
- package/src/generators/init.js +103 -7
- package/src/generators/req.js +15 -53
- package/src/generators/roadmap.js +81 -28
- package/src/homedir.js +30 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +224 -0
- package/src/integrations/index.js +2 -1
- package/src/integrations/manager.js +10 -2
- package/src/integrations/render.js +39 -8
- package/src/integrations/scaffold_doctor.js +29 -0
- package/src/lib/pathfmt.js +43 -0
- package/src/lib/update-engine.js +15 -2
- package/src/serve/api_board.js +11 -10
- package/src/serve/api_chain.js +43 -20
- package/src/serve/api_metrics.js +2 -8
- package/src/validator/index.js +914 -177
package/src/generators/hooks.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
|
+
const { homedir } = require('../homedir')
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Helpers
|
|
@@ -143,8 +144,8 @@ if command -v jq &>/dev/null; then
|
|
|
143
144
|
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')
|
|
144
145
|
MSG=$(echo "$INPUT" | jq -r '(.tool_input.question // .tool_input.command // "Agent is executing: \\(.tool_name // "unknown")") | .[0:300]')
|
|
145
146
|
else
|
|
146
|
-
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || echo "")
|
|
147
|
-
MSG=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input',{}); print((ti.get('question') or ti.get('command') or 'Agent is executing: '+d.get('tool_name','unknown'))[:300])" 2>/dev/null || echo "Agent needs attention")
|
|
147
|
+
TOOL=$(echo "$INPUT" | PYTHONIOENCODING=utf-8 python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || echo "")
|
|
148
|
+
MSG=$(echo "$INPUT" | PYTHONIOENCODING=utf-8 python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input',{}); print((ti.get('question') or ti.get('command') or 'Agent is executing: '+d.get('tool_name','unknown'))[:300])" 2>/dev/null || echo "Agent needs attention")
|
|
148
149
|
fi
|
|
149
150
|
|
|
150
151
|
ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | head -1 | sed 's/^roadmap_dir:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d '"' | tr -d "'" || true)
|
|
@@ -427,9 +428,12 @@ const GLOBAL_CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_DETECTION_CORE + CG_GLOBAL
|
|
|
427
428
|
// Byte-identical port of internal/generators/scaffold.go:gitBranchGuardScript
|
|
428
429
|
// (Go's canonical reference). Blocks raw `git commit`/`git push`/
|
|
429
430
|
// `git checkout -b` by a subagent, regardless of runtime contract: emits BOTH
|
|
430
|
-
// `{"
|
|
431
|
-
//
|
|
432
|
-
//
|
|
431
|
+
// `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":
|
|
432
|
+
// "deny","permissionDecisionReason":"..."}}` on stdout (Claude/Gemini
|
|
433
|
+
// JSON-stdout style — UPDATED 2026-09-09, see the Go const's doc comment for
|
|
434
|
+
// why the previous `{"decision":"block",...}` shape stopped validating) AND
|
|
435
|
+
// `exit 2` (Codex/Windsurf/Cursor exit-code style) simultaneously, same
|
|
436
|
+
// simplification decision as the Go const's doc comment. Unlike
|
|
433
437
|
// CREDENTIAL_GUARD_SCRIPT, this script is IDENTICAL between project and
|
|
434
438
|
// global scope (no trackfw.yaml dependency, no mode/roadmap_dir resolution)
|
|
435
439
|
// — a single constant is written by both generateGitBranchGuardScript and
|
|
@@ -494,12 +498,31 @@ set -f
|
|
|
494
498
|
# --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
|
|
495
499
|
# global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
|
|
496
500
|
# quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
|
|
497
|
-
# — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing.
|
|
498
|
-
#
|
|
499
|
-
#
|
|
500
|
-
#
|
|
501
|
+
# — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. O dreno
|
|
502
|
+
# roda incondicionalmente, com orçamento de tempo (ML-3A, ver abaixo). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda leitura.
|
|
503
|
+
#
|
|
504
|
+
# ML-3A (ROADMAP-2026-09-09-guard-emite-hookspecificoutput-e-a-razao-chega-ao-modelo-nos-3-
|
|
505
|
+
# clis.md): o discriminante que existia aqui era "-t 0" -- separa "terminal interativo" de
|
|
506
|
+
# "pipe", não "vai receber EOF" de "não vai". Qualquer chamador não-tty que segure stdin aberta
|
|
507
|
+
# (ex.: um runtime de hook que nunca fecha o descritor) travava o antigo "cat" para sempre, sem
|
|
508
|
+
# limite -- foi o que pendurou "make quality" por 1h05 quando o gate de schema (ML-2A) chamou o
|
|
509
|
+
# guard sem "</dev/null" em 3 sítios. Substituído por dreno com ORÇAMENTO DE TEMPO via
|
|
510
|
+
# "read -t <segundos> -d ''": builtin do próprio bash desde a 3.0 (não do coreutils) -- medido
|
|
511
|
+
# idêntico no bash 3.2 (padrão do macOS) e no bash 5.3 (Linux/Homebrew); o bash do MSYS2/Git-Bash
|
|
512
|
+
# do Windows é bash de verdade e traz o mesmo builtin. Evita depender de "timeout(1)", que NÃO
|
|
513
|
+
# existe no macOS base nem no MSYS2 mínimo. "-d ''" faz o read tentar ler até o EOF real (NUL
|
|
514
|
+
# nunca aparece em payload de hook); "-t" interrompe a espera no orçamento e, medido contra um
|
|
515
|
+
# FIFO nunca fechado (bash 3.2 e 5.3), preserva na variável qualquer prefixo já lido antes do
|
|
516
|
+
# timeout -- sem perda do que chegou, só do que nunca chegou. 2s de orçamento: folga generosa
|
|
517
|
+
# para o payload pequeno de um hook sob scheduler sob carga (CI, "make quality" paralelo), e
|
|
518
|
+
# ainda curto o bastante para o travamento continuar perceptível em vez de indefinido. Estourar
|
|
519
|
+
# o orçamento NÃO libera o bloqueio: o passo 1 abaixo já prefere "$*" quando há argumento
|
|
520
|
+
# posicional, e cai para o que foi lido até o limite quando não há -- "exit 2" e o fail-closed
|
|
521
|
+
# continuam idênticos nos dois casos. O antigo desvio para invocação manual em terminal (skip
|
|
522
|
+
# total quando "-t 0") foi removido de propósito: era o próprio discriminante errado que este ML
|
|
523
|
+
# elimina, não um caso a preservar -- agora toda invocação drena com o mesmo orçamento de 2s.
|
|
501
524
|
_TRACKFW_STDIN=""
|
|
502
|
-
|
|
525
|
+
IFS= read -r -t 2 -d '' _TRACKFW_STDIN || true
|
|
503
526
|
|
|
504
527
|
# --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
|
|
505
528
|
# projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
|
|
@@ -1029,7 +1052,7 @@ case "$SUBCOMMAND" in
|
|
|
1029
1052
|
;;
|
|
1030
1053
|
esac
|
|
1031
1054
|
|
|
1032
|
-
printf '{"
|
|
1055
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "$REASON"
|
|
1033
1056
|
echo "$REASON" >&2
|
|
1034
1057
|
exit 2
|
|
1035
1058
|
`
|
|
@@ -1257,14 +1280,14 @@ const GBG_CMD_WINDSURF = 'bash scripts/trackfw-git-branch-guard.sh'
|
|
|
1257
1280
|
|
|
1258
1281
|
/** Mirrors Go's globalCredentialGuardScriptPath. */
|
|
1259
1282
|
function globalCredentialGuardScriptPath() {
|
|
1260
|
-
const home =
|
|
1283
|
+
const home = homedir()
|
|
1261
1284
|
if (!home) return null
|
|
1262
1285
|
return path.join(home, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
|
|
1263
1286
|
}
|
|
1264
1287
|
|
|
1265
1288
|
/** Reads+parses JSON at $HOME/<...relParts>; returns null on any failure (fail-open). */
|
|
1266
1289
|
function readGlobalHookJSON(...relParts) {
|
|
1267
|
-
const home =
|
|
1290
|
+
const home = homedir()
|
|
1268
1291
|
if (!home) return null
|
|
1269
1292
|
try {
|
|
1270
1293
|
const raw = fs.readFileSync(path.join(home, ...relParts), 'utf8')
|
|
@@ -1290,9 +1313,121 @@ function readGlobalHookJSON(...relParts) {
|
|
|
1290
1313
|
* (normalizeGuardPath) and pypi/trackfw/generators/hooks.py
|
|
1291
1314
|
* (_normalize_guard_path). Never call with anything other than a script
|
|
1292
1315
|
* path -- it is not a general string normalizer.
|
|
1316
|
+
*
|
|
1317
|
+
* ROADMAP-2026-09-03 ML-7B -- Windows separator canonicalization, gated on
|
|
1318
|
+
* anchoring, NOT a blanket "\\" -> "/" translate. On POSIX "\\" is a legal
|
|
1319
|
+
* filename byte, so translating it unconditionally would make two
|
|
1320
|
+
* genuinely different paths (one with a literal backslash in a segment
|
|
1321
|
+
* name, one with an extra path separator there) compare equal -- the exact
|
|
1322
|
+
* dangerous loosening this function's own doc comment warns against, and
|
|
1323
|
+
* the risk this ML was told to treat explicitly. The decision, per input
|
|
1324
|
+
* shape (mirrors the UNC/drive-letter arms of
|
|
1325
|
+
* internal/validator/pathIsAnchoredForHookConfig (Go) / index.js's
|
|
1326
|
+
* pathIsAnchoredForHookConfig, reimplemented locally -- not imported,
|
|
1327
|
+
* different question, and this module must not import the validator):
|
|
1328
|
+
*
|
|
1329
|
+
* - "C:\Users\x\guard.sh" / "C:/Users/x/guard.sh" (ASCII drive letter,
|
|
1330
|
+
* ":", then "\" or "/") -- CANONICALIZED: every "\" is translated to
|
|
1331
|
+
* "/" before the collapse below runs, so both forms land on the same
|
|
1332
|
+
* "C:/Users/x/guard.sh". This is the case ML-7A measured as the actual
|
|
1333
|
+
* trigger (a path.join-computed Windows path vs. a hand-concatenated
|
|
1334
|
+
* one).
|
|
1335
|
+
* - "\\servidor\share\guard.sh" (valid UNC: non-empty SERVER not "." or
|
|
1336
|
+
* "..", followed by a non-empty SHARE not itself starting with "\") --
|
|
1337
|
+
* UNCHANGED, byte-for-byte, including its backslashes. Translating it
|
|
1338
|
+
* would collapse "\\server\share" into "//server/share" and then this
|
|
1339
|
+
* function's own "//" -> "/" collapse would eat the second slash,
|
|
1340
|
+
* producing "/server/share/..." -- indistinguishable from a
|
|
1341
|
+
* single-leading-backslash, drive-root-relative path
|
|
1342
|
+
* ("\server\share\..." means something else on Windows) or from a
|
|
1343
|
+
* same-named POSIX path. That collision is precisely a false "already
|
|
1344
|
+
* installed": a network-share guard would dedup-match a local one.
|
|
1345
|
+
* Left untouched, a UNC command never cross-matches a non-UNC one -- no
|
|
1346
|
+
* new equality is introduced.
|
|
1347
|
+
* - "//servidor/share/guard.sh" (the POSIX-typed equivalent of UNC) --
|
|
1348
|
+
* UNCHANGED behavior from before this ML: it does not start with "\",
|
|
1349
|
+
* so it never enters the new branch above; it still collapses via the
|
|
1350
|
+
* existing "//" -> "/" rule below, same as any other POSIX path. Not
|
|
1351
|
+
* unified with the "\\servidor\share\..." form above (pre-existing
|
|
1352
|
+
* asymmetry, out of scope for this ML).
|
|
1353
|
+
* - "\\" and "\\x" alone (no SHARE segment) -- NOT valid UNC by the
|
|
1354
|
+
* predicate above, so they fall through unchanged: no drive letter, no
|
|
1355
|
+
* valid UNC, no translation.
|
|
1356
|
+
* - "C:foo" (drive-relative, no separator after ":") and homoglyph/
|
|
1357
|
+
* zero-width-prefixed strings (e.g. "\uFF43:\...", "\u200bC:\...") --
|
|
1358
|
+
* do NOT match the ASCII-only, position-0 drive-letter check, so no
|
|
1359
|
+
* translation happens. Same anti-spoofing posture as the validator's
|
|
1360
|
+
* drive-letter check.
|
|
1361
|
+
*
|
|
1362
|
+
* Known residual, documented not fixed (hades-tf ML-7B barrier review,
|
|
1363
|
+
* 2026-09-05 parecer): three real Windows-with-"\" shapes have no drive
|
|
1364
|
+
* letter at position 0, so hasWindowsDriveLetterPrefix's gate leaves them
|
|
1365
|
+
* uncanonicalized -- the same pre-ML-7A defect survives for them. Direction
|
|
1366
|
+
* is always TIGHTENS (possible duplicate hook entry), never loosens (the
|
|
1367
|
+
* guard is never silently skipped) -- same safe direction as every other
|
|
1368
|
+
* case above:
|
|
1369
|
+
*
|
|
1370
|
+
* - "\\?\C:\Users\x\guard.sh" (the Win32 long-path prefix; a real form
|
|
1371
|
+
* Windows/long-path APIs produce automatically, not a hypothetical).
|
|
1372
|
+
* - A relative path containing "\" (e.g. "guard\scripts\hook.sh"). Low
|
|
1373
|
+
* practical risk: today both sides of the real comparison always come
|
|
1374
|
+
* from path.join with an absolute home, so a relative command should
|
|
1375
|
+
* not reach this function -- but nothing in the comparator itself
|
|
1376
|
+
* prevents it.
|
|
1377
|
+
* - A home resolved via a network-profile UNC path (e.g.
|
|
1378
|
+
* "\\fileserver\homes\kg\.trackfw\scripts\..."). It never enters the
|
|
1379
|
+
* drive-letter branch and is intentionally left untouched (see the UNC
|
|
1380
|
+
* bullet above), so the original defect persists for both UNC
|
|
1381
|
+
* spellings, not just between UNC and drive-letter forms.
|
|
1382
|
+
*
|
|
1383
|
+
* Not fixed here on purpose: closing any of the three would touch the
|
|
1384
|
+
* drive-letter gate this barrier just approved as conservative, trading a
|
|
1385
|
+
* duplicate-entry nuisance for the more expensive failure mode (collapsing
|
|
1386
|
+
* genuinely different paths into a false "already installed"). Do not treat
|
|
1387
|
+
* rediscovering these three as a new finding.
|
|
1293
1388
|
*/
|
|
1389
|
+
/**
|
|
1390
|
+
* Reports whether p begins with a Windows UNC prefix ("\\server\share...")
|
|
1391
|
+
* with a non-empty SERVER segment (not "." or "..") followed by a non-empty
|
|
1392
|
+
* SHARE segment that does not itself start with another backslash. Mirrors
|
|
1393
|
+
* the UNC arm of index.js's pathIsAnchoredForHookConfig -- reimplemented
|
|
1394
|
+
* here (not imported: different question, and this module must not import
|
|
1395
|
+
* the validator). "\\", "\\x" (no share segment) and "\\.\x" / "\\..\evil"
|
|
1396
|
+
* (server "." or "..") are NOT valid UNC. Currently unused by
|
|
1397
|
+
* normalizeGuardPath itself (a valid-UNC string never has a drive-letter
|
|
1398
|
+
* prefix, so the two checks are already mutually exclusive by construction)
|
|
1399
|
+
* -- kept as a named, tested predicate so the "UNC stays untouched"
|
|
1400
|
+
* decision above is verifiable by name, not just by absence of a call, same
|
|
1401
|
+
* as Go's hasValidUNCPrefix (internal/generators/agentfiles.go) and
|
|
1402
|
+
* Python's _has_valid_unc_prefix (pypi/trackfw/generators/hooks.py).
|
|
1403
|
+
* EXPORTED (unlike Go's package-private equivalent) only because
|
|
1404
|
+
* npm/tests/guard_path_normalize.test.js reaches it via
|
|
1405
|
+
* require('../src/generators/hooks') destructuring -- there is no
|
|
1406
|
+
* production caller. Do not build new logic against this export assuming
|
|
1407
|
+
* UNC gets active handling; it does not (see the doc comment above).
|
|
1408
|
+
*/
|
|
1409
|
+
function hasValidUNCPrefix(p) {
|
|
1410
|
+
if (p.length < 2 || p[0] !== '\\' || p[1] !== '\\') return false
|
|
1411
|
+
const rest = p.slice(2)
|
|
1412
|
+
const cut = rest.indexOf('\\')
|
|
1413
|
+
if (cut === -1) return false
|
|
1414
|
+
const server = rest.slice(0, cut)
|
|
1415
|
+
const share = rest.slice(cut + 1)
|
|
1416
|
+
return server !== '' && server !== '.' && server !== '..' && share !== '' && share[0] !== '\\'
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function hasWindowsDriveLetterPrefix(p) {
|
|
1420
|
+
if (p.length < 3) return false
|
|
1421
|
+
const c = p.charCodeAt(0)
|
|
1422
|
+
const isASCIILetter = (c >= 97 && c <= 122) || (c >= 65 && c <= 90)
|
|
1423
|
+
return isASCIILetter && p[1] === ':' && (p[2] === '\\' || p[2] === '/')
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1294
1426
|
function normalizeGuardPath(p) {
|
|
1295
1427
|
if (!p) return p
|
|
1428
|
+
if (hasWindowsDriveLetterPrefix(p)) {
|
|
1429
|
+
p = p.split('\\').join('/')
|
|
1430
|
+
}
|
|
1296
1431
|
let out = ''
|
|
1297
1432
|
let prevSlash = false
|
|
1298
1433
|
for (const ch of p) {
|
|
@@ -1410,7 +1545,7 @@ function globalCredentialGuardInstalledCopilot() {
|
|
|
1410
1545
|
* explicit instruction for Kiro.
|
|
1411
1546
|
*/
|
|
1412
1547
|
function globalCredentialGuardInstalledKiro() {
|
|
1413
|
-
const home =
|
|
1548
|
+
const home = homedir()
|
|
1414
1549
|
if (!home) return false
|
|
1415
1550
|
try {
|
|
1416
1551
|
const stat = fs.statSync(path.join(home, '.kiro', 'hooks', 'trackfw-credential-guard.json'))
|
|
@@ -1437,7 +1572,7 @@ function globalCredentialGuardInstalledKiro() {
|
|
|
1437
1572
|
|
|
1438
1573
|
/** Mirrors Go's globalGitBranchGuardScriptPath. */
|
|
1439
1574
|
function globalGitBranchGuardScriptPath() {
|
|
1440
|
-
const home =
|
|
1575
|
+
const home = homedir()
|
|
1441
1576
|
if (!home) return null
|
|
1442
1577
|
return path.join(home, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
|
|
1443
1578
|
}
|
|
@@ -2264,6 +2399,8 @@ module.exports = {
|
|
|
2264
2399
|
mergeCopilotHookArray,
|
|
2265
2400
|
normalizeGuardPath,
|
|
2266
2401
|
samePathCommand,
|
|
2402
|
+
hasValidUNCPrefix,
|
|
2403
|
+
hasWindowsDriveLetterPrefix,
|
|
2267
2404
|
// Template constants — exported for scaffold doctor (ADR-2026-08-27).
|
|
2268
2405
|
// These are the single source of truth for each script's content; the
|
|
2269
2406
|
// scaffold doctor compares on-disk files against these exact bytes, using
|
package/src/generators/init.js
CHANGED
|
@@ -4,6 +4,17 @@ const fs = require('fs')
|
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
6
|
const { readAgentConventions } = require('../config/index.js')
|
|
7
|
+
const { homedir } = require('../homedir');
|
|
8
|
+
|
|
9
|
+
// PACKAGE_VERSION — the version of the trackfw binary generating the CI
|
|
10
|
+
// workflow templates. Read once from package.json (never a literal), so
|
|
11
|
+
// the pin written into the workflow always tracks the generator that
|
|
12
|
+
// wrote it (ADR-2026-08-28: gate de CI nasce pinado na versão que o
|
|
13
|
+
// gerou). Reused by buildGitHubActionsWorkflowContent /
|
|
14
|
+
// buildGitLabCIWorkflowContent AND by scaffold doctor's comparison
|
|
15
|
+
// (which calls the same functions), so drift between write and compare
|
|
16
|
+
// paths is structurally impossible.
|
|
17
|
+
const { version: PACKAGE_VERSION } = require('../../package.json')
|
|
7
18
|
|
|
8
19
|
const GOV_DIRS = [
|
|
9
20
|
'docs/adr',
|
|
@@ -31,6 +42,7 @@ async function scaffold(cfg, cwd) {
|
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
generateVaultIndex(root)
|
|
45
|
+
generateGitAttributes(root)
|
|
34
46
|
writeTrackfwConfig(cfg, root)
|
|
35
47
|
generateValidateScript(cfg, root)
|
|
36
48
|
generateAttentionScripts(cfg, root)
|
|
@@ -45,6 +57,60 @@ async function scaffold(cfg, cwd) {
|
|
|
45
57
|
injectHooksDetected(root)
|
|
46
58
|
}
|
|
47
59
|
|
|
60
|
+
// gitAttributesRuleTarget — primeiro campo da regra mantida por este gerador.
|
|
61
|
+
// Casa o BASENAME (padrão sem barra) de propósito: `roadmap_dir` e `req_dir` são
|
|
62
|
+
// configuráveis por projeto (trackfw.yaml) e ambos carregam um `.trackfw-log`,
|
|
63
|
+
// então caminho fixo nasceria quebrado em quem configurou diretório diferente.
|
|
64
|
+
const GITATTRIBUTES_RULE_TARGET = '.trackfw-log'
|
|
65
|
+
|
|
66
|
+
// GITATTRIBUTES_BLOCK — byte-idêntico ao de internal/generators/scaffold.go e
|
|
67
|
+
// pypi/trackfw/generators/init_gen.py (regra dura de paridade).
|
|
68
|
+
const GITATTRIBUTES_BLOCK = `# trackfw: .trackfw-log is append-only — every write lands on the last line, so
|
|
69
|
+
# two parallel branches conflict on every merge. merge=union keeps the lines
|
|
70
|
+
# from both sides (chronological order is not guaranteed). The pattern has no
|
|
71
|
+
# slash, so it matches the file in any directory — roadmap_dir and req_dir both
|
|
72
|
+
# carry one, and both are configurable per project.
|
|
73
|
+
.trackfw-log merge=union
|
|
74
|
+
`
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* hasGitAttributesRule — verdadeiro quando ALGUMA linha não-comentário tem
|
|
78
|
+
* `.trackfw-log` como primeiro campo. Predicado sobre o CAMPO, não sobre a
|
|
79
|
+
* string literal da linha inteira: espaçamento diferente ou outro atributo já
|
|
80
|
+
* são "a regra existe", e reescrever por cima sobrescreveria decisão do projeto.
|
|
81
|
+
* @param {string} content
|
|
82
|
+
*/
|
|
83
|
+
function hasGitAttributesRule(content) {
|
|
84
|
+
for (const line of content.split('\n')) {
|
|
85
|
+
const trimmed = line.trim()
|
|
86
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue
|
|
87
|
+
if (trimmed.split(/\s+/)[0] === GITATTRIBUTES_RULE_TARGET) return true
|
|
88
|
+
}
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* generateGitAttributes — garante `merge=union` para o `.trackfw-log` no
|
|
94
|
+
* `.gitattributes` da raiz. Três ramos, todos idempotentes: ausente → cria;
|
|
95
|
+
* existe sem a regra → APPEND (nunca sobrescreve); existe com a regra → no-op.
|
|
96
|
+
* @param {string} root
|
|
97
|
+
*/
|
|
98
|
+
function generateGitAttributes(root) {
|
|
99
|
+
const filePath = path.join(root || process.cwd(), '.gitattributes')
|
|
100
|
+
if (!fs.existsSync(filePath)) {
|
|
101
|
+
fs.writeFileSync(filePath, GITATTRIBUTES_BLOCK, 'utf8')
|
|
102
|
+
console.log(' ✓ .gitattributes')
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const existing = fs.readFileSync(filePath, 'utf8')
|
|
106
|
+
if (hasGitAttributesRule(existing)) return
|
|
107
|
+
// Arquivo preexistente sem newline final: emendar direto grudaria a primeira
|
|
108
|
+
// linha do bloco na última linha do projeto, corrompendo a config em silêncio.
|
|
109
|
+
const prefix = existing !== '' && !existing.endsWith('\n') ? existing + '\n' : existing
|
|
110
|
+
fs.writeFileSync(filePath, prefix + GITATTRIBUTES_BLOCK, 'utf8')
|
|
111
|
+
console.log(' ✓ .gitattributes')
|
|
112
|
+
}
|
|
113
|
+
|
|
48
114
|
/**
|
|
49
115
|
* generateVaultIndex — cria vault/notes/index.md se ainda não existir.
|
|
50
116
|
* @param {string} root
|
|
@@ -209,7 +275,18 @@ function generateCIWorkflow(cfg) {
|
|
|
209
275
|
// workflow generator would write. Exported for scaffold doctor (ADR-2026-08-27): the
|
|
210
276
|
// comparison uses the same function the write path uses, so drift is structurally impossible.
|
|
211
277
|
// The cfg parameter is accepted for API symmetry with Go's buildGitHubActionsWorkflowContent
|
|
212
|
-
// (which also ignores cfg for now — the template is
|
|
278
|
+
// (which also ignores cfg for now — the template is cfg-independent but, since
|
|
279
|
+
// ADR-2026-08-28, NOT version-independent: it pins TRACKFW_VERSION to the version of the
|
|
280
|
+
// trackfw binary that generated it, read from package.json — never a literal).
|
|
281
|
+
//
|
|
282
|
+
// Job id is `governance-install-script` (ML-1A, ROADMAP-2026-09-01): was `governance`,
|
|
283
|
+
// colliding with the discover-installed sibling workflow's job id (trackfw-validate.yml,
|
|
284
|
+
// buildDiscoverGitHubActionsWorkflowContent in commands/discover.js) — both previously
|
|
285
|
+
// `governance`, producing two identically-named check-runs on any PR of a project with
|
|
286
|
+
// both workflows installed, which makes required_status_checks ambiguous (GitHub
|
|
287
|
+
// matches by name). Named after the install mechanism (install.sh here, `go install`
|
|
288
|
+
// there) rather than the workflow file, since that's what a required_status_checks
|
|
289
|
+
// reader needs to tell the two apart without opening the YAML.
|
|
213
290
|
function buildGitHubActionsWorkflowContent(_cfg) {
|
|
214
291
|
return `name: trackfw-gate
|
|
215
292
|
on:
|
|
@@ -217,8 +294,11 @@ on:
|
|
|
217
294
|
branches: [main]
|
|
218
295
|
|
|
219
296
|
jobs:
|
|
220
|
-
governance:
|
|
297
|
+
governance-install-script:
|
|
221
298
|
runs-on: ubuntu-latest
|
|
299
|
+
timeout-minutes: 10
|
|
300
|
+
env:
|
|
301
|
+
TRACKFW_VERSION: "${PACKAGE_VERSION}"
|
|
222
302
|
steps:
|
|
223
303
|
- uses: actions/checkout@v4
|
|
224
304
|
|
|
@@ -232,11 +312,15 @@ jobs:
|
|
|
232
312
|
}
|
|
233
313
|
|
|
234
314
|
// buildGitLabCIWorkflowContent mirrors buildGitHubActionsWorkflowContent for GitLab CI.
|
|
315
|
+
// Same version-pin rationale as above — see buildGitHubActionsWorkflowContent.
|
|
235
316
|
function buildGitLabCIWorkflowContent(_cfg) {
|
|
236
317
|
return `# trackfw governance gate
|
|
237
318
|
trackfw-gate:
|
|
238
319
|
stage: test
|
|
239
320
|
image: alpine:latest
|
|
321
|
+
timeout: 10 minutes
|
|
322
|
+
variables:
|
|
323
|
+
TRACKFW_VERSION: "${PACKAGE_VERSION}"
|
|
240
324
|
before_script:
|
|
241
325
|
- apk add --no-cache curl
|
|
242
326
|
- curl -sSfL https://github.com/kgsaran/trackfw/releases/latest/download/install.sh | sh
|
|
@@ -333,9 +417,12 @@ function generateCommitMsgHook(cfg) {
|
|
|
333
417
|
// ---------------------------------------------------------------------------
|
|
334
418
|
|
|
335
419
|
function generatePomXml(cfg) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
420
|
+
// Usa o toSlug compartilhado em vez de uma variante inline: a versao anterior nao
|
|
421
|
+
// fazia NFKD e PERDIA a letra acentuada — "Cafe App" (com acento) virava "caf-app"
|
|
422
|
+
// aqui e "cafe-app" no Go, que usa o toSlug dele. Ver a secao
|
|
423
|
+
// "Artifact slug contract" em docs/cli-parity.md.
|
|
424
|
+
const { toSlug } = require('./adr')
|
|
425
|
+
const slug = cfg.projectName ? toSlug(cfg.projectName) : 'my-app'
|
|
339
426
|
const name = cfg.projectName || 'My App'
|
|
340
427
|
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
341
428
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
@@ -789,6 +876,13 @@ function backendCommands(cfg) {
|
|
|
789
876
|
// (missing roadmap.md, implement.md, barrier.md) undetected.
|
|
790
877
|
// ---------------------------------------------------------------------------
|
|
791
878
|
|
|
879
|
+
// CLAUDE_COMMANDS "trackfw não está instalado" blurbs (AC13) are deliberately
|
|
880
|
+
// OUT of the version pin introduced by ADR-2026-08-28. This text is shown when
|
|
881
|
+
// the `trackfw` command fails because it is not installed — recommending the
|
|
882
|
+
// exact old version that generated the project would be worse UX than
|
|
883
|
+
// pointing at `releases/latest`, since the reason to (re)install is usually
|
|
884
|
+
// being outdated. Do not pin these on a future pass without a product
|
|
885
|
+
// decision; see ROADMAP-2026-08-28 Wave 0 §3 (AC13 row).
|
|
792
886
|
const CLAUDE_COMMANDS = {
|
|
793
887
|
'adr.md': `Execute o seguinte comando bash: \`trackfw adr new "$ARGUMENTS"\`
|
|
794
888
|
|
|
@@ -1304,7 +1398,7 @@ async function installAmazonQ(cwd = process.cwd()) {
|
|
|
1304
1398
|
async function installIntegrationTarget(target, cwd = process.cwd(), scope = 'project', { onSkip } = {}) {
|
|
1305
1399
|
const { execute, buildPlans } = require('../integrations')
|
|
1306
1400
|
const { resolveAgentModels } = require('../config')
|
|
1307
|
-
const { models: agentModels, warning: agentModelsWarning } = resolveAgentModels(scope,
|
|
1401
|
+
const { models: agentModels, warning: agentModelsWarning } = resolveAgentModels(scope, homedir(), cwd)
|
|
1308
1402
|
if (agentModelsWarning) process.stderr.write(agentModelsWarning + '\n')
|
|
1309
1403
|
const roots = { projectRoot: cwd }
|
|
1310
1404
|
const options = { targets: [target], scope, onSkip, agentModels }
|
|
@@ -1340,7 +1434,7 @@ function generateClaudeCommandsForce(rootDir) {
|
|
|
1340
1434
|
* installSkillsForce — sobrescreve ~/.claude/skills/trackfw/SKILL.md sempre.
|
|
1341
1435
|
*/
|
|
1342
1436
|
function installSkillsForce() {
|
|
1343
|
-
const skillDir = path.join(
|
|
1437
|
+
const skillDir = path.join(homedir(), '.claude', 'skills', 'trackfw')
|
|
1344
1438
|
fs.mkdirSync(skillDir, { recursive: true })
|
|
1345
1439
|
|
|
1346
1440
|
const content = `---
|
|
@@ -1418,6 +1512,8 @@ module.exports = {
|
|
|
1418
1512
|
generateClaudeCommands,
|
|
1419
1513
|
generateClaudeCommandsForce,
|
|
1420
1514
|
generateVaultIndex,
|
|
1515
|
+
generateGitAttributes,
|
|
1516
|
+
GITATTRIBUTES_BLOCK,
|
|
1421
1517
|
installSkillsForce,
|
|
1422
1518
|
installAgents,
|
|
1423
1519
|
installGemini,
|
package/src/generators/req.js
CHANGED
|
@@ -4,66 +4,21 @@ const path = require('path')
|
|
|
4
4
|
const { localDateISO } = require('./date')
|
|
5
5
|
const roadmapGen = require('./roadmap')
|
|
6
6
|
const config = require('../config')
|
|
7
|
+
const { resolveAgentNamespaces, resolveReqFiles, reqWriteDir } = require('../validator')
|
|
8
|
+
const { normalizeCRLF } = require('../integrations/render')
|
|
7
9
|
|
|
8
10
|
const VALID_STATES = roadmapGen.VALID_STATES
|
|
9
11
|
const STATE_ORDER = roadmapGen.STATE_ORDER
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
|
-
* listREQFiles —
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Os três conjuntos não são mutuamente exclusivos — concatena todos.
|
|
14
|
+
* listREQFiles — NÃO reimplementa a descoberta: delega ao ponto único de leitura do validador
|
|
15
|
+
* (resolveReqFiles — ADR-2026-09-03, D3/D4), o mesmo consumido pelas regras de validate. Duas
|
|
16
|
+
* noções de layout no mesmo runtime foram a causa do defeito da REQ-2026-08-30.
|
|
16
17
|
* @param {object} cfg — config completo (ver npm/src/config)
|
|
17
|
-
* @returns {string[]} paths completos,
|
|
18
|
+
* @returns {string[]} paths completos, união dos 4 layouts, deduplicados e ordenados
|
|
18
19
|
*/
|
|
19
20
|
function listREQFiles(cfg) {
|
|
20
|
-
|
|
21
|
-
const files = []
|
|
22
|
-
|
|
23
|
-
// (a) flat legado — reqDir/*.md
|
|
24
|
-
try {
|
|
25
|
-
for (const f of fs.readdirSync(reqDir)) {
|
|
26
|
-
if (!f.endsWith('.md')) continue
|
|
27
|
-
const full = path.join(reqDir, f)
|
|
28
|
-
try {
|
|
29
|
-
if (!fs.statSync(full).isDirectory()) files.push(full)
|
|
30
|
-
} catch (_) { /* ignora */ }
|
|
31
|
-
}
|
|
32
|
-
} catch (_) { /* reqDir não existe */ }
|
|
33
|
-
|
|
34
|
-
// (b) por-estado, sem agente — reqDir/<estado>/*.md
|
|
35
|
-
for (const state of STATE_ORDER) {
|
|
36
|
-
const dir = path.join(reqDir, state)
|
|
37
|
-
try {
|
|
38
|
-
for (const f of fs.readdirSync(dir)) {
|
|
39
|
-
if (f.endsWith('.md')) files.push(path.join(dir, f))
|
|
40
|
-
}
|
|
41
|
-
} catch (_) { /* estado não existe */ }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// (c) by_agent — reqDir/<agente>/<estado>/*.md
|
|
45
|
-
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
46
|
-
let agents = cfg.agents || []
|
|
47
|
-
if (agents.length === 0) {
|
|
48
|
-
try {
|
|
49
|
-
agents = fs.readdirSync(reqDir).filter(f => {
|
|
50
|
-
try { return fs.statSync(path.join(reqDir, f)).isDirectory() } catch (_) { return false }
|
|
51
|
-
})
|
|
52
|
-
} catch (_) { agents = [] }
|
|
53
|
-
}
|
|
54
|
-
for (const agent of agents) {
|
|
55
|
-
for (const state of STATE_ORDER) {
|
|
56
|
-
const dir = path.join(reqDir, agent, state)
|
|
57
|
-
try {
|
|
58
|
-
for (const f of fs.readdirSync(dir)) {
|
|
59
|
-
if (f.endsWith('.md')) files.push(path.join(dir, f))
|
|
60
|
-
}
|
|
61
|
-
} catch (_) { /* agente/estado não existe */ }
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return files
|
|
21
|
+
return resolveReqFiles(cfg)
|
|
67
22
|
}
|
|
68
23
|
|
|
69
24
|
/**
|
|
@@ -114,7 +69,11 @@ function parseREQStatus(filepath) {
|
|
|
114
69
|
return 'unknown'
|
|
115
70
|
}
|
|
116
71
|
|
|
72
|
+
// D1/D3 (ADR-2026-09-04-parser-de-frontmatter-tolera-crlf-na-fronteira-de-entrada, ML-5B):
|
|
73
|
+
// normaliza CRLF -> LF via normalizeCRLF na própria entrada, antes de qualquer casamento de
|
|
74
|
+
// "---\n" — reaproveita a única implementação Node, sem criar uma segunda cópia.
|
|
117
75
|
function rewriteREQStatus(source, status) {
|
|
76
|
+
source = normalizeCRLF(String(source))
|
|
118
77
|
if (!source.startsWith('---\n')) return { content: source, changed: false }
|
|
119
78
|
const end = source.slice(4).indexOf('\n---')
|
|
120
79
|
if (end < 0) return { content: source, changed: false }
|
|
@@ -286,7 +245,10 @@ function toSlug(s) {
|
|
|
286
245
|
* @returns {Promise<void>}
|
|
287
246
|
*/
|
|
288
247
|
async function newREQ(content) {
|
|
289
|
-
|
|
248
|
+
// Ponto único de decisão de caminho de ESCRITA (ADR-2026-09-03, D2/D4): by_agent grava no
|
|
249
|
+
// canônico req_dir/<agente>/; flat grava em req_dir/ — o mesmo ponto que alimenta a união de leitura.
|
|
250
|
+
const cfg = require('../config').load()
|
|
251
|
+
const reqDir = reqWriteDir(cfg) || cfg.reqDir
|
|
290
252
|
fs.mkdirSync(reqDir, { recursive: true })
|
|
291
253
|
|
|
292
254
|
const slug = toSlug(content.title)
|