trackfw 4.0.0 → 5.0.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/package.json +1 -1
- package/src/commands/barrier.js +59 -31
- package/src/commands/init.js +10 -2
- package/src/commands/integrations.js +9 -0
- package/src/generators/init.js +2 -2
- package/src/generators/roadmap.js +125 -0
- package/src/integrations/index.js +1 -1
- package/src/integrations/manager.js +34 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.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, and Amazon Q.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
package/src/commands/barrier.js
CHANGED
|
@@ -39,38 +39,65 @@ function resolveRoadmapFile(cfg, roadmapArg) {
|
|
|
39
39
|
throw new UsageError(`roadmap "${roadmapArg}" not found in wip/ nor done/ under ${cfg.roadmapDir}`)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// Wave label grammar (pinned by docs/cli-parity.md, "Wave label grammar" section)
|
|
44
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
// Scan regex (mirrors Go's waveHeadingRe): catches any "## Wave <token> " line.
|
|
47
|
+
// The trailing space is part of rule 1 — a heading whose label touches EOL is not
|
|
48
|
+
// recognised as a wave heading (neither valid nor malformed).
|
|
49
|
+
const WAVE_SCAN_RE = /^## Wave (\S+) /
|
|
50
|
+
|
|
51
|
+
// Grammar: <integer>[-<suffix>] where integer >= 1 and suffix is [a-z0-9]+ (lowercase).
|
|
52
|
+
// Valid: 1, 2, 2-bis, 2-hotfix, 10-a2.
|
|
53
|
+
// Invalid: X, 2-BIS, -bis, 2-, 2-bis-ter, 0.
|
|
54
|
+
const WAVE_LABEL_RE = /^(\d+)(?:-([a-z0-9]+))?$/
|
|
55
|
+
|
|
56
|
+
// isValidWaveLabel returns true iff token matches the grammar above.
|
|
57
|
+
// Exported so unit tests can assert the full table of valid/invalid examples.
|
|
58
|
+
function isValidWaveLabel(token) {
|
|
59
|
+
const m = WAVE_LABEL_RE.exec(String(token))
|
|
60
|
+
if (!m) return false
|
|
61
|
+
return parseInt(m[1], 10) >= 1
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
// ────────────────────────────────────────────────────────────────────────────
|
|
43
65
|
// Roadmap parsing rules (string-level, see docs/cli-parity.md)
|
|
44
66
|
// ────────────────────────────────────────────────────────────────────────────
|
|
45
67
|
|
|
46
|
-
// findWave locates the `## Wave <
|
|
47
|
-
// line range [startLine, endLine) within `lines`.
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
// findWave locates the `## Wave <label> ` heading matching waveLabel and returns its
|
|
69
|
+
// line range [startLine, endLine) within `lines`. Performs a full pre-pass over all
|
|
70
|
+
// wave headings (mirroring Go's parseWaves): throws UsageError for the first malformed
|
|
71
|
+
// heading regardless of position — this ensures a typo like `## Wave X — ...` aborts
|
|
72
|
+
// the entire document (ADR decision 16, non-vacuity). Throws UsageError naming the
|
|
73
|
+
// wave label and the resolved roadmap basename when not found (pinned literally by
|
|
74
|
+
// docs/cli-parity.md). roadmapBasename is optional so unit tests that only exercise
|
|
75
|
+
// parsing can omit it; the CLI path always supplies it.
|
|
76
|
+
function findWave(lines, waveLabel, roadmapBasename) {
|
|
77
|
+
// Pre-pass: validate ALL wave headings and collect them.
|
|
78
|
+
// Abort immediately on the first malformed heading — this is intentional and
|
|
79
|
+
// mirrors Go's parseWaves() full-scan approach (ADR decision 16).
|
|
80
|
+
const waves = []
|
|
54
81
|
for (let i = 0; i < lines.length; i++) {
|
|
55
|
-
const
|
|
56
|
-
const attempt = /^## Wave (\S+)/.exec(line)
|
|
82
|
+
const attempt = WAVE_SCAN_RE.exec(lines[i])
|
|
57
83
|
if (!attempt) continue
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
const token = attempt[1]
|
|
85
|
+
if (!isValidWaveLabel(token)) {
|
|
86
|
+
throw new UsageError(`malformed wave heading at line ${i + 1}: "${token}" is not a valid wave label`)
|
|
60
87
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
break
|
|
88
|
+
let endLine = lines.length
|
|
89
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
90
|
+
if (/^## /.test(lines[j])) { endLine = j; break }
|
|
64
91
|
}
|
|
92
|
+
waves.push({ label: token, startLine: i, endLine })
|
|
65
93
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (/^## /.test(lines[j])) { endLine = j; break }
|
|
94
|
+
|
|
95
|
+
// Find the requested wave by exact label match (no prefix/fuzzy matching).
|
|
96
|
+
const wave = waves.find(w => w.label === String(waveLabel))
|
|
97
|
+
if (!wave) {
|
|
98
|
+
throw new UsageError(`wave ${waveLabel} not found in roadmap "${roadmapBasename}"`)
|
|
72
99
|
}
|
|
73
|
-
return { startLine, endLine }
|
|
100
|
+
return { startLine: wave.startLine, endLine: wave.endLine }
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
// findMLs locates every `### ML-` heading inside [startLine, endLine) and returns
|
|
@@ -166,7 +193,7 @@ function parseGates(lines, startLine, endLine) {
|
|
|
166
193
|
// Checks (fixed evaluation order: mls_complete, acceptance_evidence, gates, validate)
|
|
167
194
|
// ────────────────────────────────────────────────────────────────────────────
|
|
168
195
|
|
|
169
|
-
function evalMlsComplete(mls,
|
|
196
|
+
function evalMlsComplete(mls, waveLabel) {
|
|
170
197
|
const evidence = []
|
|
171
198
|
const failures = []
|
|
172
199
|
for (const ml of mls) {
|
|
@@ -176,7 +203,7 @@ function evalMlsComplete(mls, waveNumber) {
|
|
|
176
203
|
}
|
|
177
204
|
const status = mls.length > 0 && failures.length === 0 ? 'passed' : 'blocked'
|
|
178
205
|
// Pinned literally by docs/cli-parity.md ("Wave contains zero MLs" case).
|
|
179
|
-
if (mls.length === 0) failures.push(`wave ${
|
|
206
|
+
if (mls.length === 0) failures.push(`wave ${waveLabel}: no ML found`)
|
|
180
207
|
return { name: 'mls_complete', status, evidence, failures }
|
|
181
208
|
}
|
|
182
209
|
|
|
@@ -280,10 +307,10 @@ async function runBarrier(roadmapArg, waveOption, jsonOutput) {
|
|
|
280
307
|
if (waveOption === undefined || waveOption === null || String(waveOption).trim() === '') {
|
|
281
308
|
throw new UsageError('--wave is required')
|
|
282
309
|
}
|
|
283
|
-
|
|
284
|
-
|
|
310
|
+
const waveLabel = String(waveOption).trim()
|
|
311
|
+
if (!isValidWaveLabel(waveLabel)) {
|
|
312
|
+
throw new UsageError(`invalid --wave "${waveOption}" — not a valid wave label`)
|
|
285
313
|
}
|
|
286
|
-
const waveNumber = parseInt(waveOption, 10)
|
|
287
314
|
|
|
288
315
|
const cfg = config.load()
|
|
289
316
|
const resolved = resolveRoadmapFile(cfg, roadmapArg)
|
|
@@ -291,18 +318,18 @@ async function runBarrier(roadmapArg, waveOption, jsonOutput) {
|
|
|
291
318
|
const lines = content.split('\n')
|
|
292
319
|
|
|
293
320
|
const startedAt = new Date()
|
|
294
|
-
const wave = findWave(lines,
|
|
321
|
+
const wave = findWave(lines, waveLabel, resolved.basename)
|
|
295
322
|
const mls = findMLs(lines, wave.startLine, wave.endLine)
|
|
296
323
|
const gates = parseGates(lines, wave.startLine, wave.endLine)
|
|
297
324
|
|
|
298
325
|
const checks = []
|
|
299
|
-
checks.push(evalMlsComplete(mls,
|
|
326
|
+
checks.push(evalMlsComplete(mls, waveLabel))
|
|
300
327
|
checks.push(evalAcceptanceEvidence(mls))
|
|
301
328
|
checks.push(evalGates(gates.commands, process.cwd()))
|
|
302
329
|
checks.push(await evalValidate())
|
|
303
330
|
const finishedAt = new Date()
|
|
304
331
|
|
|
305
|
-
const doc = buildDoc(resolved.basename,
|
|
332
|
+
const doc = buildDoc(resolved.basename, waveLabel, checks, startedAt, finishedAt)
|
|
306
333
|
|
|
307
334
|
if (jsonOutput) {
|
|
308
335
|
console.log(JSON.stringify(doc, null, 2))
|
|
@@ -322,7 +349,7 @@ function createBarrierCommand() {
|
|
|
322
349
|
'declared in a roadmap.'
|
|
323
350
|
)
|
|
324
351
|
.argument('<roadmap>', 'Roadmap basename, with or without .md')
|
|
325
|
-
.option('--wave <
|
|
352
|
+
.option('--wave <label>', 'Wave label to evaluate (e.g. 1, 2-bis, 2-hotfix)')
|
|
326
353
|
.option('--json', 'Emit the result document as JSON instead of a text report')
|
|
327
354
|
.action(async (roadmapArg, options) => {
|
|
328
355
|
try {
|
|
@@ -344,6 +371,7 @@ function createBarrierCommand() {
|
|
|
344
371
|
|
|
345
372
|
module.exports = createBarrierCommand()
|
|
346
373
|
module.exports.UsageError = UsageError
|
|
374
|
+
module.exports.isValidWaveLabel = isValidWaveLabel
|
|
347
375
|
module.exports.resolveRoadmapFile = resolveRoadmapFile
|
|
348
376
|
module.exports.findWave = findWave
|
|
349
377
|
module.exports.findMLs = findMLs
|
package/src/commands/init.js
CHANGED
|
@@ -61,9 +61,12 @@ cmd.action(async (options, command) => {
|
|
|
61
61
|
const supported = new Set(['claude', 'codex', 'gemini', 'antigravity', 'cursor', 'copilot', 'windsurf', 'amazonq', 'kiro'])
|
|
62
62
|
// Sem TTY, o escopo nunca é perguntado: default `global` (ADR D1/D4).
|
|
63
63
|
const scope = await resolveScope({}, { interactive: false })
|
|
64
|
+
const makeOnSkip = () => (_destination, reason) => {
|
|
65
|
+
process.stderr.write(`${reason}\n`)
|
|
66
|
+
}
|
|
64
67
|
for (const tool of aiTools) {
|
|
65
68
|
if (!supported.has(tool)) throw new Error(`Unsupported AI tool: ${tool}`)
|
|
66
|
-
await generators.installIntegrationTarget(tool, process.cwd(), scope)
|
|
69
|
+
await generators.installIntegrationTarget(tool, process.cwd(), scope, { onSkip: makeOnSkip() })
|
|
67
70
|
}
|
|
68
71
|
console.log(`\n${t('init.success')}`)
|
|
69
72
|
require('../generators/init').printArchitectNextSteps(process.cwd())
|
|
@@ -277,7 +280,12 @@ cmd.action(async (options, command) => {
|
|
|
277
280
|
const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci, forge: forgeValue || '', requireReqInCommit }
|
|
278
281
|
await generators.scaffold(cfg)
|
|
279
282
|
|
|
280
|
-
for (const tool of (aiTools || []))
|
|
283
|
+
for (const tool of (aiTools || [])) {
|
|
284
|
+
const onSkip = (_destination, reason) => {
|
|
285
|
+
process.stderr.write(`${reason}\n`)
|
|
286
|
+
}
|
|
287
|
+
await generators.installIntegrationTarget(tool, process.cwd(), scope, { onSkip })
|
|
288
|
+
}
|
|
281
289
|
|
|
282
290
|
console.log(`\n${t('init.success')}`)
|
|
283
291
|
require('../generators/init').printArchitectNextSteps(process.cwd())
|
|
@@ -183,6 +183,15 @@ function createLifecycleCommand(kind) {
|
|
|
183
183
|
// operações de mutação (install/update/uninstall) e fora de --json.
|
|
184
184
|
if (mutation) printResolvedDestinations(kind, options)
|
|
185
185
|
|
|
186
|
+
// Ligar onSkip: aviso em stderr por artefato outdated+owned pulado pelo
|
|
187
|
+
// preflight de install (contrato: docs/cli-parity.md, seção "install
|
|
188
|
+
// sobre artefato gerenciado desatualizado — skip, não erro fatal").
|
|
189
|
+
if (mutation) {
|
|
190
|
+
options.onSkip = (_destination, reason) => {
|
|
191
|
+
process.stderr.write(`${reason}\n`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
186
195
|
const output = execute(kind, operation, options)
|
|
187
196
|
console.log(options.json ? JSON.stringify(output) : human(output))
|
|
188
197
|
})
|
package/src/generators/init.js
CHANGED
|
@@ -1217,10 +1217,10 @@ async function installAmazonQ(cwd = process.cwd()) {
|
|
|
1217
1217
|
* resolved (ADR-2026-07-25-escopo-de-instalacao-selecionavel-para-agents-e-
|
|
1218
1218
|
* skills, D4).
|
|
1219
1219
|
*/
|
|
1220
|
-
async function installIntegrationTarget(target, cwd = process.cwd(), scope = 'project') {
|
|
1220
|
+
async function installIntegrationTarget(target, cwd = process.cwd(), scope = 'project', { onSkip } = {}) {
|
|
1221
1221
|
const { execute, buildPlans } = require('../integrations')
|
|
1222
1222
|
const roots = { projectRoot: cwd }
|
|
1223
|
-
const options = { targets: [target], scope }
|
|
1223
|
+
const options = { targets: [target], scope, onSkip }
|
|
1224
1224
|
// D5 — transparency: print resolved destinations before writing anything.
|
|
1225
1225
|
// buildPlans has no side effects, so it is safe to call here purely to
|
|
1226
1226
|
// enumerate destinations; the actual write happens below via execute().
|
|
@@ -3,6 +3,7 @@ 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
7
|
|
|
7
8
|
const VALID_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
|
|
8
9
|
const STATE_ORDER = ['analyzing', 'wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
@@ -251,6 +252,9 @@ function moveRoadmap(name, state) {
|
|
|
251
252
|
|
|
252
253
|
appendTransitionLog(logBasename, fromState, state)
|
|
253
254
|
console.log(`✓ moved ${basename} → ${targetDir}`)
|
|
255
|
+
|
|
256
|
+
// Sincroniza o roadmap: das REQs que apontem para este roadmap.
|
|
257
|
+
syncReqReferences(basename, dst, cfg)
|
|
254
258
|
}
|
|
255
259
|
|
|
256
260
|
/**
|
|
@@ -273,6 +277,124 @@ function appendTransitionLog(basename, fromState, toState) {
|
|
|
273
277
|
} catch (_) {}
|
|
274
278
|
}
|
|
275
279
|
|
|
280
|
+
/**
|
|
281
|
+
* extractFrontmatterRoadmap — extrai o valor do campo `roadmap:` SOMENTE do frontmatter.
|
|
282
|
+
* Retorna null se não encontrado ou se não termina em .md.
|
|
283
|
+
* Não usa extractRefPath do validator porque este percorre todo o conteúdo (inclusive corpo)
|
|
284
|
+
* sem distinguir frontmatter de body — geraria falsos positivos em REQs sem roadmap: no FM.
|
|
285
|
+
*/
|
|
286
|
+
function extractFrontmatterRoadmap(content) {
|
|
287
|
+
const lines = content.split('\n')
|
|
288
|
+
let inFm = false
|
|
289
|
+
for (let i = 0; i < lines.length; i++) {
|
|
290
|
+
const line = lines[i]
|
|
291
|
+
if (i === 0 && line.trim() === '---') { inFm = true; continue }
|
|
292
|
+
if (inFm && line.trim() === '---') return null // fechou frontmatter sem encontrar
|
|
293
|
+
if (!inFm) return null // nunca entrou no frontmatter
|
|
294
|
+
const idx = line.indexOf(':')
|
|
295
|
+
if (idx === -1) continue
|
|
296
|
+
if (line.slice(0, idx).trim() !== 'roadmap') continue
|
|
297
|
+
let val = line.slice(idx + 1).trim()
|
|
298
|
+
val = val.replace(/^["']|["']$/g, '').trim()
|
|
299
|
+
if (val.endsWith('.md')) return val
|
|
300
|
+
return null // roadmap: existe mas sem .md (ex: "" ou slug)
|
|
301
|
+
}
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* rewriteReqRoadmapRef — reescreve o campo `roadmap:` no frontmatter e a linha `Roadmap:` no
|
|
307
|
+
* corpo de uma REQ, substituindo oldRef por newRef.
|
|
308
|
+
* Preserva toda formatação existente (aspas, backticks) via substituição literal de string.
|
|
309
|
+
*/
|
|
310
|
+
function rewriteReqRoadmapRef(content, oldRef, newRef) {
|
|
311
|
+
const lines = content.split('\n')
|
|
312
|
+
let inFm = false
|
|
313
|
+
let fmDone = false
|
|
314
|
+
const result = []
|
|
315
|
+
|
|
316
|
+
for (let i = 0; i < lines.length; i++) {
|
|
317
|
+
const line = lines[i]
|
|
318
|
+
if (i === 0 && line.trim() === '---') { inFm = true; result.push(line); continue }
|
|
319
|
+
if (inFm && line.trim() === '---') { inFm = false; fmDone = true; result.push(line); continue }
|
|
320
|
+
if (inFm) {
|
|
321
|
+
const idx = line.indexOf(':')
|
|
322
|
+
if (idx !== -1 && line.slice(0, idx).trim() === 'roadmap' && line.includes(oldRef)) {
|
|
323
|
+
result.push(line.replace(oldRef, newRef))
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
result.push(line)
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
if (fmDone) {
|
|
330
|
+
// No corpo: reescreve linhas `Roadmap:` que contenham oldRef
|
|
331
|
+
if (line.trim().toLowerCase().startsWith('roadmap:') && line.includes(oldRef)) {
|
|
332
|
+
result.push(line.replace(oldRef, newRef))
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
result.push(line)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return result.join('\n')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* syncReqReferences — após rename bem-sucedido, varre req_dir (flat + by_agent) e reescreve o
|
|
344
|
+
* frontmatter `roadmap:` e a linha `Roadmap:` do corpo em toda REQ que aponte para o roadmap
|
|
345
|
+
* movido. Implementa as cinco cardinalidades do contrato:
|
|
346
|
+
* zero → no-op silencioso; uma → reescreve; várias → reescreve todas; aponta para outro →
|
|
347
|
+
* não toca; já correta → nenhuma escrita (idempotente byte-a-byte).
|
|
348
|
+
* Erros: diagnóstico em stderr nomeando a REQ; tenta as restantes; exit não-zero ao fim.
|
|
349
|
+
*/
|
|
350
|
+
function syncReqReferences(movedBasename, newRoadmapPath, cfg) {
|
|
351
|
+
// Ordenação explícita por basename (independente de locale) para garantir saída determinística
|
|
352
|
+
// independente do filesystem. Desempate por caminho completo quando dois agentes têm REQs de
|
|
353
|
+
// mesmo basename. Não confiar na ordem do readdirSync — varia entre filesystems.
|
|
354
|
+
const files = resolveReqFiles(cfg).sort((a, b) => {
|
|
355
|
+
const ba = path.basename(a)
|
|
356
|
+
const bb = path.basename(b)
|
|
357
|
+
if (ba < bb) return -1
|
|
358
|
+
if (ba > bb) return 1
|
|
359
|
+
if (a < b) return -1
|
|
360
|
+
if (a > b) return 1
|
|
361
|
+
return 0
|
|
362
|
+
})
|
|
363
|
+
let anyError = false
|
|
364
|
+
|
|
365
|
+
for (const filePath of files) {
|
|
366
|
+
let content
|
|
367
|
+
try {
|
|
368
|
+
content = fs.readFileSync(filePath, 'utf8')
|
|
369
|
+
} catch (e) {
|
|
370
|
+
console.error(`trackfw roadmap move: failed to sync ${path.basename(filePath)}: ${e.message}`)
|
|
371
|
+
anyError = true
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const currentRef = extractFrontmatterRoadmap(content)
|
|
376
|
+
if (!currentRef) continue
|
|
377
|
+
if (path.basename(currentRef) !== movedBasename) continue // aponta para outro roadmap
|
|
378
|
+
if (currentRef === newRoadmapPath) {
|
|
379
|
+
// Guarda rápida: frontmatter já correto — confirma idempotência com reescrita estrutural
|
|
380
|
+
const updated = rewriteReqRoadmapRef(content, currentRef, newRoadmapPath)
|
|
381
|
+
if (updated === content) continue // sem escrita, sem output
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const updated = rewriteReqRoadmapRef(content, currentRef, newRoadmapPath)
|
|
386
|
+
if (updated === content) continue // nenhuma escrita, nenhum output (idempotência byte-a-byte)
|
|
387
|
+
fs.writeFileSync(filePath, updated, 'utf8')
|
|
388
|
+
console.log(`✓ synced ${path.basename(filePath)} → ${newRoadmapPath}`)
|
|
389
|
+
} catch (e) {
|
|
390
|
+
console.error(`trackfw roadmap move: failed to sync ${path.basename(filePath)}: ${e.message}`)
|
|
391
|
+
anyError = true
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (anyError) process.exitCode = 1
|
|
396
|
+
}
|
|
397
|
+
|
|
276
398
|
/**
|
|
277
399
|
* newRoadmap — cria roadmap em <roadmapDir>/backlog/ROADMAP-YYYY-MM-DD-<slug>.md.
|
|
278
400
|
* Em modo by_agent, usa o primeiro agente configurado.
|
|
@@ -524,6 +646,9 @@ module.exports = {
|
|
|
524
646
|
moveRoadmap,
|
|
525
647
|
rewriteRoadmapStatus,
|
|
526
648
|
appendTransitionLog,
|
|
649
|
+
extractFrontmatterRoadmap,
|
|
650
|
+
rewriteReqRoadmapRef,
|
|
651
|
+
syncReqReferences,
|
|
527
652
|
newRoadmap,
|
|
528
653
|
newRoadmapFromReq,
|
|
529
654
|
stateDir,
|
|
@@ -101,7 +101,7 @@ function execute(kind, operation, options = {}, roots = {}) {
|
|
|
101
101
|
const homeRoot = roots.homeRoot || os.homedir()
|
|
102
102
|
const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
|
|
103
103
|
const plans = buildPlans(kind, { ...options, identity: identityConfig })
|
|
104
|
-
const manager = new IntegrationManager(roots)
|
|
104
|
+
const manager = new IntegrationManager(roots, { onSkip: options.onSkip })
|
|
105
105
|
let statuses
|
|
106
106
|
if (operation === 'list') statuses = manager.inspect(plans)
|
|
107
107
|
else if (operation === 'install') statuses = manager.install(plans, { force: options.force })
|
|
@@ -6,6 +6,7 @@ const os = require('node:os')
|
|
|
6
6
|
const path = require('node:path')
|
|
7
7
|
|
|
8
8
|
const { frontmatterName } = require('./render')
|
|
9
|
+
const { tildeify } = require('../lib/update-engine')
|
|
9
10
|
|
|
10
11
|
const SCHEMA_VERSION = 1
|
|
11
12
|
const sha256 = content => crypto.createHash('sha256').update(content).digest('hex')
|
|
@@ -13,8 +14,9 @@ const claimKey = claim => [claim.target, claim.surface, claim.scope, claim.kind,
|
|
|
13
14
|
const cleanClaim = claim => ({ target: claim.target, surface: claim.surface, scope: claim.scope, kind: claim.kind, item: claim.item })
|
|
14
15
|
|
|
15
16
|
class IntegrationManager {
|
|
16
|
-
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}) {
|
|
17
|
+
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
|
|
17
18
|
this.roots = { project: path.resolve(projectRoot), global: path.resolve(homeRoot) }
|
|
19
|
+
this.onSkip = onSkip
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
manifestPath(scope) { return path.join(this.roots[scope], '.trackfw', 'integrations-manifest.json') }
|
|
@@ -127,17 +129,33 @@ class IntegrationManager {
|
|
|
127
129
|
const manifests = new Map()
|
|
128
130
|
for (const { plan } of resolved) if (!manifests.has(plan.claim.scope)) manifests.set(plan.claim.scope, this.loadManifest(plan.claim.scope))
|
|
129
131
|
const desiredByFile = new Map()
|
|
132
|
+
const skippedFiles = new Set()
|
|
130
133
|
for (const item of resolved) {
|
|
131
134
|
const desired = sha256(item.plan.content)
|
|
132
135
|
if (operation !== 'uninstall' && desiredByFile.has(item.file) && desiredByFile.get(item.file) !== desired) throw new Error(`Conflicting content planned for: ${item.file}`)
|
|
133
136
|
desiredByFile.set(item.file, desired)
|
|
134
|
-
this.preflight(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
137
|
+
const skip = this.preflight(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
138
|
+
if (skip) {
|
|
139
|
+
skippedFiles.add(item.file)
|
|
140
|
+
if (typeof this.onSkip === 'function') {
|
|
141
|
+
// O manager compõe a linha completa e passa tilde-abreviado como
|
|
142
|
+
// destination (contrato: docs/cli-parity.md, "Valor de cada
|
|
143
|
+
// parâmetro — pinado"). A remediação é derivada de
|
|
144
|
+
// plan.claim.scope por artefato, nunca de inferência sobre o
|
|
145
|
+
// caminho renderizado.
|
|
146
|
+
const abbrev = this.tildeAbbrev(item.file, item.plan.claim.scope)
|
|
147
|
+
const remediation = item.plan.claim.scope === 'global' ? 'trackfw update harness' : 'trackfw update'
|
|
148
|
+
const reason = `warning: skipping outdated artifact ${abbrev}; run '${remediation}' to refresh it`
|
|
149
|
+
this.onSkip(abbrev, reason)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
135
152
|
}
|
|
153
|
+
const active = resolved.filter(item => !skippedFiles.has(item.file))
|
|
136
154
|
const snapshots = new Map()
|
|
137
|
-
for (const item of
|
|
155
|
+
for (const item of active) this.snapshot(snapshots, item.file)
|
|
138
156
|
for (const scope of manifests.keys()) this.snapshot(snapshots, this.manifestPath(scope))
|
|
139
157
|
try {
|
|
140
|
-
for (const item of
|
|
158
|
+
for (const item of active) this.apply(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
141
159
|
for (const [scope, manifest] of [...manifests].sort(([a], [b]) => a.localeCompare(b))) this.saveManifest(scope, manifest)
|
|
142
160
|
} catch (error) {
|
|
143
161
|
this.rollback(snapshots)
|
|
@@ -153,13 +171,14 @@ class IntegrationManager {
|
|
|
153
171
|
const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === claimKey(plan.claim)))
|
|
154
172
|
if (operation === 'install') {
|
|
155
173
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
156
|
-
if (status.state === 'outdated' && owned && !force)
|
|
174
|
+
if (status.state === 'outdated' && owned && !force) return true // skip: bytes preserved, batch continues
|
|
157
175
|
} else if (operation === 'update') {
|
|
158
176
|
if (!owned && status.state === 'modified') throw new Error(`Unmanaged artifact does not match a trackfw template: ${file}`)
|
|
159
177
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
160
178
|
} else if (operation === 'uninstall' && owned && status.state === 'modified' && !force) {
|
|
161
179
|
throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
162
180
|
}
|
|
181
|
+
return false
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
// detectNameCollision protege contra dois artefatos de agente gerenciados
|
|
@@ -273,6 +292,16 @@ class IntegrationManager {
|
|
|
273
292
|
}
|
|
274
293
|
}
|
|
275
294
|
|
|
295
|
+
// tildeAbbrev — retorna o caminho de exibição abreviado para uso em mensagens
|
|
296
|
+
// de aviso de skip. Espelha internal/integrations/manager.go:tildeAbbrev.
|
|
297
|
+
// - scope global: substitui homeRoot por '~' (via tildeify, com salvaguarda
|
|
298
|
+
// de barra dupla corrigida no ML-6H).
|
|
299
|
+
// - scope de projeto: caminho relativo ao projectRoot, sem './' prefixo.
|
|
300
|
+
tildeAbbrev(file, scope) {
|
|
301
|
+
if (scope === 'global') return tildeify(this.roots.global, file)
|
|
302
|
+
return path.relative(this.roots.project, file)
|
|
303
|
+
}
|
|
304
|
+
|
|
276
305
|
cleanEmpty(directory, root) {
|
|
277
306
|
while (directory !== root) {
|
|
278
307
|
const rel = path.relative(root, directory)
|