trackfw 7.3.0 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,13 @@ ADR → REQ → ROADMAP → backlog / wip / blocked / done / abandoned
18
18
 
19
19
  Every piece of work traces back to a decision. Every decision links to a requirement. Every requirement lands in a roadmap. No orphan work, no undocumented choices.
20
20
 
21
+ > 🚧 **Platform support: Linux and macOS are supported. Windows support is partial.**
22
+ > The CLIs install on Windows and core governance commands run — but the generated
23
+ > guard hooks are POSIX shell scripts, and **on several agent CLIs they do not execute
24
+ > on Windows.** They are written to disk and reported as installed while never running.
25
+ > Native Windows hooks are in progress.
26
+ > **Read [Windows support (partial)](#windows-support-partial) before adopting on Windows.**
27
+
21
28
  ---
22
29
 
23
30
  ## The problem
@@ -93,9 +100,68 @@ npm install -g trackfw
93
100
  ```
94
101
 
95
102
  The npm package is pure Node.js — no compiled binary or postinstall download.
96
- It works wherever Node.js ≥ 18 is installed. Shared behavior, including the AI
103
+ It installs wherever Node.js ≥ 18 is installed. Shared behavior, including the AI
97
104
  integration lifecycle, follows the [CLI parity contract](docs/cli-parity.md).
98
105
 
106
+ > **The CLI installs on Node ≥ 18 alone — the generated guard hooks do not run on
107
+ > Node alone.** They are POSIX shell scripts and need a POSIX shell to execute. On
108
+ > Windows, see [Windows support](#windows-support-partial) before relying on them.
109
+
110
+ ### Windows support (partial)
111
+
112
+ 🚧 **Windows support is partial and in progress. Read this before adopting `trackfw`
113
+ on Windows.**
114
+
115
+ We publish a Windows binary and the npm/pip packages install on Windows. That is not
116
+ the same as the tool working end to end, and we would rather tell you where the edges
117
+ are than let you find them after adoption.
118
+
119
+ **What we know works**
120
+
121
+ - The three CLIs install (`npm install -g trackfw`, `pip install trackfw`, and the
122
+ published `trackfw_<version>_windows_amd64.tar.gz`).
123
+ - Core governance commands — `req new`, `roadmap new`, `roadmap move`, `status`,
124
+ `validate` — run, and artifacts are written with LF endings.
125
+
126
+ **What we know does not work yet**
127
+
128
+ - `scripts/install.sh` **refuses Windows**, even though we publish a Windows binary.
129
+ Install manually from the release archive for now.
130
+ - Our Windows CI still reports **known test failures**. They are mapped by root cause,
131
+ not unknown — but they are not zero.
132
+ - **Windows ARM64 is not built.** Only `windows_amd64` is published.
133
+
134
+ **Guard hooks on Windows — measured, per agent CLI**
135
+
136
+ The guard hooks are `.sh` scripts, executed by *your* AI agent CLI. Which shell that CLI
137
+ uses on Windows decides whether they run at all. We measured it:
138
+
139
+ | Agent CLI | Shell on Windows | Do the hooks run? | Basis |
140
+ |---|---|---|---|
141
+ | Gemini CLI | PowerShell, always | ❌ **No** | measured in vendor source |
142
+ | Codex CLI | PowerShell on the normal path | ❌ **No** | measured in vendor source |
143
+ | GitHub Copilot CLI | — | ❌ **No** — we populate the wrong config field | vendor docs + our code |
144
+ | Claude Code | Git Bash if installed, else PowerShell | ⚠️ **Only with Git Bash** | vendor documentation |
145
+ | Cursor · Kiro | unknown | ❓ **Unknown** | closed, undocumented |
146
+
147
+ 🔴 **This is the failure mode we care most about, because it is silent.** A guard that
148
+ never executes still reports health over something it never inspected. On the CLIs marked
149
+ ❌, `trackfw validate` will tell you the hook is installed — and it will never fire.
150
+
151
+ **Until native Windows hooks ship, do not rely on `credential_guard` or
152
+ `git_branch_guard` as an enforced control on Windows.** Treat them as documentation of
153
+ intent, not as enforcement.
154
+
155
+ We are **not** going to answer this by requiring Git Bash: it would fix one CLI out of
156
+ six and push the cost onto you. Windows hooks should run on Windows. Native hook
157
+ generation is the direction — see
158
+ [`docs/portabilidade/2026-09-05-contrato-de-execucao-de-hook-por-cli-de-agente-no-windows.md`](docs/portabilidade/2026-09-05-contrato-de-execucao-de-hook-por-cli-de-agente-no-windows.md)
159
+ for the full measurement, per CLI, with the level of certainty of each row.
160
+
161
+ **If you are on Windows**, we want your report. The Windows defects fixed so far came
162
+ from a user running the tool on real Windows 11 and measuring before reporting — open
163
+ an issue with what you measured.
164
+
99
165
  ### pip
100
166
 
101
167
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "7.3.0",
3
+ "version": "7.5.0",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
5
5
  "keywords": [
6
6
  "cli",
@@ -6,6 +6,7 @@ const { Command } = require('commander')
6
6
  const { input } = require('@inquirer/prompts')
7
7
  const generators = require('../generators/adr')
8
8
  const { t } = require('../i18n')
9
+ const { homedir } = require('../homedir')
9
10
 
10
11
  const cmd = new Command('adr')
11
12
  cmd.description(t('adr.description'))
@@ -19,7 +20,7 @@ cmd.description(t('adr.description'))
19
20
  */
20
21
  function resolveAdrDir(scope) {
21
22
  if (scope === 'global') {
22
- return path.join(os.homedir(), '.trackfw', 'adr')
23
+ return path.join(homedir(), '.trackfw', 'adr')
23
24
  }
24
25
  if (scope === 'project') {
25
26
  return require('../config').load().adrDirs[0]
@@ -103,11 +103,201 @@ function findWave(lines, waveLabel, roadmapBasename) {
103
103
  return { startLine: wave.startLine, endLine: wave.endLine }
104
104
  }
105
105
 
106
+ // CRITERIA_HEADER_RE accepts both the canonical English header (ADR
107
+ // 2026-08-29 decision 1) and the Portuguese one, which remains accepted with
108
+ // no removal date (decision 2 — 99/143 roadmaps in the corpus use it,
109
+ // including artifacts in done/). The `^` anchor is load-bearing: an
110
+ // unanchored match would casar dentro de prosa ou de cerca de código, turning
111
+ // a quoted literal into forged acceptance evidence.
112
+ const CRITERIA_HEADER_RE = /^\*\*(?:Acceptance criteria|Crit[ée]rios de aceite):\*\*/
113
+
114
+ // STATUS_VOCABULARY is the closed set of first-token markers recognised as
115
+ // "complete" (ADR decision 3): the checkmark emoji, and the English/Portuguese
116
+ // words, matched after case-folding and diacritics-folding. Deliberately
117
+ // closed and explicit — "feito", "ok", "finalizado" are out (ADR, Alternatives
118
+ // Considered — "accept any non-empty status" is the rejected no-op design).
119
+ const STATUS_VOCABULARY = new Set(['✅', 'done', 'concluido'])
120
+
121
+ // VS16 is the single variation selector this module treats as cosmetic
122
+ // noise: the one emoji keyboards insert after "checkmark" to force
123
+ // text-style presentation (ADR 2026-08-29 decision 9, exception clause). No
124
+ // other variation selector or combining mark gets this treatment.
125
+ const VS16 = '\u{FE0F}'
126
+
127
+ // stripVS16 removes only U+FE0F occurrences from token. Deliberately
128
+ // narrower than "strip every variation selector" or "strip every Mn": VS16
129
+ // is the one documented cosmetic exception (ADR decision 9); anything else
130
+ // of category Mn is now a rejection, not a fold (see
131
+ // hasDisallowedCombiningMark).
132
+ function stripVS16(token) {
133
+ return token.split(VS16).join('')
134
+ }
135
+
136
+ // hasDisallowedCombiningMark reports whether token (with VS16 already
137
+ // removed) contains any Unicode category Mn (Mark, Nonspacing) codepoint.
138
+ // ADR 2026-08-29 decision 9: after the single VS16 exception, any combining
139
+ // mark on the first status token is rejected outright \u2014 the ML is NOT
140
+ // complete \u2014 rather than folded away. A vocabulary this small exists to
141
+ // refuse ambiguity; silently folding combining marks reopens exactly the
142
+ // ambiguity it exists to close ("d<U+1DC0>one" must not read as "done").
143
+ //
144
+ // ORDER IS LOAD-BEARING: this check MUST run on the token BEFORE NFD
145
+ // decomposition, not after. "Conclu\u00eddo" in its authored (NFC) form has
146
+ // no literal Mn codepoint \u2014 the accented "\u00ed" is a single
147
+ // precomposed codepoint. NFD-decomposing it *produces* a trailing Mn
148
+ // (U+0301, COMBINING ACUTE ACCENT) that normalizeStatusToken then strips
149
+ // for vocabulary matching. Running this rejection check on the
150
+ // already-decomposed string would treat that legitimate,
151
+ // vocabulary-sanctioned accent the same as an injected combining mark and
152
+ // reject "Conclu\u00eddo" outright, breaking AC15's own positive case.
153
+ // Checking the raw, pre-decomposition token instead lets it through here
154
+ // (no literal Mn present) while still catching a combining mark authored
155
+ // directly onto the token, which is category Mn in either form, decomposed
156
+ // or not.
157
+ function hasDisallowedCombiningMark(token) {
158
+ return /\p{Mn}/u.test(token)
159
+ }
160
+
161
+ // normalizeStatusToken folds diacritics and lower-cases the token for
162
+ // vocabulary comparison. Uses the \p{Mn} Unicode property escape (General
163
+ // Category "Mark, Nonspacing") instead of a hand-enumerated code point range,
164
+ // to match Go's runes.In(unicode.Mn) and Python's unicodedata.category(ch) \u2014
165
+ // all three fold every combining mark that survives to this point, which,
166
+ // after hasDisallowedCombiningMark has already run, only happens as a
167
+ // byproduct of NFD-decomposing an accented Latin letter (e.g. "Conclu\u00eddo"
168
+ // -> "Concluido"). Callers MUST have already rejected via
169
+ // hasDisallowedCombiningMark before calling this \u2014 see that function's
170
+ // doc comment for why.
171
+ function normalizeStatusToken(token) {
172
+ return token.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()
173
+ }
174
+
175
+ // statusIsComplete implements rule 3 by FIRST TOKEN, not substring (ADR
176
+ // decision 3, AC8/AC9/AC14). marker is the already-trimmed remainder of the
177
+ // "**Status:**" line. Splitting on /\s+/ treats U+00A0 (NBSP) as a separator \u2014
178
+ // matching the accepted "NBSP separator" case \u2014 while a zero-width character
179
+ // (U+200B, not matched by \s) stays glued to the token and safely causes
180
+ // rejection (a usability false-negative, not a security concern).
181
+ //
182
+ // This is the fix for vault/notes/adr-status-substring-livre-falso-positivo-2026-08-01.md:
183
+ // `marker.includes('checkmark')` would classify "**Status:** [pending] checkmark" as
184
+ // complete (reproduced live against 7.3.0 \u2014 ADR decision 8). First-token
185
+ // comparison rejects it because the first token is the pending glyph, not the marker.
186
+ //
187
+ // ADR decision 9: VS16 is stripped first (the one cosmetic exception), then
188
+ // any remaining combining mark on the raw token rejects outright \u2014 see
189
+ // hasDisallowedCombiningMark for why this must happen before NFD.
190
+ function statusIsComplete(marker) {
191
+ const trimmed = marker.trim()
192
+ if (trimmed.length === 0) return false
193
+ const first = stripVS16(trimmed.split(/\s+/)[0])
194
+ if (hasDisallowedCombiningMark(first)) return false
195
+ return STATUS_VOCABULARY.has(normalizeStatusToken(first))
196
+ }
197
+
198
+ // splitRoadmapLines is the single boundary where the raw file content becomes
199
+ // the array every marker regex operates on. It normalizes CRLF line endings
200
+ // by stripping a trailing "\r" from each line produced by splitting on
201
+ // "\n" — every downstream marker (ML heading, "**Status:**", acceptance
202
+ // header, criterion lines, "**Gates da wave:**", the fence delimiter) then
203
+ // sees the same content it would see for an LF-only file. It does NOT handle
204
+ // a lone-CR (old-Mac-style) file: splitting on "\n" alone leaves such a file
205
+ // as one giant line, same as internal/commands/barrier.go's splitRoadmapLines.
206
+ // Python's universal-newlines read does handle lone CR — a known, accepted
207
+ // asymmetry; issue #216 (the defect motivating this fix) is CRLF specifically.
208
+ //
209
+ // Unlike Go and Python, this runtime DOES depend on this normalization: JS
210
+ // regex "." excludes "\r" (it is a LineTerminator per the ECMAScript spec,
211
+ // unlike Go's RE2 "."), so `/^\*\*Status:\*\*(.*)$/.exec("**Status:** done\r")`
212
+ // returns null — a CRLF roadmap with every ML fully completed was reported as
213
+ // "not complete (status: missing)" (mirrors internal/commands/barrier.go
214
+ // splitRoadmapLines and the universal-newline read in
215
+ // pypi/trackfw/commands/barrier.py).
216
+ //
217
+ // Only the trailing "\r" immediately before the split point is stripped —
218
+ // this must never be confused with per-line indentation trimming, which
219
+ // ML-1B deliberately removed: leading whitespace on a marker line still fails
220
+ // to match (markers are anchored at column 0, untouched by this function).
221
+ function splitRoadmapLines(content) {
222
+ return content.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
223
+ }
224
+
225
+ // detectFenceMarker inspects a whitespace-trimmed line and reports whether it
226
+ // opens or closes a CommonMark-style fence: a run of 3+ identical backtick
227
+ // (`) or tilde (~) characters at the start of the line. Returns
228
+ // { char, length } or null. ADR decision (ML-1B, achado 1): CommonMark
229
+ // defines a fence as 3+ of the SAME character (backtick or tilde), closed by
230
+ // a run of the same character with length >= the opening run — masking only
231
+ // "```" left both "~~~" fences and 4+-backtick fences (whose interior can
232
+ // nest a 3-backtick block) unmasked, which is the escape route a hostile
233
+ // roadmap would use.
234
+ function detectFenceMarker(trimmed) {
235
+ if (trimmed.length === 0) return null
236
+ const first = trimmed[0]
237
+ if (first !== '`' && first !== '~') return null
238
+ let i = 0
239
+ while (i < trimmed.length && trimmed[i] === first) i++
240
+ if (i < 3) return null
241
+ return { char: first, length: i }
242
+ }
243
+
244
+ // computeFenceMask returns, for each line index, whether that line lies
245
+ // strictly inside a fenced code block (``` ... ``` or ~~~ ... ~~~, per
246
+ // CommonMark: 3+ of the same fence character, closed by a run of the same
247
+ // character with length >= the opening run's length). A line that is itself
248
+ // a fence delimiter is never reported as "inside" — only the lines between an
249
+ // opening and a closing delimiter are masked. ADR decision 7 / AC13: ML
250
+ // detection, status and acceptance-header parsing must ignore documentation/
251
+ // examples inside a cerca — otherwise a roadmap that cites those literals (as
252
+ // this very roadmap, its REQ and its ADR do, repeatedly) is read as real ML
253
+ // content. parseGates already has its own, independent fence-matching for the
254
+ // "```bash ... ```" gates block and is untouched by this mask.
255
+ function computeFenceMask(lines) {
256
+ const mask = new Array(lines.length).fill(false)
257
+ let fenced = false
258
+ let fenceChar = null
259
+ let fenceLen = 0
260
+ for (let i = 0; i < lines.length; i++) {
261
+ const trimmed = lines[i].trim()
262
+ const marker = detectFenceMarker(trimmed)
263
+ if (!fenced) {
264
+ if (marker) {
265
+ fenced = true
266
+ fenceChar = marker.char
267
+ fenceLen = marker.length
268
+ continue
269
+ }
270
+ continue
271
+ }
272
+ // Currently inside a fence: only a marker of the SAME character with
273
+ // length >= the opening run closes it (a nested shorter/different
274
+ // marker stays masked as interior content) — AND, per CommonMark, a
275
+ // closing fence line contains NOTHING besides the fence run and
276
+ // (already-trimmed) surrounding whitespace: marker.length === trimmed.length.
277
+ // Fix for hades-tf security review (2026-08-29, achado #1 /
278
+ // vault/notes/barrier-fence-closing-trailing-content-bypass-2026-08-29.md):
279
+ // a line like "```trailing-junk" found INSIDE an open fence does not
280
+ // close it in real CommonMark — it stays interior content — but the
281
+ // prior check treated any run of length >= fenceLen as a valid close
282
+ // regardless of trailing text. The opening branch above is unchanged:
283
+ // CommonMark allows an info string after the opening run (` ```bash `).
284
+ if (marker && marker.char === fenceChar && marker.length >= fenceLen && marker.length === trimmed.length) {
285
+ fenced = false
286
+ continue
287
+ }
288
+ mask[i] = true
289
+ }
290
+ return mask
291
+ }
292
+
106
293
  // findMLs locates every `### ML-` heading inside [startLine, endLine) and returns
107
- // { id, lines } for each, where `lines` is the ML body (heading excluded).
108
- function findMLs(lines, startLine, endLine) {
294
+ // { id, lines, fenced } for each, where `lines` is the ML body (heading excluded)
295
+ // and `fenced` is the fence mask aligned to `lines`. fenceMask defaults to "no
296
+ // fences" so existing call sites that omit it keep working (ML-1A, AC13).
297
+ function findMLs(lines, startLine, endLine, fenceMask = computeFenceMask(lines)) {
109
298
  const headings = []
110
299
  for (let i = startLine; i < endLine; i++) {
300
+ if (fenceMask[i]) continue
111
301
  if (/^### ML-/.test(lines[i])) {
112
302
  const m = /^### (\S+)/.exec(lines[i])
113
303
  headings.push({ id: m[1], headingLine: i })
@@ -116,57 +306,104 @@ function findMLs(lines, startLine, endLine) {
116
306
  return headings.map((h, idx) => {
117
307
  let end = endLine
118
308
  for (let j = h.headingLine + 1; j < endLine; j++) {
309
+ if (fenceMask[j]) continue
119
310
  if (/^### /.test(lines[j]) || /^## /.test(lines[j])) { end = j; break }
120
311
  }
121
- return { id: h.id, lines: lines.slice(h.headingLine + 1, end) }
312
+ return {
313
+ id: h.id,
314
+ lines: lines.slice(h.headingLine + 1, end),
315
+ fenced: fenceMask.slice(h.headingLine + 1, end),
316
+ }
122
317
  })
123
318
  }
124
319
 
125
- // mlCompletionStatus applies rule 3 — completion is a `**Status:**` line whose
126
- // remainder contains ✅. Returns { complete, marker } where marker is "missing"
127
- // when no `**Status:**` line exists at all.
128
- function mlCompletionStatus(mlLines) {
129
- for (const raw of mlLines) {
130
- const line = raw.trim()
131
- const m = /^\*\*Status:\*\*(.*)$/.exec(line)
320
+ // mlCompletionStatus applies rule 3 — completion is by FIRST TOKEN (ADR
321
+ // decision 3), not substring. Returns { complete, marker } where marker is
322
+ // "missing" when no `**Status:**` line exists at all. Lines inside a fenced
323
+ // code block are ignored (ADR decision 7, AC13-a) — `fenced` is aligned to
324
+ // `mlLines`. Matched against the RAW line (no per-line .trim()) — an indented
325
+ // marker does not count (ML-1B, achado 2): Go and Python already require
326
+ // column 0 via `^` against the untrimmed line, and Node's prior .trim() made
327
+ // it the only runtime that released a wave on indented markers.
328
+ //
329
+ // Default omitted on purpose (hades-tf security review, 2026-08-29, achado
330
+ // #3 / hefesto-tf): a `fenced = []` default left every `fenced[i]` as
331
+ // `undefined` when the caller forgot the argument — indistinguishable from
332
+ // "nothing is fenced", silently turning OFF the fence-masking protection this
333
+ // very function exists to enforce. Production call sites always pass
334
+ // `ml.fenced` (never exploitable today), but a completion gate must fail
335
+ // CLOSED on a missing argument, not open. The default below marks every line
336
+ // as fenced (masked) instead, so an accidentally omitted argument yields
337
+ // "missing"/not-complete rather than "everything counts as real content".
338
+ function mlCompletionStatus(mlLines, fenced = mlLines.map(() => true)) {
339
+ for (let i = 0; i < mlLines.length; i++) {
340
+ if (fenced[i]) continue
341
+ const m = /^\*\*Status:\*\*(.*)$/.exec(mlLines[i])
132
342
  if (m) {
133
343
  const marker = m[1].trim()
134
- return { complete: marker.includes('✅'), marker: marker.length > 0 ? marker : 'missing' }
344
+ return { complete: statusIsComplete(marker), marker: marker.length > 0 ? marker : 'missing' }
135
345
  }
136
346
  }
137
347
  return { complete: false, marker: 'missing' }
138
348
  }
139
349
 
140
350
  // mlAcceptanceEvidence applies rule 4. Returns { hasBlock, total, unmet }.
141
- function mlAcceptanceEvidence(mlLines) {
351
+ // Lines inside a fenced code block are ignored throughout — for the header
352
+ // search, for the "**" block-end boundary, and for counting criterion lines —
353
+ // otherwise a cerca citing the acceptance header/`- [x]` as an example would
354
+ // forge acceptance evidence (ADR decision 7, AC13-a). All markers are matched
355
+ // against the RAW line (no per-line .trim()) — an indented marker does not
356
+ // count (ML-1B, achado 2): Go and Python already require column 0 via `^`
357
+ // against the untrimmed line, and Node's prior .trim() made it the only
358
+ // runtime that released a wave on indented markers.
359
+ //
360
+ // Default omitted on purpose — same rationale as mlCompletionStatus above
361
+ // (hades-tf security review, 2026-08-29, achado #3 / hefesto-tf): fail
362
+ // CLOSED (every line treated as fenced/masked, so `hasBlock: false` and no
363
+ // forged criteria are counted) if the caller omits `fenced`, instead of
364
+ // silently disabling the fence mask.
365
+ function mlAcceptanceEvidence(mlLines, fenced = mlLines.map(() => true)) {
142
366
  let blockStart = -1
143
367
  for (let i = 0; i < mlLines.length; i++) {
144
- if (/^\*\*Crit[ée]rios de aceite:\*\*/.test(mlLines[i].trim())) { blockStart = i; break }
368
+ if (fenced[i]) continue
369
+ if (CRITERIA_HEADER_RE.test(mlLines[i])) { blockStart = i; break }
145
370
  }
146
371
  if (blockStart === -1) return { hasBlock: false, total: 0, unmet: 0 }
147
372
 
148
373
  let blockEnd = mlLines.length
149
374
  for (let j = blockStart + 1; j < mlLines.length; j++) {
150
- if (/^\*\*/.test(mlLines[j].trim())) { blockEnd = j; break }
375
+ if (fenced[j]) continue
376
+ if (/^\*\*/.test(mlLines[j])) { blockEnd = j; break }
151
377
  }
152
378
 
153
- const blockLines = mlLines.slice(blockStart + 1, blockEnd)
154
- .map(l => l.trim())
155
- .filter(l => l.length > 0)
156
-
157
- const criteria = blockLines.filter(l => /^- \[/.test(l))
379
+ const criteria = []
380
+ for (let i = blockStart + 1; i < blockEnd; i++) {
381
+ if (fenced[i]) continue
382
+ const l = mlLines[i]
383
+ if (/^- \[.\]/.test(l)) criteria.push(l)
384
+ }
158
385
  if (criteria.length === 0) return { hasBlock: false, total: 0, unmet: 0 }
159
386
 
160
387
  const unmet = criteria.filter(l => /^- \[ \]/.test(l))
161
388
  return { hasBlock: true, total: criteria.length, unmet: unmet.length }
162
389
  }
163
390
 
391
+ // GATES_HEADER_RE mirrors Go's gatesHeaderRe / Python's _GATES_HEADER_RE: a
392
+ // PREFIX match at column 0, not full-line equality — a header followed by
393
+ // trailing prose/whitespace on the same line ("**Gates da wave:** (obrigatórios)")
394
+ // or a CRLF-terminated line ("**Gates da wave:**\r") must still be recognised,
395
+ // exactly as Go/Python already do.
396
+ const GATES_HEADER_RE = /^\*\*Gates da wave:\*\*/
397
+
164
398
  // parseGates applies rule 5. Returns { commands }. Throws UsageError naming the
165
- // offending line number for an unterminated fence.
399
+ // offending line number for an unterminated fence. The header marker is
400
+ // matched against the RAW line (no per-line .trim()) — an indented
401
+ // '**Gates da wave:**' does not count (ML-1B, achado 2): Go and Python
402
+ // already require column 0 for this marker.
166
403
  function parseGates(lines, startLine, endLine) {
167
404
  let markerLine = -1
168
405
  for (let i = startLine; i < endLine; i++) {
169
- if (lines[i].trim() === '**Gates da wave:**') { markerLine = i; break }
406
+ if (GATES_HEADER_RE.test(lines[i])) { markerLine = i; break }
170
407
  }
171
408
  if (markerLine === -1) return { commands: [] }
172
409
 
@@ -200,7 +437,7 @@ function evalMlsComplete(mls, waveLabel) {
200
437
  const evidence = []
201
438
  const failures = []
202
439
  for (const ml of mls) {
203
- const { complete, marker } = mlCompletionStatus(ml.lines)
440
+ const { complete, marker } = mlCompletionStatus(ml.lines, ml.fenced)
204
441
  if (complete) evidence.push(`${ml.id}: ✅`)
205
442
  else failures.push(`${ml.id}: not complete (status: ${marker})`)
206
443
  }
@@ -214,7 +451,7 @@ function evalAcceptanceEvidence(mls) {
214
451
  const evidence = []
215
452
  const failures = []
216
453
  for (const ml of mls) {
217
- const ev = mlAcceptanceEvidence(ml.lines)
454
+ const ev = mlAcceptanceEvidence(ml.lines, ml.fenced)
218
455
  if (!ev.hasBlock) {
219
456
  failures.push(`${ml.id}: no acceptance block`)
220
457
  } else if (ev.unmet > 0) {
@@ -304,6 +541,13 @@ function roadmapTrustForGates(roadmapPath) {
304
541
  return { trusted: true }
305
542
  }
306
543
 
544
+ // SH_MISSING_MSG is the pinned failure string for a `gates` check that could not
545
+ // be evaluated because `sh` is not on $PATH (AC3, AC4). All three runtimes (Go,
546
+ // Node, Python) must emit this byte-for-byte — see docs/cli-parity.md
547
+ // "Pinned failure strings for not_evaluated".
548
+ const SH_MISSING_MSG =
549
+ 'gates not evaluated: sh not found in PATH — install a POSIX shell (e.g. Git Bash, WSL) to evaluate gates'
550
+
307
551
  function evalGates(commands, cwd, trustResult = { trusted: true }) {
308
552
  // trustResult: { trusted: true } | { trusted: false, failureMsg: string }
309
553
  if (!trustResult.trusted) {
@@ -320,12 +564,31 @@ function evalGates(commands, cwd, trustResult = { trusted: true }) {
320
564
  const evidence = []
321
565
  const failures = []
322
566
  for (const command of commands) {
323
- const result = spawnSync(command, {
324
- shell: true,
567
+ // `sh` is invoked explicitly, as argv[0], and resolved through $PATH — NOT
568
+ // spawnSync's `shell: true`, which is pinned to a fixed /bin/sh. This is the
569
+ // same $PATH resolution Go has always used via exec.LookPath, and it is
570
+ // required for Windows: the Git Bash `sh.exe` is never at /bin/sh and is
571
+ // only reachable via $PATH.
572
+ const result = spawnSync('sh', ['-c', command], {
325
573
  cwd,
326
574
  encoding: 'utf8',
327
575
  stdio: 'pipe',
328
576
  })
577
+ if (result.error) {
578
+ // The process never started at all (e.g. `sh` missing from $PATH) —
579
+ // distinct from the gate command itself failing inside a running `sh`,
580
+ // which surfaces as a normal exit code (e.g. 127 for "tool not found")
581
+ // and never sets result.error. "Could not measure" is not "measured and
582
+ // failed" (AC3, AC4) — stop immediately: gates after this one were never
583
+ // observed, so they must not appear in evidence or failures.
584
+ return {
585
+ name: 'gates',
586
+ status: 'not_evaluated',
587
+ commands,
588
+ evidence: [],
589
+ failures: [SH_MISSING_MSG],
590
+ }
591
+ }
329
592
  const code = result.status === null || result.status === undefined ? 1 : result.status
330
593
  if (code === 0) evidence.push(`${command}: exit 0`)
331
594
  else failures.push(`${command}: exit ${code}`)
@@ -407,7 +670,7 @@ async function runBarrier(roadmapArg, waveOption, jsonOutput, trustLocalGates) {
407
670
  const cfg = config.load()
408
671
  const resolved = resolveRoadmapFile(cfg, roadmapArg)
409
672
  const content = fs.readFileSync(resolved.path, 'utf8')
410
- const lines = content.split('\n')
673
+ const lines = splitRoadmapLines(content)
411
674
 
412
675
  const startedAt = new Date()
413
676
  const wave = findWave(lines, waveLabel, resolved.basename)
@@ -476,6 +739,10 @@ module.exports.findMLs = findMLs
476
739
  module.exports.mlCompletionStatus = mlCompletionStatus
477
740
  module.exports.mlAcceptanceEvidence = mlAcceptanceEvidence
478
741
  module.exports.parseGates = parseGates
742
+ module.exports.statusIsComplete = statusIsComplete
743
+ module.exports.computeFenceMask = computeFenceMask
744
+ module.exports.splitRoadmapLines = splitRoadmapLines
745
+ module.exports.CRITERIA_HEADER_RE = CRITERIA_HEADER_RE
479
746
  module.exports.roadmapTrustForGates = roadmapTrustForGates
480
747
  module.exports.evalMlsComplete = evalMlsComplete
481
748
  module.exports.evalAcceptanceEvidence = evalAcceptanceEvidence
@@ -4,7 +4,7 @@ const { Command } = require('commander')
4
4
  const fs = require('fs')
5
5
  const path = require('path')
6
6
  const config = require('../config')
7
- const { validate } = require('../validator')
7
+ const { validate, resolveAgentNamespaces, resolveReqFiles } = require('../validator')
8
8
 
9
9
  /**
10
10
  * extractFrontmatterField — extrai valor de campo YAML dentro de bloco --- ... ---.
@@ -85,11 +85,37 @@ function collectEntries(dir, type, state) {
85
85
  return entries
86
86
  }
87
87
 
88
+ /**
89
+ * collectReqEntries — entradas de REQ para o `context`, derivadas do ponto único de leitura
90
+ * (resolveReqFiles). O `state` sai do nome da pasta-pai quando ela é um estado legado; em layout
91
+ * flat ou no canônico req_dir/<agente>/*.md não há estado (invariante D1: REQ não tem estado).
92
+ * @param {object} cfg
93
+ * @returns {Array<{type:string,file:string,status:string,state?:string}>}
94
+ */
95
+ function collectReqEntries(cfg) {
96
+ const STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
97
+ const entries = []
98
+ for (const full of resolveReqFiles(cfg)) {
99
+ let content = ''
100
+ try { content = fs.readFileSync(full, 'utf8') } catch (_) {}
101
+ const parent = path.basename(path.dirname(full))
102
+ const state = STATES.includes(parent) ? parent : ''
103
+ let status = extractFrontmatterField(content, 'status')
104
+ if (!status) status = extractInlineStatus(content)
105
+ if (!status) status = state || 'unknown'
106
+ const entry = { type: 'REQ', file: path.basename(full), status }
107
+ if (state) entry.state = state
108
+ entries.push(entry)
109
+ }
110
+ return entries
111
+ }
112
+
88
113
  /**
89
114
  * getContext — coleta governança e imprime em md ou json.
90
115
  * @param {string} format - 'md' | 'json'
116
+ * @returns {Promise<void>}
91
117
  */
92
- function getContext(format) {
118
+ async function getContext(format) {
93
119
  const cfg = config.load()
94
120
 
95
121
  // ADRs
@@ -98,41 +124,15 @@ function getContext(format) {
98
124
  adrs.push(...collectEntries(adrDir, 'ADR'))
99
125
  }
100
126
 
101
- // REQs
102
- const reqs = []
103
- const reqDir = cfg.reqDir || cfg.req_dir || 'docs/req'
104
- const reqNamespacing = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
105
- if (reqNamespacing === 'by_agent') {
106
- const STATES = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
107
- let agents = cfg.agents || []
108
- if (!agents.length) {
109
- try {
110
- agents = fs.readdirSync(reqDir).filter(f => {
111
- try { return fs.statSync(path.join(reqDir, f)).isDirectory() } catch (_) { return false }
112
- })
113
- } catch (_) {}
114
- }
115
- for (const agent of agents) {
116
- for (const state of STATES) {
117
- reqs.push(...collectEntries(path.join(reqDir, agent, state), 'REQ', state))
118
- }
119
- }
120
- } else {
121
- reqs.push(...collectEntries(reqDir, 'REQ'))
122
- }
127
+ // REQs — pelo PONTO ÚNICO de leitura (ADR-2026-09-03, D3/D4). Antes, `context` montava a própria
128
+ // árvore (flat, ou <agente>/<estado>/ em by_agent) e não enxergava o layout canônico <agente>/*.md.
129
+ const reqs = collectReqEntries(cfg)
123
130
 
124
131
  // Roadmaps
125
132
  const roadmaps = []
126
133
  const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
127
134
  if (cfg.roadmapNamespacing === 'by_agent') {
128
- let agents = cfg.agents || []
129
- if (agents.length === 0) {
130
- try {
131
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
132
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
133
- })
134
- } catch (_) { agents = [] }
135
- }
135
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
136
136
  for (const agent of agents) {
137
137
  for (const state of states) {
138
138
  const dir = path.join(cfg.roadmapDir, agent, state)
@@ -147,7 +147,7 @@ function getContext(format) {
147
147
  }
148
148
 
149
149
  // Validate
150
- const { violations, warnings } = validate()
150
+ const { violations, warnings } = await validate()
151
151
 
152
152
  // Score
153
153
  let score = 0
@@ -202,8 +202,14 @@ module.exports = (function () {
202
202
  cmd
203
203
  .description('Print governance context for LLM consumption')
204
204
  .option('--format <fmt>', 'Output format: md or json', 'md')
205
- .action((opts) => {
206
- getContext(opts.format)
207
- })
205
+ // Retorna a Promise: o bin roda parseAsync() e só assim uma rejeição chega
206
+ // ao .catch(reportFatalError) com exit code 1 em vez de virar unhandled
207
+ // rejection (ML-1A, ROADMAP-2026-09-02-context-do-cli-node-aguarda-validate).
208
+ .action((opts) => getContext(opts.format))
208
209
  return cmd
209
210
  })()
211
+
212
+ // Exposto para teste: o teste de context em by_agent duplicava a lógica de coleta e, por isso,
213
+ // continuava verde mesmo quando a produção mudava (asserção sobre uma CÓPIA). Anexar a função ao
214
+ // Command exportado é o menor caminho para o teste exercitar o código de produção de fato.
215
+ module.exports.collectReqEntries = collectReqEntries