trackfw 3.1.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.
@@ -31,4 +31,28 @@ function readAsset(item) {
31
31
  return fs.readFileSync(path.join(ASSET_ROOT, relative), 'utf8')
32
32
  }
33
33
 
34
- module.exports = { catalog, items, target, surfaceFor, readAsset }
34
+ // truncateBeforeIdSegment drops the "{{id}}"-bearing path segment and
35
+ // everything after it, returning the shared parent directory — mirrors
36
+ // internal/integrations/plan.go:truncateBeforeIDSegment.
37
+ function truncateBeforeIdSegment(p) {
38
+ const segments = p.split('/')
39
+ const index = segments.findIndex(segment => segment.includes('{{id}}'))
40
+ if (index === -1) return p
41
+ return segments.slice(0, index).join('/')
42
+ }
43
+
44
+ // globalGroupPath returns the tilde-abbreviated directory shared by every
45
+ // catalog item of (toolId, kind) at global scope, derived from the catalog's
46
+ // own path template rather than any individual installed plan's destination
47
+ // — so the reported path never depends on catalog item iteration order. See
48
+ // docs/cli-parity.md, "Declared harness targets — pinned list", and the Go
49
+ // counterpart internal/integrations/plan.go:GlobalGroupPath.
50
+ function globalGroupPath(toolId, kind) {
51
+ const targetEntry = target(toolId)
52
+ const surface = surfaceFor(targetEntry, undefined, kind)
53
+ const installPath = surface.paths[kind].find(entry => entry.scope === 'global')
54
+ if (!installPath) throw new Error(`target ${toolId} has no global ${kind} path`)
55
+ return truncateBeforeIdSegment(installPath.path)
56
+ }
57
+
58
+ module.exports = { catalog, items, target, surfaceFor, readAsset, globalGroupPath }
@@ -2,11 +2,12 @@
2
2
 
3
3
  const os = require('node:os')
4
4
 
5
- const { catalog, items, target, surfaceFor, readAsset } = require('./catalog')
5
+ const { catalog, items, target, surfaceFor, readAsset, globalGroupPath } = require('./catalog')
6
6
  const { render } = require('./render')
7
7
  const { IntegrationManager } = require('./manager')
8
8
  const { legacyHashes } = require('./legacy')
9
9
  const identityStore = require('../identity')
10
+ const { injectRulesForTool } = require('../generators/init')
10
11
 
11
12
  function parseSurfaces(values = []) {
12
13
  const result = {}
@@ -100,14 +101,31 @@ function execute(kind, operation, options = {}, roots = {}) {
100
101
  const homeRoot = roots.homeRoot || os.homedir()
101
102
  const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
102
103
  const plans = buildPlans(kind, { ...options, identity: identityConfig })
103
- const manager = new IntegrationManager(roots)
104
+ const manager = new IntegrationManager(roots, { onSkip: options.onSkip })
104
105
  let statuses
105
106
  if (operation === 'list') statuses = manager.inspect(plans)
106
107
  else if (operation === 'install') statuses = manager.install(plans, { force: options.force })
107
108
  else if (operation === 'update') statuses = manager.update(plans, { force: options.force })
108
109
  else if (operation === 'uninstall') statuses = manager.uninstall(plans, { force: options.force })
109
110
  else throw new Error(`Unsupported integration operation: ${operation}`)
111
+
112
+ // Auxiliary rules files (GEMINI.md, .github/copilot-instructions.md,
113
+ // .windsurfrules, .amazonq/developer/guidelines.md, etc.) are outside the
114
+ // agents/skills catalog managed by IntegrationManager above — they are a
115
+ // separate, tool-specific mechanism (injectRulesForTool), and this is the
116
+ // canonical catalog-based install path (`trackfw agents|skills install
117
+ // --targets <tool>`). Mirrors internal/commands/integrations_flags.go:
118
+ // executeIntegrationMutation (ML-5E/ML-5G of ROADMAP-2026-07-29-barrier-
119
+ // governanca-e-autoridade-do-orquestrador). Scoped to "install" only,
120
+ // mirroring the one-shot semantics the removed deprecated CLI aliases had.
121
+ // injectRulesForTool no-ops for targets without a rules surface (e.g.
122
+ // antigravity, kiro) and is idempotent for repeated runs.
123
+ if (operation === 'install') {
124
+ const targetValues = options.targets && options.targets.length ? options.targets : catalog.targets.map(entry => entry.id)
125
+ for (const targetID of targetValues) injectRulesForTool(targetID, manager.roots.project)
126
+ }
127
+
110
128
  return result(kind, plans, statuses)
111
129
  }
112
130
 
113
- module.exports = { catalog, buildPlans, execute, IntegrationManager, parseSurfaces }
131
+ module.exports = { catalog, buildPlans, execute, IntegrationManager, parseSurfaces, globalGroupPath }
@@ -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)
@@ -0,0 +1,217 @@
1
+ 'use strict'
2
+
3
+ // update-engine.js — shared state machine for `trackfw update` and
4
+ // `trackfw update harness` (ML-6C of ROADMAP-2026-07-29-barrier-governanca-
5
+ // e-autoridade-do-orquestrador). Both commands report one of exactly four
6
+ // states per target (`updated`, `skipped`, `missing`, `failed`) and emit the
7
+ // same JSON document shape — see docs/cli-parity.md, section
8
+ // "`trackfw update` vs `trackfw update harness`" for the frozen contract.
9
+ //
10
+ // Key invariant enforced here: a `missing` target is NEVER installed unless
11
+ // `installMissing` is explicitly true. This is the single most
12
+ // safety-critical rule in the contract, so it is centralized in
13
+ // `runFileTarget` rather than re-implemented per target.
14
+
15
+ const fs = require('fs')
16
+ const os = require('os')
17
+ const path = require('path')
18
+ const crypto = require('crypto')
19
+
20
+ function sha256(buf) {
21
+ return crypto.createHash('sha256').update(buf).digest('hex')
22
+ }
23
+
24
+ function collectDirEntries(dir) {
25
+ const out = []
26
+ function walk(current, rel) {
27
+ let entries
28
+ try {
29
+ entries = fs.readdirSync(current, { withFileTypes: true })
30
+ } catch (_) {
31
+ return
32
+ }
33
+ entries.sort((a, b) => a.name.localeCompare(b.name))
34
+ for (const entry of entries) {
35
+ const childRel = rel ? path.join(rel, entry.name) : entry.name
36
+ const childAbs = path.join(current, entry.name)
37
+ if (entry.isDirectory()) walk(childAbs, childRel)
38
+ else out.push([childRel, sha256(fs.readFileSync(childAbs))])
39
+ }
40
+ }
41
+ walk(dir, '')
42
+ return out
43
+ }
44
+
45
+ // hashPath — returns null when the path does not exist, a content hash for
46
+ // a file, or a hash of the recursive (relative-path, content-hash) listing
47
+ // for a directory. Used to detect "nothing changed" without caring whether
48
+ // the target is a single file or a directory of generated artifacts.
49
+ function hashPath(target) {
50
+ if (!fs.existsSync(target)) return null
51
+ const stat = fs.lstatSync(target)
52
+ if (stat.isDirectory()) return JSON.stringify(collectDirEntries(target))
53
+ return sha256(fs.readFileSync(target))
54
+ }
55
+
56
+ function copyPath(src, dst) {
57
+ if (!fs.existsSync(src)) return
58
+ const stat = fs.lstatSync(src)
59
+ if (stat.isDirectory()) {
60
+ fs.mkdirSync(dst, { recursive: true })
61
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
62
+ copyPath(path.join(src, entry.name), path.join(dst, entry.name))
63
+ }
64
+ } else {
65
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
66
+ fs.copyFileSync(src, dst)
67
+ }
68
+ }
69
+
70
+ // silenceConsole — apply()s reused from generators/init.js and friends log
71
+ // their own progress via console.log; those messages are only meaningful
72
+ // against the real root, not the scratch copy used to predict --dry-run
73
+ // state. Suppressed during simulation only.
74
+ function silenceConsole(fn) {
75
+ const original = console.log
76
+ console.log = () => {}
77
+ try {
78
+ return fn()
79
+ } finally {
80
+ console.log = original
81
+ }
82
+ }
83
+
84
+ /**
85
+ * runFileTarget — computes updated/skipped/missing/failed for a target whose
86
+ * only observable effect is writing under a fixed set of paths (files or
87
+ * directories) relative to `root`, by diffing content hashes before/after
88
+ * invoking `apply(root)`.
89
+ *
90
+ * - `--dry-run`: `apply` runs against a scratch temp directory seeded only
91
+ * with copies of the paths that already exist under `root` — the real
92
+ * `root` is never touched.
93
+ * - real run: `apply` runs directly against `root`.
94
+ * - `missing` (nothing present under any of `relPaths`) never triggers
95
+ * `apply` unless `installMissing` is true.
96
+ */
97
+ function runFileTarget({ id, path: displayPath, root, relPaths, apply, dryRun, installMissing }) {
98
+ const before = relPaths.map(rel => hashPath(path.join(root, rel)))
99
+ const allMissingBefore = before.every(h => h === null)
100
+
101
+ if (allMissingBefore && !installMissing) {
102
+ return { id, state: 'missing', path: displayPath }
103
+ }
104
+
105
+ try {
106
+ let after
107
+ if (dryRun) {
108
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trackfw-update-'))
109
+ try {
110
+ for (const rel of relPaths) copyPath(path.join(root, rel), path.join(tmp, rel))
111
+ silenceConsole(() => apply(tmp))
112
+ after = relPaths.map(rel => hashPath(path.join(tmp, rel)))
113
+ } finally {
114
+ fs.rmSync(tmp, { recursive: true, force: true })
115
+ }
116
+ } else {
117
+ apply(root)
118
+ after = relPaths.map(rel => hashPath(path.join(root, rel)))
119
+ }
120
+
121
+ const allMissingAfter = after.every(h => h === null)
122
+ if (allMissingBefore && allMissingAfter) return { id, state: 'missing', path: displayPath }
123
+ const unchanged = before.length === after.length && before.every((h, i) => h === after[i])
124
+ return { id, state: unchanged ? 'skipped' : 'updated', path: displayPath }
125
+ } catch (e) {
126
+ return { id, state: 'failed', path: displayPath, message: e.message }
127
+ }
128
+ }
129
+
130
+ function summarize(targets) {
131
+ const summary = { updated: 0, skipped: 0, missing: 0, failed: 0 }
132
+ for (const t of targets) summary[t.state] += 1
133
+ return summary
134
+ }
135
+
136
+ // tildeify — the contract's JSON example shows global paths abbreviated as
137
+ // `~/...`; emitting the absolute path would both break byte-parity with the
138
+ // other runtimes and leak the local username into committed/shared output.
139
+ //
140
+ // Both operands are run through path.normalize before comparison: a raw
141
+ // process.env.HOME can contain a redundant separator (e.g. macOS's default
142
+ // $TMPDIR ends in "/", so a test harness building HOME as `"$TMPDIR/foo"`
143
+ // yields ".../T//foo"), while `absPath` here is always produced via
144
+ // path.join, which normalizes internally — comparing the two without
145
+ // normalizing homeRoot first made the prefix check silently fail and fall
146
+ // through to the absolute-path branch. Mirrors Python's _tildeify
147
+ // (pypi/trackfw/commands/update_harness.py), which normalizes both sides
148
+ // via os.path.normpath for the same reason.
149
+ //
150
+ // A second, subtler case (ML-6H): path.normalize PRESERVES a trailing
151
+ // separator when the input already has one (macOS's default $TMPDIR itself
152
+ // ends in "/", so a HOME built as "$TMPDIR/foo" collapses the internal
153
+ // "//" but still ends in a single trailing "/"). The prefix check below
154
+ // then appended ANOTHER path.sep, comparing against ".../foo//" — which
155
+ // normalizedPath (which never carries a trailing separator, since it comes
156
+ // from path.join) never starts with — so the check silently failed and
157
+ // fell through to the absolute-path branch even though homeRoot had
158
+ // already been normalized. Stripping a trailing separator from
159
+ // normalizedHome (root "/" excepted) before the prefix check closes this.
160
+ function tildeify(homeRoot, absPath) {
161
+ let normalizedHome = path.normalize(homeRoot)
162
+ if (normalizedHome.length > path.sep.length && normalizedHome.endsWith(path.sep)) {
163
+ normalizedHome = normalizedHome.slice(0, -path.sep.length)
164
+ }
165
+ const normalizedPath = path.normalize(absPath)
166
+ if (normalizedPath === normalizedHome) return '~'
167
+ if (normalizedPath.startsWith(normalizedHome + path.sep)) return '~' + normalizedPath.slice(normalizedHome.length)
168
+ return normalizedPath
169
+ }
170
+
171
+ // validateTargets — throws (usage error) when the caller asked for an id
172
+ // that does not exist in the declared universe for this command. Returns
173
+ // the requested id list unchanged (or null when no filter was requested).
174
+ function validateTargets(allIds, wanted) {
175
+ if (!wanted || !wanted.length) return null
176
+ for (const id of wanted) {
177
+ if (!allIds.includes(id)) throw new Error(`Unknown update target: ${id}`)
178
+ }
179
+ return wanted
180
+ }
181
+
182
+ function buildDocument(scope, dryRun, targets) {
183
+ return {
184
+ scope,
185
+ dry_run: dryRun,
186
+ targets: targets.map(t => {
187
+ const entry = { id: t.id, state: t.state, path: t.path }
188
+ if (t.state === 'failed' && t.message) entry.message = t.message
189
+ return entry
190
+ }),
191
+ summary: summarize(targets),
192
+ }
193
+ }
194
+
195
+ function humanReport(scope, dryRun, targets) {
196
+ const lines = [`trackfw update — scope: ${scope}${dryRun ? ' (dry-run)' : ''}`, '']
197
+ for (const t of targets) {
198
+ const mark = { updated: '✓', skipped: '·', missing: '—', failed: '✗' }[t.state] || '?'
199
+ const suffix = t.state === 'failed' && t.message ? ` (${t.message})` : ''
200
+ lines.push(` ${mark} ${t.id.padEnd(24)} ${t.state.padEnd(8)} ${t.path}${suffix}`)
201
+ }
202
+ const summary = summarize(targets)
203
+ lines.push('', `updated=${summary.updated} skipped=${summary.skipped} missing=${summary.missing} failed=${summary.failed}`)
204
+ return lines.join('\n')
205
+ }
206
+
207
+ module.exports = {
208
+ hashPath,
209
+ copyPath,
210
+ runFileTarget,
211
+ summarize,
212
+ tildeify,
213
+ validateTargets,
214
+ buildDocument,
215
+ humanReport,
216
+ silenceConsole,
217
+ }
@@ -1288,7 +1288,9 @@ module.exports = {
1288
1288
  listDir,
1289
1289
  tryListDir,
1290
1290
  resolveReqFiles,
1291
+ resolveStateDirs,
1291
1292
  resolveWIPDirs,
1293
+ resolveDoneDirs,
1292
1294
  readGovernanceMode,
1293
1295
  readWIPConfig,
1294
1296
  parseSquadFromFrontmatter,