trackfw 7.0.0 → 7.2.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/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/commands/branch.js +47 -0
- package/src/commands/commit.js +7 -3
- package/src/commands/discover.js +6 -0
- package/src/commands/doctor.js +33 -0
- package/src/commands/index.js +3 -0
- package/src/commands/integrations.js +79 -2
- package/src/commands/push.js +51 -0
- package/src/commands/release.js +47 -0
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +10 -2
- package/src/commands/update-harness.js +319 -12
- package/src/commands/update.js +1 -1
- package/src/config/index.js +17 -2
- package/src/generators/hooks.js +650 -45
- package/src/generators/init.js +12 -0
- package/src/i18n/locales/en-US.json +0 -3
- package/src/i18n/locales/es-ES.json +0 -3
- package/src/i18n/locales/pt-BR.json +0 -3
- package/src/integrations/assets/agents/architect.md +12 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +1 -1
- package/src/integrations/manager.js +118 -17
- package/src/integrations/render.js +116 -2
- package/src/lib/fatal-error.js +64 -0
- package/src/push/runner.js +223 -0
- package/src/release/runner.js +474 -0
- package/src/ship/runner.js +226 -93
- package/src/validator/index.js +686 -95
package/src/generators/init.js
CHANGED
|
@@ -723,6 +723,18 @@ function generateClaudeMD(cfg) {
|
|
|
723
723
|
content += 'Decide low-risk details autonomously following existing project conventions, '
|
|
724
724
|
content += 'and record autonomous decisions in the commit message.\n'
|
|
725
725
|
|
|
726
|
+
content += '\n## Architect responses\n\n'
|
|
727
|
+
content += 'Default: what changed · what was decided · what is needed from you. Three to five lines.\n\n'
|
|
728
|
+
content += 'Scale up only on these three triggers, and only on them: a **blocker** that stops the next wave; '
|
|
729
|
+
content += 'a **pending user decision** that cannot be inferred from context; '
|
|
730
|
+
content += 'an **error the architect made** that cannot be self-corrected.\n\n'
|
|
731
|
+
content += 'Never cut, even when short: measured evidence (command and result), barrier verdict, decision taken and why. '
|
|
732
|
+
content += 'A response that buries a blocker in paragraph seven produced the same effect as not reporting it.\n\n'
|
|
733
|
+
content += 'Cut: restating what an executor already reported, re-explaining reasoning already given, '
|
|
734
|
+
content += 'recapping state that has not changed, closing praise. '
|
|
735
|
+
content += 'Tables and code blocks only when they replace prose, never when they add to it.\n\n'
|
|
736
|
+
content += 'Depth is on demand from the user.\n'
|
|
737
|
+
|
|
726
738
|
injectOrUpdateRules('CLAUDE.md', content, '.')
|
|
727
739
|
console.log(' ✓ CLAUDE.md')
|
|
728
740
|
}
|
|
@@ -44,4 +44,16 @@ Before releasing the next wave, verify each acceptance criterion yourself: read
|
|
|
44
44
|
## Mission
|
|
45
45
|
Map the existing architecture and traceability chain before proposing changes. Record material decisions as ADRs, produce decision-complete plans, and delegate implementation to the appropriate specialist. Do not implement product code.
|
|
46
46
|
|
|
47
|
+
## Response length
|
|
48
|
+
|
|
49
|
+
Default: what changed · what I decided · what I need from you. Three to five lines.
|
|
50
|
+
|
|
51
|
+
Scale up only on these three triggers, and only on them: a **blocker** that stops the next wave; a **pending user decision** that cannot be inferred from context; an **error I made** that cannot be self-corrected.
|
|
52
|
+
|
|
53
|
+
Never cut, even when short: measured evidence (command and result), barrier verdict, decision taken and why. A response that buries a blocker in paragraph seven produced the same effect as not reporting it.
|
|
54
|
+
|
|
55
|
+
Cut: restating what an executor already reported, re-explaining reasoning already given, recapping state that has not changed, closing praise. Tables and code blocks only when they replace prose, never when they add to it.
|
|
56
|
+
|
|
57
|
+
Depth is on demand from the user.
|
|
58
|
+
|
|
47
59
|
— Architect, Principal Software Architect
|
|
Binary file
|
|
@@ -56,7 +56,7 @@ function buildPlans(kind, options = {}) {
|
|
|
56
56
|
for (const item of selected.itemEntries) {
|
|
57
57
|
for (const installPath of paths) {
|
|
58
58
|
const destination = installPath.path.replace('{{id}}', item.id)
|
|
59
|
-
let content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination, identity: identityConfig })
|
|
59
|
+
let content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination, identity: identityConfig, agentModels: options.agentModels || {} })
|
|
60
60
|
// D5/D9 extension point: reproduce any persisted third-party
|
|
61
61
|
// reference block so regenerating this exact artifact (e.g. a
|
|
62
62
|
// later `trackfw agents update`) settles at state "current"
|
|
@@ -30,6 +30,14 @@ class IntegrationManager {
|
|
|
30
30
|
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
|
|
31
31
|
this.roots = { project: path.resolve(projectRoot), global: path.resolve(homeRoot) }
|
|
32
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
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
manifestPath(scope) { return path.join(this.roots[scope], '.trackfw', 'integrations-manifest.json') }
|
|
@@ -118,7 +126,16 @@ class IntegrationManager {
|
|
|
118
126
|
const file = this.resolve(scope, plan.destination)
|
|
119
127
|
const record = manifests.get(scope).artifacts[file]
|
|
120
128
|
const managed = Boolean(record && record.claims.some(claim => claimKey(claim) === claimKey(plan.claim)))
|
|
121
|
-
|
|
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 }
|
|
122
139
|
const actual = sha256(fs.readFileSync(file))
|
|
123
140
|
const desired = sha256(plan.content)
|
|
124
141
|
let state
|
|
@@ -129,7 +146,7 @@ class IntegrationManager {
|
|
|
129
146
|
} else if (actual === desired) state = 'current'
|
|
130
147
|
else if ((plan.legacyHashes || []).includes(actual)) state = 'outdated'
|
|
131
148
|
else state = 'modified'
|
|
132
|
-
return { ...plan, destination: file, state, managed }
|
|
149
|
+
return { ...plan, destination: file, state, managed, registered }
|
|
133
150
|
})
|
|
134
151
|
}
|
|
135
152
|
|
|
@@ -167,9 +184,44 @@ class IntegrationManager {
|
|
|
167
184
|
const snapshots = new Map()
|
|
168
185
|
for (const item of active) this.snapshot(snapshots, item.file)
|
|
169
186
|
for (const scope of manifests.keys()) this.snapshot(snapshots, this.manifestPath(scope))
|
|
170
|
-
|
|
171
|
-
for (const item of active) this.apply(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
187
|
+
const saveManifests = () => {
|
|
172
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
|
+
}
|
|
173
225
|
} catch (error) {
|
|
174
226
|
this.rollback(snapshots)
|
|
175
227
|
throw error
|
|
@@ -186,7 +238,7 @@ class IntegrationManager {
|
|
|
186
238
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
187
239
|
if (status.state === 'outdated' && owned && !force) return true // skip: bytes preserved, batch continues
|
|
188
240
|
} else if (operation === 'update') {
|
|
189
|
-
if (!owned && status.state === 'modified') throw new Error(
|
|
241
|
+
if (!owned && status.state === 'modified') throw new Error(this.unmanagedArtifactError(file, plan.claim))
|
|
190
242
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
191
243
|
} else if (operation === 'uninstall' && owned && status.state === 'modified' && !force) {
|
|
192
244
|
throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
@@ -256,19 +308,42 @@ class IntegrationManager {
|
|
|
256
308
|
return { state: 'modified', managed: false }
|
|
257
309
|
}
|
|
258
310
|
|
|
259
|
-
|
|
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) {
|
|
260
344
|
let record = manifest.artifacts[file]
|
|
261
345
|
const key = claimKey(plan.claim)
|
|
262
346
|
const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === key))
|
|
263
|
-
if (operation === 'uninstall') {
|
|
264
|
-
if (!owned) return
|
|
265
|
-
record.claims = record.claims.filter(claim => claimKey(claim) !== key)
|
|
266
|
-
if (record.claims.length) return
|
|
267
|
-
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
268
|
-
delete manifest.artifacts[file]
|
|
269
|
-
this.cleanEmpty(path.dirname(file), this.roots[plan.claim.scope])
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
347
|
|
|
273
348
|
const exists = fs.existsSync(file)
|
|
274
349
|
let actual = exists ? sha256(fs.readFileSync(file)) : ''
|
|
@@ -278,16 +353,29 @@ class IntegrationManager {
|
|
|
278
353
|
if (exists && !owned) writeDesired = (operation === 'update' && actual !== desired) || (force && actual !== desired)
|
|
279
354
|
else if (exists && owned) writeDesired = actual !== desired
|
|
280
355
|
if (!record) record = { destination: file, sha256: '', catalog_version: '', claims: [] }
|
|
356
|
+
let pending = null
|
|
281
357
|
if (writeDesired) {
|
|
282
|
-
|
|
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).
|
|
283
360
|
actual = desired
|
|
361
|
+
pending = { file, content: plan.content, mode: 0o644 }
|
|
284
362
|
} else if (exists && !owned && actual !== desired && !knownLegacy && !force) {
|
|
285
|
-
|
|
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))
|
|
286
373
|
}
|
|
287
374
|
if (!record.claims.some(claim => claimKey(claim) === key)) record.claims.push(cleanClaim(plan.claim))
|
|
288
375
|
record.sha256 = actual
|
|
289
376
|
record.catalog_version = actual === desired ? plan.catalogVersion : 'legacy'
|
|
290
377
|
manifest.artifacts[file] = record
|
|
378
|
+
return pending
|
|
291
379
|
}
|
|
292
380
|
|
|
293
381
|
snapshot(snapshots, file) {
|
|
@@ -305,6 +393,19 @@ class IntegrationManager {
|
|
|
305
393
|
}
|
|
306
394
|
}
|
|
307
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
|
+
|
|
308
409
|
// tildeAbbrev — retorna o caminho de exibição abreviado para uso em mensagens
|
|
309
410
|
// de aviso de skip. Espelha internal/integrations/manager.go:tildeAbbrev.
|
|
310
411
|
// - scope global: substitui homeRoot por '~' (via tildeify, com salvaguarda
|
|
@@ -97,6 +97,19 @@ function mapModelCursor(model) {
|
|
|
97
97
|
return CURSOR_MODEL_MAP[model] || ''
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// containsControlChar reports whether s contains any ASCII control character
|
|
101
|
+
// (U+0000–U+001F) or a Unicode line/paragraph separator (U+2028, U+2029).
|
|
102
|
+
// Mirrors internal/integrations/render.go:containsControlChar (ML-5C).
|
|
103
|
+
// In JavaScript, strings are UTF-16; U+2028/U+2029 are single code units
|
|
104
|
+
// accessible via charCodeAt, so no surrogate-pair handling is needed.
|
|
105
|
+
function containsControlChar(s) {
|
|
106
|
+
for (let i = 0; i < s.length; i++) {
|
|
107
|
+
const c = s.charCodeAt(i)
|
|
108
|
+
if (c < 0x20 || c === 0x2028 || c === 0x2029) return true
|
|
109
|
+
}
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
// rewriteFrontmatterModelLine substitui a linha "model:" do frontmatter de um
|
|
101
114
|
// markdown cru por value, preservando toda outra linha do frontmatter e o
|
|
102
115
|
// corpo byte a byte. Se o frontmatter não tiver linha "model:", uma é
|
|
@@ -105,7 +118,14 @@ function mapModelCursor(model) {
|
|
|
105
118
|
// espelha a detecção de fronteira usada por rewriteFrontmatterFields,
|
|
106
119
|
// escopada à chave única "model". Espelha
|
|
107
120
|
// internal/integrations/render.go:rewriteFrontmatterModelLine.
|
|
121
|
+
//
|
|
122
|
+
// Throws an Error if value contains any ASCII control character (U+0000–U+001F).
|
|
123
|
+
// Model IDs never require control characters; any such value is rejected to
|
|
124
|
+
// prevent frontmatter injection (ML-5A).
|
|
108
125
|
function rewriteFrontmatterModelLine(source, value) {
|
|
126
|
+
if (containsControlChar(value)) {
|
|
127
|
+
throw new Error(`model value contains control character and was rejected: model IDs never require newlines or other control characters (got ${JSON.stringify(value)})`)
|
|
128
|
+
}
|
|
109
129
|
const trimmed = String(source).trim()
|
|
110
130
|
if (!trimmed.startsWith('---\n')) return trimmed
|
|
111
131
|
const end = trimmed.indexOf('\n---', 4)
|
|
@@ -296,6 +316,27 @@ function frontmatterName(content) {
|
|
|
296
316
|
return undefined
|
|
297
317
|
}
|
|
298
318
|
|
|
319
|
+
// isVersionString reports whether s is a bare version string (digits and dots
|
|
320
|
+
// only, e.g. "5", "4.6", "1.0.2"). Mirrors internal/integrations/render.go:isVersionString.
|
|
321
|
+
// Values that don't match — pre-composed IDs like "claude-sonnet-4-5-20250929" (hyphens),
|
|
322
|
+
// "latest" (letters), or "" (empty) — return false and the caller uses the value literally
|
|
323
|
+
// (escape hatch, ADR-2026-08-21 §3).
|
|
324
|
+
function isVersionString(s) {
|
|
325
|
+
if (!s) return false
|
|
326
|
+
return /^[0-9]+(\.[0-9]+)*$/.test(s)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// composeClaudeModelID builds a Claude model identifier from tier and version,
|
|
330
|
+
// applying the three composition rules from the official Anthropic docs.
|
|
331
|
+
// Mirrors internal/integrations/render.go:composeClaudeModelID.
|
|
332
|
+
// Rule 1: dots become hyphens ("4.6" → "claude-sonnet-4-6")
|
|
333
|
+
// Rule 2: major-only version never gets "-0" ("5" → "claude-sonnet-5")
|
|
334
|
+
// Rule 3: handled via escape hatch — pre-composed IDs are never version strings
|
|
335
|
+
function composeClaudeModelID(tier, version) {
|
|
336
|
+
const hyphenated = version.replace(/\./g, '-')
|
|
337
|
+
return `claude-${tier}-${hyphenated}`
|
|
338
|
+
}
|
|
339
|
+
|
|
299
340
|
// render converte um item canônico do catálogo para a representação nativa
|
|
300
341
|
// declarada por uma surface alvo. Quando identity carrega uma identidade
|
|
301
342
|
// customizada para item.id, o name/description/body renderizados são
|
|
@@ -321,7 +362,12 @@ function frontmatterName(content) {
|
|
|
321
362
|
// normalize(content) — a mesma expressão usada antes de existir suporte a
|
|
322
363
|
// identidade — então a saída sem identidade é garantida byte a byte
|
|
323
364
|
// inalterada por construção, não por coincidência.
|
|
324
|
-
|
|
365
|
+
//
|
|
366
|
+
// agentModels (opcional) — objeto de tier→versão lido de trackfw.yaml's agent_models.
|
|
367
|
+
// Ausente ou vazio: saída idêntica a antes. Somente o alvo "claude" recebe IDs
|
|
368
|
+
// compostos; Codex, Cursor, Antigravity, OpenCode, Gemini, Kiro não são afetados
|
|
369
|
+
// (ADR-2026-08-21 §4 — gate, não cuidado).
|
|
370
|
+
function render({ kind, content, capability, item, identity: cfg, target, agentModels }) {
|
|
325
371
|
if (kind === 'skills') return normalize(content)
|
|
326
372
|
|
|
327
373
|
const id = item && cfg ? lookupIdentity(cfg, item.id) : undefined
|
|
@@ -407,6 +453,18 @@ function render({ kind, content, capability, item, identity: cfg, target }) {
|
|
|
407
453
|
if (target === 'cursor') {
|
|
408
454
|
const mappedModel = mapModelCursor(parts.model)
|
|
409
455
|
source = mappedModel ? rewriteFrontmatterModelLine(source, mappedModel) : removeFrontmatterModelLine(source)
|
|
456
|
+
} else if (target === 'claude' && agentModels && Object.keys(agentModels).length > 0) {
|
|
457
|
+
// Allowlist: only the "claude" target receives composed model IDs.
|
|
458
|
+
// Codex, Cursor, Antigravity, OpenCode, Gemini, Kiro and any other target are left
|
|
459
|
+
// untouched even when agentModels is populated (ADR-2026-08-21 §4 — gate, not cuidado).
|
|
460
|
+
const version = agentModels[parts.model]
|
|
461
|
+
if (version) {
|
|
462
|
+
const modelID = isVersionString(version)
|
|
463
|
+
? composeClaudeModelID(parts.model, version)
|
|
464
|
+
: version // escape hatch: non-version value used literally (ADR-2026-08-21 §3)
|
|
465
|
+
source = rewriteFrontmatterModelLine(source, modelID)
|
|
466
|
+
}
|
|
467
|
+
// Empty string version means "no pin": leave source unchanged (tier alias preserved).
|
|
410
468
|
}
|
|
411
469
|
if (!id) return normalize(source)
|
|
412
470
|
const withBody = insertBodyPrefix(source, greeting)
|
|
@@ -415,4 +473,60 @@ function render({ kind, content, capability, item, identity: cfg, target }) {
|
|
|
415
473
|
return normalize(withSignature)
|
|
416
474
|
}
|
|
417
475
|
|
|
418
|
-
|
|
476
|
+
// resolveAgentModel returns the model string that render() would write to the
|
|
477
|
+
// model field of an artifact with the given representation and targetID, for
|
|
478
|
+
// an agent with the given tier. Mirrors
|
|
479
|
+
// internal/integrations/models.go:ResolveAgentModel.
|
|
480
|
+
//
|
|
481
|
+
// Returns { resolved: string, present: boolean }.
|
|
482
|
+
// present=false means the artifact format omits the model field entirely — the
|
|
483
|
+
// caller should display "—" or equivalent rather than the tier alias.
|
|
484
|
+
//
|
|
485
|
+
// agentModels applies only for the "claude" target (ADR-2026-08-21 §4).
|
|
486
|
+
function resolveAgentModel(tier, representation, targetID, agentModels) {
|
|
487
|
+
if (representation === 'custom-agent-toml') {
|
|
488
|
+
const v = resolveModelCodex(tier)
|
|
489
|
+
return { resolved: v, present: v !== '' }
|
|
490
|
+
}
|
|
491
|
+
if (representation === 'cli-agent-json' || representation === 'agent-json') {
|
|
492
|
+
return { resolved: '', present: false }
|
|
493
|
+
}
|
|
494
|
+
if (representation === 'agent-directory') {
|
|
495
|
+
const v = resolveModel(tier)
|
|
496
|
+
return { resolved: v, present: v !== '' }
|
|
497
|
+
}
|
|
498
|
+
if (representation === 'opencode-agent') {
|
|
499
|
+
return { resolved: '', present: false }
|
|
500
|
+
}
|
|
501
|
+
// default branch — mirrors render()'s default case
|
|
502
|
+
if (targetID === 'cursor') {
|
|
503
|
+
const v = mapModelCursor(tier)
|
|
504
|
+
return { resolved: v, present: v !== '' }
|
|
505
|
+
}
|
|
506
|
+
if (targetID === 'claude') {
|
|
507
|
+
const am = agentModels || {}
|
|
508
|
+
const version = am[tier]
|
|
509
|
+
if (version) {
|
|
510
|
+
const modelID = isVersionString(version) ? composeClaudeModelID(tier, version) : version
|
|
511
|
+
return { resolved: modelID, present: true }
|
|
512
|
+
}
|
|
513
|
+
// no pin → tier alias unchanged
|
|
514
|
+
}
|
|
515
|
+
return { resolved: tier, present: true }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// looksLikeSuspectModelValue reports whether v is an agent_models value that
|
|
519
|
+
// will trigger the escape-hatch path and likely produce an invalid model
|
|
520
|
+
// identifier in the rendered artifact. Returns true when v is not a bare
|
|
521
|
+
// version string AND does not start with "claude-". Callers should emit a
|
|
522
|
+
// per-tier warning to stderr (not per-row) when this returns true.
|
|
523
|
+
// Mirrors internal/integrations/models.go:LooksLikeSuspectModelValue.
|
|
524
|
+
// looksLikeSuspectModelValue mirrors internal/integrations/models.go:LooksLikeSuspectModelValue.
|
|
525
|
+
// ML-5A: values with control characters are always suspect — rewriteFrontmatterModelLine
|
|
526
|
+
// rejects them outright, so this function must agree with the write path to keep the
|
|
527
|
+
// "agents models" inspection command aligned with "agents install/update" behavior.
|
|
528
|
+
function looksLikeSuspectModelValue(v) {
|
|
529
|
+
return containsControlChar(v) || (!isVersionString(v) && !v.startsWith('claude-'))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
module.exports = { render, markdownParts, frontmatterName, greetingLine, insertBodyPrefix, rewriteFrontmatterFields, rewriteFrontmatterModelLine, rewriteSignatureLine, isVersionString, composeClaudeModelID, resolveAgentModel, looksLikeSuspectModelValue }
|
|
@@ -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 }
|