trackfw 7.3.0 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -1
- package/package.json +1 -1
- package/src/commands/adr.js +2 -1
- package/src/commands/barrier.js +293 -26
- package/src/commands/context.js +42 -36
- package/src/commands/discover.js +72 -21
- package/src/commands/doctor.js +23 -3
- package/src/commands/init.js +2 -1
- package/src/commands/integrations.js +5 -4
- package/src/commands/req.js +2 -1
- package/src/commands/thirdparty.js +2 -1
- package/src/commands/update-harness.js +3 -2
- package/src/commands/update.js +127 -9
- package/src/config/index.js +4 -3
- package/src/generators/hooks.js +152 -15
- package/src/generators/init.js +103 -7
- package/src/generators/req.js +15 -53
- package/src/generators/roadmap.js +81 -28
- package/src/homedir.js +30 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +224 -0
- package/src/integrations/index.js +2 -1
- package/src/integrations/manager.js +10 -2
- package/src/integrations/render.js +39 -8
- package/src/integrations/scaffold_doctor.js +29 -0
- package/src/lib/pathfmt.js +43 -0
- package/src/lib/update-engine.js +15 -2
- package/src/serve/api_board.js +11 -10
- package/src/serve/api_chain.js +43 -20
- package/src/serve/api_metrics.js +2 -8
- package/src/validator/index.js +914 -177
package/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
|
|
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
|
+
"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",
|
package/src/commands/adr.js
CHANGED
|
@@ -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(
|
|
23
|
+
return path.join(homedir(), '.trackfw', 'adr')
|
|
23
24
|
}
|
|
24
25
|
if (scope === 'project') {
|
|
25
26
|
return require('../config').load().adrDirs[0]
|
package/src/commands/barrier.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
126
|
-
//
|
|
127
|
-
// when no `**Status:**` line exists at all.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
375
|
+
if (fenced[j]) continue
|
|
376
|
+
if (/^\*\*/.test(mlLines[j])) { blockEnd = j; break }
|
|
151
377
|
}
|
|
152
378
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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]
|
|
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
|
-
|
|
324
|
-
|
|
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
|
|
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
|
package/src/commands/context.js
CHANGED
|
@@ -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
|
-
|
|
103
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|