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.
- package/README.md +67 -1
- package/package.json +1 -1
- package/src/commands/adr.js +2 -1
- package/src/commands/audit-surface.js +520 -0
- package/src/commands/barrier.js +400 -34
- package/src/commands/context.js +42 -36
- package/src/commands/discover.js +72 -21
- package/src/commands/doctor.js +37 -4
- package/src/commands/index.js +1 -0
- package/src/commands/init.js +2 -1
- package/src/commands/integrations.js +22 -4
- package/src/commands/req.js +2 -1
- package/src/commands/thirdparty.js +8 -2
- package/src/commands/update-harness.js +6 -3
- package/src/commands/update.js +147 -10
- package/src/config/index.js +82 -2
- package/src/generators/hooks.js +139 -6
- package/src/generators/init.js +173 -21
- package/src/generators/req.js +15 -53
- package/src/generators/roadmap.js +132 -28
- package/src/homedir.js +30 -0
- package/src/integrations/assets/agents/architect.md +5 -3
- package/src/integrations/assets/agents/security.md +3 -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 +395 -0
- package/src/lib/pathfmt.js +43 -0
- package/src/lib/update-engine.js +28 -3
- package/src/serve/api_board.js +11 -10
- package/src/serve/api_chain.js +13 -17
- package/src/serve/api_metrics.js +2 -8
- package/src/validator/index.js +768 -167
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:**
|
|
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:**
|
|
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
|
-
|
|
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
|
|
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
|
|
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 ||
|
|
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 =
|
|
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) {
|