trackfw 6.1.0 → 6.3.1

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 CHANGED
@@ -144,7 +144,8 @@ trackfw ship -m "feat(auth): add login flow"
144
144
  | `trackfw adr new "title"` | Create a new Architecture Decision Record |
145
145
  | `trackfw adr list` | List all ADRs with status |
146
146
  | `trackfw req new "title"` | Create a REQ with guided ADR discovery |
147
- | `trackfw req list` | List all REQs with status |
147
+ | `trackfw req list` | List all REQs with status, discovered across flat, per-state, and by_agent layouts |
148
+ | `trackfw req move <name> <status>` | Update a REQ's status; physically relocates the file when it already lives in a recognized state subfolder (see [Multi-agent namespacing](#multi-agent-namespacing)) |
148
149
  | `trackfw roadmap new "title"` | Create a roadmap in `backlog/` |
149
150
  | `trackfw roadmap show <name>` | Print a roadmap with its current state |
150
151
  | `trackfw roadmap move <name> <state>` | Move roadmap between states |
@@ -210,6 +211,29 @@ agents: [claude, gemini, copilot]
210
211
 
211
212
  Artifacts are organized by agent: `docs/roadmaps/claude/wip/`, `docs/req/gemini/done/`. `trackfw validate` and `trackfw context` are fully by_agent-aware — no false positives.
212
213
 
214
+ REQs reuse this same `roadmap_namespacing` setting — there is no separate `req_namespacing` key. When
215
+ `by_agent` is configured, REQs may live under `req_dir/<agent>/<state>/` just like roadmaps do.
216
+
217
+ `trackfw req list` and `trackfw req move` discover REQs across three layouts at once (each is a fixed,
218
+ non-recursive glob — a REQ nested deeper than these patterns is not found), with no extra flag required:
219
+
220
+ - **Flat (legacy):** `req_dir/*.md` — REQs loose directly in the REQ directory.
221
+ - **Per-state:** `req_dir/<state>/*.md` — organized by state, without an agent segment.
222
+ - **By agent:** `req_dir/<agent>/<state>/*.md` — used when `roadmap_namespacing: by_agent` is set.
223
+
224
+ `trackfw req move <name> <status>` behaves conditionally depending on where the REQ currently lives:
225
+
226
+ - If the REQ already sits inside a recognized state subfolder (per-state or by_agent layout above), the
227
+ move **physically relocates the file** to the target state's folder, mirroring `trackfw roadmap move` —
228
+ the folder is the source of truth for state. In this mode `<status>` must be one of the six governance
229
+ states (`backlog`, `analyzing`, `wip`, `blocked`, `done`, `abandoned`) — any other value is rejected
230
+ with `invalid state`.
231
+ - If the REQ is loose in `req_dir/` (flat legacy layout), the move rewrites the `status:` frontmatter
232
+ field **in place** and does not move or create any folder. `<status>` is written verbatim in this
233
+ mode — it accepts the free-form values existing REQs already use (`Open`, `Done`, ...), not just the
234
+ six governance state names. Existing flat REQs are never migrated automatically — you are not forced
235
+ to reorganize a project's existing REQs to adopt this behavior.
236
+
213
237
  ### Bidirectional traceability (`trace_id_field`)
214
238
 
215
239
  ```yaml
@@ -248,6 +272,32 @@ governance_mode: strict # CI fails on any violation
248
272
  # governance_mode: lenient # CI passes with warnings only
249
273
  ```
250
274
 
275
+ ### `update` and `sync` configuration fields
276
+
277
+ `trackfw update` and `trackfw sync` read the following keys, flat at the root of `trackfw.yaml`, through
278
+ the same loader used by every other command. All default to an empty string when absent.
279
+
280
+ ```yaml
281
+ # trackfw.yaml — consumed by `trackfw update`
282
+ hooks: husky # husky | lefthook | native | "" (no hooks regenerated)
283
+ ci: github # github | gitlab | "" (no CI workflow regenerated)
284
+ backend: node # backend stack — informs CLAUDE.md/agent stack sections and hook commands
285
+ frontend: react # frontend stack — same as backend
286
+ pkg_manager: npm # npm | yarn | pnpm | ... — composes build/test commands in generated hooks
287
+
288
+ # trackfw.yaml — consumed by `trackfw sync` (checked before the matching env var, same order in all 3 CLIs)
289
+ linear_api_key: ""
290
+ linear_team_id: ""
291
+ jira_base_url: ""
292
+ jira_email: ""
293
+ jira_token: ""
294
+ jira_project: ""
295
+ ```
296
+
297
+ Full contract, including per-field consumers and the intentional exception for generated Git hooks
298
+ (which read `roadmap_dir` with their own `grep`/`sed`, since they run without the `trackfw` binary
299
+ present): `docs/cli-parity.md` → `## trackfw update vs trackfw update harness`.
300
+
251
301
  ---
252
302
 
253
303
  ## REQ-driven ADR discovery
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "6.1.0",
3
+ "version": "6.3.1",
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, and Amazon Q.",
5
5
  "keywords": [
6
6
  "cli",
@@ -58,7 +58,8 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "commander": "^12.0.0",
61
- "@inquirer/prompts": "^5.0.0"
61
+ "@inquirer/prompts": "^5.0.0",
62
+ "yaml": "^2.9.0"
62
63
  },
63
64
  "engines": {
64
65
  "node": ">=18"
@@ -64,13 +64,18 @@ cmd.command('new <title>')
64
64
  cmd.command('list')
65
65
  .description(t('req.list.description'))
66
66
  .action(async () => {
67
- listREQs(require('../config').load().reqDir)
67
+ listREQs(require('../config').load())
68
68
  })
69
69
 
70
70
  cmd.command('move <name> <status>')
71
71
  .description('Update a REQ status in place')
72
72
  .action(async (name, status) => {
73
- moveREQ(name, status)
73
+ try {
74
+ moveREQ(name, status)
75
+ } catch (err) {
76
+ console.error(`Error: ${err.message}`)
77
+ process.exitCode = 1
78
+ }
74
79
  })
75
80
 
76
81
  module.exports = cmd
@@ -6,7 +6,7 @@ const { t } = require('../i18n')
6
6
  const cmd = new Command('status')
7
7
  cmd.description(t('status.description'))
8
8
  cmd.action(async () => {
9
- console.log(await getStatus())
9
+ process.stdout.write(await getStatus())
10
10
  })
11
11
 
12
12
  module.exports = cmd
@@ -5,38 +5,24 @@ const https = require('https')
5
5
  const http = require('http')
6
6
  const fs = require('fs')
7
7
  const path = require('path')
8
+ const projectConfig = require('../config')
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Config helpers
11
12
  // ---------------------------------------------------------------------------
12
13
 
13
14
  /**
14
- * Lê um campo de trackfw.yaml (parse linha a linha, sem dependências externas).
15
- * @param {string} field
15
+ * Lê um campo de cfg.sync (namespace resolvido pelo carregador único, ../config —
16
+ * ver ADR-2026-08-02-caminho-unico-de-leitura-do-trackfw-yaml-com-namespaces-tipados.md),
17
+ * com fallback para variável de ambiente. Substitui o antigo scanner artesanal
18
+ * readConfigField (parse linha a linha de trackfw.yaml).
19
+ * @param {string} syncKey chave camelCase em cfg.sync (ex.: "linearApiKey")
20
+ * @param {string} envVar
16
21
  * @returns {string}
17
22
  */
18
- function readConfigField(field) {
19
- try {
20
- const data = fs.readFileSync('trackfw.yaml', 'utf8')
21
- const prefix = field + ':'
22
- for (const line of data.split('\n')) {
23
- const trimmed = line.trimStart()
24
- if (trimmed.startsWith(prefix)) {
25
- let value = trimmed.slice(prefix.length).trim()
26
- if (value.length >= 2 &&
27
- ((value[0] === '"' && value[value.length - 1] === '"') ||
28
- (value[0] === "'" && value[value.length - 1] === "'"))) {
29
- value = value.slice(1, -1)
30
- }
31
- return value
32
- }
33
- }
34
- } catch (_) { /* sem arquivo */ }
35
- return ''
36
- }
37
-
38
- function getConfig(field, envVar) {
39
- return readConfigField(field) || process.env[envVar] || ''
23
+ function getConfig(syncKey, envVar) {
24
+ const cfg = projectConfig.load(process.cwd())
25
+ return cfg.sync[syncKey] || process.env[envVar] || ''
40
26
  }
41
27
 
42
28
  // ---------------------------------------------------------------------------
@@ -296,18 +282,18 @@ async function syncToProvider(createFn, issueField) {
296
282
  }
297
283
 
298
284
  async function syncToLinear() {
299
- const apiKey = getConfig('linear_api_key', 'LINEAR_API_KEY')
300
- const teamId = getConfig('linear_team_id', 'LINEAR_TEAM_ID')
285
+ const apiKey = getConfig('linearApiKey', 'LINEAR_API_KEY')
286
+ const teamId = getConfig('linearTeamId', 'LINEAR_TEAM_ID')
301
287
  if (!apiKey) throw new Error('Linear API key not found. Set LINEAR_API_KEY env var or linear_api_key in trackfw.yaml')
302
288
  if (!teamId) throw new Error('Linear Team ID not found. Set LINEAR_TEAM_ID env var or linear_team_id in trackfw.yaml')
303
289
  return syncToProvider((title, desc) => linearCreateIssue(apiKey, teamId, title, desc), 'linear_issue')
304
290
  }
305
291
 
306
292
  async function syncToJira() {
307
- const baseUrl = getConfig('jira_base_url', 'JIRA_BASE_URL')
308
- const email = getConfig('jira_email', 'JIRA_EMAIL')
309
- const token = getConfig('jira_token', 'JIRA_TOKEN')
310
- const project = getConfig('jira_project', 'JIRA_PROJECT')
293
+ const baseUrl = getConfig('jiraBaseUrl', 'JIRA_BASE_URL')
294
+ const email = getConfig('jiraEmail', 'JIRA_EMAIL')
295
+ const token = getConfig('jiraToken', 'JIRA_TOKEN')
296
+ const project = getConfig('jiraProject', 'JIRA_PROJECT')
311
297
  if (!baseUrl) throw new Error('Jira base URL not found. Set JIRA_BASE_URL env var or jira_base_url in trackfw.yaml')
312
298
  if (!email) throw new Error('Jira email not found. Set JIRA_EMAIL env var or jira_email in trackfw.yaml')
313
299
  if (!token) throw new Error('Jira API token not found. Set JIRA_TOKEN env var or jira_token in trackfw.yaml')
@@ -5,6 +5,7 @@ const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
  const identityStore = require('../identity');
8
+ const projectConfig = require('../config');
8
9
  const { runFileTarget, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine');
9
10
 
10
11
  // `trackfw update` is project-scoped only — see docs/cli-parity.md,
@@ -12,23 +13,20 @@ const { runFileTarget, validateTargets, buildDocument, humanReport, silenceConso
12
13
  // state (~/.claude, ~/.codex, etc.). Global artifacts moved to
13
14
  // `trackfw update harness` (update-harness.js).
14
15
 
15
- function readUpdateConfig(rootDir) {
16
- const yaml = path.join(rootDir, 'trackfw.yaml');
17
- if (!fs.existsSync(yaml)) return {};
18
- const lines = fs.readFileSync(yaml, 'utf8').split('\n');
19
- const cfg = {};
20
- for (const line of lines) {
21
- const trimmed = line.trim();
22
- if (trimmed.startsWith('#')) continue;
23
- const idx = trimmed.indexOf(':');
24
- if (idx < 0) continue;
25
- const key = trimmed.slice(0, idx).trim();
26
- let val = trimmed.slice(idx + 1).trim();
27
- const ci = val.indexOf(' #');
28
- if (ci >= 0) val = val.slice(0, ci).trim();
29
- cfg[key] = val;
30
- }
31
- return cfg;
16
+ // loadUpdateConfig replaces the former artisanal line-by-line scanner (readUpdateConfig) with
17
+ // the single config loader (../config, see ADR-2026-08-02-caminho-unico-de-leitura-do-trackfw-
18
+ // yaml-com-namespaces-tipados.md). Returns the same snake_case shape the rest of this file
19
+ // already consumes (cfg.hooks, cfg.ci, cfg.backend, cfg.frontend, cfg.pkg_manager) so downstream
20
+ // code (updateHooksSurgical, buildProjectTargets) needs no further change.
21
+ function loadUpdateConfig(rootDir) {
22
+ const u = projectConfig.load(rootDir).update;
23
+ return {
24
+ hooks: u.hooks,
25
+ ci: u.ci,
26
+ backend: u.backend,
27
+ frontend: u.frontend,
28
+ pkg_manager: u.pkgManager,
29
+ };
32
30
  }
33
31
 
34
32
  function updateHooksSurgical(cfg, rootDir) {
@@ -164,7 +162,7 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
164
162
  // discover.js's writeValidateScript, which produces a different
165
163
  // (simpler, non-per-backend) script and made every `update` re-run
166
164
  // report "updated" against a project actually already current
167
- // (ML-6H fix). readUpdateConfig returns raw trackfw.yaml keys
165
+ // (ML-6H fix). loadUpdateConfig returns raw trackfw.yaml keys
168
166
  // (snake_case, e.g. "pkg_manager"); buildValidateScript expects the
169
167
  // camelCase shape used by the rest of the init generators.
170
168
  apply: (root) => generators.generateValidateScript({
@@ -252,7 +250,7 @@ cmd.action((mode, options) => {
252
250
  process.exit(1);
253
251
  }
254
252
 
255
- const cfg = readUpdateConfig(cwd);
253
+ const cfg = loadUpdateConfig(cwd);
256
254
  const dryRun = Boolean(options.dryRun);
257
255
  const installMissing = Boolean(options.installMissing);
258
256
 
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { parseDocument, isScalar, isAlias, isSeq, isMap } = require('yaml');
6
7
 
7
8
  function expandPath(filePath) {
8
9
  if (!filePath || typeof filePath !== 'string') return filePath;
@@ -38,6 +39,24 @@ function defaults() {
38
39
  roadmap: ['Roadmap:'],
39
40
  },
40
41
  acceptanceMarkers: ['## Acceptance Criteria', '## Critérios de Aceite'],
42
+ // ML-1A namespaces — see ADR-2026-08-02-caminho-unico-de-leitura-do-trackfw-yaml-com-
43
+ // namespaces-tipados.md. Keys stay flat at the YAML root; these are memory-only groupings
44
+ // populated by the same single parse() below, not a second read of trackfw.yaml.
45
+ update: {
46
+ hooks: '',
47
+ ci: '',
48
+ backend: '',
49
+ frontend: '',
50
+ pkgManager: '',
51
+ },
52
+ sync: {
53
+ linearApiKey: '',
54
+ linearTeamId: '',
55
+ jiraBaseUrl: '',
56
+ jiraEmail: '',
57
+ jiraToken: '',
58
+ jiraProject: '',
59
+ },
41
60
  rules: {
42
61
  wip_has_req: 'error',
43
62
  wip_acceptance: 'error',
@@ -48,19 +67,30 @@ function defaults() {
48
67
  folder_status: 'warning',
49
68
  filename_uniqueness: 'error',
50
69
  blocked_by_draft_adr: 'error',
70
+ adr_accepted_when_req_done: 'error',
51
71
  },
52
72
  };
53
73
  }
54
74
 
55
75
  let _instance = null;
56
76
 
77
+ // MALFORMED_CONFIG_MESSAGE is written to stderr, verbatim, when trackfw.yaml exists but fails to
78
+ // parse as YAML. Kept identical, character-for-character, to Go's MalformedConfigMessage and
79
+ // Python's MALFORMED_CONFIG_MESSAGE — see the comment on parse() below for why the text is
80
+ // static rather than built from the underlying library's error.
81
+ const MALFORMED_CONFIG_MESSAGE = 'trackfw: erro ao carregar "trackfw.yaml": YAML malformado. Corrija a sintaxe do arquivo antes de continuar.';
82
+
57
83
  function load(cwd) {
58
84
  if (_instance) return _instance;
59
85
  _instance = defaults();
60
86
  const yamlPath = path.join(cwd || process.cwd(), 'trackfw.yaml');
61
87
  if (!fs.existsSync(yamlPath)) return _instance;
62
88
  const content = fs.readFileSync(yamlPath, 'utf8');
63
- parse(content, _instance);
89
+ const malformed = parse(content, _instance);
90
+ if (malformed) {
91
+ process.stderr.write(MALFORMED_CONFIG_MESSAGE + '\n');
92
+ process.exit(1);
93
+ }
64
94
  return _instance;
65
95
  }
66
96
 
@@ -68,147 +98,180 @@ function reset() {
68
98
  _instance = null;
69
99
  }
70
100
 
71
- function parse(content, cfg) {
72
- const lines = content.split('\n');
73
-
74
- // estados existentes
75
- let inAdrDirs = false;
76
- let inAgents = false;
77
- let adrDirs = [];
78
- let agents = [];
79
-
80
- // NOVOS estados
81
- let inLinkFields = false;
82
- let inLinkFieldsReq = false;
83
- let inLinkFieldsAdr = false;
84
- let inLinkFieldsRoadmap = false;
85
- let linkFieldsReq = [];
86
- let linkFieldsAdr = [];
87
- let linkFieldsRoadmap = [];
88
-
89
- let inAcceptanceMarkers = false;
90
- let acceptanceMarkers = [];
91
-
92
- let inRules = false;
93
- let rules = {};
94
-
95
- function flushBlocks() {
96
- if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs.map(expandPath);
97
- if (inAgents && agents.length) cfg.agents = agents;
98
- if (inLinkFields) {
99
- if (inLinkFieldsReq && linkFieldsReq.length) cfg.linkFields.req = linkFieldsReq;
100
- if (inLinkFieldsAdr && linkFieldsAdr.length) cfg.linkFields.adr = linkFieldsAdr;
101
- if (inLinkFieldsRoadmap && linkFieldsRoadmap.length) cfg.linkFields.roadmap = linkFieldsRoadmap;
101
+ // resolveAlias segue a cadeia de um Alias (b: *x) até o nó âncora. Ler .source direto de um
102
+ // Alias não resolvido devolveria o NOME da âncora, não o valor — risco confirmado no ML-0A.
103
+ //
104
+ // state (opcional) é marcado como { unresolved: true } quando node.resolve(doc) devolve
105
+ // undefined — caso de uma âncora referenciada antes de ser definida (b: *x / a: &x 3), que a
106
+ // spec YAML trata como inválido. gopkg.in/yaml.v3 e PyYAML rejeitam esse arquivo (yaml.v3:
107
+ // "unknown anchor 'x' referenced"; PyYAML: ComposerError "found undefined alias"); a lib `yaml`
108
+ // do Node não popula doc.errors para isso — resolve() simplesmente devolve undefined em tempo
109
+ // de leitura. Sem este sinalizador, o valor viraria string vazia em silêncio (divergência de
110
+ // exit code encontrada na auditoria cruzada do ML-1B); com ele, parse() devolve malformado=true
111
+ // e load() converge com Go/Python.
112
+ function resolveAlias(doc, node, state) {
113
+ let n = node;
114
+ while (isAlias(n)) {
115
+ const resolved = n.resolve(doc);
116
+ if (resolved === undefined) {
117
+ if (state) state.unresolved = true;
118
+ return null;
102
119
  }
103
- if (inAcceptanceMarkers && acceptanceMarkers.length) cfg.acceptanceMarkers = acceptanceMarkers;
104
- if (inRules && Object.keys(rules).length) Object.assign(cfg.rules, rules);
105
- // reset
106
- inAdrDirs = false; adrDirs = [];
107
- inAgents = false; agents = [];
108
- inLinkFields = false;
109
- inLinkFieldsReq = false; inLinkFieldsAdr = false; inLinkFieldsRoadmap = false;
110
- linkFieldsReq = []; linkFieldsAdr = []; linkFieldsRoadmap = [];
111
- inAcceptanceMarkers = false; acceptanceMarkers = [];
112
- inRules = false; rules = {};
120
+ n = resolved;
113
121
  }
122
+ return n;
123
+ }
114
124
 
115
- for (const rawLine of lines) {
116
- const line = rawLine.trim();
117
- if (!line) continue;
118
- const hasIndent = rawLine.length > 0 && (rawLine[0] === ' ' || rawLine[0] === '\t');
119
-
120
- if (!hasIndent) {
121
- flushBlocks();
125
+ // normalizeNode converte um nó da árvore `yaml` (Scalar/Seq/Map/Alias) para uma string
126
+ // (escalar, usando o texto bruto pré-coerção via Scalar.source), um array de strings
127
+ // (sequência) ou um objeto plano (mapeamento) — recursivamente.
128
+ //
129
+ // Scalar.source já devolve o texto correto tanto para escalares "plain" (não processados —
130
+ // preserva "yes", "010", "2026-08-02" como estão no arquivo) quanto para escalares quoted/bloco
131
+ // (já des-escapados, iguais ao .value) — confirmado empiricamente no ML-1A: não há necessidade
132
+ // de tratar quoted e plain de formas diferentes.
133
+ function normalizeNode(doc, node, state) {
134
+ const n = resolveAlias(doc, node, state);
135
+ if (n == null) return '';
136
+ if (isScalar(n)) {
137
+ return n.source != null ? n.source : (n.value == null ? '' : String(n.value));
138
+ }
139
+ if (isSeq(n)) {
140
+ return n.items.map((item) => normalizeNode(doc, item, state));
141
+ }
142
+ if (isMap(n)) {
143
+ const result = {};
144
+ for (const pair of n.items) {
145
+ const key = resolveAlias(doc, pair.key, state);
146
+ const keyStr = isScalar(key) ? (key.source != null ? key.source : String(key.value)) : String(key);
147
+ result[keyStr] = normalizeNode(doc, pair.value, state);
122
148
  }
149
+ return result;
150
+ }
151
+ return '';
152
+ }
123
153
 
124
- if (hasIndent) {
125
- if (inAdrDirs) {
126
- if (line.startsWith('- ')) {
127
- let val = line.slice(2).trim();
128
- val = val.replace(/^["']|["']$/g, '');
129
- adrDirs.push(expandPath(val));
130
- }
131
- continue;
132
- }
133
- if (inAgents) {
134
- if (line.startsWith('- ')) agents.push(line.slice(2).trim());
135
- continue;
136
- }
137
- if (inAcceptanceMarkers) {
138
- if (line.startsWith('- ')) {
139
- let val = line.slice(2).trim();
140
- val = val.replace(/^["']|["']$/g, '');
141
- acceptanceMarkers.push(val);
142
- }
143
- continue;
144
- }
145
- if (inRules) {
146
- const colonIdx = line.indexOf(':');
147
- if (colonIdx > 0) {
148
- const k = line.slice(0, colonIdx).trim();
149
- const v = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
150
- if (k) rules[k] = v;
151
- }
152
- continue;
153
- }
154
- if (inLinkFields) {
155
- if (line.startsWith('- ')) {
156
- let val = line.slice(2).trim();
157
- val = val.replace(/^["']|["']$/g, '');
158
- if (inLinkFieldsReq) linkFieldsReq.push(val);
159
- else if (inLinkFieldsAdr) linkFieldsAdr.push(val);
160
- else if (inLinkFieldsRoadmap) linkFieldsRoadmap.push(val);
161
- } else {
162
- // sub-chave dentro de link_fields
163
- const colonIdx = line.indexOf(':');
164
- const subKey = colonIdx > 0 ? line.slice(0, colonIdx).trim() : line.replace(':', '').trim();
165
- // flush sub-campo anterior
166
- if (inLinkFieldsReq && linkFieldsReq.length) { cfg.linkFields.req = linkFieldsReq; linkFieldsReq = []; }
167
- if (inLinkFieldsAdr && linkFieldsAdr.length) { cfg.linkFields.adr = linkFieldsAdr; linkFieldsAdr = []; }
168
- if (inLinkFieldsRoadmap && linkFieldsRoadmap.length) { cfg.linkFields.roadmap = linkFieldsRoadmap; linkFieldsRoadmap = []; }
169
- inLinkFieldsReq = false; inLinkFieldsAdr = false; inLinkFieldsRoadmap = false;
170
- if (subKey === 'req') inLinkFieldsReq = true;
171
- else if (subKey === 'adr') inLinkFieldsAdr = true;
172
- else if (subKey === 'roadmap') inLinkFieldsRoadmap = true;
173
- }
174
- continue;
175
- }
176
- continue;
177
- }
154
+ function stringVal(m, key) {
155
+ const v = m[key];
156
+ return typeof v === 'string' ? v : undefined;
157
+ }
178
158
 
179
- // linha top-level
180
- const colonIdx = line.indexOf(':');
181
- if (colonIdx < 0) continue;
182
- const key = line.slice(0, colonIdx).trim();
183
- const val = line.slice(colonIdx + 1).trim();
184
- if (!key) continue;
185
-
186
- switch (key) {
187
- case 'adr_dirs': inAdrDirs = true; adrDirs = []; break;
188
- case 'req_dir': cfg.reqDir = expandPath(val.replace(/^["']|["']$/g, '')); break;
189
- case 'roadmap_dir': cfg.roadmapDir = expandPath(val.replace(/^["']|["']$/g, '')); break;
190
- case 'roadmap_namespacing': cfg.roadmapNamespacing = val; break;
191
- case 'agents': inAgents = true; agents = []; break;
192
- case 'governance_mode': cfg.governanceMode = val; break;
193
- case 'lenient_until': cfg.lenientUntil = val; break;
194
- case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
195
- case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
196
- case 'stale_wip_days': { const n = parseInt(val, 10); if (n > 0) cfg.staleWipDays = n; break; }
197
- case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
198
- case 'strict_ci_paths': cfg.strictCiPaths = val === 'true'; break;
199
- case 'trace_id_field': cfg.traceIdField = val.replace(/^["']|["']$/g, ''); break;
200
- case 'forge': cfg.forge = val.replace(/^["']|["']$/g, ''); break;
201
- case 'link_fields': inLinkFields = true; break;
202
- case 'acceptance_markers': inAcceptanceMarkers = true; acceptanceMarkers = []; break;
203
- case 'rules': inRules = true; rules = {}; break;
159
+ // stringList converte um valor normalizado (array) em array de strings. Uma sequência
160
+ // presente-porém-vazia devolve array vazio (não undefined), distinguindo "presente e vazio" de
161
+ // "ausente" — contrato herdado do fix de lista inline.
162
+ function stringList(v) {
163
+ if (!Array.isArray(v)) return undefined;
164
+ return v.filter((item) => typeof item === 'string');
165
+ }
166
+
167
+ // NON_FATAL_ERROR_CODES holds `yaml` package error codes that must NOT trigger the fatal path,
168
+ // because gopkg.in/yaml.v3 (decoding into a generic Node, not a struct) and PyYAML's
169
+ // yaml.compose() silently accept the same input — divergence found by ML-1B audit: a
170
+ // "wip_limit: 3\nwip_limit: 4\n" duplicate-key file made Node exit 1 while Go and Python both
171
+ // parsed it fine (both resolve to "last key wins", same value trackfw ends up using in all
172
+ // three once DUPLICATE_KEY is excluded here). Only Node's `yaml` treats duplicate keys as a
173
+ // composer-level error; treating it as fatal here would make the fatal trigger itself diverge
174
+ // across CLIs, which is exactly the defect this ML exists to avoid. Any other doc.errors entry
175
+ // (e.g. BAD_INDENT — an actually-unparseable document) still triggers the fatal path below.
176
+ const NON_FATAL_ERROR_CODES = new Set(['DUPLICATE_KEY']);
177
+
178
+ // parse applies the ~20 known keys from content onto cfg. Returns true when content is
179
+ // malformed YAML (the caller, load(), turns that into a fatal stderr message + exit 1) and
180
+ // false otherwise — including the benign cases of an absent/empty/comments-only document
181
+ // (doc.contents === null with no errors) or a document whose top-level node parses fine but
182
+ // isn't a mapping (valid YAML, unexpected shape): neither of those is a parse failure, so both
183
+ // stay silent no-ops, same as before this function grew an error signal.
184
+ //
185
+ // The `yaml` package doesn't throw on syntax errors — parseDocument() always returns a Document,
186
+ // and populates doc.errors instead (a passive failure channel). The try/catch below is kept as
187
+ // defense-in-depth for library versions/inputs that do throw, but the actual malformed-YAML path
188
+ // exercised by trackfw.yaml goes through the doc.errors.length > 0 check.
189
+ function parse(content, cfg) {
190
+ let doc;
191
+ try {
192
+ doc = parseDocument(content);
193
+ } catch (e) {
194
+ return true;
195
+ }
196
+ if (doc && doc.errors && doc.errors.some((e) => !NON_FATAL_ERROR_CODES.has(e.code))) return true;
197
+ if (!doc || !doc.contents) return false;
198
+ if (!isMap(doc.contents)) return false;
199
+
200
+ const state = { unresolved: false };
201
+ const m = normalizeNode(doc, doc.contents, state);
202
+ if (state.unresolved) return true;
203
+ if (typeof m !== 'object' || m === null || Array.isArray(m)) return false;
204
+
205
+ if (m.adr_dirs !== undefined) {
206
+ const items = stringList(m.adr_dirs);
207
+ if (items) cfg.adrDirs = items.map(expandPath);
208
+ }
209
+ if (stringVal(m, 'req_dir') !== undefined) cfg.reqDir = expandPath(m.req_dir);
210
+ if (stringVal(m, 'roadmap_dir') !== undefined) cfg.roadmapDir = expandPath(m.roadmap_dir);
211
+ if (stringVal(m, 'roadmap_namespacing') !== undefined) cfg.roadmapNamespacing = m.roadmap_namespacing;
212
+ if (m.agents !== undefined) {
213
+ const items = stringList(m.agents);
214
+ if (items) cfg.agents = items;
215
+ }
216
+ if (stringVal(m, 'governance_mode') !== undefined) cfg.governanceMode = m.governance_mode;
217
+ if (stringVal(m, 'lenient_until') !== undefined) cfg.lenientUntil = m.lenient_until;
218
+ if (stringVal(m, 'wip_limit') !== undefined) {
219
+ const n = parseInt(m.wip_limit, 10);
220
+ if (n > 0) cfg.wipLimit = n;
221
+ }
222
+ if (stringVal(m, 'wip_by_squad') !== undefined) cfg.wipBySquad = m.wip_by_squad === 'true';
223
+ if (stringVal(m, 'stale_wip_days') !== undefined) {
224
+ const n = parseInt(m.stale_wip_days, 10);
225
+ if (n > 0) cfg.staleWipDays = n;
226
+ }
227
+ if (stringVal(m, 'require_req_in_commit') !== undefined) cfg.requireReqInCommit = m.require_req_in_commit === 'true';
228
+ if (stringVal(m, 'strict_ci_paths') !== undefined) cfg.strictCiPaths = m.strict_ci_paths === 'true';
229
+ if (stringVal(m, 'trace_id_field') !== undefined) cfg.traceIdField = m.trace_id_field;
230
+ if (stringVal(m, 'forge') !== undefined) cfg.forge = m.forge;
231
+ if (m.acceptance_markers !== undefined) {
232
+ const items = stringList(m.acceptance_markers);
233
+ if (items) cfg.acceptanceMarkers = items;
234
+ }
235
+ if (m.link_fields !== undefined && typeof m.link_fields === 'object' && !Array.isArray(m.link_fields)) {
236
+ const lf = m.link_fields;
237
+ const req = stringList(lf.req);
238
+ if (req) cfg.linkFields.req = req;
239
+ const adr = stringList(lf.adr);
240
+ if (adr) cfg.linkFields.adr = adr;
241
+ const roadmap = stringList(lf.roadmap);
242
+ if (roadmap) cfg.linkFields.roadmap = roadmap;
243
+ }
244
+ if (m.rules !== undefined && typeof m.rules === 'object' && !Array.isArray(m.rules)) {
245
+ for (const [k, v] of Object.entries(m.rules)) {
246
+ if (typeof v === 'string') cfg.rules[k] = v;
204
247
  }
205
248
  }
206
249
 
207
- // flush final (EOF)
208
- flushBlocks();
250
+ // ML-1A — update and sync namespaces. Same normalized `m` as above, no second read.
251
+ if (stringVal(m, 'hooks') !== undefined) cfg.update.hooks = m.hooks;
252
+ if (stringVal(m, 'ci') !== undefined) cfg.update.ci = m.ci;
253
+ if (stringVal(m, 'backend') !== undefined) cfg.update.backend = m.backend;
254
+ if (stringVal(m, 'frontend') !== undefined) cfg.update.frontend = m.frontend;
255
+ if (stringVal(m, 'pkg_manager') !== undefined) cfg.update.pkgManager = m.pkg_manager;
256
+ if (stringVal(m, 'linear_api_key') !== undefined) cfg.sync.linearApiKey = m.linear_api_key;
257
+ if (stringVal(m, 'linear_team_id') !== undefined) cfg.sync.linearTeamId = m.linear_team_id;
258
+ if (stringVal(m, 'jira_base_url') !== undefined) cfg.sync.jiraBaseUrl = m.jira_base_url;
259
+ if (stringVal(m, 'jira_email') !== undefined) cfg.sync.jiraEmail = m.jira_email;
260
+ if (stringVal(m, 'jira_token') !== undefined) cfg.sync.jiraToken = m.jira_token;
261
+ if (stringVal(m, 'jira_project') !== undefined) cfg.sync.jiraProject = m.jira_project;
262
+
263
+ return false;
209
264
  }
210
265
 
211
266
  const NAMESPACING_FLAT = 'flat';
212
267
  const NAMESPACING_BY_AGENT = 'by_agent';
213
268
 
214
- module.exports = { load, reset, defaults, expandPath, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
269
+ module.exports = {
270
+ load,
271
+ reset,
272
+ defaults,
273
+ expandPath,
274
+ NAMESPACING_FLAT,
275
+ NAMESPACING_BY_AGENT,
276
+ MALFORMED_CONFIG_MESSAGE,
277
+ };
@@ -2,27 +2,86 @@
2
2
  const fs = require('fs')
3
3
  const path = require('path')
4
4
  const { localDateISO } = require('./date')
5
+ const roadmapGen = require('./roadmap')
6
+ const config = require('../config')
7
+
8
+ const VALID_STATES = roadmapGen.VALID_STATES
9
+ const STATE_ORDER = roadmapGen.STATE_ORDER
5
10
 
6
11
  /**
7
- * listREQs — lista arquivos .md em dir, imprimindo filename e status (coluna 60 chars).
8
- * Extrai status da linha `> Date: ... | Status: ...`.
9
- * Se dir não existe ou vazio: imprime "No REQs found in <dir>".
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.
16
+ * @param {object} cfg — config completo (ver npm/src/config)
17
+ * @returns {string[]} paths completos, na ordem flat → por-estado → by_agent
10
18
  */
11
- function listREQs(dir) {
12
- let files = []
19
+ function listREQFiles(cfg) {
20
+ const reqDir = cfg.reqDir
21
+ const files = []
22
+
23
+ // (a) flat legado — reqDir/*.md
13
24
  try {
14
- files = fs.readdirSync(dir).filter(f => f.endsWith('.md'))
15
- } catch (_) {
16
- // dir não existe
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
+ }
17
64
  }
18
65
 
66
+ return files
67
+ }
68
+
69
+ /**
70
+ * listREQs — lista REQs (recursivo nos 3 layouts), imprimindo filename e status (coluna 60 chars).
71
+ * Extrai status da linha `> Date: ... | Status: ...`.
72
+ * Se nenhum REQ encontrado: imprime "No REQs found in <reqDir>".
73
+ * @param {object} cfg — config completo (ver npm/src/config)
74
+ */
75
+ function listREQs(cfg) {
76
+ const files = listREQFiles(cfg)
77
+
19
78
  if (files.length === 0) {
20
- console.log(`No REQs found in ${dir}`)
79
+ console.log(`No REQs found in ${cfg.reqDir}`)
21
80
  return
22
81
  }
23
82
 
24
- for (const filename of files) {
25
- const filepath = path.join(dir, filename)
83
+ for (const filepath of files) {
84
+ const filename = path.basename(filepath)
26
85
  const status = parseREQStatus(filepath)
27
86
  console.log(`${filename.padEnd(60)} ${status}`)
28
87
  }
@@ -105,30 +164,105 @@ function rewriteREQStatus(source, status) {
105
164
  return { content: `---\n${lines.join('\n')}${rest}`, changed: true }
106
165
  }
107
166
 
108
- function findREQ(name, reqDir) {
109
- let files = []
110
- try {
111
- files = fs.readdirSync(reqDir).filter(f => f.endsWith('.md'))
112
- } catch (e) {
113
- throw new Error(`reading REQ dir: ${e.message}`)
114
- }
167
+ /**
168
+ * findREQ — busca recursiva nos 3 layouts (flat → por-estado → by_agent), retornando o primeiro
169
+ * path cujo basename contém `name` (case-insensitive).
170
+ * @param {string} name
171
+ * @param {object} cfg — config completo (ver npm/src/config)
172
+ * @returns {string} path completo
173
+ */
174
+ function findREQ(name, cfg) {
175
+ const files = listREQFiles(cfg)
115
176
  const lower = name.toLowerCase()
116
- const found = files.find(f => f.toLowerCase().includes(lower))
117
- if (!found) throw new Error(`REQ "${name}" not found in ${reqDir}`)
118
- return path.join(reqDir, found)
177
+ const found = files.find(f => path.basename(f).toLowerCase().includes(lower))
178
+ if (!found) throw new Error(`REQ "${name}" not found in ${cfg.reqDir}`)
179
+ return found
119
180
  }
120
181
 
182
+ /**
183
+ * appendREQTransitionLog — append em <reqDir>/.trackfw-log, mesmo formato de
184
+ * appendTransitionLog (roadmap.js), em arquivo de log separado (escopo de REQ, não roadmap).
185
+ */
186
+ function appendREQTransitionLog(cfg, basename, fromState, toState) {
187
+ const now = new Date()
188
+ const yyyy = now.getFullYear()
189
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
190
+ const dd = String(now.getDate()).padStart(2, '0')
191
+ const hh = String(now.getHours()).padStart(2, '0')
192
+ const min = String(now.getMinutes()).padStart(2, '0')
193
+ const timestamp = `${yyyy}-${mm}-${dd} ${hh}:${min}`
194
+ const line = `${timestamp} ${basename.padEnd(50)} ${fromState} → ${toState}\n`
195
+
196
+ try {
197
+ const lp = path.join(cfg.reqDir, '.trackfw-log')
198
+ fs.mkdirSync(path.dirname(lp), { recursive: true })
199
+ fs.appendFileSync(lp, line, 'utf8')
200
+ } catch (_) { /* best-effort, mesmo padrão do roadmap */ }
201
+ }
202
+
203
+ /**
204
+ * moveREQ — reescreve status: e, condicionalmente, move fisicamente o arquivo
205
+ * (ADR-2026-08-04, decisão D3):
206
+ * - REQ solta em `reqDir/` → modo in-place (comportamento legado: só reescreve status, sem mover).
207
+ * - REQ já organizada em `reqDir/<estado>/` ou `reqDir/<agente>/<estado>/` → move fisicamente
208
+ * para o novo estado, preservando o layout (por-estado ou by_agent), e loga a transição.
209
+ */
121
210
  function moveREQ(name, status) {
122
211
  if (!String(status || '').trim()) throw new Error('status is required')
123
- const reqDir = require('../config').load().reqDir
124
- const filepath = findREQ(name, reqDir)
212
+ const cfg = require('../config').load()
213
+ const filepath = findREQ(name, cfg)
125
214
  const source = fs.readFileSync(filepath, 'utf8')
126
215
  const result = rewriteREQStatus(source, status)
127
216
  if (!result.changed) {
128
217
  throw new Error(`REQ "${path.basename(filepath)}" has no frontmatter status/header Status to update`)
129
218
  }
130
- fs.writeFileSync(filepath, result.content, 'utf8')
131
- console.log(`✓ updated ${path.basename(filepath)} status → ${status}`)
219
+
220
+ const basename = path.basename(filepath)
221
+ const parentDir = path.dirname(filepath)
222
+ const grandparentDir = path.dirname(parentDir)
223
+ const greatGrandparentDir = path.dirname(grandparentDir)
224
+ const reqDirAbs = path.resolve(cfg.reqDir)
225
+ const fromState = path.basename(parentDir)
226
+
227
+ if (path.resolve(parentDir) === reqDirAbs) {
228
+ // modo in-place — REQ solta em reqDir/, comportamento legado (sem mover)
229
+ fs.writeFileSync(filepath, result.content, 'utf8')
230
+ console.log(`✓ updated ${basename} status → ${status}`)
231
+ return
232
+ }
233
+
234
+ if (!VALID_STATES.includes(status)) {
235
+ throw new Error(`invalid state "${status}" — valid states: ${VALID_STATES.join(', ')}`)
236
+ }
237
+
238
+ let targetDir = null
239
+ let logBasename = basename
240
+
241
+ if (path.resolve(grandparentDir) === reqDirAbs && VALID_STATES.includes(fromState)) {
242
+ // layout por-estado — reqDir/<estado>/
243
+ targetDir = path.join(cfg.reqDir, status)
244
+ } else if (VALID_STATES.includes(fromState) && path.resolve(greatGrandparentDir) === reqDirAbs) {
245
+ // layout by_agent — reqDir/<agente>/<estado>/
246
+ const agent = path.basename(grandparentDir)
247
+ targetDir = path.join(cfg.reqDir, agent, status)
248
+ logBasename = `${agent}/${basename}`
249
+ }
250
+
251
+ if (!targetDir) {
252
+ // layout não reconhecido — fallback seguro para in-place (não inventa destino)
253
+ fs.writeFileSync(filepath, result.content, 'utf8')
254
+ console.log(`✓ updated ${basename} status → ${status}`)
255
+ return
256
+ }
257
+
258
+ fs.mkdirSync(targetDir, { recursive: true })
259
+ const dst = path.join(targetDir, basename)
260
+ fs.writeFileSync(dst, result.content, 'utf8')
261
+ if (path.resolve(dst) !== path.resolve(filepath)) {
262
+ fs.unlinkSync(filepath)
263
+ }
264
+ appendREQTransitionLog(cfg, logBasename, fromState, status)
265
+ console.log(`✓ moved ${basename} → ${targetDir}`)
132
266
  }
133
267
 
134
268
  /**
@@ -328,4 +462,4 @@ function detectDomains(intention) {
328
462
  )
329
463
  }
330
464
 
331
- module.exports = { listREQs, parseREQStatus, rewriteREQStatus, moveREQ, newREQ, PROBES_CATALOG, detectDomains, localDateISO, toSlug }
465
+ module.exports = { listREQs, listREQFiles, findREQ, parseREQStatus, rewriteREQStatus, moveREQ, newREQ, PROBES_CATALOG, detectDomains, localDateISO, toSlug }
@@ -254,22 +254,97 @@ function contentHasMarker(content, markers) {
254
254
  return false
255
255
  }
256
256
 
257
- // adrIsDraft verifica se <adrBasename> contém "Status: Draft" buscando recursivamente nas adrDirs.
258
- function adrIsDraft(basename) {
259
- return adrDraftStatusForRule(basename, null).draft
257
+ // extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
258
+ // ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
259
+ // documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
260
+ // (ex: citações de código, exemplos) com o status real do artefato. Retorna '' se a linha não
261
+ // existir.
262
+ function extractAdrHeaderStatus(content) {
263
+ const marker = '| Status: '
264
+ for (const line of content.split('\n')) {
265
+ const trimmed = line.trim()
266
+ const idx = trimmed.indexOf(marker)
267
+ if (idx >= 0) {
268
+ let rest = trimmed.slice(idx + marker.length)
269
+ const pipeIdx = rest.indexOf(' |')
270
+ if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
271
+ return rest.trim()
272
+ }
273
+ }
274
+ return ''
260
275
  }
261
276
 
262
- function adrDraftStatusForRule(basename, messages) {
277
+ // extractFrontmatterField extrai o valor de um campo do bloco frontmatter YAML
278
+ // (delimitado por "---" ... "---" no início do arquivo). Retorna '' se o frontmatter
279
+ // estiver ausente ou o campo não existir/estiver vazio.
280
+ function extractFrontmatterField(content, field) {
281
+ if (!content.startsWith('---')) return ''
282
+ const rest = content.slice(3)
283
+ const end = rest.indexOf('\n---')
284
+ if (end < 0) return ''
285
+ const block = rest.slice(0, end)
286
+ for (const line of block.split('\n')) {
287
+ const trimmed = line.trim()
288
+ if (trimmed.startsWith(field + ':')) {
289
+ let val = trimmed.slice(field.length + 1).trim()
290
+ val = val.replace(/^["']|["']$/g, '')
291
+ return val
292
+ }
293
+ }
294
+ return ''
295
+ }
296
+
297
+ // resolveAdrStatus extrai o valor bruto do status de um ADR: frontmatter `status:`
298
+ // primeiro — é o campo machine-readable canônico, o mesmo que os geradores (`adr new`,
299
+ // NewADRDraft) escrevem e que a regra folder_status já usa como fonte de verdade — com
300
+ // fallback para a linha de cabeçalho ("> Date: ... | Status: X") somente se o
301
+ // frontmatter estiver ausente ou sem o campo (cobre ADRs legados sem frontmatter). Em um
302
+ // ADR bem formado os dois concordam. ML-1D (2026-08-01): alinha o Node ao Go e ao
303
+ // Python, que já liam frontmatter-first (ADR-2026-08-01-nocao-canonica-de-adr-nao-aceito).
304
+ function resolveAdrStatus(content) {
305
+ const fm = extractFrontmatterField(content, 'status')
306
+ if (fm) return fm
307
+ return extractAdrHeaderStatus(content)
308
+ }
309
+
310
+ // adrNotAcceptedStatusForRule é o helper canônico de "ADR não aceito": único lugar que conhece o
311
+ // vocabulário Draft/Proposed. Verdadeiro para ADR cujo status seja "Draft" ou "Proposed"; qualquer
312
+ // outro status (Accepted, Superseded, Deprecated, Rejected, ...) conta como aceito por exclusão —
313
+ // não há allowlist fechada de status aceitos.
314
+ //
315
+ // A fonte do status é resolveAdrStatus (frontmatter-first, fallback de cabeçalho — ver acima).
316
+ // Crucial: o fallback de cabeçalho extrai o valor de UMA linha específica
317
+ // (extractAdrHeaderStatus), não faz content.includes('Status: Draft') sobre o documento
318
+ // inteiro — esse era o defeito do código herdado (adrDraftStatusForRule original): um ADR
319
+ // com status real "Accepted" mas cuja prosa cita literalmente a string "Status: Draft"
320
+ // (ex: este próprio ADR, que documenta o bug) seria classificado como não aceito. Ver
321
+ // vault/notes para o caso concreto que expôs isso.
322
+ function adrNotAcceptedStatusForRule(rule, basename, messages) {
263
323
  const p = findAdrFile(basename)
264
- if (!p) return { draft: false, inspected: true }
324
+ if (!p) return { notAccepted: false, status: '', inspected: true }
265
325
  try {
266
- return { draft: fs.readFileSync(p, 'utf8').includes('Status: Draft'), inspected: true }
326
+ const content = fs.readFileSync(p, 'utf8')
327
+ const status = resolveAdrStatus(content)
328
+ const notAccepted = status.toLowerCase() === 'draft' || status.toLowerCase() === 'proposed'
329
+ return { notAccepted, status, inspected: true }
267
330
  } catch (err) {
268
- if (messages) messages.push(inspectionDiagnostic('blocked_by_draft_adr', p, err))
269
- return { draft: false, inspected: false }
331
+ if (messages) messages.push(inspectionDiagnostic(rule, p, err))
332
+ return { notAccepted: false, status: '', inspected: false }
270
333
  }
271
334
  }
272
335
 
336
+ // adrIsDraft verifica se <adrBasename> está em status não aceito (Draft ou Proposed) buscando
337
+ // recursivamente nas adrDirs. Nome mantido por compatibilidade (chamadores existentes); delega no
338
+ // helper canônico adrNotAcceptedStatusForRule.
339
+ function adrIsDraft(basename) {
340
+ return adrDraftStatusForRule(basename, null).draft
341
+ }
342
+
343
+ function adrDraftStatusForRule(basename, messages) {
344
+ const result = adrNotAcceptedStatusForRule('blocked_by_draft_adr', basename, messages)
345
+ return { draft: result.notAccepted, inspected: result.inspected }
346
+ }
347
+
273
348
  // validateWIPHasREQ — roadmaps em wip/ sem marker REQ no conteúdo → violation
274
349
  // Suporta modo by_agent via resolveWIPDirs.
275
350
  function validateWIPHasREQ() {
@@ -419,29 +494,10 @@ function validateWIPHasAcceptanceCriteria() {
419
494
  return violations
420
495
  }
421
496
 
422
- // readWIPConfig lê wip_limit e wip_by_squad do trackfw.yaml no CWD.
423
- // Retorna { limit: 1, bySquad: false } se o arquivo não existe ou campos ausentes.
424
- function readWIPConfig() {
425
- const cfg = { limit: 1, bySquad: false }
426
- let content
427
- try {
428
- content = fs.readFileSync('trackfw.yaml', 'utf8')
429
- } catch (_) {
430
- return cfg
431
- }
432
- for (const line of content.split('\n')) {
433
- const trimmed = line.trim()
434
- if (trimmed.startsWith('wip_limit:')) {
435
- const val = trimmed.slice('wip_limit:'.length).trim().split(/\s+/)[0]
436
- const n = parseInt(val, 10)
437
- if (!isNaN(n) && n > 0) cfg.limit = n
438
- }
439
- if (trimmed.startsWith('wip_by_squad:')) {
440
- const val = trimmed.slice('wip_by_squad:'.length).trim().split(/\s+/)[0]
441
- if (val === 'true') cfg.bySquad = true
442
- }
443
- }
444
- return cfg
497
+ // wipConfigFrom deriva { limit, bySquad } a partir do ProjectConfig já normalizado por
498
+ // config.load() — nenhuma releitura de trackfw.yaml acontece aqui.
499
+ function wipConfigFrom(cfg) {
500
+ return { limit: cfg.wipLimit > 0 ? cfg.wipLimit : 1, bySquad: !!cfg.wipBySquad }
445
501
  }
446
502
 
447
503
  // parseSquadFromFrontmatter extrai o valor do campo "squad:" de um arquivo markdown.
@@ -498,7 +554,7 @@ function validateWIPLimit() {
498
554
  return { violations, warnings }
499
555
  }
500
556
 
501
- const wipCfg = readWIPConfig()
557
+ const wipCfg = wipConfigFrom(cfg)
502
558
 
503
559
  if (!wipCfg.bySquad) {
504
560
  if (files.length > wipCfg.limit) {
@@ -622,13 +678,63 @@ function validateREQsNotBlockedByDraftADRs() {
622
678
  const blockedADRs = parseBlockedADRs(filePath)
623
679
  for (const adrBasename of blockedADRs) {
624
680
  if (adrDraftStatusForRule(adrBasename, violations).draft) {
625
- violations.push(`REQ ${path.basename(filePath)} is blocked by Draft ADR: ${adrBasename}`)
681
+ violations.push(`REQ ${path.basename(filePath)} is blocked by not-accepted ADR: ${adrBasename}`)
626
682
  }
627
683
  }
628
684
  }
629
685
  return violations
630
686
  }
631
687
 
688
+ // reqStatusEquals compara o status de uma REQ (case-insensitive) contra o valor esperado.
689
+ // Casa tanto o frontmatter ("status: X") quanto a linha de cabeçalho ("> Date: ... | Status: X"),
690
+ // mesma lógica de detecção usada por reqStatusIsOpen — duplicada aqui (em vez de generalizar
691
+ // reqStatusIsOpen) para não alterar o comportamento de req_roadmap_lifecycle, que já a consome.
692
+ function reqStatusEquals(content, status) {
693
+ const target = String(status).toLowerCase()
694
+ for (const line of content.split('\n')) {
695
+ const trimmed = line.trim()
696
+ const idx = trimmed.indexOf(':')
697
+ if (idx >= 0 && trimmed.slice(0, idx).trim().toLowerCase() === 'status') {
698
+ return trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, '').toLowerCase() === target
699
+ }
700
+ const marker = '| Status: '
701
+ const markerIdx = trimmed.indexOf(marker)
702
+ if (markerIdx >= 0) {
703
+ let rest = trimmed.slice(markerIdx + marker.length)
704
+ const pipeIdx = rest.indexOf(' |')
705
+ if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
706
+ return rest.trim().toLowerCase() === target
707
+ }
708
+ }
709
+ return false
710
+ }
711
+
712
+ // validateADRAcceptedWhenREQDone — REQ Done cujo ADR vinculado (campo "ADR:") não está aceito
713
+ // (Draft ou Proposed, via o helper canônico adrNotAcceptedStatusForRule) → violation. Fecha a
714
+ // lacuna que deixou um ADR Proposed atravessar sete REQs Done sem nenhum gate detectar.
715
+ function validateADRAcceptedWhenREQDone() {
716
+ const cfg = config.load()
717
+ const files = resolveReqFiles(cfg)
718
+ const violations = []
719
+ for (const filePath of files) {
720
+ const content = readFileForRule('adr_accepted_when_req_done', filePath, violations)
721
+ if (content === null) continue
722
+ if (!reqStatusEquals(content, 'done')) continue
723
+
724
+ const adrRef = extractRefPath(content, 'ADR')
725
+ if (!adrRef) continue
726
+ const adrBasename = path.basename(adrRef)
727
+ const status = adrNotAcceptedStatusForRule('adr_accepted_when_req_done', adrBasename, violations)
728
+ if (status.notAccepted) {
729
+ const reqBasename = path.basename(filePath)
730
+ violations.push(
731
+ `REQ "${reqBasename}" is Done but linked ADR "${adrBasename}" is not accepted (status: ${status.status})`
732
+ )
733
+ }
734
+ }
735
+ return violations
736
+ }
737
+
632
738
  // blockedREQs retorna mapa de reqBasename → [adrBasenames Draft] para uso em getStatus()
633
739
  function blockedREQs() {
634
740
  const cfg = config.load()
@@ -652,37 +758,23 @@ function blockedREQs() {
652
758
  return result
653
759
  }
654
760
 
655
- // readGovernanceMode lê governance_mode e lenient_until do trackfw.yaml no CWD.
656
- // Retorna { mode: 'strict', lenientUntil: null } se o arquivo não existe ou campos ausentes.
657
- function readGovernanceMode() {
658
- let content
659
- try {
660
- content = fs.readFileSync('trackfw.yaml', 'utf8')
661
- } catch (_) {
662
- return { mode: 'strict', lenientUntil: null }
663
- }
664
- let mode = 'strict'
761
+ // governanceModeFrom deriva { mode, lenientUntil } a partir do ProjectConfig já normalizado por
762
+ // config.load() — nenhuma releitura de trackfw.yaml acontece aqui. cfg.governanceMode chega como
763
+ // o valor bruto do campo (string vazia se ausente); cfg.lenientUntil chega como a data literal
764
+ // (ex.: "2026-08-02"), convertida aqui para Date.
765
+ function governanceModeFrom(cfg) {
766
+ const mode = cfg.governanceMode ? cfg.governanceMode : 'strict'
665
767
  let lenientUntil = null
666
- for (const line of content.split('\n')) {
667
- const trimmed = line.trim()
668
- if (trimmed.startsWith('governance_mode:')) {
669
- const val = trimmed.slice('governance_mode:'.length).trim().split(/\s+/)[0]
670
- if (val) mode = val
671
- }
672
- if (trimmed.startsWith('lenient_until:')) {
673
- const val = trimmed.slice('lenient_until:'.length).trim().split(/\s+/)[0]
674
- if (val) {
675
- const d = new Date(val)
676
- if (!isNaN(d.getTime())) lenientUntil = d
677
- }
678
- }
768
+ if (cfg.lenientUntil) {
769
+ const d = new Date(cfg.lenientUntil)
770
+ if (!isNaN(d.getTime())) lenientUntil = d
679
771
  }
680
772
  return { mode, lenientUntil }
681
773
  }
682
774
 
683
775
  // isLenient retorna true se o projeto está em modo lenient e o prazo não expirou.
684
776
  function isLenient() {
685
- const gm = readGovernanceMode()
777
+ const gm = governanceModeFrom(config.load())
686
778
  if (gm.mode !== 'lenient') return false
687
779
  if (!gm.lenientUntil) return true
688
780
  return new Date() < gm.lenientUntil
@@ -690,7 +782,7 @@ function isLenient() {
690
782
 
691
783
  // lenientUntilDate retorna a data de expiração formatada (YYYY-MM-DD) ou ''.
692
784
  function lenientUntilDate() {
693
- const gm = readGovernanceMode()
785
+ const gm = governanceModeFrom(config.load())
694
786
  if (gm.mode !== 'lenient' || !gm.lenientUntil) return ''
695
787
  return gm.lenientUntil.toISOString().slice(0, 10)
696
788
  }
@@ -735,7 +827,7 @@ function extractRefPath(content, field) {
735
827
  let val = trimmed.slice(idx + 1).trim()
736
828
  if (!val || val === '—' || val === '-' || val === '–') return null
737
829
  val = val.split(/\s+/)[0]
738
- val = val.replace(/^["']|["']$/g, '')
830
+ val = val.replace(/^["'`]|["'`]$/g, '')
739
831
  if (val.endsWith('.md')) return val
740
832
  }
741
833
  }
@@ -1131,6 +1223,7 @@ async function validateUnfiltered() {
1131
1223
  applyRule('filename_uniqueness', validateFilenameUniqueness(), violations, warnings)
1132
1224
  applyRule('branch_has_wip_roadmap', validateBranchHasWIPRoadmap(), violations, warnings)
1133
1225
  applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
1226
+ applyRule('adr_accepted_when_req_done', validateADRAcceptedWhenREQDone(), violations, warnings)
1134
1227
  applyRule('note_orphan', validateNoteOrphan(), violations, warnings)
1135
1228
 
1136
1229
  // Regras configuráveis via applyRule (popula _itemMeta automaticamente)
@@ -1187,10 +1280,60 @@ async function validate() {
1187
1280
  return { violations, warnings }
1188
1281
  }
1189
1282
 
1283
+ // ROADMAP_STATES enumera os 6 estados de roadmap na ordem exibida pelo bloco Inventory.
1284
+ const ROADMAP_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
1285
+
1286
+ // buildInventorySection monta o bloco "📊 Inventory" — contagem agregada de ADRs, REQs
1287
+ // (discriminadas por status real via reqStatusEquals) e roadmaps (pelos 6 estados, incluindo
1288
+ // "analyzing", historicamente omitido). Namespacing-agnóstico: agrega através de
1289
+ // resolveStateDirs()/resolveReqFiles(), que já resolvem flat vs by_agent.
1290
+ function buildInventorySection(cfg) {
1291
+ let adrCount = 0
1292
+ for (const adrDir of cfg.adrDirs || []) {
1293
+ adrCount += walkDirMdWithPathsForRule('status_inventory', adrDir, []).length
1294
+ }
1295
+
1296
+ const reqFiles = resolveReqFiles(cfg)
1297
+ let reqOpen = 0
1298
+ let reqDone = 0
1299
+ let reqClosed = 0
1300
+ for (const filePath of reqFiles) {
1301
+ let content
1302
+ try {
1303
+ content = fs.readFileSync(filePath, 'utf8')
1304
+ } catch (_) {
1305
+ continue
1306
+ }
1307
+ if (reqStatusEquals(content, 'open')) reqOpen++
1308
+ else if (reqStatusEquals(content, 'done')) reqDone++
1309
+ else if (reqStatusEquals(content, 'closed')) reqClosed++
1310
+ }
1311
+
1312
+ const roadmapCounts = {}
1313
+ let roadmapTotal = 0
1314
+ for (const state of ROADMAP_STATES) {
1315
+ let count = 0
1316
+ for (const dir of resolveStateDirs(cfg, state)) {
1317
+ count += listDir(dir).length
1318
+ }
1319
+ roadmapCounts[state] = count
1320
+ roadmapTotal += count
1321
+ }
1322
+
1323
+ let section = '\n📊 Inventory\n'
1324
+ section += ` ${'ADRs'.padEnd(12)}${adrCount}\n`
1325
+ section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed)\n`
1326
+ section += ` ${'Roadmaps'.padEnd(12)}${roadmapTotal}\n`
1327
+ section += ` backlog ${roadmapCounts.backlog} · analyzing ${roadmapCounts.analyzing} · wip ${roadmapCounts.wip}\n`
1328
+ section += ` blocked ${roadmapCounts.blocked} · done ${roadmapCounts.done} · abandoned ${roadmapCounts.abandoned}\n`
1329
+ return section
1330
+ }
1331
+
1190
1332
  // getStatus retorna string formatada com o status de governança do projeto
1191
1333
  async function getStatus() {
1192
1334
  const cfg = config.load()
1193
1335
  let out = '── trackfw status ──────────────────────\n'
1336
+ out += buildInventorySection(cfg)
1194
1337
 
1195
1338
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
1196
1339
  let agents = cfg.agents || []
@@ -1217,7 +1360,7 @@ async function getStatus() {
1217
1360
  out += `\n🔄 WIP (${wip.length})\n`
1218
1361
  for (const f of wip) out += ` ${f}\n`
1219
1362
 
1220
- const wipCfg = readWIPConfig()
1363
+ const wipCfg = wipConfigFrom(cfg)
1221
1364
  if (wipCfg.bySquad && wip.length > 0) {
1222
1365
  const bySquad = {}
1223
1366
  for (const f of wip) {
@@ -1242,14 +1385,19 @@ async function getStatus() {
1242
1385
  for (const w of staleWIPs) out += ` ${w}\n`
1243
1386
  }
1244
1387
 
1388
+ // Seção: REQs bloqueadas por ADRs não aceitos (Draft ou Proposed). O status exibido por
1389
+ // ADR é resolvido via adrNotAcceptedStatusForRule (helper canônico) em vez de hardcodar
1390
+ // "Draft" — blockedREQs() cobre ambos os status desde que delega em adrIsDraft/
1391
+ // adrDraftStatusForRule, e um rótulo fixo "(Draft)" mentiria para um ADR Proposed.
1245
1392
  const blockedByDraft = blockedREQs()
1246
1393
  const blockedKeys = Object.keys(blockedByDraft)
1247
1394
  if (blockedKeys.length > 0) {
1248
- out += `\n⏳ REQs blocked by Draft ADRs (${blockedKeys.length})\n`
1395
+ out += `\n⏳ REQs blocked by not-accepted ADRs (${blockedKeys.length})\n`
1249
1396
  for (const reqFile of blockedKeys) {
1250
1397
  out += ` ${reqFile}\n`
1251
1398
  for (const adr of blockedByDraft[reqFile]) {
1252
- out += ` → ${adr} (Draft)\n`
1399
+ const { status } = adrNotAcceptedStatusForRule('blocked_by_draft_adr', adr, null)
1400
+ out += ` → ${adr} (${status})\n`
1253
1401
  }
1254
1402
  }
1255
1403
  }
@@ -1291,8 +1439,6 @@ module.exports = {
1291
1439
  resolveStateDirs,
1292
1440
  resolveWIPDirs,
1293
1441
  resolveDoneDirs,
1294
- readGovernanceMode,
1295
- readWIPConfig,
1296
1442
  parseSquadFromFrontmatter,
1297
1443
  validateFrontmatterPresence,
1298
1444
  // novas funções ML-1B
@@ -1316,4 +1462,12 @@ module.exports = {
1316
1462
  isInsideDir,
1317
1463
  walkDirMdWithPaths,
1318
1464
  validateADRDirsExist,
1465
+ // novas funções ML-1B (2026-08-01 — adr_accepted_when_req_done)
1466
+ extractAdrHeaderStatus,
1467
+ adrNotAcceptedStatusForRule,
1468
+ reqStatusEquals,
1469
+ validateADRAcceptedWhenREQDone,
1470
+ // ML-1D (2026-08-01 — reconciliação de paridade: frontmatter-first)
1471
+ extractFrontmatterField,
1472
+ resolveAdrStatus,
1319
1473
  }