trackfw 7.2.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,56 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const config = require('../config')
5
5
  const { localDateISO } = require('./date')
6
- const { resolveReqFiles } = require('../validator/index.js')
6
+ const { resolveReqFiles, resolveAgentNamespaces } = require('../validator/index.js')
7
+ const { normalizeRefSeparator: pathfmtNormalizeRefSeparator } = require('../lib/pathfmt')
8
+ const { normalizeCRLF } = require('../integrations/render')
9
+
10
+ // STATUS_LEGEND teaches the vocabulary the `barrier` parser accepts for "**Status:**"
11
+ // (AC11, ADR decision 5): the canonical form the template now writes (⬜ Pendente) plus the
12
+ // other three states. Placed once, right before the first wave, so it is close to the first
13
+ // place a "**Status:**" line appears — not repeated per-ML (would clutter) and not left for
14
+ // the end (nobody reads that far). Byte-identical across the 3 CLIs
15
+ // (gate: scripts/check-artifact-parity.sh).
16
+ const STATUS_LEGEND = `## Status Legend
17
+ ⬜ Pendente · 🔄 Em andamento · ✅ Concluído · ❌ Bloqueado
18
+
19
+ `
20
+
21
+ // wave0Block — "## Wave 0 — Threat Model" section prepended to every generated roadmap, before
22
+ // the first implementation wave (AC1, AC12). Byte-identical to internal/generators/roadmap.go's
23
+ // wave0Block/wave0GateFence and to pypi/trackfw/generators/roadmap.py's WAVE0_BLOCK (gate:
24
+ // scripts/check-artifact-parity.sh).
25
+ //
26
+ // The gate command (`exit 1`) is fixed, literal and never interpolated with any REQ title, slug
27
+ // or user-controlled string — see the Go generator's comment for the full rationale (AC13,
28
+ // docs/cli-parity.md § "trackfw barrier"). It intentionally fails closed until ML-0A replaces it
29
+ // with a real, project-specific check.
30
+ //
31
+ // The ML is always ML-0A, never ML-1A — newRoadmapFromReq labels MLs derived from REQ acceptance
32
+ // criteria "ML-1A", "ML-1B", ... starting at the first criterion.
33
+ const WAVE0_BLOCK = STATUS_LEGEND + `## Wave 0 — Threat Model
34
+ > Dependencies: none. Blocks all implementation.
35
+
36
+ ### ML-0A — Threat model for this roadmap
37
+ **Status:** ⬜ Pendente
38
+ **Files affected:**
39
+ **Actions:**
40
+ 1. Enumeration completeness — is the list of surfaces in this roadmap complete? Name what is missing, or show the list is closed. Do not limit the search to the files already named by the REQ — before declaring the list closed, search the repository for other places that emit the same artifact or the same pattern (for example, grep for the literal the final artifact contains).
41
+ 2. Threat model — who empties this Wave 0 without breaking any written rule, and how?
42
+ 3. Falsification targets in both directions — for each surface, what breaks when the behavior regresses, and what breaks when it regresses the opposite way?
43
+ 4. Declared residual — what this design accepts not covering.
44
+ **Acceptance criteria:**
45
+ - [ ] The four sections above answered with evidence, not a one-line assertion
46
+ - [ ] No implementation line written for this ML
47
+
48
+ **Gates da wave:**
49
+ \`\`\`bash
50
+ # Wave 0 gate — replace this placeholder with a project-specific check before
51
+ # marking ML-0A done. Do not remove the gate; replace its command (AC13).
52
+ exit 1 # placeholder gate fails closed until ML-0A replaces it — see docs/cli-parity.md
53
+ \`\`\`
54
+
55
+ `
7
56
 
8
57
  const VALID_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
9
58
  const STATE_ORDER = ['analyzing', 'wip', 'backlog', 'blocked', 'done', 'abandoned']
@@ -41,14 +90,7 @@ function listRoadmaps() {
41
90
  let found = false
42
91
 
43
92
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
44
- let agents = cfg.agents || []
45
- if (agents.length === 0) {
46
- try {
47
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
48
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
49
- })
50
- } catch (_) { agents = [] }
51
- }
93
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
52
94
  for (const agent of agents) {
53
95
  for (const state of STATE_ORDER) {
54
96
  const dir = cfg.roadmapDir + '/' + agent + '/' + state
@@ -130,9 +172,14 @@ function showRoadmap(name) {
130
172
  * antes do primeiro "## " heading é atualizada.
131
173
  *
132
174
  * Retorna { content: string, changed: boolean }.
175
+ *
176
+ * D1/D3 (ADR-2026-09-04-parser-de-frontmatter-tolera-crlf-na-fronteira-de-entrada, ML-5B):
177
+ * normaliza CRLF -> LF via normalizeCRLF (npm/src/integrations/render.js) na própria entrada,
178
+ * antes de qualquer casamento de "---\n" — reaproveita a única implementação Node, sem criar
179
+ * uma segunda cópia.
133
180
  */
134
181
  function rewriteRoadmapStatus(source, state) {
135
- const s = String(source)
182
+ const s = normalizeCRLF(String(source))
136
183
  if (!s.startsWith('---\n')) return { content: s, changed: false }
137
184
  const end = s.indexOf('\n---', 4)
138
185
  if (end < 0) return { content: s, changed: false }
@@ -253,8 +300,29 @@ function moveRoadmap(name, state) {
253
300
  appendTransitionLog(logBasename, fromState, state)
254
301
  console.log(`✓ moved ${basename} → ${targetDir}`)
255
302
 
256
- // Sincroniza o roadmap: das REQs que apontem para este roadmap.
257
- syncReqReferences(basename, dst, cfg)
303
+ // Sincroniza o roadmap: das REQs que apontem para este roadmap. O valor escrito no
304
+ // frontmatter da REQ pareada é dado portável, nunca separador nativo — normaliza antes de
305
+ // sincronizar (dst continua nativo acima, para rename/read/write no filesystem).
306
+ syncReqReferences(basename, normalizeRefSeparator(dst), cfg)
307
+ }
308
+
309
+ /**
310
+ * normalizeRefSeparator — normaliza um valor já extraído (não o buffer inteiro do arquivo)
311
+ * para o separador portável (/) antes de ele ser escrito ou comparado como referência dentro
312
+ * de conteúdo versionado. Substituição incondicional: em POSIX, path.join nunca produz "\",
313
+ * então isto só atua sobre um valor herdado de um commit feito no Windows — exatamente o
314
+ * defeito que esta função existe para curar
315
+ * (docs/seguranca/2026-09-01-modelo-de-ameaca-do-separador-em-artefato.md).
316
+ * @param {string} p
317
+ * @returns {string}
318
+ */
319
+ function normalizeRefSeparator(p) {
320
+ // Delegação ao ponto único do runtime (ADR-2026-09-04, D3). A função continua
321
+ // exportada daqui porque consumidores e testes já a importam por este caminho;
322
+ // a IMPLEMENTAÇÃO agora é uma só, em npm/src/lib/pathfmt.js, para que o CLI Node
323
+ // não tenha duas noções de formato — a origem documentada das ocorrências de
324
+ // gerador e verificador discordando.
325
+ return pathfmtNormalizeRefSeparator(p)
258
326
  }
259
327
 
260
328
  /**
@@ -306,11 +374,30 @@ function extractFrontmatterRoadmap(content) {
306
374
  * rewriteReqRoadmapRef — reescreve o campo `roadmap:` no frontmatter e a linha `Roadmap:` no
307
375
  * corpo de uma REQ, substituindo oldRef por newRef.
308
376
  * Preserva toda formatação existente (aspas, backticks) via substituição literal de string.
377
+ *
378
+ * D2/D3 (ADR-2026-09-04-parser-de-frontmatter-tolera-crlf-na-fronteira-de-entrada, ML-5D):
379
+ * normaliza CRLF -> LF via normalizeCRLF na própria entrada, antes de qualquer split por "\n"
380
+ * — mesmo ponto único Node reaproveitado por rewriteRoadmapStatus/rewriteREQStatus. Sem isto,
381
+ * um REQ gravado com CRLF (Windows) tinha as linhas RESCRITAS saindo em LF (via .replace) e as
382
+ * linhas NÃO tocadas saindo com o "\r" original ainda embutido no elemento que o split
383
+ * preservava — join produzia um arquivo com terminador MISTO. Medido com o binário real
384
+ * (ML-5C): 10 bytes "\r" vazados na REQ reescrita por `roadmap move`.
385
+ *
386
+ * 🔴 Se nenhuma linha REALMENTE muda (comparando o texto produzido com a linha original, não
387
+ * apenas "houve match de oldRef"), devolve o `content` ORIGINAL (não normalizado), não o
388
+ * resultado da normalização. Isto importa porque o chamador da guarda de idempotência
389
+ * (syncReqReferences) invoca esta função com oldRef === newRef — um "replace" sem-efeito, que
390
+ * bate no ramo de match mas não deve contar como mudança. O único chamador detecta "nada a
391
+ * escrever" comparando `updated === content` por igualdade de string, não por um bool de
392
+ * "changed" explícito — normalizar incondicionalmente quebraria a cardinalidade "já correta →
393
+ * nenhuma escrita" (contrato pinado em docs/cli-parity.md) para qualquer REQ com CRLF em outro
394
+ * trecho do arquivo, mesmo quando o campo roadmap: já está correto.
309
395
  */
310
396
  function rewriteReqRoadmapRef(content, oldRef, newRef) {
311
- const lines = content.split('\n')
397
+ const lines = normalizeCRLF(String(content)).split('\n')
312
398
  let inFm = false
313
399
  let fmDone = false
400
+ let changed = false
314
401
  const result = []
315
402
 
316
403
  for (let i = 0; i < lines.length; i++) {
@@ -320,7 +407,9 @@ function rewriteReqRoadmapRef(content, oldRef, newRef) {
320
407
  if (inFm) {
321
408
  const idx = line.indexOf(':')
322
409
  if (idx !== -1 && line.slice(0, idx).trim() === 'roadmap' && line.includes(oldRef)) {
323
- result.push(line.replace(oldRef, newRef))
410
+ const newLine = line.replace(oldRef, newRef)
411
+ if (newLine !== line) changed = true
412
+ result.push(newLine)
324
413
  continue
325
414
  }
326
415
  result.push(line)
@@ -329,13 +418,16 @@ function rewriteReqRoadmapRef(content, oldRef, newRef) {
329
418
  if (fmDone) {
330
419
  // No corpo: reescreve linhas `Roadmap:` que contenham oldRef
331
420
  if (line.trim().toLowerCase().startsWith('roadmap:') && line.includes(oldRef)) {
332
- result.push(line.replace(oldRef, newRef))
421
+ const newLine = line.replace(oldRef, newRef)
422
+ if (newLine !== line) changed = true
423
+ result.push(newLine)
333
424
  continue
334
425
  }
335
426
  }
336
427
  result.push(line)
337
428
  }
338
429
 
430
+ if (!changed) return String(content)
339
431
  return result.join('\n')
340
432
  }
341
433
 
@@ -374,7 +466,10 @@ function syncReqReferences(movedBasename, newRoadmapPath, cfg) {
374
466
 
375
467
  const currentRef = extractFrontmatterRoadmap(content)
376
468
  if (!currentRef) continue
377
- if (path.basename(currentRef) !== movedBasename) continue // aponta para outro roadmap
469
+ // path.basename sobre um valor sujo com "\" (gravado no Windows antes do fix de escrita)
470
+ // não separa nada em POSIX — normaliza antes de comparar, senão uma REQ já suja nunca é
471
+ // curada por um roadmap move subsequente.
472
+ if (path.basename(normalizeRefSeparator(currentRef)) !== movedBasename) continue // aponta para outro roadmap
378
473
  if (currentRef === newRoadmapPath) {
379
474
  // Guarda rápida: frontmatter já correto — confirma idempotência com reescrita estrutural
380
475
  const updated = rewriteReqRoadmapRef(content, currentRef, newRoadmapPath)
@@ -400,6 +495,14 @@ function syncReqReferences(movedBasename, newRoadmapPath, cfg) {
400
495
  * Em modo by_agent, usa o primeiro agente configurado.
401
496
  */
402
497
  function newRoadmap(title, reqPath) {
498
+ // AC1/AC2: o título é dado de uma linha — newline e CR são entrada malformada.
499
+ // Mensagem byte-idêntica nos 3 CLIs (docs/cli-parity.md).
500
+ if (/[\n\r]/.test(title)) {
501
+ console.error('Error: roadmap title must be a single line: newline and carriage return are not allowed')
502
+ process.exitCode = 1
503
+ return
504
+ }
505
+
403
506
  const cfg = config.load()
404
507
  const date = localDateISO()
405
508
  const slug = toSlug(title)
@@ -441,11 +544,11 @@ REQ: ${reqPath || ''}
441
544
  - [ ]
442
545
  - [ ]
443
546
 
444
- ## Wave 1 — <name> (parallel MLs)
547
+ ${WAVE0_BLOCK}## Wave 1 — <name> (parallel MLs)
445
548
  > Dependencies: none
446
549
 
447
550
  ### ML-1A — ${title}
448
- **Status:** pending
551
+ **Status:** ⬜ Pendente
449
552
  **Files affected:**
450
553
  **Actions:**
451
554
  **Acceptance criteria:**
@@ -476,6 +579,13 @@ function newRoadmapFromReq(reqPath) {
476
579
  const basename = path.basename(reqPath)
477
580
  const title = parsedTitle || basename.replace(/\.md$/, '').replace(/^REQ-/, '')
478
581
 
582
+ // AC1: o título lido da REQ também pode conter newline forjado — rejeitar cedo.
583
+ if (/[\n\r]/.test(title)) {
584
+ console.error('Error: roadmap title must be a single line: newline and carriage return are not allowed')
585
+ process.exitCode = 1
586
+ return
587
+ }
588
+
479
589
  const cfg = config.load()
480
590
  const date = localDateISO()
481
591
  const slug = toSlug(title)
@@ -501,7 +611,7 @@ function newRoadmapFromReq(reqPath) {
501
611
  const mlLabel = `ML-1${String.fromCharCode(65 + i)}`
502
612
  const crit = criteria[i]
503
613
  mlLines.push(`\n### ${mlLabel} — ${crit}`)
504
- mlLines.push('**Status:** pending')
614
+ mlLines.push('**Status:** ⬜ Pendente')
505
615
  mlLines.push('**Files affected:**')
506
616
  mlLines.push('**Actions:**')
507
617
  mlLines.push('**Acceptance criteria:**')
@@ -509,7 +619,7 @@ function newRoadmapFromReq(reqPath) {
509
619
  mlLines.push('- [ ] build passes')
510
620
  mlLines.push('- [ ] tests green')
511
621
  }
512
- const mlSection = mlLines.join('\n')
622
+ const mlSection = WAVE0_BLOCK + mlLines.join('\n')
513
623
 
514
624
  const adrRef = linkedADR ? `\nADR: ${linkedADR}` : ''
515
625
 
@@ -604,14 +714,7 @@ function findRoadmapMatches(name) {
604
714
  const nameLower = name.toLowerCase()
605
715
 
606
716
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
607
- let agents = cfg.agents || []
608
- if (agents.length === 0) {
609
- try {
610
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
611
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
612
- })
613
- } catch (_) { agents = ['default'] }
614
- }
717
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
615
718
  for (const agent of agents) {
616
719
  for (const state of STATE_ORDER) {
617
720
  const dir = cfg.roadmapDir + '/' + agent + '/' + state
@@ -661,6 +764,7 @@ module.exports = {
661
764
  extractFrontmatterRoadmap,
662
765
  rewriteReqRoadmapRef,
663
766
  syncReqReferences,
767
+ normalizeRefSeparator,
664
768
  newRoadmap,
665
769
  newRoadmapFromReq,
666
770
  stateDir,
package/src/homedir.js ADDED
@@ -0,0 +1,30 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * homedir.js — resolves the user's home directory consistently across platforms.
5
+ *
6
+ * Mirrors internal/homedir/homedir.go (Go, canonical source of truth) and
7
+ * pypi/trackfw/homedir.py (Python). See that file's doc comment for the full
8
+ * rationale.
9
+ *
10
+ * Why it exists: os.homedir() reads $HOME on Linux and macOS, but %USERPROFILE%
11
+ * on Windows. Tests and gates isolate the home directory with HOME=<tempdir>,
12
+ * which on Windows isolates nothing — the process keeps reading and writing the
13
+ * developer's real home.
14
+ *
15
+ * homedir() makes Windows behave like the other platforms: $HOME first,
16
+ * os.homedir() as the fallback. Where $HOME is unset nothing changes.
17
+ *
18
+ * The empty string does NOT count as set: HOME="" would resolve to "" and every
19
+ * derived path would silently become relative.
20
+ */
21
+
22
+ const os = require('os')
23
+
24
+ function homedir() {
25
+ const fromEnv = process.env.HOME
26
+ if (fromEnv) return fromEnv
27
+ return os.homedir()
28
+ }
29
+
30
+ module.exports = { homedir }
@@ -24,16 +24,18 @@ Append an entry to `docs/agents-working-context.md` when you start and when you
24
24
  Before investigating a bug or unexpected behavior, read `vault/notes/index.md` when it exists and open the related notes. After reaching a non-obvious root cause, write a note and link it in the index. Rule of thumb: if another agent would lose more than ten minutes tomorrow without the note, the note must exist.
25
25
 
26
26
  ## Git authority
27
- `trackfw_architect` is the **only** role with Git authority in this project. No other agent creates branches, commits, pushes, checks out, merges or rebases — every specialist hands its work back to `trackfw_architect` uncommitted. This role: creates the branch with `trackfw branch new <type>/<slug>`, which validates a matching roadmap already sits in `wip/` or `done/` before running `git checkout -b` and blocks with governance orientation if none is found; falls back to a raw `git checkout -b` only if `trackfw branch new` is an unknown command (binary predating v6.4.0 or missing from PATH) or fails for a reason other than the expected missing-roadmap block, while still requiring REQ + roadmap in `wip` first; audits the full diff produced by specialists against the assigned scope; performs every commit, including orchestration artifacts (ADRs, REQs, roadmaps, vault notes, the working context file) and the product code produced by specialists once audited; pushes to the working branch; and suggests opening a pull request, opening it only when the user explicitly asks. Never merge.
27
+ `trackfw_architect` is the **only** role with Git authority in this project. No other agent creates branches, commits, pushes, checks out, merges or rebases — every specialist hands its work back to `trackfw_architect` uncommitted. This role: creates the branch with `trackfw branch new <type>/<slug>`, which validates a matching roadmap already sits in `wip/` or `done/` before running `git checkout -b` and blocks with governance orientation if none is found; falls back to a raw `git checkout -b` only if `trackfw branch new` is an unknown command (binary predating v6.4.0 or missing from PATH) or fails for a reason other than the expected missing-roadmap block, while still requiring REQ + roadmap in `wip` first; audits the full diff produced by specialists against the assigned scope; performs every commit, including orchestration artifacts (ADRs, REQs, roadmaps, vault notes, the working context file) and the product code produced by specialists once audited; pushes commits already created to the working branch with `trackfw push`; and suggests opening a pull request, opening it only when the user explicitly asks. Never merge.
28
+
29
+ Three distinct commands, never interchangeable: `trackfw commit` commits staged changes on the working branch; `trackfw push` pushes commits already created — use it instead of a raw `git push`, which the guard intercepts and redirects here; `trackfw ship` composes commit + push + PR into one governed step. Prefer the raw `git commit`/`git push` only when `trackfw commit`/`trackfw push` are unknown commands (binary predating the version that introduced them) or fail for a reason unrelated to governance.
28
30
 
29
31
  ## Barrier protocol
30
- Before releasing the next wave: invoke the `code-quality` and `security` roles whenever the change warrants their review (new code paths, dependencies, permissions, secrets, parsers, or attack surface); block the next wave on any failed check and dispatch a corrective microbatch to the owning role instead of proceeding; audit the diff and re-run the wave's gates before performing any commit. A green `trackfw barrier` result from the CLI is necessary but not sufficient — it does not replace the specialized code-quality/security review or the manual diff audit.
32
+ Before releasing the next wave: confirm Wave 0 (threat model) of the current roadmap is audited — every MB in it, before dispatching any implementation wave; invoke the `code-quality` and `security` roles whenever the change warrants their review (new code paths, dependencies, permissions, secrets, parsers, or attack surface); block the next wave on any failed check and dispatch a corrective microbatch to the owning role instead of proceeding; audit the diff and re-run the wave's gates before performing any commit. A green `trackfw barrier` result from the CLI is necessary but not sufficient — it does not replace the specialized code-quality/security review or the manual diff audit.
31
33
 
32
34
  ## Parallelization
33
35
  Analyze real dependencies between microbatches before assigning work. Microbatches touching disjoint files run in parallel; microbatches sharing any file — including generated trees, build outputs and the git index — become sequential, and the reason is documented. Put an explicit barrier between waves. Every handoff prompt must be self-contained: exact files, exact values, exact commands. Never let two agents edit the same file at the same time.
34
36
 
35
37
  ## Workflow
36
- Analyze the codebase and requirements; record material decisions in an ADR; create the REQ with an explicit negative scope; produce a roadmap of waves and microbatches with measurable acceptance criteria; create the branch; commit the governance artifacts before any handoff; dispatch the wave; audit each microbatch against its acceptance criteria; update the roadmap; open the pull request only on request.
38
+ Analyze the codebase and requirements; record material decisions in an ADR; create the REQ with an explicit negative scope; produce a roadmap of waves and microbatches with measurable acceptance criteria, starting with a Wave 0 threat model (`security` role, `trackfw barrier <roadmap> --wave 0`) that MUST be audited before any implementation wave is dispatched; create the branch; commit the governance artifacts before any handoff; dispatch Wave 0 first, then the implementation waves; audit each microbatch against its acceptance criteria; update the roadmap; open the pull request only on request.
37
39
 
38
40
  ## Dispatch contract
39
41
  Naming a specialist in prose or in a roadmap's `squad:` field is documentation, not delegation — it does not route the Agent tool call by itself. Every dispatch to a specialist MUST pass the Agent tool's `subagent_type` parameter explicitly; omitting it silently falls back to the generic `general-purpose` agent, which has none of the intended specialist's domain instructions. The correct `subagent_type` value is the `name:` from the frontmatter of that role's installed agent file — always `<slug>-tf`, where `<slug>` depends on the identity the user configured (Greek, Norse, custom, or otherwise); never assume a fixed name. If the exact value is not already known, read the installed agent file for that role before dispatching instead of guessing from the name used in prose. Confirm `subagent_type` is present and correct before every dispatch call.
@@ -42,6 +42,9 @@ This role never executes Git operations — no `branch`, `commit`, `push`, `chec
42
42
  ## Definition of done
43
43
  Green build and tests do not close a microbatch. It is done when the roadmap reflects the new status and the governance artifacts sit in the correct state folder. Leaving an artifact in the wrong folder is the failure the gate exists to catch.
44
44
 
45
+ ## Wave 0 deliverable
46
+ When dispatched for a roadmap's `## Wave 0 — Threat Model`, produce a written threat model with exactly four sections, before any implementation line is written: **enumeration completeness** (is the list of surfaces in the roadmap complete? name what is missing, or show the list is closed); **threat model** (who empties this Wave 0 without breaking any written rule, and how — the adversary here is the rushed implementer and the optimistic architect, not an external attacker); **falsification targets in both directions** (for each surface: where the sabotage enters, which gate should catch it, in which direction — false negative and false positive); **declared residual** (what this design accepts not covering, said plainly). Mark each of the four sections done with evidence, not a one-line assertion — `trackfw barrier <roadmap> --wave 0` checks that the ML is `✅` and its acceptance criteria are `[x]`, never the quality of the analysis; a shallow Wave 0 can still pass those two checks, so the `trackfw_architect` audit of the artifact's content is what actually catches it.
47
+
45
48
  ## Mission
46
49
  Perform evidence-backed threat analysis. Prioritize concrete exploit paths and mitigations, preserve authentication and least privilege, and never expose secrets or weaken controls to make a test pass.
47
50
 
Binary file
@@ -0,0 +1,224 @@
1
+ 'use strict'
2
+
3
+ // Mirrors internal/commands/doctor_remote.go (Go, canonical source of truth for wording/
4
+ // semantics) — see that file's doc comments for the full rationale. Implements the --remote
5
+ // modality of `trackfw doctor` (ADR-2026-09-02, ML-3A): GitHub branch protection
6
+ // (required_status_checks, enforce_admins) plus local core.hooksPath neutralization. Never
7
+ // runs unless explicitly requested via --remote.
8
+
9
+ const { spawnSync } = require('child_process')
10
+ const {
11
+ REQUIRED_STATUS_CHECKS_MISSING,
12
+ ENFORCE_ADMINS_DISABLED,
13
+ HOOKS_PATH_NEUTRALIZED,
14
+ NOT_EVALUATED,
15
+ } = require('./doctor')
16
+ const { resolve: resolveForge } = require('../forge/resolve')
17
+ const { forgeAdapter } = require('../forge/adapter')
18
+
19
+ // defaultExecGit runs a git command and returns { stdout, error } — stdout trimmed, error null
20
+ // on success. Mirrors release/runner.js's defaultExecGit.
21
+ function defaultExecGit(args) {
22
+ const result = spawnSync('git', args, { encoding: 'utf8' })
23
+ if (result.status !== 0) {
24
+ const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
25
+ return { stdout: '', error: new Error(msg) }
26
+ }
27
+ return { stdout: (result.stdout || '').trim(), error: null }
28
+ }
29
+
30
+ // defaultExecForgeAPI runs a forge CLI command (gh api ...) and returns { stdout, error }.
31
+ // Mirrors release/runner.js's defaultExecForgeAPI.
32
+ function defaultExecForgeAPI(name, args, stdin) {
33
+ const result = spawnSync(name, args, { input: stdin || '', encoding: 'utf8' })
34
+ if (result.status !== 0) {
35
+ const msg = (result.stderr || '').trim() || `${name} ${args.join(' ')} exited with ${result.status}`
36
+ return { stdout: (result.stdout || '').trim(), error: new Error(msg) }
37
+ }
38
+ return { stdout: (result.stdout || '').trim(), error: null }
39
+ }
40
+
41
+ // Values that discard every git hook invocation on each OS git supports as a hooksPath target.
42
+ // Anything else (including unset) is left alone — a custom husky/lefthook directory is
43
+ // legitimate and must never be flagged.
44
+ const NEUTRALIZED_HOOKS_PATH_VALUES = new Set(['/dev/null', 'NUL'])
45
+
46
+ /**
47
+ * runDoctorRemote implements the --remote modality: every branch either produces a genuine
48
+ * finding (evaluated, and wrong) or a NOT_EVALUATED finding (could not evaluate) — never
49
+ * silence, which would read as "ok" to a report that treats an empty finding list as a clean
50
+ * bill of health.
51
+ *
52
+ * @param {object} deps
53
+ * @param {function} [deps.execGit] - (args: string[]) => { stdout, error }
54
+ * @param {function} [deps.execForgeAPI] - (name, args, stdin) => { stdout, error }
55
+ * @param {function} [deps.availFn] - (name: string) => boolean
56
+ * @param {string} [deps.configForge] - forge: field from trackfw.yaml
57
+ * @param {string} [deps.repoDir] - repo root, for CI file detection during forge resolution
58
+ * @returns {Array<object>} findings
59
+ */
60
+ function runDoctorRemote(deps = {}) {
61
+ const execGit = deps.execGit || defaultExecGit
62
+ const execForgeAPI = deps.execForgeAPI || defaultExecForgeAPI
63
+ const availFn = deps.availFn
64
+ const configForge = deps.configForge || ''
65
+ const repoDir = deps.repoDir !== undefined ? deps.repoDir : ''
66
+
67
+ const findings = []
68
+
69
+ // ── Local check: core.hooksPath neutralized (no network needed) ──────────────────────
70
+ const hooksPathResp = execGit(['config', '--get', 'core.hooksPath'])
71
+ if (!hooksPathResp.error && NEUTRALIZED_HOOKS_PATH_VALUES.has(hooksPathResp.stdout.trim())) {
72
+ findings.push({
73
+ finding: HOOKS_PATH_NEUTRALIZED,
74
+ destination: 'git:core.hooksPath',
75
+ remedy: `git config --unset core.hooksPath # currently "${hooksPathResp.stdout.trim()}" discards every hook invocation; unset to restore .git/hooks, or point it at your real hooks directory`,
76
+ })
77
+ }
78
+
79
+ // ── Forge resolution: only GitHub is evaluated; every other forge is not applicable ──
80
+ const remoteURLResp = execGit(['remote', 'get-url', 'origin'])
81
+ const remoteURL = remoteURLResp.error ? '' : remoteURLResp.stdout.trim()
82
+ let resolution
83
+ try {
84
+ resolution = resolveForge({ configForge, remoteURL, repoDir })
85
+ } catch (e) {
86
+ findings.push({
87
+ finding: NOT_EVALUATED,
88
+ destination: 'branch-protection',
89
+ remedy: `not applicable: branch protection is checked only on GitHub, and this repository's forge resolved to "unknown". Not a failure — no action needed unless this repository is actually hosted on GitHub, in which case set forge: github in trackfw.yaml or add a github.com origin remote.`,
90
+ })
91
+ return findings
92
+ }
93
+ if (resolution.forge !== 'github') {
94
+ findings.push({
95
+ finding: NOT_EVALUATED,
96
+ destination: 'branch-protection',
97
+ remedy: `not applicable: branch protection is checked only on GitHub, and this repository's forge resolved to "${resolution.forge}". Not a failure — no action needed unless this repository is actually hosted on GitHub, in which case set forge: github in trackfw.yaml or add a github.com origin remote.`,
98
+ })
99
+ return findings
100
+ }
101
+
102
+ // ── gh CLI availability ───────────────────────────────────────────────────────────────
103
+ const adapter = forgeAdapter('github', availFn)
104
+ if (!adapter.available) {
105
+ findings.push({
106
+ finding: NOT_EVALUATED,
107
+ destination: 'branch-protection',
108
+ remedy: 'install the GitHub CLI (gh) to evaluate branch protection remotely: https://cli.github.com, then retry with --remote',
109
+ })
110
+ return findings
111
+ }
112
+
113
+ // ── Credential presence: distinct from credential SCOPE below (ADR-2026-09-02) ────────
114
+ const authResp = execForgeAPI('gh', ['auth', 'status'], '')
115
+ if (authResp.error) {
116
+ findings.push({
117
+ finding: NOT_EVALUATED,
118
+ destination: 'branch-protection',
119
+ remedy: 'GitHub CLI has no credential — authenticate first: gh auth login (or set GITHUB_TOKEN/GH_TOKEN), then retry with --remote',
120
+ })
121
+ return findings
122
+ }
123
+
124
+ // ── Repository info: default branch + whether this credential has admin access ────────
125
+ const repoInfoResp = execForgeAPI('gh', ['api', 'repos/{owner}/{repo}'], '')
126
+ if (repoInfoResp.error) {
127
+ findings.push({
128
+ finding: NOT_EVALUATED,
129
+ destination: 'branch-protection',
130
+ remedy: `could not reach the GitHub API to resolve this repository: ${repoInfoResp.error.message}. Check network connectivity and retry with --remote`,
131
+ })
132
+ return findings
133
+ }
134
+ let repoInfo
135
+ try {
136
+ repoInfo = JSON.parse(repoInfoResp.stdout)
137
+ } catch (e) {
138
+ repoInfo = null
139
+ }
140
+ if (!repoInfo || !repoInfo.default_branch) {
141
+ findings.push({
142
+ finding: NOT_EVALUATED,
143
+ destination: 'branch-protection',
144
+ remedy: `could not parse the repository response from the GitHub API: ${repoInfoResp.stdout}. Retry with --remote`,
145
+ })
146
+ return findings
147
+ }
148
+
149
+ // Credential SCOPE: reading branch protection requires admin access to the repository.
150
+ // Distinct remedy from "no credential" above — one is fixed by authenticating, this one by
151
+ // being granted admin access (or using a token for a repo you administer).
152
+ const isAdmin = Boolean(repoInfo.permissions && repoInfo.permissions.admin)
153
+ if (!isAdmin) {
154
+ findings.push({
155
+ finding: NOT_EVALUATED,
156
+ destination: 'branch-protection',
157
+ remedy: 'the authenticated GitHub credential lacks admin access to this repository — reading branch protection requires it. Ask a repository admin to grant access, or authenticate as an account that has it, then retry with --remote',
158
+ })
159
+ return findings
160
+ }
161
+
162
+ // ── Branch protection itself ────────────────────────────────────────────────────────────
163
+ const defaultBranch = repoInfo.default_branch
164
+ const protectionResp = execForgeAPI('gh', ['api', `repos/{owner}/{repo}/branches/${defaultBranch}/protection`], '')
165
+ if (protectionResp.error) {
166
+ if (protectionResp.error.message.includes('(HTTP 404)')) {
167
+ // Evaluated (admin confirmed above): the branch genuinely has no protection at all,
168
+ // which means both checks fail — GitHub does not return the two settings separately
169
+ // when there is no rule to read them from.
170
+ findings.push({
171
+ finding: REQUIRED_STATUS_CHECKS_MISSING,
172
+ destination: `branch-protection:${defaultBranch}:required_status_checks`,
173
+ remedy: `configure required status checks: GitHub repo Settings > Branches > Branch protection rules > ${defaultBranch} > Require status checks to pass before merging`,
174
+ })
175
+ findings.push({
176
+ finding: ENFORCE_ADMINS_DISABLED,
177
+ destination: `branch-protection:${defaultBranch}:enforce_admins`,
178
+ remedy: `gh api repos/{owner}/{repo}/branches/${defaultBranch}/protection/enforce_admins --method POST`,
179
+ })
180
+ return findings
181
+ }
182
+ findings.push({
183
+ finding: NOT_EVALUATED,
184
+ destination: `branch-protection:${defaultBranch}`,
185
+ remedy: `could not read branch protection from the GitHub API: ${protectionResp.error.message}. This may be transient (rate limit, network) — retry with --remote`,
186
+ })
187
+ return findings
188
+ }
189
+
190
+ let protection
191
+ try {
192
+ protection = JSON.parse(protectionResp.stdout)
193
+ } catch (e) {
194
+ findings.push({
195
+ finding: NOT_EVALUATED,
196
+ destination: `branch-protection:${defaultBranch}`,
197
+ remedy: `could not parse the branch protection response from the GitHub API: ${protectionResp.stdout}. Retry with --remote`,
198
+ })
199
+ return findings
200
+ }
201
+
202
+ const rsc = protection.required_status_checks
203
+ const contexts = (rsc && rsc.contexts) || []
204
+ const checks = (rsc && rsc.checks) || []
205
+ if (!rsc || (contexts.length === 0 && checks.length === 0)) {
206
+ findings.push({
207
+ finding: REQUIRED_STATUS_CHECKS_MISSING,
208
+ destination: `branch-protection:${defaultBranch}:required_status_checks`,
209
+ remedy: `configure required status checks: GitHub repo Settings > Branches > Branch protection rules > ${defaultBranch} > Require status checks to pass before merging`,
210
+ })
211
+ }
212
+ const enforceAdmins = protection.enforce_admins
213
+ if (!enforceAdmins || !enforceAdmins.enabled) {
214
+ findings.push({
215
+ finding: ENFORCE_ADMINS_DISABLED,
216
+ destination: `branch-protection:${defaultBranch}:enforce_admins`,
217
+ remedy: `gh api repos/{owner}/{repo}/branches/${defaultBranch}/protection/enforce_admins --method POST`,
218
+ })
219
+ }
220
+
221
+ return findings
222
+ }
223
+
224
+ module.exports = { runDoctorRemote, defaultExecGit, defaultExecForgeAPI }
@@ -9,6 +9,7 @@ const { legacyHashes } = require('./legacy')
9
9
  const identityStore = require('../identity')
10
10
  const { injectRulesForTool } = require('../generators/init')
11
11
  const { applyThirdPartyReferences } = require('../thirdparty/references')
12
+ const { homedir } = require('../homedir')
12
13
 
13
14
  function parseSurfaces(values = []) {
14
15
  const result = {}
@@ -109,7 +110,7 @@ function execute(kind, operation, options = {}, roots = {}) {
109
110
  // reverteria silenciosamente os nomes customizados dos agentes para os
110
111
  // defaults neutros (espelha internal/commands/integrations_flags.go:
111
112
  // executeIntegrationMutation/executeIntegrationList).
112
- const homeRoot = roots.homeRoot || os.homedir()
113
+ const homeRoot = roots.homeRoot || homedir()
113
114
  const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
114
115
  const manager = new IntegrationManager(roots, { onSkip: options.onSkip })
115
116
  // D5/D9: manager.roots.project lets buildPlans reproduce any persisted
@@ -7,6 +7,8 @@ const path = require('node:path')
7
7
 
8
8
  const { frontmatterName } = require('./render')
9
9
  const { tildeify } = require('../lib/update-engine')
10
+ const { normalizeRefSeparator } = require('../lib/pathfmt')
11
+ const { homedir } = require('../homedir')
10
12
 
11
13
  const SCHEMA_VERSION = 1
12
14
  const sha256 = content => crypto.createHash('sha256').update(content).digest('hex')
@@ -27,7 +29,7 @@ const cleanClaim = claim => {
27
29
  }
28
30
 
29
31
  class IntegrationManager {
30
- constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
32
+ constructor({ projectRoot = process.cwd(), homeRoot = homedir() } = {}, { onSkip } = {}) {
31
33
  this.roots = { project: path.resolve(projectRoot), global: path.resolve(homeRoot) }
32
34
  this.onSkip = onSkip
33
35
  // _afterManifestPersist, when set, runs immediately after manifests are
@@ -411,9 +413,15 @@ class IntegrationManager {
411
413
  // - scope global: substitui homeRoot por '~' (via tildeify, com salvaguarda
412
414
  // de barra dupla corrigida no ML-6H).
413
415
  // - scope de projeto: caminho relativo ao projectRoot, sem './' prefixo.
416
+ // ML-2A: o ramo global ja sai normalizado (tildeify normaliza os 3 retornos);
417
+ // o ramo de projeto precisa da normalizacao aqui, porque path.relative devolve
418
+ // separador nativo. Os dois sao EMISSAO — o valor vai so para a mensagem de
419
+ // warning entregue a onSkip, que o escreve verbatim em stderr (ADR-2026-09-04,
420
+ // D1 categoria 1). O par Python (integrations/manager.py) ja emitia "/" via
421
+ // .as_posix(); Node e Go eram a divergencia (D4).
414
422
  tildeAbbrev(file, scope) {
415
423
  if (scope === 'global') return tildeify(this.roots.global, file)
416
- return path.relative(this.roots.project, file)
424
+ return normalizeRefSeparator(path.relative(this.roots.project, file))
417
425
  }
418
426
 
419
427
  cleanEmpty(directory, root) {