trackfw 6.10.0 → 7.1.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 +0 -3
- package/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +59 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +75 -0
- package/src/commands/doctor.js +32 -0
- package/src/commands/index.js +23 -4
- package/src/commands/integrations.js +23 -1
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +320 -12
- package/src/commands/update.js +3 -1
- package/src/commit/runner.js +352 -0
- package/src/config/index.js +27 -0
- package/src/generators/hooks.js +925 -6
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +0 -21
- package/src/i18n/locales/es-ES.json +0 -21
- package/src/i18n/locales/pt-BR.json +0 -21
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +133 -19
- package/src/lib/fatal-error.js +64 -0
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +259 -95
- package/src/thirdparty/fetch.js +140 -0
- package/src/thirdparty/markers.js +207 -0
- package/src/thirdparty/provenance.js +114 -0
- package/src/thirdparty/quarantine.js +113 -0
- package/src/thirdparty/references.js +194 -0
- package/src/validator/index.js +1060 -27
- package/src/commands/plugins.js +0 -184
package/src/generators/init.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
|
+
const { readAgentConventions } = require('../config/index.js')
|
|
6
7
|
|
|
7
8
|
const GOV_DIRS = [
|
|
8
9
|
'docs/adr',
|
|
@@ -34,6 +35,7 @@ async function scaffold(cfg, cwd) {
|
|
|
34
35
|
generateValidateScript(cfg, root)
|
|
35
36
|
generateAttentionScripts(cfg, root)
|
|
36
37
|
generateCredentialGuardScript(root)
|
|
38
|
+
generateGitBranchGuardScript(root)
|
|
37
39
|
generateCIWorkflow(cfg, root)
|
|
38
40
|
generateGitHooks(cfg, root)
|
|
39
41
|
generateCommitMsgHook(cfg, root)
|
|
@@ -174,6 +176,11 @@ function generateCredentialGuardScript(cwd) {
|
|
|
174
176
|
gen(cwd || process.cwd())
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
function generateGitBranchGuardScript(cwd) {
|
|
180
|
+
const { generateGitBranchGuardScript: gen } = require('./hooks')
|
|
181
|
+
gen(cwd || process.cwd())
|
|
182
|
+
}
|
|
183
|
+
|
|
177
184
|
function injectHooksDetected(cwd) {
|
|
178
185
|
const { injectHooksDetected: inject } = require('./hooks')
|
|
179
186
|
inject(cwd || process.cwd())
|
|
@@ -392,7 +399,20 @@ const AGENT_HEADERS = {
|
|
|
392
399
|
cursor: '---\ndescription: trackfw governance rules\nglob: "**/*"\nalwaysApply: true\n---\n',
|
|
393
400
|
}
|
|
394
401
|
|
|
395
|
-
function trackfwRulesBlock() {
|
|
402
|
+
function trackfwRulesBlock(agentConventions) {
|
|
403
|
+
let conventionsSection = ''
|
|
404
|
+
if (agentConventions && String(agentConventions).trim() !== '') {
|
|
405
|
+
conventionsSection = `
|
|
406
|
+
|
|
407
|
+
### Project Conventions
|
|
408
|
+
> Declared by the team in \`trackfw.yaml\`'s \`agent_conventions\` field — NOT
|
|
409
|
+
> inferred automatically. trackfw does not impose an architectural standard; it only
|
|
410
|
+
> propagates what the project has already decided.
|
|
411
|
+
|
|
412
|
+
${String(agentConventions).trim()}
|
|
413
|
+
`
|
|
414
|
+
}
|
|
415
|
+
|
|
396
416
|
return RULES_START + `
|
|
397
417
|
## trackfw — Governance Rules
|
|
398
418
|
|
|
@@ -435,7 +455,7 @@ Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
|
|
|
435
455
|
- **Security wave:** include a red-team review wave in every feature roadmap
|
|
436
456
|
- **Test coverage:** TDD for critical logic; min 60% (prototype) / 80% (production)
|
|
437
457
|
- Use \`/trackfw:architect\` to define stack before the first REQ
|
|
438
|
-
|
|
458
|
+
` + conventionsSection + `
|
|
439
459
|
### Key Commands
|
|
440
460
|
- \`trackfw context\` — current governance state (always run first)
|
|
441
461
|
- \`trackfw status\` — all artifacts and states
|
|
@@ -451,10 +471,10 @@ Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
|
|
|
451
471
|
* - Arquivo existe, sem marcador: appenda o bloco no final
|
|
452
472
|
* - Arquivo existe, com marcador: substitui conteúdo entre marcadores
|
|
453
473
|
*/
|
|
454
|
-
function injectOrUpdateRules(filePath, headerIfNew) {
|
|
474
|
+
function injectOrUpdateRules(filePath, headerIfNew, cwd) {
|
|
455
475
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
456
476
|
|
|
457
|
-
const block = trackfwRulesBlock()
|
|
477
|
+
const block = trackfwRulesBlock(readAgentConventions(cwd))
|
|
458
478
|
|
|
459
479
|
if (!fs.existsSync(filePath)) {
|
|
460
480
|
let content = headerIfNew || ''
|
|
@@ -497,7 +517,8 @@ function injectRulesForTool(tool, cwd) {
|
|
|
497
517
|
const relPath = AGENT_FILES[tool]
|
|
498
518
|
if (!relPath) return
|
|
499
519
|
const header = AGENT_HEADERS[tool] || ''
|
|
500
|
-
|
|
520
|
+
const root = cwd || process.cwd()
|
|
521
|
+
injectOrUpdateRules(path.join(root, relPath), header, root)
|
|
501
522
|
}
|
|
502
523
|
|
|
503
524
|
/**
|
|
@@ -702,7 +723,7 @@ function generateClaudeMD(cfg) {
|
|
|
702
723
|
content += 'Decide low-risk details autonomously following existing project conventions, '
|
|
703
724
|
content += 'and record autonomous decisions in the commit message.\n'
|
|
704
725
|
|
|
705
|
-
injectOrUpdateRules('CLAUDE.md', content)
|
|
726
|
+
injectOrUpdateRules('CLAUDE.md', content, '.')
|
|
706
727
|
console.log(' ✓ CLAUDE.md')
|
|
707
728
|
}
|
|
708
729
|
|
|
@@ -1329,6 +1350,7 @@ module.exports = {
|
|
|
1329
1350
|
generateValidateScript,
|
|
1330
1351
|
generateAttentionScripts,
|
|
1331
1352
|
generateCredentialGuardScript,
|
|
1353
|
+
generateGitBranchGuardScript,
|
|
1332
1354
|
generateCIWorkflow,
|
|
1333
1355
|
generateGitHooks,
|
|
1334
1356
|
generateCommitMsgHook,
|
|
@@ -115,31 +115,10 @@
|
|
|
115
115
|
"tail": "Number of recent transitions to show",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Manage trackfw plugins",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "List installed plugins",
|
|
122
|
-
"empty": "No plugins installed. Use `trackfw plugins add <user/repo>` to install one."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Install a plugin from GitHub Releases (user/repo or user/repo@tag)",
|
|
126
|
-
"installing": "Installing plugin from {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" installed successfully."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Remove an installed plugin",
|
|
131
|
-
"success": "Plugin \"{{name}}\" removed."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Show delivery metrics",
|
|
136
120
|
"no_data": "No transitions recorded yet.",
|
|
137
121
|
"since": "Filter by period (e.g. 7d, 30d, 90d)",
|
|
138
122
|
"export": "Export metrics to CSV file"
|
|
139
|
-
},
|
|
140
|
-
"errors": {
|
|
141
|
-
"notFound": "Not found: {{path}}",
|
|
142
|
-
"downloadFailed": "download failed: HTTP {{status}} for {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" not found"
|
|
144
123
|
}
|
|
145
124
|
}
|
|
@@ -115,31 +115,10 @@
|
|
|
115
115
|
"tail": "Número de transiciones recientes a mostrar",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Gestionar plugins de trackfw",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "Listar plugins instalados",
|
|
122
|
-
"empty": "No hay plugins instalados. Usa `trackfw plugins add <user/repo>` para instalar uno."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Instalar un plugin desde GitHub Releases (user/repo o user/repo@tag)",
|
|
126
|
-
"installing": "Instalando plugin desde {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" instalado correctamente."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Eliminar un plugin instalado",
|
|
131
|
-
"success": "Plugin \"{{name}}\" eliminado."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Muestra métricas de entrega",
|
|
136
120
|
"no_data": "No hay transiciones registradas aún.",
|
|
137
121
|
"since": "Filtrar por período (ej: 7d, 30d, 90d)",
|
|
138
122
|
"export": "Exportar métricas a archivo CSV"
|
|
139
|
-
},
|
|
140
|
-
"errors": {
|
|
141
|
-
"notFound": "No encontrado: {{path}}",
|
|
142
|
-
"downloadFailed": "fallo en la descarga: HTTP {{status}} para {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" no encontrado"
|
|
144
123
|
}
|
|
145
124
|
}
|
|
@@ -115,31 +115,10 @@
|
|
|
115
115
|
"tail": "Número de transições recentes a exibir",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Gerenciar plugins do trackfw",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "Listar plugins instalados",
|
|
122
|
-
"empty": "Nenhum plugin instalado. Use `trackfw plugins add <user/repo>` para instalar um."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Instalar um plugin do GitHub Releases (user/repo ou user/repo@tag)",
|
|
126
|
-
"installing": "Instalando plugin de {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" instalado com sucesso."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Remover um plugin instalado",
|
|
131
|
-
"success": "Plugin \"{{name}}\" removido."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Exibe métricas de delivery",
|
|
136
120
|
"no_data": "Nenhuma transição registrada ainda.",
|
|
137
121
|
"since": "Filtrar por período (ex: 7d, 30d, 90d)",
|
|
138
122
|
"export": "Exportar métricas para arquivo CSV"
|
|
139
|
-
},
|
|
140
|
-
"errors": {
|
|
141
|
-
"notFound": "Não encontrado: {{path}}",
|
|
142
|
-
"downloadFailed": "falha no download: HTTP {{status}} para {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" não encontrado"
|
|
144
123
|
}
|
|
145
124
|
}
|
|
Binary file
|
|
@@ -8,6 +8,7 @@ const { IntegrationManager } = require('./manager')
|
|
|
8
8
|
const { legacyHashes } = require('./legacy')
|
|
9
9
|
const identityStore = require('../identity')
|
|
10
10
|
const { injectRulesForTool } = require('../generators/init')
|
|
11
|
+
const { applyThirdPartyReferences } = require('../thirdparty/references')
|
|
11
12
|
|
|
12
13
|
function parseSurfaces(values = []) {
|
|
13
14
|
const result = {}
|
|
@@ -55,7 +56,17 @@ function buildPlans(kind, options = {}) {
|
|
|
55
56
|
for (const item of selected.itemEntries) {
|
|
56
57
|
for (const installPath of paths) {
|
|
57
58
|
const destination = installPath.path.replace('{{id}}', item.id)
|
|
58
|
-
|
|
59
|
+
let content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination, identity: identityConfig })
|
|
60
|
+
// D5/D9 extension point: reproduce any persisted third-party
|
|
61
|
+
// reference block so regenerating this exact artifact (e.g. a
|
|
62
|
+
// later `trackfw agents update`) settles at state "current"
|
|
63
|
+
// instead of treating the attachment as drift. See the doc
|
|
64
|
+
// comment on applyThirdPartyReferences (../thirdparty/references.js)
|
|
65
|
+
// for why this cannot live inside render() itself. Mirrors
|
|
66
|
+
// internal/integrations/plan.go:BuildPlans.
|
|
67
|
+
if (kind === 'agents' && options.projectRoot) {
|
|
68
|
+
content = applyThirdPartyReferences(options.projectRoot, content, targetEntry.id, item.id)
|
|
69
|
+
}
|
|
59
70
|
const claim = { target: targetEntry.id, surface: surface.id, scope, kind, item: item.id }
|
|
60
71
|
plans.push({
|
|
61
72
|
claim,
|
|
@@ -100,8 +111,14 @@ function execute(kind, operation, options = {}, roots = {}) {
|
|
|
100
111
|
// executeIntegrationMutation/executeIntegrationList).
|
|
101
112
|
const homeRoot = roots.homeRoot || os.homedir()
|
|
102
113
|
const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
|
|
103
|
-
const plans = buildPlans(kind, { ...options, identity: identityConfig })
|
|
104
114
|
const manager = new IntegrationManager(roots, { onSkip: options.onSkip })
|
|
115
|
+
// D5/D9: manager.roots.project lets buildPlans reproduce any persisted
|
|
116
|
+
// third-party reference block (see the projectRoot doc comment above the
|
|
117
|
+
// applyThirdPartyReferences call in buildPlans) so a plain `agents update`
|
|
118
|
+
// settles at state "current" after a third-party attachment instead of
|
|
119
|
+
// reporting drift. Mirrors internal/commands/integrations_flags.go passing
|
|
120
|
+
// manager.ProjectRoot to BuildPlans at both call sites.
|
|
121
|
+
const plans = buildPlans(kind, { ...options, identity: identityConfig, projectRoot: options.projectRoot || manager.roots.project })
|
|
105
122
|
let statuses
|
|
106
123
|
if (operation === 'list') statuses = manager.inspect(plans)
|
|
107
124
|
else if (operation === 'install') statuses = manager.install(plans, { force: options.force })
|
|
@@ -10,13 +10,34 @@ const { tildeify } = require('../lib/update-engine')
|
|
|
10
10
|
|
|
11
11
|
const SCHEMA_VERSION = 1
|
|
12
12
|
const sha256 = content => crypto.createHash('sha256').update(content).digest('hex')
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// claimKey includes origin (ADR-2026-08-15 D11) so a catalog claim and a
|
|
14
|
+
// third-party claim that happen to share target/surface/scope/kind/item are
|
|
15
|
+
// never treated as the same ownership record — mirrors Go's Claim struct
|
|
16
|
+
// equality (`==`), field-by-field, which already includes Origin
|
|
17
|
+
// (internal/integrations/manager.go's claimOwned/appendClaim/removeClaim).
|
|
18
|
+
const claimKey = claim => [claim.target, claim.surface, claim.scope, claim.kind, claim.item, claim.origin || ''].join('\u0000')
|
|
19
|
+
// cleanClaim omits origin entirely when falsy/absent, mirroring Go's
|
|
20
|
+
// `json:"origin,omitempty"` — a catalog claim (origin "") must serialize
|
|
21
|
+
// with NO "origin" key at all, byte-identical to a manifest written before
|
|
22
|
+
// this field existed (retrocompatibility, D11).
|
|
23
|
+
const cleanClaim = claim => {
|
|
24
|
+
const out = { target: claim.target, surface: claim.surface, scope: claim.scope, kind: claim.kind, item: claim.item }
|
|
25
|
+
if (claim.origin) out.origin = claim.origin
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
class IntegrationManager {
|
|
17
30
|
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
|
|
18
31
|
this.roots = { project: path.resolve(projectRoot), global: path.resolve(homeRoot) }
|
|
19
32
|
this.onSkip = onSkip
|
|
33
|
+
// _afterManifestPersist, when set, runs immediately after manifests are
|
|
34
|
+
// persisted and before artifact bytes are written during install/update
|
|
35
|
+
// (never during uninstall, which is intentionally not inverted — see the
|
|
36
|
+
// comment in mutate()). It exists only so tests can simulate an
|
|
37
|
+
// interruption exactly at the ADR-2026-08-18 ordering seam. Production
|
|
38
|
+
// code never assigns it. Mirrors internal/integrations/manager.go's
|
|
39
|
+
// afterManifestPersist package var.
|
|
40
|
+
this._afterManifestPersist = undefined
|
|
20
41
|
}
|
|
21
42
|
|
|
22
43
|
manifestPath(scope) { return path.join(this.roots[scope], '.trackfw', 'integrations-manifest.json') }
|
|
@@ -105,7 +126,16 @@ class IntegrationManager {
|
|
|
105
126
|
const file = this.resolve(scope, plan.destination)
|
|
106
127
|
const record = manifests.get(scope).artifacts[file]
|
|
107
128
|
const managed = Boolean(record && record.claims.some(claim => claimKey(claim) === claimKey(plan.claim)))
|
|
108
|
-
|
|
129
|
+
// registered reports whether the manifest has ANY entry for this
|
|
130
|
+
// destination, regardless of claim ownership — unlike managed, which
|
|
131
|
+
// additionally requires this exact claim to own that entry. doctor
|
|
132
|
+
// (ML-2A) needs this distinction: a destination registered under a
|
|
133
|
+
// *different* claim must never be reported as an "unregistered
|
|
134
|
+
// write" — that would be the dominant false-positive doctor exists to
|
|
135
|
+
// avoid. Additive field; mirrors Inspection.Registered
|
|
136
|
+
// (internal/integrations/manager.go).
|
|
137
|
+
const registered = Boolean(record)
|
|
138
|
+
if (!fs.existsSync(file)) return { ...plan, destination: file, state: 'not-installed', managed, registered }
|
|
109
139
|
const actual = sha256(fs.readFileSync(file))
|
|
110
140
|
const desired = sha256(plan.content)
|
|
111
141
|
let state
|
|
@@ -116,7 +146,7 @@ class IntegrationManager {
|
|
|
116
146
|
} else if (actual === desired) state = 'current'
|
|
117
147
|
else if ((plan.legacyHashes || []).includes(actual)) state = 'outdated'
|
|
118
148
|
else state = 'modified'
|
|
119
|
-
return { ...plan, destination: file, state, managed }
|
|
149
|
+
return { ...plan, destination: file, state, managed, registered }
|
|
120
150
|
})
|
|
121
151
|
}
|
|
122
152
|
|
|
@@ -154,9 +184,44 @@ class IntegrationManager {
|
|
|
154
184
|
const snapshots = new Map()
|
|
155
185
|
for (const item of active) this.snapshot(snapshots, item.file)
|
|
156
186
|
for (const scope of manifests.keys()) this.snapshot(snapshots, this.manifestPath(scope))
|
|
157
|
-
|
|
158
|
-
for (const item of active) this.apply(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
187
|
+
const saveManifests = () => {
|
|
159
188
|
for (const [scope, manifest] of [...manifests].sort(([a], [b]) => a.localeCompare(b))) this.saveManifest(scope, manifest)
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
// ADR-2026-08-18: install/update persist the manifest before writing
|
|
192
|
+
// artifact bytes (self-healing direction on interruption); uninstall
|
|
193
|
+
// keeps the original artifacts-before-manifest order. The two are
|
|
194
|
+
// deliberately NOT symmetric.
|
|
195
|
+
if (operation === 'uninstall') {
|
|
196
|
+
// Uninstall is not inverted. Removing bytes first and persisting the
|
|
197
|
+
// manifest last means an interruption leaves the manifest still
|
|
198
|
+
// declaring an artifact whose file is now absent — inspectResolved
|
|
199
|
+
// resolves that as 'not-installed', the same self-healing direction
|
|
200
|
+
// install/update get from the inversion below. Inverting uninstall
|
|
201
|
+
// the same way (drop the manifest entry, then remove bytes) would
|
|
202
|
+
// instead leave, on interruption, a file on disk whose content still
|
|
203
|
+
// matches the catalog template but with no manifest entry at all —
|
|
204
|
+
// reported as an orphaned state='current'/managed=false artifact
|
|
205
|
+
// that looks legitimate and that nothing detects or repairs
|
|
206
|
+
// automatically. That is exactly the "disk ahead of manifest" bad
|
|
207
|
+
// direction the ADR exists to eliminate, so uninstall must not be
|
|
208
|
+
// simetrized with install/update here. Mirrors the comment in
|
|
209
|
+
// internal/integrations/manager.go:mutate.
|
|
210
|
+
for (const item of active) this.applyUninstall(item, manifests.get(item.plan.claim.scope))
|
|
211
|
+
saveManifests()
|
|
212
|
+
} else {
|
|
213
|
+
// Install/Update: compute the manifest update for every active item
|
|
214
|
+
// in memory (no bytes touched yet), persist all manifests, and only
|
|
215
|
+
// then write the artifact bytes.
|
|
216
|
+
const pendingWrites = []
|
|
217
|
+
for (const item of active) {
|
|
218
|
+
const write = this.planArtifactWrite(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
219
|
+
if (write) pendingWrites.push(write)
|
|
220
|
+
}
|
|
221
|
+
saveManifests()
|
|
222
|
+
if (typeof this._afterManifestPersist === 'function') this._afterManifestPersist()
|
|
223
|
+
for (const write of pendingWrites) this.atomicWrite(write.file, write.content, write.mode)
|
|
224
|
+
}
|
|
160
225
|
} catch (error) {
|
|
161
226
|
this.rollback(snapshots)
|
|
162
227
|
throw error
|
|
@@ -173,7 +238,7 @@ class IntegrationManager {
|
|
|
173
238
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
174
239
|
if (status.state === 'outdated' && owned && !force) return true // skip: bytes preserved, batch continues
|
|
175
240
|
} else if (operation === 'update') {
|
|
176
|
-
if (!owned && status.state === 'modified') throw new Error(
|
|
241
|
+
if (!owned && status.state === 'modified') throw new Error(this.unmanagedArtifactError(file, plan.claim))
|
|
177
242
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
178
243
|
} else if (operation === 'uninstall' && owned && status.state === 'modified' && !force) {
|
|
179
244
|
throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
@@ -243,19 +308,42 @@ class IntegrationManager {
|
|
|
243
308
|
return { state: 'modified', managed: false }
|
|
244
309
|
}
|
|
245
310
|
|
|
246
|
-
|
|
311
|
+
// applyUninstall removes ownership of one artifact from the manifest, and —
|
|
312
|
+
// once no claim remains — the artifact's bytes and any empty ancestor
|
|
313
|
+
// directories it managed. It mutates disk directly (not deferred), because
|
|
314
|
+
// uninstall deliberately keeps the pre-ADR-2026-08-18 ordering: see the
|
|
315
|
+
// comment in mutate() for why this is not simetrized with
|
|
316
|
+
// planArtifactWrite. Mirrors internal/integrations/manager.go:applyUninstall.
|
|
317
|
+
applyUninstall({ plan, file }, manifest) {
|
|
318
|
+
const record = manifest.artifacts[file]
|
|
319
|
+
const key = claimKey(plan.claim)
|
|
320
|
+
const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === key))
|
|
321
|
+
if (!owned) return
|
|
322
|
+
record.claims = record.claims.filter(claim => claimKey(claim) !== key)
|
|
323
|
+
if (record.claims.length) return
|
|
324
|
+
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
325
|
+
delete manifest.artifacts[file]
|
|
326
|
+
this.cleanEmpty(path.dirname(file), this.roots[plan.claim.scope])
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// planArtifactWrite computes the manifest update for one install/update
|
|
330
|
+
// item entirely in memory — it never touches the artifact's bytes on disk.
|
|
331
|
+
// The caller (mutate) persists every manifest in the batch first, and only
|
|
332
|
+
// then applies the returned pending write (ADR-2026-08-18). Returns null
|
|
333
|
+
// when no byte write is needed.
|
|
334
|
+
//
|
|
335
|
+
// The manifest values it stores are deliberately *optimistic* when a write
|
|
336
|
+
// is pending: sha256/catalog_version describe the content this call is
|
|
337
|
+
// about to write, not what is currently on disk. If interrupted before the
|
|
338
|
+
// pending write lands, the manifest already declares the target state and
|
|
339
|
+
// inspectResolved resolves the (absent or stale) file to
|
|
340
|
+
// 'not-installed'/'modified', both self-repairable by a later
|
|
341
|
+
// install/update, never modified+unowned ("unmanaged"). Mirrors
|
|
342
|
+
// internal/integrations/manager.go:planArtifactWrite.
|
|
343
|
+
planArtifactWrite(operation, { plan, file }, manifest, force) {
|
|
247
344
|
let record = manifest.artifacts[file]
|
|
248
345
|
const key = claimKey(plan.claim)
|
|
249
346
|
const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === key))
|
|
250
|
-
if (operation === 'uninstall') {
|
|
251
|
-
if (!owned) return
|
|
252
|
-
record.claims = record.claims.filter(claim => claimKey(claim) !== key)
|
|
253
|
-
if (record.claims.length) return
|
|
254
|
-
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
255
|
-
delete manifest.artifacts[file]
|
|
256
|
-
this.cleanEmpty(path.dirname(file), this.roots[plan.claim.scope])
|
|
257
|
-
return
|
|
258
|
-
}
|
|
259
347
|
|
|
260
348
|
const exists = fs.existsSync(file)
|
|
261
349
|
let actual = exists ? sha256(fs.readFileSync(file)) : ''
|
|
@@ -265,16 +353,29 @@ class IntegrationManager {
|
|
|
265
353
|
if (exists && !owned) writeDesired = (operation === 'update' && actual !== desired) || (force && actual !== desired)
|
|
266
354
|
else if (exists && owned) writeDesired = actual !== desired
|
|
267
355
|
if (!record) record = { destination: file, sha256: '', catalog_version: '', claims: [] }
|
|
356
|
+
let pending = null
|
|
268
357
|
if (writeDesired) {
|
|
269
|
-
|
|
358
|
+
// Optimistic: bytes have not moved yet, but the manifest must already
|
|
359
|
+
// describe the content we are about to write (see doc comment above).
|
|
270
360
|
actual = desired
|
|
361
|
+
pending = { file, content: plan.content, mode: 0o644 }
|
|
271
362
|
} else if (exists && !owned && actual !== desired && !knownLegacy && !force) {
|
|
272
|
-
|
|
363
|
+
// Defense-in-depth: preflight already rejects this exact case for
|
|
364
|
+
// 'update' (unconditionally) and for 'install' without --force (any
|
|
365
|
+
// state === 'modified' is blocked before an active item ever reaches
|
|
366
|
+
// planArtifactWrite()). This branch is therefore not reachable via
|
|
367
|
+
// install/update today, but stays as a second line of defense in case
|
|
368
|
+
// preflight's guard is ever loosened — hence the identical remediation
|
|
369
|
+
// text, so a user who somehow hits it still gets the same actionable
|
|
370
|
+
// message. No manifest mutation happens on this path. Mirrors
|
|
371
|
+
// internal/integrations/manager.go:planArtifactWrite.
|
|
372
|
+
throw new Error(this.unmanagedArtifactError(file, plan.claim))
|
|
273
373
|
}
|
|
274
374
|
if (!record.claims.some(claim => claimKey(claim) === key)) record.claims.push(cleanClaim(plan.claim))
|
|
275
375
|
record.sha256 = actual
|
|
276
376
|
record.catalog_version = actual === desired ? plan.catalogVersion : 'legacy'
|
|
277
377
|
manifest.artifacts[file] = record
|
|
378
|
+
return pending
|
|
278
379
|
}
|
|
279
380
|
|
|
280
381
|
snapshot(snapshots, file) {
|
|
@@ -292,6 +393,19 @@ class IntegrationManager {
|
|
|
292
393
|
}
|
|
293
394
|
}
|
|
294
395
|
|
|
396
|
+
// unmanagedArtifactError — mensagem de erro para update/uninstall (ou,
|
|
397
|
+
// defensivamente, install) recusando bytes que o trackfw não escreveu.
|
|
398
|
+
// Nomeia o remédio — trackfw did not write these bytes, então a única forma
|
|
399
|
+
// segura de trazer o artefato sob gestão é `<kind> install --force`, que
|
|
400
|
+
// explicitamente autoriza adotar/substituir conteúdo unmanaged — com as
|
|
401
|
+
// flags exatas para reproduzir o claim deste plano (item, target, scope),
|
|
402
|
+
// prontas para copiar e colar. Espelha
|
|
403
|
+
// internal/integrations/manager.go:unmanagedArtifactError — fonte canônica
|
|
404
|
+
// do texto, byte a byte idêntica nos 3 CLIs.
|
|
405
|
+
unmanagedArtifactError(file, claim) {
|
|
406
|
+
return `unmanaged artifact "${file}" does not match a trackfw template — trackfw did not write these bytes.\nAdopt it with: trackfw ${claim.kind} install --force --items ${claim.item} --targets ${claim.target} --scope ${claim.scope}`
|
|
407
|
+
}
|
|
408
|
+
|
|
295
409
|
// tildeAbbrev — retorna o caminho de exibição abreviado para uso em mensagens
|
|
296
410
|
// de aviso de skip. Espelha internal/integrations/manager.go:tildeAbbrev.
|
|
297
411
|
// - scope global: substitui homeRoot por '~' (via tildeify, com salvaguarda
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
|
|
5
|
+
// fatal-error.js — global backstop for errors that escape every commander
|
|
6
|
+
// action (sync or async, since npm/bin/trackfw drives the program via
|
|
7
|
+
// parseAsync()). Installed ONCE at the entrypoint, never per-command:
|
|
8
|
+
// the real leak pattern (REQ-2026-08-16-erro-nao-tratado-no-cli-node-vaza-
|
|
9
|
+
// stack-trace-caminhos-absolutos-e-versao-do-runtime) was per-command —
|
|
10
|
+
// commands that catch internally (e.g. `roadmap move`) already print a
|
|
11
|
+
// clean message via console.error + process.exitCode, but any command that
|
|
12
|
+
// doesn't catch let Node's own uncaught-exception/unhandled-rejection
|
|
13
|
+
// printer dump the full stack, absolute install path and runtime version.
|
|
14
|
+
// A single handler at the entrypoint closes every one of those gaps at
|
|
15
|
+
// once instead of relying on every future command remembering a try/catch.
|
|
16
|
+
//
|
|
17
|
+
// TRACKFW_DEBUG=1 restores the full stack — this must never blind someone
|
|
18
|
+
// who genuinely needs to debug a crash.
|
|
19
|
+
//
|
|
20
|
+
// Writes with fs.writeSync(2, ...) — NOT process.stderr.write() — on
|
|
21
|
+
// purpose: installGlobalHandlers() below calls process.exit() right after
|
|
22
|
+
// this returns, and when stderr is a pipe (exactly what every spawnSync-
|
|
23
|
+
// based test in this repo uses, and what a piped CI log is) Node's stream
|
|
24
|
+
// writes are asynchronous; process.exit() can drop a still-pending write
|
|
25
|
+
// before the OS ever sees it, silently eating the message (failure mode
|
|
26
|
+
// #2 in the REQ: "engolir a mensagem" — worse than the leak this handler
|
|
27
|
+
// exists to fix). fs.writeSync is a raw, synchronous syscall — it cannot
|
|
28
|
+
// race process.exit().
|
|
29
|
+
function reportFatalError(err) {
|
|
30
|
+
// TRACKFW_DEBUG=1: print the full stack (its first line already is
|
|
31
|
+
// "Error: <message>", so this is the debug-only superset of the clean
|
|
32
|
+
// path below, not a duplicate of it).
|
|
33
|
+
if (process.env.TRACKFW_DEBUG === '1' && err && err.stack) {
|
|
34
|
+
fs.writeSync(2, `${err.stack}\n`)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const message = err && err.message !== undefined ? err.message : String(err)
|
|
38
|
+
fs.writeSync(2, `Error: ${message}\n`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// installGlobalHandlers — defense in depth beyond the parseAsync().catch()
|
|
42
|
+
// in bin/trackfw. Covers errors that reach the process outside that single
|
|
43
|
+
// promise chain (e.g. a rejection from a detached/floating promise started
|
|
44
|
+
// by a command, or a genuine uncaughtException). Per Node's own contract,
|
|
45
|
+
// once a listener is attached Node no longer prints its default trace and
|
|
46
|
+
// no longer auto-exits — the listener owns both the message and the exit.
|
|
47
|
+
// Both listeners call process.exit() immediately after reportFatalError()
|
|
48
|
+
// returns — safe here specifically because reportFatalError writes
|
|
49
|
+
// synchronously (see comment above); process.exitCode (which waits for the
|
|
50
|
+
// event loop to drain) would be wrong here regardless, since long-running
|
|
51
|
+
// commands (e.g. `serve`) hold the loop open and the process would hang
|
|
52
|
+
// instead of exiting on a genuinely fatal error.
|
|
53
|
+
function installGlobalHandlers() {
|
|
54
|
+
process.on('unhandledRejection', (err) => {
|
|
55
|
+
reportFatalError(err)
|
|
56
|
+
process.exit(1)
|
|
57
|
+
})
|
|
58
|
+
process.on('uncaughtException', (err) => {
|
|
59
|
+
reportFatalError(err)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { reportFatalError, installGlobalHandlers }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// unknown-command.js — canonical cross-CLI "unknown command" message and the
|
|
4
|
+
// shared suggestion algorithm.
|
|
5
|
+
//
|
|
6
|
+
// ADR-2026-08-15-remocao-do-subsistema-de-plugins-em-vez-de-gate-de-binario-de-
|
|
7
|
+
// terceiro.md (D3): argument dispatch to an external `trackfw-<name>` binary was
|
|
8
|
+
// removed together with the plugin subsystem. An unrecognized top-level command
|
|
9
|
+
// now always produces the message below instead — in all three CLIs.
|
|
10
|
+
//
|
|
11
|
+
// The plain (no-transposition) Levenshtein distance and the suggestion-picking
|
|
12
|
+
// rule here are reimplemented IDENTICALLY in Go (internal/commands/root.go,
|
|
13
|
+
// suggestCommand/levenshteinDistance) and Python (pypi/trackfw/cli.py,
|
|
14
|
+
// _suggest_command/_levenshtein_distance) — deliberately NOT delegated to each
|
|
15
|
+
// framework's own suggestion helper (cobra's Command.SuggestionsFor, commander's
|
|
16
|
+
// suggestSimilar), which use different distance functions/thresholds and would
|
|
17
|
+
// make the three CLIs disagree on whether/what to suggest for the same typo.
|
|
18
|
+
// Parity is enforced by scripts/check-unknown-command-parity.sh.
|
|
19
|
+
|
|
20
|
+
function levenshteinDistance(a, b) {
|
|
21
|
+
const la = a.length
|
|
22
|
+
const lb = b.length
|
|
23
|
+
const d = []
|
|
24
|
+
for (let i = 0; i <= la; i++) {
|
|
25
|
+
d.push(new Array(lb + 1).fill(0))
|
|
26
|
+
d[i][0] = i
|
|
27
|
+
}
|
|
28
|
+
for (let j = 0; j <= lb; j++) {
|
|
29
|
+
d[0][j] = j
|
|
30
|
+
}
|
|
31
|
+
for (let i = 1; i <= la; i++) {
|
|
32
|
+
for (let j = 1; j <= lb; j++) {
|
|
33
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
34
|
+
d[i][j] = Math.min(
|
|
35
|
+
d[i - 1][j] + 1, // deletion
|
|
36
|
+
d[i][j - 1] + 1, // insertion
|
|
37
|
+
d[i - 1][j - 1] + cost, // substitution
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return d[la][lb]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// suggestCommand — a candidate is eligible when its case-insensitive
|
|
45
|
+
// Levenshtein distance to `typed` is <= 2, OR it is a case-insensitive prefix
|
|
46
|
+
// match (candidate starts with the typed text). Among eligible candidates the
|
|
47
|
+
// winner is the one with the lowest distance, alphabetical tie-break, so the
|
|
48
|
+
// pick is deterministic and single — matching Go/Python exactly.
|
|
49
|
+
function suggestCommand(typed, candidates) {
|
|
50
|
+
const lowerTyped = typed.toLowerCase()
|
|
51
|
+
let bestDist = -1
|
|
52
|
+
let best = ''
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
const lowerC = candidate.toLowerCase()
|
|
55
|
+
const dist = levenshteinDistance(lowerTyped, lowerC)
|
|
56
|
+
const prefixMatch = lowerTyped.length > 0 && lowerC.startsWith(lowerTyped)
|
|
57
|
+
if (dist > 2 && !prefixMatch) continue
|
|
58
|
+
if (bestDist === -1 || dist < bestDist || (dist === bestDist && candidate < best)) {
|
|
59
|
+
bestDist = dist
|
|
60
|
+
best = candidate
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return bestDist === -1 ? null : best
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// formatUnknownCommandError — canonical message, byte-identical across the
|
|
67
|
+
// three CLIs modulo the typed name and the suggestion (present only when
|
|
68
|
+
// suggestCommand finds an eligible candidate):
|
|
69
|
+
// Error: unknown command "x" for "trackfw"
|
|
70
|
+
// Did you mean "validate"?
|
|
71
|
+
// Run 'trackfw --help' for usage.
|
|
72
|
+
function formatUnknownCommandError(typed, candidates, cmdPath) {
|
|
73
|
+
const lines = [`Error: unknown command "${typed}" for "${cmdPath}"`]
|
|
74
|
+
const suggestion = suggestCommand(typed, candidates)
|
|
75
|
+
if (suggestion) {
|
|
76
|
+
lines.push(`Did you mean "${suggestion}"?`)
|
|
77
|
+
}
|
|
78
|
+
lines.push(`Run '${cmdPath} --help' for usage.`)
|
|
79
|
+
return lines.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { levenshteinDistance, suggestCommand, formatUnknownCommandError }
|