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.
@@ -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
- // `{"decision":"block","reason":"..."}` on stdout (Claude/Gemini JSON-stdout
431
- // style) AND `exit 2` (Codex/Windsurf/Cursor exit-code style) simultaneously,
432
- // same simplification decision as the Go const's doc comment. Unlike
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. Só drena
498
- # se stdin não for um terminal interativo (-t 0): em invocação manual sem pipe, "cat" bloquearia
499
- # esperando EOF (Ctrl-D). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda
500
- # leitura.
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
- [ -t 0 ] || _TRACKFW_STDIN=$(cat 2>/dev/null || true)
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 '{"decision":"block","reason":"%s"}\\n' "$REASON"
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 = os.homedir()
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 = os.homedir()
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 = os.homedir()
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 = os.homedir()
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
@@ -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 not cfg-dependent).
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
- const slug = cfg.projectName
337
- ? cfg.projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
338
- : 'my-app'
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, os.homedir(), cwd)
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(os.homedir(), '.claude', 'skills', 'trackfw')
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,
@@ -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 — descoberta recursiva de REQs nos 3 layouts suportados, mesmo algoritmo usado
13
- * em Go/Python (ADR-2026-08-04): flat (reqDir/*.md), por-estado (reqDir/<estado>/*.md) e,
14
- * quando roadmapNamespacing === 'by_agent', by_agent (reqDir/<agente>/<estado>/*.md).
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, na ordem flat → por-estado → by_agent
18
+ * @returns {string[]} paths completos, união dos 4 layouts, deduplicados e ordenados
18
19
  */
19
20
  function listREQFiles(cfg) {
20
- const reqDir = cfg.reqDir
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
- const reqDir = require('../config').load().reqDir
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)