trackfw 6.10.0 → 7.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.
@@ -0,0 +1,384 @@
1
+ 'use strict'
2
+
3
+ // Node port of internal/commands/integrations_thirdparty.go — the
4
+ // `third-party fetch`/`third-party install` two-phase quarantine gate,
5
+ // reachable from both `trackfw agents third-party` and
6
+ // `trackfw skills third-party` (D1). See:
7
+ // docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
8
+
9
+ const { Command } = require('commander')
10
+
11
+ const identityStore = require('../identity')
12
+ const { items: catalogItems } = require('../integrations/catalog')
13
+ const { buildPlans, IntegrationManager } = require('../integrations')
14
+ const thirdPartyFetchModule = require('../thirdparty/fetch')
15
+ const { checkMarkers, checksum: sha256hex } = require('../thirdparty/markers')
16
+ const { quarantinePath, newQuarantineEntry, writeQuarantine, readQuarantine, decodeContent } = require('../thirdparty/quarantine')
17
+ const { loadProvenance, verifyApproval, upsertProvenanceEntry } = require('../thirdparty/provenance')
18
+ const { upsertThirdPartyReference, normalizeThirdPartyContent, resolveThirdPartySkillDestination } = require('../thirdparty/references')
19
+
20
+ // api is the module.exports object itself, referenced internally via
21
+ // `api.thirdPartyFetch(...)` so tests can substitute the network fetch by
22
+ // reassigning the exported property in place — mirrors the
23
+ // identityWizard.runIdentityWizard monkey-patch pattern already used by
24
+ // npm/tests/identity-wizard.test.js, and the package-var substitution used
25
+ // by internal/commands/integrations_thirdparty.go:thirdPartyFetch on the Go
26
+ // side (tests substitute it instead of hitting real network, keeping
27
+ // fetch-command tests compatible with TRACKFW_DISABLE_EXTERNAL_COMMANDS=1).
28
+ const api = {}
29
+
30
+ // THIRD_PARTY_PROVENANCE_RULE is the name of the trackfw validate rule that
31
+ // is the real (git-anchored) enforcement behind the orchestrator-session
32
+ // guardrail below. Named as a constant so the guardrail message and any
33
+ // future test asserting its wording never drift apart silently. Mirrors
34
+ // internal/commands/integrations_thirdparty.go:thirdPartyProvenanceRule.
35
+ const THIRD_PARTY_PROVENANCE_RULE = 'thirdparty_artifact_has_provenance'
36
+
37
+ // THIRD_PARTY_GLOBAL_SCOPE_WARNING/REFUSAL are the D4-bis literal strings
38
+ // for --scope global: printed/returned VERBATIM by all 3 CLIs (the
39
+ // roadmap's AC requires identical wording). Mirrors
40
+ // internal/commands/integrations_thirdparty.go:thirdPartyGlobalScopeWarning/thirdPartyGlobalScopeRefusal
41
+ // — keep these two strings byte-identical to the Go constants.
42
+ const THIRD_PARTY_GLOBAL_SCOPE_WARNING =
43
+ 'warning: --scope global installs outside the project tree; this artifact will NEVER be verified by ' +
44
+ '`trackfw validate` (the "' + THIRD_PARTY_PROVENANCE_RULE + '" rule only scans the project\'s own manifest — ' +
45
+ 'an artifact under a home directory is invisible to it, per ADR-2026-08-12).'
46
+ const THIRD_PARTY_GLOBAL_SCOPE_REFUSAL =
47
+ 'install to --scope global requires --yes-global-scope-unverified as its own explicit confirmation (D4-bis), ' +
48
+ 'distinct from --yes-i-trust-this-source: it confirms you understand `trackfw validate` will never verify this installation'
49
+
50
+ // SLUG_PATTERN mirrors internal/commands/integrations_thirdparty.go:thirdPartySlugPattern.
51
+ const SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/
52
+
53
+ // CHECKSUM_PATTERN mirrors internal/commands/integrations_thirdparty.go:thirdPartyChecksumPattern.
54
+ const CHECKSUM_PATTERN = /^[a-f0-9]{64}$/
55
+
56
+ function csv(value) {
57
+ return String(value).split(',').map(entry => entry.trim()).filter(Boolean)
58
+ }
59
+
60
+ // checkOrchestratorGuardrail implements D2: TRACKFW_ORCHESTRATOR_SESSION is
61
+ // a guardrail against accidental invocation from a plain terminal, never a
62
+ // security control — it is trivially set by anyone with shell access. The
63
+ // real enforcement is the THIRD_PARTY_PROVENANCE_RULE check in `trackfw
64
+ // validate` (ML-3A), which is git-anchored per ADR-2026-08-12. This message
65
+ // must never present the env var as prevention. Mirrors
66
+ // internal/commands/integrations_thirdparty.go:checkOrchestratorGuardrail.
67
+ function checkOrchestratorGuardrail() {
68
+ if (process.env.TRACKFW_ORCHESTRATOR_SESSION) return
69
+ throw new Error(
70
+ 'refused: TRACKFW_ORCHESTRATOR_SESSION is not set. This is a guardrail against accidental ' +
71
+ 'invocation from a plain terminal, not a security control — it does not resist anyone who ' +
72
+ `already has shell access. The real enforcement is the "${THIRD_PARTY_PROVENANCE_RULE}" rule checked by ` +
73
+ '`trackfw validate`, which detects any third-party artifact committed without a matching, checksum-linked ' +
74
+ 'provenance entry. If this is an orchestrated agent session, set TRACKFW_ORCHESTRATOR_SESSION=1',
75
+ )
76
+ }
77
+
78
+ function thirdPartyEntryKind(kind) {
79
+ return kind === 'agents' ? 'agent' : 'skill'
80
+ }
81
+
82
+ // deriveSlug produces a filesystem-safe slug from a quarantined artifact's
83
+ // source URL when --slug is not given. Mirrors
84
+ // internal/commands/integrations_thirdparty.go:deriveSlug.
85
+ function deriveSlug(rawURL) {
86
+ let parsed
87
+ try {
88
+ parsed = new URL(rawURL)
89
+ } catch (err) {
90
+ throw new Error(`cannot derive slug from URL "${rawURL}": ${err.message}`)
91
+ }
92
+ let base = parsed.pathname.split('/').filter(Boolean).pop() || ''
93
+ const dot = base.lastIndexOf('.')
94
+ if (dot > 0) base = base.slice(0, dot)
95
+ base = base.toLowerCase()
96
+ let out = ''
97
+ for (const ch of base) {
98
+ if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch === '-' || ch === '_' || ch === '.') out += ch
99
+ else out += '-'
100
+ }
101
+ const slug = out.replace(/^[-._]+/, '').replace(/[-._]+$/, '')
102
+ if (!slug || !SLUG_PATTERN.test(slug)) throw new Error(`cannot derive a safe slug from URL "${rawURL}"; pass --slug explicitly`)
103
+ return slug
104
+ }
105
+
106
+ // resolveThirdPartyScope is deliberately separate from resolveScope
107
+ // (../commands/integrations.js): third-party's default is "project" (D4),
108
+ // the opposite of the catalog's "global" default. Explicit --scope is
109
+ // detected via `options.scope !== undefined`, never by comparing the
110
+ // value against "project" (that comparison cannot distinguish an explicit
111
+ // `--scope project` from the flag's unset default). Mirrors
112
+ // internal/commands/integrations_thirdparty.go:resolveThirdPartyScope.
113
+ function resolveThirdPartyScope(options) {
114
+ if (options.scope !== undefined) {
115
+ if (options.scope !== 'project' && options.scope !== 'global') throw new Error(`invalid --scope "${options.scope}": use project or global`)
116
+ return options.scope
117
+ }
118
+ return 'project'
119
+ }
120
+
121
+ async function executeThirdPartyFetch(kind, url, options) {
122
+ checkOrchestratorGuardrail()
123
+ const raw = await api.thirdPartyFetch(url)
124
+ const matched = checkMarkers(raw)
125
+ if (matched.length && !options.forceThirdpartyMarkers) {
126
+ throw new Error(
127
+ `refused: content matches boundary-redefinition marker(s) [${matched.join(' ')}] (D3); pass --force-thirdparty-markers ` +
128
+ 'to quarantine it anyway (recorded in marker_check, never installed without approval)',
129
+ )
130
+ }
131
+
132
+ const manager = new IntegrationManager()
133
+ const entry = newQuarantineEntry(url, raw, matched, thirdPartyEntryKind(kind), options.targets || [])
134
+ writeQuarantine(manager.roots.project, entry)
135
+
136
+ console.log(`quarantined: ${quarantinePath(manager.roots.project, entry.checksum_sha256)}`)
137
+ console.log(`checksum: ${entry.checksum_sha256}`)
138
+ if (matched.length) console.log(`warning: marker check failed (matched=[${matched.join(' ')}]); --force-thirdparty-markers was used`)
139
+ console.log(
140
+ 'next: obtain a favorable hades-tf review, record its provenance entry keyed by the resolved destination(s), then run ' +
141
+ `\`${kind} third-party install --checksum ${entry.checksum_sha256} --targets <t1,t2> [--apply-to <agent-id,...>]\``,
142
+ )
143
+ }
144
+
145
+ async function executeThirdPartyInstall(kind, options) {
146
+ checkOrchestratorGuardrail()
147
+ if (!options.checksum) throw new Error('install requires --checksum')
148
+ if (!CHECKSUM_PATTERN.test(options.checksum)) {
149
+ throw new Error(`invalid --checksum "${options.checksum}": expected a 64-character lowercase SHA-256 hex digest`)
150
+ }
151
+ if (!options.targets || !options.targets.length) throw new Error('install requires --targets')
152
+ const scope = resolveThirdPartyScope(options)
153
+ // D4-bis — print the warning as early as possible, before any other
154
+ // output, so it is visible even if a later step aborts the command.
155
+ if (scope === 'global') console.log(THIRD_PARTY_GLOBAL_SCOPE_WARNING)
156
+
157
+ const manager = new IntegrationManager()
158
+ const projectRoot = manager.roots.project
159
+
160
+ const entry = readQuarantine(projectRoot, options.checksum)
161
+ const content = decodeContent(entry)
162
+
163
+ // D8c / TOCTOU close: the quarantine record's filename IS its checksum,
164
+ // but that alone does not prove content_base64 hasn't been edited in
165
+ // place since approval. Recompute over the decoded bytes and require
166
+ // both the record's own field and the caller-supplied --checksum to
167
+ // agree with it.
168
+ const recomputed = sha256hex(content)
169
+ if (recomputed !== options.checksum || entry.checksum_sha256 !== options.checksum) {
170
+ throw new Error(`refused: quarantined content for "${entry.url}" no longer matches checksum ${options.checksum} (TOCTOU guard, D8c)`)
171
+ }
172
+
173
+ let slug = options.slug
174
+ if (!slug) {
175
+ slug = deriveSlug(entry.url)
176
+ } else if (!SLUG_PATTERN.test(slug)) {
177
+ throw new Error(`invalid --slug "${slug}": use lowercase alphanumerics, '.', '_' or '-'`)
178
+ }
179
+
180
+ const resolvedTargets = options.targets.map(targetID => {
181
+ const { destination, surfaceID } = resolveThirdPartySkillDestination(targetID, scope, slug)
182
+ return { targetID, surfaceID, destination }
183
+ })
184
+
185
+ // D5/AC3 preconditions for --apply-to are validated here, BEFORE any
186
+ // write happens (including the skill file below) — failing everything up
187
+ // front avoids leaving a partial state (skill file written, no reference
188
+ // injected) on a precondition failure.
189
+ let identityConfig
190
+ if (options.applyTo && options.applyTo.length) {
191
+ identityConfig = identityStore.load(manager.roots.global)
192
+ for (const agentID of options.applyTo) {
193
+ if (!catalogItems('agents').some(item => item.id === agentID)) throw new Error(`unknown agent item "${agentID}"`)
194
+ for (const rt of resolvedTargets) {
195
+ const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot })
196
+ if (!agentPlans.length) throw new Error(`target ${rt.targetID} has no supported agents surface for item "${agentID}"`)
197
+ const inspection = manager.inspect([agentPlans[0]])[0]
198
+ // ADR imprecision found and resolved here (reported, not silently
199
+ // worked around — mirrors the Go reference's own documented
200
+ // resolution): D5/D8 never say which scope the referencing agent
201
+ // artifact must be at, and D4 gives third-party a DIFFERENT default
202
+ // scope (project) than the catalog default (global) — so the common
203
+ // case has no project-scoped agent artifact to inject into.
204
+ // Resolution: require the agent to already be installed, owned, and
205
+ // NOT hand-modified at the SAME scope as the skill; fail loudly with
206
+ // the exact remediation instead of silently skipping (AC3 forbids
207
+ // silent decisions).
208
+ if (!inspection.managed || inspection.state === 'not-installed') {
209
+ throw new Error(
210
+ `cannot attach reference: agent "${agentID}" is not installed at --scope ${scope} for target ${rt.targetID}; run ` +
211
+ `\`trackfw agents install --scope ${scope} --targets ${rt.targetID} --items ${agentID}\` first`,
212
+ )
213
+ }
214
+ if (inspection.state === 'modified') {
215
+ throw new Error(
216
+ `cannot attach reference: agent "${agentID}" at --scope ${scope} for target ${rt.targetID} was modified outside trackfw; run ` +
217
+ `\`trackfw agents update --scope ${scope} --targets ${rt.targetID} --items ${agentID} --force\` first`,
218
+ )
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ // AC1 — always show content and every resolved destination before
225
+ // writing anything, in both interactive and non-interactive mode.
226
+ console.log(`URL: ${entry.url}\nChecksum: ${options.checksum}\n\n--- content ---\n${content.toString('utf8')}\n--- end content ---\n`)
227
+ console.log(`Resolved destination(s) (scope=${scope}):`)
228
+ for (const rt of resolvedTargets) console.log(` ${rt.targetID}: ${rt.destination}`)
229
+
230
+ if (!process.stdin.isTTY) {
231
+ if (!options.yesITrustThisSource) throw new Error('install requires --yes-i-trust-this-source in non-interactive mode (AC1)')
232
+ } else if (!options.yesITrustThisSource) {
233
+ const { confirm } = require('@inquirer/prompts')
234
+ const confirmed = await confirm({ message: 'Install this third-party content at the destination(s) shown above?' })
235
+ if (!confirmed) throw new Error('install cancelled')
236
+ }
237
+
238
+ // D4-bis — global scope requires ITS OWN explicit confirmation, beyond
239
+ // --yes-i-trust-this-source (decision by KG, 2026-08-15, superseding the
240
+ // ML-3A choice to collapse both into --yes-i-trust-this-source).
241
+ if (scope === 'global' && !options.yesGlobalScopeUnverified) {
242
+ throw new Error(THIRD_PARTY_GLOBAL_SCOPE_REFUSAL)
243
+ }
244
+
245
+ // D8c — the TOCTOU-closing approval check, verified per resolved
246
+ // destination (provenance is keyed by destination, not by checksum alone
247
+ // — a checksum approved for one destination is not automatically
248
+ // approved for another).
249
+ for (const rt of resolvedTargets) {
250
+ try {
251
+ verifyApproval(projectRoot, options.checksum, rt.destination)
252
+ } catch (err) {
253
+ throw new Error(`not approved for ${rt.destination}: ${err.message}`)
254
+ }
255
+ }
256
+
257
+ // D3 — a failed marker check is not fatal to fetch
258
+ // (--force-thirdparty-markers already overrode that refusal), but install
259
+ // requires the approver to have knowingly recorded marker_override in the
260
+ // provenance entry for each destination.
261
+ if (entry.marker_check.result === 'fail') {
262
+ const prov = loadProvenance(projectRoot)
263
+ for (const rt of resolvedTargets) {
264
+ const provEntry = prov.entries[rt.destination]
265
+ if (!provEntry || !provEntry.marker_override) {
266
+ throw new Error(
267
+ `refused: ${rt.destination} failed the D3 boundary marker check (matched=[${entry.marker_check.matched_markers.join(' ')}]) ` +
268
+ 'and its provenance entry lacks marker_override=true',
269
+ )
270
+ }
271
+ }
272
+ }
273
+
274
+ // Write the skill file(s) — always through IntegrationManager, never a
275
+ // raw fs write. The claim kind is always "skills" regardless of which
276
+ // lifecycle ("skills"/"agents") invoked this command: the artifact always
277
+ // lives under "skills/thirdparty/" per D5.
278
+ const normalized = normalizeThirdPartyContent(content)
279
+ const plans = resolvedTargets.map(rt => ({
280
+ claim: { target: rt.targetID, surface: rt.surfaceID, scope, kind: 'skills', item: `thirdparty-${slug}`, origin: 'thirdparty' },
281
+ destination: rt.destination,
282
+ content: normalized,
283
+ catalogVersion: `thirdparty:${options.checksum.slice(0, 12)}`,
284
+ supportLevel: 'native',
285
+ }))
286
+ manager.install(plans, {})
287
+
288
+ // D2-bis — record installed_sha256 (SHA-256 of the NORMALIZED bytes just
289
+ // installed) on each destination's existing provenance entry, now that
290
+ // the install actually succeeded. checksum_sha256 (the raw-bytes D8c
291
+ // approval anchor, written externally by the approver) is left
292
+ // untouched — only installed_sha256 is added/overwritten. rt.destination
293
+ // is already the provenance key: the project-root-relative (pre-resolve)
294
+ // string resolveThirdPartySkillDestination returns, the same value
295
+ // verifyApproval was just called with above.
296
+ // Field order matters here for cross-CLI byte parity
297
+ // (scripts/check-thirdparty-parity.sh diffs thirdparty-provenance.json
298
+ // verbatim): url, checksum_sha256, installed_sha256, installed_at,
299
+ // approved_by, review_reference, scope, marker_override — mirrors Go's
300
+ // ProvenanceEntry struct field order and Python's _ENTRY_FIELD_ORDER
301
+ // (provenance.py), NOT plain object-spread insertion order (which would
302
+ // append installed_sha256 at the end instead of right after
303
+ // checksum_sha256).
304
+ const installedSHA256 = sha256hex(normalized)
305
+ for (const rt of resolvedTargets) {
306
+ const prov = loadProvenance(projectRoot)
307
+ const existing = prov.entries[rt.destination] || {}
308
+ const provEntry = {
309
+ url: existing.url,
310
+ checksum_sha256: existing.checksum_sha256,
311
+ installed_sha256: installedSHA256,
312
+ installed_at: existing.installed_at,
313
+ approved_by: existing.approved_by,
314
+ review_reference: existing.review_reference,
315
+ scope: existing.scope,
316
+ marker_override: existing.marker_override,
317
+ }
318
+ for (const key of Object.keys(provEntry)) {
319
+ if (provEntry[key] === undefined) delete provEntry[key]
320
+ }
321
+ upsertProvenanceEntry(projectRoot, rt.destination, provEntry)
322
+ }
323
+
324
+ // D5 — attach references to the requested catalog agent artifacts, at
325
+ // the SAME scope as the skill file just installed. Preconditions were
326
+ // already validated above, before the skill file was written — this loop
327
+ // only performs writes.
328
+ if (options.applyTo && options.applyTo.length) {
329
+ for (const agentID of options.applyTo) {
330
+ for (const rt of resolvedTargets) {
331
+ upsertThirdPartyReference(projectRoot, rt.targetID, agentID, { slug, destination: rt.destination, url: entry.url })
332
+ // Re-render now so the on-disk artifact reflects the new reference
333
+ // immediately — not only on the next `agents update`. buildPlans
334
+ // picks up the registry entry just written via
335
+ // applyThirdPartyReferences (../integrations/index.js).
336
+ const agentPlans = buildPlans('agents', { targets: [rt.targetID], items: [agentID], scope, identity: identityConfig, projectRoot })
337
+ manager.update(agentPlans, {})
338
+ }
339
+ }
340
+ }
341
+
342
+ console.log(`installed: ${plans.length} destination(s)`)
343
+ }
344
+
345
+ function createFetchCommand(kind) {
346
+ return new Command('fetch')
347
+ .argument('<url>', 'URL of the third-party content to fetch')
348
+ .description('Download third-party content into quarantine for review (never installs)')
349
+ .option('--targets <targets>', 'target CLIs this artifact is intended for (recorded for review only; confirmed again at install)', csv)
350
+ .option('--force-thirdparty-markers', 'override refusal on boundary-redefinition markers (D3); recorded, never silent', false)
351
+ .action(async (url, options) => executeThirdPartyFetch(kind, url, options))
352
+ }
353
+
354
+ function createInstallCommand(kind) {
355
+ return new Command('install')
356
+ .description('Consume a quarantined artifact into its resolved destination(s), requiring a prior checksum-linked approval')
357
+ .option('--checksum <checksum>', 'SHA-256 checksum of the quarantined artifact (required)')
358
+ .option('--slug <slug>', 'destination file slug (default: derived from the quarantined URL)')
359
+ .option('--targets <targets>', 'target CLIs to install the skill file into (required)', csv)
360
+ .option('--apply-to <ids>', 'catalog agent item IDs whose rendered file gets a reference to this artifact (optional; never inferred silently — AC3)', csv)
361
+ .option('--scope <scope>', 'installation scope: project or global (default: project — D4)')
362
+ .option('--yes-i-trust-this-source', 'required in non-interactive mode (AC1)', false)
363
+ .option('--yes-global-scope-unverified', 'required, in addition to --yes-i-trust-this-source, for --scope global: confirms this installation will never be verified by `trackfw validate` (D4-bis)', false)
364
+ .action(async options => executeThirdPartyInstall(kind, options))
365
+ }
366
+
367
+ // createThirdPartyCommand builds the "third-party" subcommand (fetch +
368
+ // install), attached under both `trackfw agents` and `trackfw skills` (D1).
369
+ // Mirrors internal/commands/integrations_thirdparty.go:newIntegrationsThirdPartyCmd.
370
+ function createThirdPartyCommand(kind) {
371
+ const root = new Command('third-party').description(`Fetch and install third-party ${kind} content under a two-phase quarantine gate`)
372
+ root.addCommand(createFetchCommand(kind))
373
+ root.addCommand(createInstallCommand(kind))
374
+ return root
375
+ }
376
+
377
+ api.createThirdPartyCommand = createThirdPartyCommand
378
+ api.checkOrchestratorGuardrail = checkOrchestratorGuardrail
379
+ api.deriveSlug = deriveSlug
380
+ api.resolveThirdPartyScope = resolveThirdPartyScope
381
+ api.THIRD_PARTY_PROVENANCE_RULE = THIRD_PARTY_PROVENANCE_RULE
382
+ api.thirdPartyFetch = thirdPartyFetchModule.fetch
383
+
384
+ module.exports = api
@@ -6,7 +6,7 @@ const path = require('path')
6
6
  const identityStore = require('../identity')
7
7
  const { catalog, buildPlans, IntegrationManager, globalGroupPath } = require('../integrations')
8
8
  const { tildeify, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine')
9
- const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, generateGlobalCredentialGuardScript } = require('../generators/hooks')
9
+ const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, generateGlobalCredentialGuardScript, generateGlobalGitBranchGuardScript } = require('../generators/hooks')
10
10
 
11
11
  // `trackfw update harness` is the global counterpart to `trackfw update` —
12
12
  // see docs/cli-parity.md, "`trackfw update` vs `trackfw update harness`".
@@ -589,8 +589,10 @@ function run(options) {
589
589
  if (!dryRun) {
590
590
  if (options.json) {
591
591
  silenceConsole(() => generateGlobalCredentialGuardScript(homeRoot))
592
+ silenceConsole(() => generateGlobalGitBranchGuardScript(homeRoot))
592
593
  } else {
593
594
  generateGlobalCredentialGuardScript(homeRoot)
595
+ generateGlobalGitBranchGuardScript(homeRoot)
594
596
  }
595
597
  }
596
598
 
@@ -207,7 +207,7 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
207
207
 
208
208
  if (include('agent-hooks')) targets.push(runFileTarget({
209
209
  id: 'agent-hooks',
210
- path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh, scripts/trackfw-credential-guard.sh',
210
+ path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh, scripts/trackfw-credential-guard.sh, scripts/trackfw-git-branch-guard.sh',
211
211
  root: cwd,
212
212
  relPaths: [
213
213
  '.claude/settings.json',
@@ -219,11 +219,13 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
219
219
  'scripts/trackfw-attention-signal.sh',
220
220
  'scripts/trackfw-attention-cleanup.sh',
221
221
  'scripts/trackfw-credential-guard.sh',
222
+ 'scripts/trackfw-git-branch-guard.sh',
222
223
  ],
223
224
  apply: (root) => {
224
225
  hooksGen.injectHooksDetected(root);
225
226
  hooksGen.generateAttentionScripts(cfg, root);
226
227
  hooksGen.generateCredentialGuardScript(root);
228
+ hooksGen.generateGitBranchGuardScript(root);
227
229
  },
228
230
  dryRun,
229
231
  installMissing,