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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "4.0.0",
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",
@@ -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 <n> ` heading matching waveNumber and returns its
47
- // line range [startLine, endLine) within `lines`. Throws UsageError naming the wave
48
- // number and the resolved roadmap basename when not found (pinned literally by
49
- // docs/cli-parity.md), or naming the offending line number for a malformed heading.
50
- // roadmapBasename is optional so existing unit tests that only exercise parsing can
51
- // omit it; the CLI path always supplies it.
52
- function findWave(lines, waveNumber, roadmapBasename) {
53
- let startLine = -1
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 line = lines[i]
56
- const attempt = /^## Wave (\S+)/.exec(line)
82
+ const attempt = WAVE_SCAN_RE.exec(lines[i])
57
83
  if (!attempt) continue
58
- if (!/^\d+$/.test(attempt[1])) {
59
- throw new UsageError(`malformed wave heading at line ${i + 1}: "${line}"`)
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
- if (parseInt(attempt[1], 10) === waveNumber) {
62
- startLine = i
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
- if (startLine === -1) {
67
- throw new UsageError(`wave ${waveNumber} not found in roadmap "${roadmapBasename}"`)
68
- }
69
- let endLine = lines.length
70
- for (let j = startLine + 1; j < lines.length; j++) {
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, waveNumber) {
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 ${waveNumber}: no ML found`)
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
- if (!/^\d+$/.test(String(waveOption).trim()) || parseInt(waveOption, 10) < 1) {
284
- throw new UsageError(`invalid --wave value: "${waveOption}" (must be an integer >= 1)`)
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, waveNumber, resolved.basename)
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, waveNumber))
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, waveNumber, checks, startedAt, finishedAt)
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 <n>', 'Wave number to evaluate (integer >= 1)')
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
@@ -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 || [])) await generators.installIntegrationTarget(tool, process.cwd(), scope)
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
  })
@@ -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 resolved) this.snapshot(snapshots, item.file)
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 resolved) this.apply(operation, item, manifests.get(item.plan.claim.scope), force)
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) throw new Error(`Artifact is outdated; use update: ${file}`)
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)