trackfw 7.3.0 → 7.4.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)
@@ -1257,14 +1258,14 @@ const GBG_CMD_WINDSURF = 'bash scripts/trackfw-git-branch-guard.sh'
1257
1258
 
1258
1259
  /** Mirrors Go's globalCredentialGuardScriptPath. */
1259
1260
  function globalCredentialGuardScriptPath() {
1260
- const home = os.homedir()
1261
+ const home = homedir()
1261
1262
  if (!home) return null
1262
1263
  return path.join(home, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
1263
1264
  }
1264
1265
 
1265
1266
  /** Reads+parses JSON at $HOME/<...relParts>; returns null on any failure (fail-open). */
1266
1267
  function readGlobalHookJSON(...relParts) {
1267
- const home = os.homedir()
1268
+ const home = homedir()
1268
1269
  if (!home) return null
1269
1270
  try {
1270
1271
  const raw = fs.readFileSync(path.join(home, ...relParts), 'utf8')
@@ -1290,9 +1291,121 @@ function readGlobalHookJSON(...relParts) {
1290
1291
  * (normalizeGuardPath) and pypi/trackfw/generators/hooks.py
1291
1292
  * (_normalize_guard_path). Never call with anything other than a script
1292
1293
  * path -- it is not a general string normalizer.
1294
+ *
1295
+ * ROADMAP-2026-09-03 ML-7B -- Windows separator canonicalization, gated on
1296
+ * anchoring, NOT a blanket "\\" -> "/" translate. On POSIX "\\" is a legal
1297
+ * filename byte, so translating it unconditionally would make two
1298
+ * genuinely different paths (one with a literal backslash in a segment
1299
+ * name, one with an extra path separator there) compare equal -- the exact
1300
+ * dangerous loosening this function's own doc comment warns against, and
1301
+ * the risk this ML was told to treat explicitly. The decision, per input
1302
+ * shape (mirrors the UNC/drive-letter arms of
1303
+ * internal/validator/pathIsAnchoredForHookConfig (Go) / index.js's
1304
+ * pathIsAnchoredForHookConfig, reimplemented locally -- not imported,
1305
+ * different question, and this module must not import the validator):
1306
+ *
1307
+ * - "C:\Users\x\guard.sh" / "C:/Users/x/guard.sh" (ASCII drive letter,
1308
+ * ":", then "\" or "/") -- CANONICALIZED: every "\" is translated to
1309
+ * "/" before the collapse below runs, so both forms land on the same
1310
+ * "C:/Users/x/guard.sh". This is the case ML-7A measured as the actual
1311
+ * trigger (a path.join-computed Windows path vs. a hand-concatenated
1312
+ * one).
1313
+ * - "\\servidor\share\guard.sh" (valid UNC: non-empty SERVER not "." or
1314
+ * "..", followed by a non-empty SHARE not itself starting with "\") --
1315
+ * UNCHANGED, byte-for-byte, including its backslashes. Translating it
1316
+ * would collapse "\\server\share" into "//server/share" and then this
1317
+ * function's own "//" -> "/" collapse would eat the second slash,
1318
+ * producing "/server/share/..." -- indistinguishable from a
1319
+ * single-leading-backslash, drive-root-relative path
1320
+ * ("\server\share\..." means something else on Windows) or from a
1321
+ * same-named POSIX path. That collision is precisely a false "already
1322
+ * installed": a network-share guard would dedup-match a local one.
1323
+ * Left untouched, a UNC command never cross-matches a non-UNC one -- no
1324
+ * new equality is introduced.
1325
+ * - "//servidor/share/guard.sh" (the POSIX-typed equivalent of UNC) --
1326
+ * UNCHANGED behavior from before this ML: it does not start with "\",
1327
+ * so it never enters the new branch above; it still collapses via the
1328
+ * existing "//" -> "/" rule below, same as any other POSIX path. Not
1329
+ * unified with the "\\servidor\share\..." form above (pre-existing
1330
+ * asymmetry, out of scope for this ML).
1331
+ * - "\\" and "\\x" alone (no SHARE segment) -- NOT valid UNC by the
1332
+ * predicate above, so they fall through unchanged: no drive letter, no
1333
+ * valid UNC, no translation.
1334
+ * - "C:foo" (drive-relative, no separator after ":") and homoglyph/
1335
+ * zero-width-prefixed strings (e.g. "\uFF43:\...", "\u200bC:\...") --
1336
+ * do NOT match the ASCII-only, position-0 drive-letter check, so no
1337
+ * translation happens. Same anti-spoofing posture as the validator's
1338
+ * drive-letter check.
1339
+ *
1340
+ * Known residual, documented not fixed (hades-tf ML-7B barrier review,
1341
+ * 2026-09-05 parecer): three real Windows-with-"\" shapes have no drive
1342
+ * letter at position 0, so hasWindowsDriveLetterPrefix's gate leaves them
1343
+ * uncanonicalized -- the same pre-ML-7A defect survives for them. Direction
1344
+ * is always TIGHTENS (possible duplicate hook entry), never loosens (the
1345
+ * guard is never silently skipped) -- same safe direction as every other
1346
+ * case above:
1347
+ *
1348
+ * - "\\?\C:\Users\x\guard.sh" (the Win32 long-path prefix; a real form
1349
+ * Windows/long-path APIs produce automatically, not a hypothetical).
1350
+ * - A relative path containing "\" (e.g. "guard\scripts\hook.sh"). Low
1351
+ * practical risk: today both sides of the real comparison always come
1352
+ * from path.join with an absolute home, so a relative command should
1353
+ * not reach this function -- but nothing in the comparator itself
1354
+ * prevents it.
1355
+ * - A home resolved via a network-profile UNC path (e.g.
1356
+ * "\\fileserver\homes\kg\.trackfw\scripts\..."). It never enters the
1357
+ * drive-letter branch and is intentionally left untouched (see the UNC
1358
+ * bullet above), so the original defect persists for both UNC
1359
+ * spellings, not just between UNC and drive-letter forms.
1360
+ *
1361
+ * Not fixed here on purpose: closing any of the three would touch the
1362
+ * drive-letter gate this barrier just approved as conservative, trading a
1363
+ * duplicate-entry nuisance for the more expensive failure mode (collapsing
1364
+ * genuinely different paths into a false "already installed"). Do not treat
1365
+ * rediscovering these three as a new finding.
1366
+ */
1367
+ /**
1368
+ * Reports whether p begins with a Windows UNC prefix ("\\server\share...")
1369
+ * with a non-empty SERVER segment (not "." or "..") followed by a non-empty
1370
+ * SHARE segment that does not itself start with another backslash. Mirrors
1371
+ * the UNC arm of index.js's pathIsAnchoredForHookConfig -- reimplemented
1372
+ * here (not imported: different question, and this module must not import
1373
+ * the validator). "\\", "\\x" (no share segment) and "\\.\x" / "\\..\evil"
1374
+ * (server "." or "..") are NOT valid UNC. Currently unused by
1375
+ * normalizeGuardPath itself (a valid-UNC string never has a drive-letter
1376
+ * prefix, so the two checks are already mutually exclusive by construction)
1377
+ * -- kept as a named, tested predicate so the "UNC stays untouched"
1378
+ * decision above is verifiable by name, not just by absence of a call, same
1379
+ * as Go's hasValidUNCPrefix (internal/generators/agentfiles.go) and
1380
+ * Python's _has_valid_unc_prefix (pypi/trackfw/generators/hooks.py).
1381
+ * EXPORTED (unlike Go's package-private equivalent) only because
1382
+ * npm/tests/guard_path_normalize.test.js reaches it via
1383
+ * require('../src/generators/hooks') destructuring -- there is no
1384
+ * production caller. Do not build new logic against this export assuming
1385
+ * UNC gets active handling; it does not (see the doc comment above).
1293
1386
  */
1387
+ function hasValidUNCPrefix(p) {
1388
+ if (p.length < 2 || p[0] !== '\\' || p[1] !== '\\') return false
1389
+ const rest = p.slice(2)
1390
+ const cut = rest.indexOf('\\')
1391
+ if (cut === -1) return false
1392
+ const server = rest.slice(0, cut)
1393
+ const share = rest.slice(cut + 1)
1394
+ return server !== '' && server !== '.' && server !== '..' && share !== '' && share[0] !== '\\'
1395
+ }
1396
+
1397
+ function hasWindowsDriveLetterPrefix(p) {
1398
+ if (p.length < 3) return false
1399
+ const c = p.charCodeAt(0)
1400
+ const isASCIILetter = (c >= 97 && c <= 122) || (c >= 65 && c <= 90)
1401
+ return isASCIILetter && p[1] === ':' && (p[2] === '\\' || p[2] === '/')
1402
+ }
1403
+
1294
1404
  function normalizeGuardPath(p) {
1295
1405
  if (!p) return p
1406
+ if (hasWindowsDriveLetterPrefix(p)) {
1407
+ p = p.split('\\').join('/')
1408
+ }
1296
1409
  let out = ''
1297
1410
  let prevSlash = false
1298
1411
  for (const ch of p) {
@@ -1410,7 +1523,7 @@ function globalCredentialGuardInstalledCopilot() {
1410
1523
  * explicit instruction for Kiro.
1411
1524
  */
1412
1525
  function globalCredentialGuardInstalledKiro() {
1413
- const home = os.homedir()
1526
+ const home = homedir()
1414
1527
  if (!home) return false
1415
1528
  try {
1416
1529
  const stat = fs.statSync(path.join(home, '.kiro', 'hooks', 'trackfw-credential-guard.json'))
@@ -1437,7 +1550,7 @@ function globalCredentialGuardInstalledKiro() {
1437
1550
 
1438
1551
  /** Mirrors Go's globalGitBranchGuardScriptPath. */
1439
1552
  function globalGitBranchGuardScriptPath() {
1440
- const home = os.homedir()
1553
+ const home = homedir()
1441
1554
  if (!home) return null
1442
1555
  return path.join(home, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
1443
1556
  }
@@ -2264,6 +2377,8 @@ module.exports = {
2264
2377
  mergeCopilotHookArray,
2265
2378
  normalizeGuardPath,
2266
2379
  samePathCommand,
2380
+ hasValidUNCPrefix,
2381
+ hasWindowsDriveLetterPrefix,
2267
2382
  // Template constants — exported for scaffold doctor (ADR-2026-08-27).
2268
2383
  // These are the single source of truth for each script's content; the
2269
2384
  // 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)