@mavogel/cdk-vscode-server 0.0.99 → 0.0.100

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,1681 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Keep repository-owned agent instructions portable across Codex and Claude Code.
5
+ *
6
+ * Convention:
7
+ * AGENTS.md canonical shared instructions
8
+ * CLAUDE.md exactly "@AGENTS.md\n"
9
+ * .agents/skills/<name>/ Codex repository skills
10
+ * .claude/skills/<name>/ Claude Code repository skills
11
+ * .claude/skills/.pilot-sync-manifest.json last converged ownership baseline
12
+ *
13
+ * Existing repositories may instead use an exact in-repository symlink between
14
+ * the two instruction files or between the two skill roots. Those layouts
15
+ * already share one physical source and are verified without mirror writes.
16
+ *
17
+ * Tracked skills retain .agents/skills as their durable source, while edits on
18
+ * either side synchronize through the ownership baseline. Untracked and
19
+ * gitignored skills synchronize in both directions through a trusted baseline
20
+ * stored under .git/pilot; first-time one-sided skills are copied, independent
21
+ * two-sided edits fail without overwriting either version.
22
+ */
23
+
24
+ import {
25
+ chmod,
26
+ copyFile,
27
+ lstat,
28
+ mkdir,
29
+ readdir,
30
+ readFile,
31
+ realpath,
32
+ rename,
33
+ rm,
34
+ rmdir,
35
+ unlink,
36
+ writeFile,
37
+ } from 'node:fs/promises'
38
+ import { constants as fsConstants } from 'node:fs'
39
+ import { spawnSync } from 'node:child_process'
40
+ import { createHash } from 'node:crypto'
41
+ import path from 'node:path'
42
+ import process from 'node:process'
43
+ import { fileURLToPath } from 'node:url'
44
+
45
+ const CLAUDE_IMPORT = '@AGENTS.md\n'
46
+ const INSTALLED_PATH = path.join('scripts', 'sync-agent-assets.mjs')
47
+ const OWNERSHIP_MANIFEST = '.pilot-sync-manifest.json'
48
+ const OWNERSHIP_MANIFEST_PATH = `.claude/skills/${OWNERSHIP_MANIFEST}`
49
+ const LOCAL_PROVENANCE_GIT_PATH = 'pilot/sync-agent-assets.json'
50
+ const LOCAL_SKILL_PROVENANCE_GIT_PATH = 'pilot/sync-local-skills.json'
51
+ const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
52
+ const MAX_SKILL_NAME_LENGTH = 64
53
+
54
+ class UsageError extends Error {}
55
+
56
+ function usage() {
57
+ return [
58
+ 'Usage:',
59
+ ' sync-agent-assets.mjs --check [--repo <path>]',
60
+ ' sync-agent-assets.mjs --write [--force-migration] [--repo <path>]',
61
+ ' sync-agent-assets.mjs --install [--force-migration] [--repo <path>]',
62
+ ].join('\n')
63
+ }
64
+
65
+ function parseArgs(argv) {
66
+ let mode = null
67
+ let repo = null
68
+ let forceMigration = false
69
+
70
+ for (let index = 0; index < argv.length; index += 1) {
71
+ const arg = argv[index]
72
+ if (arg === '--check' || arg === '--write' || arg === '--install') {
73
+ if (mode !== null) throw new UsageError('choose exactly one mode')
74
+ mode = arg.slice(2)
75
+ continue
76
+ }
77
+ if (arg === '--repo') {
78
+ index += 1
79
+ if (index >= argv.length || argv[index].startsWith('--')) {
80
+ throw new UsageError('--repo requires a path')
81
+ }
82
+ repo = argv[index]
83
+ continue
84
+ }
85
+ if (arg === '--force-migration') {
86
+ if (forceMigration) throw new UsageError('--force-migration may only be provided once')
87
+ forceMigration = true
88
+ continue
89
+ }
90
+ if (arg.startsWith('--repo=')) {
91
+ if (repo !== null) throw new UsageError('--repo may only be provided once')
92
+ repo = arg.slice('--repo='.length)
93
+ if (repo.length === 0) throw new UsageError('--repo requires a path')
94
+ continue
95
+ }
96
+ if (arg === '--help' || arg === '-h') return { help: true }
97
+ throw new UsageError(`unknown argument: ${arg}`)
98
+ }
99
+
100
+ if (mode === null) throw new UsageError('a mode is required')
101
+ if (forceMigration && mode === 'check') {
102
+ throw new UsageError('--force-migration is only valid with --write or --install')
103
+ }
104
+ return { help: false, mode, repo: path.resolve(repo ?? process.cwd()), forceMigration }
105
+ }
106
+
107
+ async function inspect(candidate) {
108
+ try {
109
+ return await lstat(candidate)
110
+ } catch (error) {
111
+ if (error?.code === 'ENOENT') return null
112
+ throw error
113
+ }
114
+ }
115
+
116
+ async function usesExactSharedPathPair(roots, kind) {
117
+ const infos = await Promise.all(roots.map(root => inspect(root)))
118
+ const linkedIndexes = infos.flatMap((info, index) => (info?.isSymbolicLink() ? [index] : []))
119
+ if (linkedIndexes.length !== 1) return false
120
+
121
+ const linkedIndex = linkedIndexes[0]
122
+ const targetIndex = linkedIndex === 0 ? 1 : 0
123
+ const targetInfo = infos[targetIndex]
124
+ const targetMatches = kind === 'directory' ? targetInfo?.isDirectory() : targetInfo?.isFile()
125
+ if (!targetMatches || targetInfo.isSymbolicLink()) return false
126
+
127
+ try {
128
+ const [linkedRealpath, targetRealpath] = await Promise.all([
129
+ realpath(roots[linkedIndex]),
130
+ realpath(roots[targetIndex]),
131
+ ])
132
+ return linkedRealpath === targetRealpath
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ async function usesSharedSkillRoot(repo) {
139
+ return usesExactSharedPathPair(
140
+ [path.join(repo, '.agents', 'skills'), path.join(repo, '.claude', 'skills')],
141
+ 'directory',
142
+ )
143
+ }
144
+
145
+ async function usesSharedInstructions(repo) {
146
+ return usesExactSharedPathPair([path.join(repo, 'AGENTS.md'), path.join(repo, 'CLAUDE.md')], 'file')
147
+ }
148
+
149
+ function generatedInstructionSource(contents, expectedTarget) {
150
+ const header = contents.split(/\r?\n/).slice(0, 16).join('\n')
151
+ if (!/GENERATED FILE[^\n]*DO NOT EDIT/i.test(header)) return null
152
+ const source = header.match(/\bSource:\s*([^|\r\n]+)/i)?.[1]?.trim()
153
+ const target = header.match(/\bTarget:\s*([^|\r\n]+)/i)?.[1]?.trim()
154
+ if (!source || target !== expectedTarget) return null
155
+ return source
156
+ }
157
+
158
+ async function usesExternalGeneratedInstructions(repo) {
159
+ const agents = path.join(repo, 'AGENTS.md')
160
+ const claude = path.join(repo, 'CLAUDE.md')
161
+ const [agentsInfo, claudeInfo] = await Promise.all([inspect(agents), inspect(claude)])
162
+ if (
163
+ !agentsInfo?.isFile() ||
164
+ agentsInfo.isSymbolicLink() ||
165
+ !claudeInfo?.isFile() ||
166
+ claudeInfo.isSymbolicLink()
167
+ ) {
168
+ return false
169
+ }
170
+ const [agentsContents, claudeContents] = await Promise.all([
171
+ readFile(agents, 'utf8'),
172
+ readFile(claude, 'utf8'),
173
+ ])
174
+ const agentsSource = generatedInstructionSource(agentsContents, 'AGENTS.md')
175
+ const claudeSource = generatedInstructionSource(claudeContents, 'CLAUDE.md')
176
+ return agentsSource !== null && agentsSource === claudeSource
177
+ }
178
+
179
+ async function assertSafeRepoLayout(repo, { includeInstallerDestination = false } = {}) {
180
+ const [sharedSkillRoot, sharedInstructions] = await Promise.all([
181
+ usesSharedSkillRoot(repo),
182
+ usesSharedInstructions(repo),
183
+ ])
184
+ const components = [
185
+ ['AGENTS.md'],
186
+ ['CLAUDE.md'],
187
+ ['.agents'],
188
+ ['.agents', 'skills'],
189
+ ['.claude'],
190
+ ['.claude', 'skills'],
191
+ ['.claude', 'skills', OWNERSHIP_MANIFEST],
192
+ ['.claude', 'rules'],
193
+ ]
194
+ if (includeInstallerDestination) {
195
+ components.push(['scripts'], INSTALLED_PATH.split(path.sep))
196
+ }
197
+
198
+ for (const segments of components) {
199
+ const candidate = path.join(repo, ...segments)
200
+ const info = await inspect(candidate)
201
+ if (info?.isSymbolicLink()) {
202
+ const repositoryPath = segments.join('/')
203
+ if (sharedSkillRoot && (repositoryPath === '.agents/skills' || repositoryPath === '.claude/skills')) {
204
+ continue
205
+ }
206
+ if (sharedInstructions && (repositoryPath === 'AGENTS.md' || repositoryPath === 'CLAUDE.md')) {
207
+ continue
208
+ }
209
+ throw new Error(`refusing symlinked repository path: ${segments.join('/')}`)
210
+ }
211
+ }
212
+ return { sharedSkillRoot, sharedInstructions }
213
+ }
214
+
215
+ async function sharedRootSkills(repo) {
216
+ const root = path.join(repo, '.claude', 'skills')
217
+ const entries = await readdir(root, { withFileTypes: true })
218
+ return entries
219
+ .filter(entry => !entry.name.startsWith('.') && entry.isDirectory())
220
+ .sort((left, right) => left.name.localeCompare(right.name))
221
+ .map(entry => ({ name: entry.name, sourceRoot: root }))
222
+ }
223
+
224
+ async function discoverRuleFiles(repo) {
225
+ const rulesRoot = path.join(repo, '.claude', 'rules')
226
+ const rootInfo = await inspect(rulesRoot)
227
+ if (rootInfo === null) return []
228
+ if (!rootInfo.isDirectory()) throw new Error('.claude/rules must be a directory')
229
+
230
+ const rules = []
231
+ async function visit(directory) {
232
+ const entries = await readdir(directory, { withFileTypes: true })
233
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
234
+ const candidate = path.join(directory, entry.name)
235
+ const candidateRelative = relative(repo, candidate)
236
+ const info = await lstat(candidate)
237
+ if (info.isSymbolicLink()) {
238
+ throw new Error(`refusing symlinked repository rule path: ${candidateRelative}`)
239
+ }
240
+ if (info.isDirectory()) {
241
+ await visit(candidate)
242
+ } else if (info.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md') {
243
+ if (!isGitIgnored(repo, candidateRelative)) rules.push(candidateRelative)
244
+ }
245
+ }
246
+ }
247
+
248
+ await visit(rulesRoot)
249
+ return rules.sort()
250
+ }
251
+
252
+ function extractRuleReferences(agentsContents) {
253
+ const references = new Set()
254
+ const patterns = [
255
+ /`(\.claude\/rules\/[^`\n]+?\.md)(?:#[^`\n]*)?`/g,
256
+ /\((\.claude\/rules\/[^)\n]+?\.md)(?:#[^)\n]*)?\)/g,
257
+ /\.claude\/rules\/[A-Za-z0-9._~%+@/-]+\.md(?=$|[\s`"'()<>{}\[\],:;#])/g,
258
+ ]
259
+ for (const pattern of patterns) {
260
+ for (const match of agentsContents.matchAll(pattern)) {
261
+ const reference = match[1] ?? match[0]
262
+ if (match[1] === undefined && (match.index ?? 0) > 0) {
263
+ const preceding = agentsContents[(match.index ?? 0) - 1]
264
+ if (/[A-Za-z0-9_~$./-]/.test(preceding)) continue
265
+ }
266
+ if (!/[?*\[\]{}]/.test(reference)) references.add(reference)
267
+ }
268
+ }
269
+ return [...references].sort()
270
+ }
271
+
272
+ async function auditRuleRouting(repo, agentsContents) {
273
+ const rules = await discoverRuleFiles(repo)
274
+ const references = extractRuleReferences(agentsContents)
275
+ const issues = []
276
+
277
+ for (const rule of rules) {
278
+ if (!references.includes(rule)) {
279
+ issues.push(`AGENTS.md is missing exact rule reference: ${rule}; add this path to the rule index`)
280
+ }
281
+ }
282
+
283
+ const rulesRoot = path.resolve(repo, '.claude', 'rules')
284
+ for (const reference of references) {
285
+ const candidate = path.resolve(repo, ...reference.split('/'))
286
+ if (candidate !== rulesRoot && !candidate.startsWith(`${rulesRoot}${path.sep}`)) {
287
+ issues.push(`AGENTS.md has invalid rule reference outside .claude/rules: ${reference}; remove it`)
288
+ continue
289
+ }
290
+ const info = await inspect(candidate)
291
+ if (info === null || !info.isFile() || info.isSymbolicLink()) {
292
+ issues.push(`AGENTS.md references missing rule file: ${reference}; update or remove the stale reference`)
293
+ }
294
+ }
295
+ return issues
296
+ }
297
+
298
+ function relative(repo, candidate) {
299
+ return path.relative(repo, candidate).split(path.sep).join('/') || '.'
300
+ }
301
+
302
+ async function assetRecord(asset) {
303
+ if (asset?.kind !== 'file') return null
304
+ const bytes = await readFile(asset.absolute)
305
+ return {
306
+ sha256: createHash('sha256').update(bytes).digest('hex'),
307
+ // Git preserves only whether a file is executable, while checkout umasks
308
+ // decide which of the three execute bits appear on disk. Store that
309
+ // portable capability instead of a machine-specific permission mask.
310
+ executableMode: asset.info.mode & 0o111 ? 0o111 : 0,
311
+ }
312
+ }
313
+
314
+ function recordsEqual(left, right) {
315
+ return (
316
+ left !== null &&
317
+ right !== null &&
318
+ left.sha256 === right.sha256 &&
319
+ left.executableMode === right.executableMode
320
+ )
321
+ }
322
+
323
+ function comparePaths(left, right) {
324
+ return left < right ? -1 : left > right ? 1 : 0
325
+ }
326
+
327
+ function serializeOwnershipManifest(files) {
328
+ const sortedFiles = Object.fromEntries([...files.entries()].sort(([left], [right]) => comparePaths(left, right)))
329
+ return `${JSON.stringify({ version: 1, files: sortedFiles }, null, 2)}\n`
330
+ }
331
+
332
+ function parseOwnershipManifest(raw, label) {
333
+ let parsed
334
+ try {
335
+ parsed = JSON.parse(raw)
336
+ } catch {
337
+ throw new Error(`${label} contains invalid JSON; inspect or remove it before syncing`)
338
+ }
339
+ if (parsed?.version !== 1 || parsed.files === null || typeof parsed.files !== 'object' || Array.isArray(parsed.files)) {
340
+ throw new Error(`${label} has an unsupported schema; inspect or remove it before syncing`)
341
+ }
342
+
343
+ const files = new Map()
344
+ for (const [assetPath, record] of Object.entries(parsed.files)) {
345
+ const parts = assetPath.split('/')
346
+ const normalized = path.posix.normalize(assetPath)
347
+ const validPath =
348
+ parts.length >= 2 &&
349
+ normalized === assetPath &&
350
+ !path.posix.isAbsolute(assetPath) &&
351
+ !parts.includes('..') &&
352
+ assetPath !== OWNERSHIP_MANIFEST
353
+ const validRecord =
354
+ record !== null &&
355
+ typeof record === 'object' &&
356
+ !Array.isArray(record) &&
357
+ /^[a-f0-9]{64}$/.test(record.sha256) &&
358
+ Number.isInteger(record.executableMode) &&
359
+ (record.executableMode === 0 || record.executableMode === 0o111)
360
+ if (!validPath || !validRecord) {
361
+ throw new Error(`${label} has an invalid managed entry: ${assetPath}`)
362
+ }
363
+ files.set(assetPath, {
364
+ sha256: record.sha256,
365
+ executableMode: record.executableMode,
366
+ })
367
+ }
368
+
369
+ return { exists: true, files, canonical: raw === serializeOwnershipManifest(files) }
370
+ }
371
+
372
+ async function loadOwnershipManifest(repo) {
373
+ const manifestPath = path.join(repo, ...OWNERSHIP_MANIFEST_PATH.split('/'))
374
+ const info = await inspect(manifestPath)
375
+ if (info === null) return { exists: false, files: new Map(), canonical: true }
376
+ if (!info.isFile() || info.isSymbolicLink()) {
377
+ throw new Error(`${OWNERSHIP_MANIFEST_PATH} must be a regular file`)
378
+ }
379
+ return parseOwnershipManifest(await readFile(manifestPath, 'utf8'), OWNERSHIP_MANIFEST_PATH)
380
+ }
381
+
382
+ function decodeFrontmatterScalar(value, lines, lineIndex, label, field) {
383
+ if (/^[|>][+-]?$/.test(value)) {
384
+ const block = []
385
+ for (let index = lineIndex + 1; index < lines.length; index += 1) {
386
+ const line = lines[index]
387
+ if (line.length > 0 && !/^\s/.test(line)) break
388
+ block.push(line)
389
+ }
390
+ value = block.join('\n').trim()
391
+ } else if (value.startsWith('"')) {
392
+ try {
393
+ value = JSON.parse(value)
394
+ } catch {
395
+ throw new Error(`${label}: SKILL.md frontmatter has an invalid quoted ${field}`)
396
+ }
397
+ } else if (value.startsWith("'")) {
398
+ if (!value.endsWith("'") || value.length < 2) {
399
+ throw new Error(`${label}: SKILL.md frontmatter has an invalid quoted ${field}`)
400
+ }
401
+ value = value.slice(1, -1).replaceAll("''", "'")
402
+ } else {
403
+ value = value.replace(/(^|\s)#.*$/, '').trimEnd()
404
+ }
405
+
406
+ if (typeof value !== 'string' || value.trim().length === 0) {
407
+ throw new Error(`${label}: SKILL.md frontmatter ${field} must be a non-empty string`)
408
+ }
409
+ return value
410
+ }
411
+
412
+ function extractFrontmatterMetadata(contents, label) {
413
+ const lines = contents.split(/\r?\n/)
414
+ if (lines[0] !== '---') {
415
+ throw new Error(`${label}: SKILL.md must start with YAML frontmatter`)
416
+ }
417
+
418
+ const closing = lines.indexOf('---', 1)
419
+ if (closing === -1) {
420
+ throw new Error(`${label}: SKILL.md frontmatter has no closing --- delimiter`)
421
+ }
422
+
423
+ const fields = new Map([
424
+ ['name', []],
425
+ ['description', []],
426
+ ])
427
+ for (let index = 1; index < closing; index += 1) {
428
+ const line = lines[index]
429
+ if (line.includes('\t')) {
430
+ throw new Error(`${label}: SKILL.md frontmatter must not contain tabs`)
431
+ }
432
+ const match = /^(name|description)\s*:\s*(.*?)\s*$/.exec(line)
433
+ if (match) fields.get(match[1]).push({ value: match[2], lineIndex: index })
434
+ }
435
+
436
+ if (fields.get('name').length !== 1) {
437
+ throw new Error(`${label}: SKILL.md frontmatter must contain exactly one top-level name field`)
438
+ }
439
+ if (fields.get('description').length !== 1) {
440
+ throw new Error(`${label}: SKILL.md frontmatter must contain exactly one top-level description field`)
441
+ }
442
+
443
+ const name = fields.get('name')[0]
444
+ const description = fields.get('description')[0]
445
+ return {
446
+ name: decodeFrontmatterScalar(name.value, lines, name.lineIndex, label, 'name'),
447
+ description: decodeFrontmatterScalar(
448
+ description.value,
449
+ lines,
450
+ description.lineIndex,
451
+ label,
452
+ 'description',
453
+ ),
454
+ }
455
+ }
456
+
457
+ function validateSkillName(name, label) {
458
+ if (name.length > MAX_SKILL_NAME_LENGTH || !SKILL_NAME_PATTERN.test(name)) {
459
+ throw new Error(
460
+ `${label}: skill names must be 1-${MAX_SKILL_NAME_LENGTH} lowercase letters, numbers, or single hyphens`,
461
+ )
462
+ }
463
+ }
464
+
465
+ async function discoverSkills(repo) {
466
+ const sourceRoot = path.join(repo, '.agents', 'skills')
467
+ const sourceInfo = await inspect(sourceRoot)
468
+ if (sourceInfo === null) return []
469
+ if (!sourceInfo.isDirectory()) {
470
+ throw new Error('.agents/skills must exist and be a directory')
471
+ }
472
+
473
+ const entries = await readdir(sourceRoot, { withFileTypes: true })
474
+ const skills = []
475
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
476
+ if (entry.name.startsWith('.') || !entry.isDirectory()) continue
477
+
478
+ const label = `.agents/skills/${entry.name}`
479
+ const skillFileLabel = `${label}/SKILL.md`
480
+ if (isGitIgnored(repo, skillFileLabel)) continue
481
+
482
+ validateSkillName(entry.name, label)
483
+ const skillRoot = path.join(sourceRoot, entry.name)
484
+ const skillFile = path.join(skillRoot, 'SKILL.md')
485
+ const skillInfo = await inspect(skillFile)
486
+ if (skillInfo === null || !skillInfo.isFile()) {
487
+ throw new Error(`${label}: SKILL.md is required`)
488
+ }
489
+
490
+ const { name: declaredName } = extractFrontmatterMetadata(await readFile(skillFile, 'utf8'), label)
491
+ validateSkillName(declaredName, label)
492
+ if (declaredName !== entry.name) {
493
+ throw new Error(`${label}: frontmatter name ${JSON.stringify(declaredName)} must match the directory name`)
494
+ }
495
+
496
+ skills.push({ name: entry.name, sourceRoot })
497
+ }
498
+ return skills
499
+ }
500
+
501
+ function runGit(repo, args) {
502
+ const result = spawnSync('git', ['-C', repo, ...args], {
503
+ encoding: null,
504
+ maxBuffer: 16 * 1024 * 1024,
505
+ })
506
+ if (result.error) {
507
+ if (result.error.code === 'ENOENT') {
508
+ throw new Error('git is required to distinguish tracked mirror assets from agent-local skills')
509
+ }
510
+ throw result.error
511
+ }
512
+ return result
513
+ }
514
+
515
+ function isGitIgnored(repo, candidate) {
516
+ const result = runGit(repo, ['check-ignore', '--quiet', '--', candidate])
517
+ if (result.status === 0) return true
518
+ if (result.status === 1) return false
519
+ const message = result.stderr.toString('utf8').trim()
520
+ if (/not a git repository/i.test(message)) return false
521
+ throw new Error(`git check-ignore failed: ${message}`)
522
+ }
523
+
524
+ function localProvenancePath(repo) {
525
+ const result = runGit(repo, ['rev-parse', '--git-path', LOCAL_PROVENANCE_GIT_PATH])
526
+ if (result.status !== 0) {
527
+ const message = result.stderr.toString('utf8').trim()
528
+ if (/not a git repository/i.test(message)) return null
529
+ throw new Error(`git rev-parse --git-path failed: ${message}`)
530
+ }
531
+ const gitPath = result.stdout.toString('utf8').trim()
532
+ if (!gitPath) throw new Error('git rev-parse --git-path returned an empty provenance path')
533
+ return path.isAbsolute(gitPath) ? gitPath : path.resolve(repo, gitPath)
534
+ }
535
+
536
+ async function loadLocalProvenance(repo) {
537
+ const provenancePath = localProvenancePath(repo)
538
+ if (provenancePath === null) return { exists: false, files: new Map(), canonical: true, path: null }
539
+ const info = await inspect(provenancePath)
540
+ if (info === null || !info.isFile() || info.isSymbolicLink()) {
541
+ return { exists: false, files: new Map(), canonical: true, path: provenancePath }
542
+ }
543
+ try {
544
+ return {
545
+ ...parseOwnershipManifest(await readFile(provenancePath, 'utf8'), 'trusted local provenance'),
546
+ path: provenancePath,
547
+ }
548
+ } catch {
549
+ return { exists: false, files: new Map(), canonical: false, path: provenancePath }
550
+ }
551
+ }
552
+
553
+ function localSkillProvenancePath(repo) {
554
+ const result = runGit(repo, ['rev-parse', '--git-path', LOCAL_SKILL_PROVENANCE_GIT_PATH])
555
+ if (result.status !== 0) {
556
+ const message = result.stderr.toString('utf8').trim()
557
+ if (/not a git repository/i.test(message)) return null
558
+ throw new Error(`git rev-parse --git-path failed: ${message}`)
559
+ }
560
+ const gitPath = result.stdout.toString('utf8').trim()
561
+ if (!gitPath) throw new Error('git rev-parse --git-path returned an empty local-skill provenance path')
562
+ return path.isAbsolute(gitPath) ? gitPath : path.resolve(repo, gitPath)
563
+ }
564
+
565
+ function serializeLocalSkillProvenance(skills) {
566
+ return `${JSON.stringify(
567
+ {
568
+ version: 1,
569
+ skills: Object.fromEntries([...skills.entries()].sort(([left], [right]) => comparePaths(left, right))),
570
+ },
571
+ null,
572
+ 2,
573
+ )}\n`
574
+ }
575
+
576
+ async function loadLocalSkillProvenance(repo) {
577
+ const provenancePath = localSkillProvenancePath(repo)
578
+ if (provenancePath === null) return { path: null, skills: new Map() }
579
+ const info = await inspect(provenancePath)
580
+ if (info === null) return { path: provenancePath, skills: new Map() }
581
+ if (!info.isFile() || info.isSymbolicLink()) {
582
+ throw new Error('trusted local-skill provenance must be a regular file')
583
+ }
584
+ let parsed
585
+ try {
586
+ parsed = JSON.parse(await readFile(provenancePath, 'utf8'))
587
+ } catch (error) {
588
+ throw new Error(`trusted local-skill provenance is unreadable: ${error.message}`)
589
+ }
590
+ if (parsed?.version !== 1 || parsed.skills === null || typeof parsed.skills !== 'object' || Array.isArray(parsed.skills)) {
591
+ throw new Error('trusted local-skill provenance has an unsupported schema')
592
+ }
593
+ const skills = new Map()
594
+ for (const [name, digest] of Object.entries(parsed.skills)) {
595
+ validateSkillName(name, 'trusted local-skill provenance')
596
+ if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) {
597
+ throw new Error(`trusted local-skill provenance has an invalid digest for ${name}`)
598
+ }
599
+ skills.set(name, digest)
600
+ }
601
+ return { path: provenancePath, skills }
602
+ }
603
+
604
+ async function skillDigest(skillRoot, name) {
605
+ const tree = await collectTree(skillRoot, { rejectSymlinks: true })
606
+ const skillFile = tree.files.get('SKILL.md')
607
+ if (skillFile === undefined || skillFile.kind !== 'file') {
608
+ throw new Error(`${relative(path.dirname(path.dirname(path.dirname(skillRoot))), skillRoot)}: SKILL.md is required`)
609
+ }
610
+ const label = relative(path.dirname(path.dirname(path.dirname(skillRoot))), skillRoot)
611
+ const { name: declaredName } = extractFrontmatterMetadata(await readFile(skillFile.absolute, 'utf8'), label)
612
+ validateSkillName(declaredName, label)
613
+ if (declaredName !== name) {
614
+ throw new Error(`${label}: frontmatter name ${JSON.stringify(declaredName)} must match the directory name`)
615
+ }
616
+ const digest = createHash('sha256')
617
+ for (const [file, asset] of [...tree.files.entries()].sort(([left], [right]) => comparePaths(left, right))) {
618
+ digest.update(file)
619
+ digest.update('\0')
620
+ digest.update(String(asset.info.mode & 0o111))
621
+ digest.update('\0')
622
+ digest.update(await readFile(asset.absolute))
623
+ digest.update('\0')
624
+ }
625
+ return digest.digest('hex')
626
+ }
627
+
628
+ async function skillTreeContains(largerRoot, smallerRoot) {
629
+ const [larger, smaller] = await Promise.all([
630
+ collectTree(largerRoot, { rejectSymlinks: true }),
631
+ collectTree(smallerRoot, { rejectSymlinks: true }),
632
+ ])
633
+ for (const [file, smallerAsset] of smaller.files) {
634
+ const largerAsset = larger.files.get(file)
635
+ if (largerAsset === undefined || !(await filesMatch(smallerAsset, largerAsset))) return false
636
+ }
637
+ return true
638
+ }
639
+
640
+ async function replaceSkillDirectory(source, destination) {
641
+ const suffix = `${process.pid}-${Math.random().toString(16).slice(2)}`
642
+ const temporary = `${destination}.pilot-sync-${suffix}`
643
+ const backup = `${destination}.pilot-backup-${suffix}`
644
+ await rm(temporary, { recursive: true, force: true })
645
+ await rm(backup, { recursive: true, force: true })
646
+ await mkdir(temporary, { recursive: true })
647
+ const tree = await collectTree(source, { rejectSymlinks: true })
648
+ for (const directory of [...tree.directories].sort()) {
649
+ await mkdir(path.join(temporary, directory), { recursive: true })
650
+ }
651
+ for (const [file, asset] of tree.files) {
652
+ await atomicCopy(asset.absolute, path.join(temporary, file), asset.info.mode)
653
+ }
654
+
655
+ const destinationInfo = await inspect(destination)
656
+ try {
657
+ if (destinationInfo !== null) await rename(destination, backup)
658
+ await rename(temporary, destination)
659
+ await rm(backup, { recursive: true, force: true })
660
+ } catch (error) {
661
+ if ((await inspect(destination)) === null && (await inspect(backup)) !== null) {
662
+ await rename(backup, destination)
663
+ }
664
+ await rm(temporary, { recursive: true, force: true })
665
+ throw error
666
+ }
667
+ }
668
+
669
+ async function skillDirectories(root) {
670
+ const info = await inspect(root)
671
+ if (info === null) return new Map()
672
+ if (!info.isDirectory() || info.isSymbolicLink()) {
673
+ throw new Error(`${root}: skill root must be a regular directory`)
674
+ }
675
+ const entries = new Map()
676
+ for (const entry of await readdir(root, { withFileTypes: true })) {
677
+ if (entry.name.startsWith('.')) continue
678
+ entries.set(entry.name, path.join(root, entry.name))
679
+ }
680
+ return entries
681
+ }
682
+
683
+ function hasTrackedSkillAssets(repo, name) {
684
+ const result = runGit(repo, [
685
+ 'ls-files',
686
+ '-z',
687
+ '--',
688
+ `.agents/skills/${name}`,
689
+ `.claude/skills/${name}`,
690
+ ])
691
+ if (result.status !== 0) return false
692
+ return result.stdout.length > 0
693
+ }
694
+
695
+ async function discoverIgnoredSkillPairs(repo) {
696
+ const canonicalRoot = path.join(repo, '.agents', 'skills')
697
+ const mirrorRoot = path.join(repo, '.claude', 'skills')
698
+ const [canonicalEntries, mirrorEntries] = await Promise.all([
699
+ skillDirectories(canonicalRoot),
700
+ skillDirectories(mirrorRoot),
701
+ ])
702
+ const names = new Set([...canonicalEntries.keys(), ...mirrorEntries.keys()])
703
+ const pairs = []
704
+ for (const name of [...names].sort()) {
705
+ validateSkillName(name, `local skill ${name}`)
706
+ const canonical = canonicalEntries.get(name) ?? path.join(canonicalRoot, name)
707
+ const mirror = mirrorEntries.get(name) ?? path.join(mirrorRoot, name)
708
+ const [canonicalInfo, mirrorInfo] = await Promise.all([inspect(canonical), inspect(mirror)])
709
+
710
+ if (mirrorInfo?.isSymbolicLink() && canonicalInfo?.isDirectory() && !canonicalInfo.isSymbolicLink()) {
711
+ if ((await realpath(mirror)) === (await realpath(canonical))) continue
712
+ }
713
+ if (canonicalInfo?.isSymbolicLink() || mirrorInfo?.isSymbolicLink()) {
714
+ throw new Error(`local skill ${name}: symbolic links must point from the Claude mirror to its canonical skill`)
715
+ }
716
+ if (canonicalInfo !== null && !canonicalInfo.isDirectory()) {
717
+ throw new Error(`.agents/skills/${name}: skill must be a directory`)
718
+ }
719
+ if (mirrorInfo !== null && !mirrorInfo.isDirectory()) {
720
+ throw new Error(`.claude/skills/${name}: skill must be a directory`)
721
+ }
722
+ const tracked = hasTrackedSkillAssets(repo, name)
723
+ if (tracked && canonicalInfo !== null && mirrorInfo !== null) continue
724
+
725
+ const [canonicalSkillFile, mirrorSkillFile] = await Promise.all([
726
+ inspect(path.join(canonical, 'SKILL.md')),
727
+ inspect(path.join(mirror, 'SKILL.md')),
728
+ ])
729
+ if (canonicalSkillFile === null && mirrorSkillFile === null) continue
730
+ if (canonicalInfo === null || mirrorInfo === null) {
731
+ const existingFile = canonicalInfo === null ? mirrorSkillFile : canonicalSkillFile
732
+ const existingRoot = canonicalInfo === null ? mirror : canonical
733
+ if (existingFile === null || !existingFile.isFile() || existingFile.isSymbolicLink()) continue
734
+ try {
735
+ extractFrontmatterMetadata(await readFile(path.join(existingRoot, 'SKILL.md'), 'utf8'), `local skill ${name}`)
736
+ } catch {
737
+ // A one-sided directory without valid skill metadata is private agent
738
+ // state, not a portable skill. Preserve it without blocking all sync.
739
+ continue
740
+ }
741
+ }
742
+
743
+ pairs.push({ name, canonical, mirror, canonicalInfo, mirrorInfo })
744
+ }
745
+ return pairs
746
+ }
747
+
748
+ async function validatePairedMirrorSkills(repo) {
749
+ const canonicalRoot = path.join(repo, '.agents', 'skills')
750
+ const mirrorRoot = path.join(repo, '.claude', 'skills')
751
+ const mirrors = await skillDirectories(mirrorRoot)
752
+ for (const [name, mirror] of mirrors) {
753
+ const canonical = path.join(canonicalRoot, name)
754
+ const [canonicalInfo, mirrorInfo, mirrorSkill] = await Promise.all([
755
+ inspect(canonical),
756
+ inspect(mirror),
757
+ inspect(path.join(mirror, 'SKILL.md')),
758
+ ])
759
+ if (canonicalInfo === null || mirrorSkill === null) continue
760
+ if (mirrorInfo?.isSymbolicLink() && canonicalInfo.isDirectory() && !canonicalInfo.isSymbolicLink()) {
761
+ if ((await realpath(mirror)) === (await realpath(canonical))) continue
762
+ }
763
+ await skillDigest(mirror, name)
764
+ }
765
+ }
766
+
767
+ async function planIgnoredSkillSync(repo) {
768
+ const provenance = await loadLocalSkillProvenance(repo)
769
+ const pairs = await discoverIgnoredSkillPairs(repo)
770
+ const actions = []
771
+ const issues = []
772
+ const finalDigests = new Map()
773
+
774
+ for (const pair of pairs) {
775
+ const canonicalDigest = pair.canonicalInfo === null ? null : await skillDigest(pair.canonical, pair.name)
776
+ const mirrorDigest = pair.mirrorInfo === null ? null : await skillDigest(pair.mirror, pair.name)
777
+ const baseline = provenance.skills.get(pair.name)
778
+
779
+ if (canonicalDigest === null || mirrorDigest === null) {
780
+ const existingPath = canonicalDigest === null ? pair.mirror : pair.canonical
781
+ const missingPath = canonicalDigest === null ? pair.canonical : pair.mirror
782
+ const existingDigest = canonicalDigest ?? mirrorDigest
783
+ if (baseline === undefined) {
784
+ actions.push({ type: 'replace', source: existingPath, destination: missingPath })
785
+ finalDigests.set(pair.name, existingDigest)
786
+ } else if (existingDigest === baseline) {
787
+ actions.push({ type: 'delete', destination: existingPath })
788
+ } else {
789
+ issues.push(`local skill ${pair.name}: one side was deleted while the other changed; preserved both states`)
790
+ }
791
+ continue
792
+ }
793
+
794
+ if (canonicalDigest === mirrorDigest) {
795
+ finalDigests.set(pair.name, canonicalDigest)
796
+ continue
797
+ }
798
+ if (baseline === undefined) {
799
+ if (await skillTreeContains(pair.mirror, pair.canonical)) {
800
+ actions.push({ type: 'replace', source: pair.mirror, destination: pair.canonical })
801
+ finalDigests.set(pair.name, mirrorDigest)
802
+ } else if (await skillTreeContains(pair.canonical, pair.mirror)) {
803
+ actions.push({ type: 'replace', source: pair.canonical, destination: pair.mirror })
804
+ finalDigests.set(pair.name, canonicalDigest)
805
+ } else {
806
+ issues.push(`local skill ${pair.name}: both sides differ without a trusted baseline; preserved both versions`)
807
+ }
808
+ } else if (canonicalDigest === baseline && mirrorDigest !== baseline) {
809
+ actions.push({ type: 'replace', source: pair.mirror, destination: pair.canonical })
810
+ finalDigests.set(pair.name, mirrorDigest)
811
+ } else if (mirrorDigest === baseline && canonicalDigest !== baseline) {
812
+ actions.push({ type: 'replace', source: pair.canonical, destination: pair.mirror })
813
+ finalDigests.set(pair.name, canonicalDigest)
814
+ } else {
815
+ issues.push(`local skill ${pair.name}: both sides changed; preserved both versions`)
816
+ }
817
+ }
818
+
819
+ return { provenance, actions, issues, finalDigests }
820
+ }
821
+
822
+ async function auditIgnoredSkills(repo) {
823
+ const plan = await planIgnoredSkillSync(repo)
824
+ return [
825
+ ...plan.issues,
826
+ ...plan.actions.map(action => {
827
+ const destination = relative(repo, action.destination)
828
+ if (action.type === 'delete') return `${destination}: local skill deletion has not synchronized`
829
+ const side = destination.startsWith('.agents/') ? 'Codex' : 'Claude'
830
+ return `${destination}: missing ${side} counterpart or local skill drift`
831
+ }),
832
+ ]
833
+ }
834
+
835
+ async function synchronizeIgnoredSkills(repo) {
836
+ const plan = await planIgnoredSkillSync(repo)
837
+ if (plan.issues.length > 0) throw new Error(plan.issues.join('\n'))
838
+ for (const action of plan.actions) {
839
+ if (action.type === 'replace') {
840
+ await replaceSkillDirectory(action.source, action.destination)
841
+ } else {
842
+ await rm(action.destination, { recursive: true, force: true })
843
+ }
844
+ }
845
+ if (plan.provenance.path !== null) {
846
+ await atomicWriteText(
847
+ plan.provenance.path,
848
+ serializeLocalSkillProvenance(plan.finalDigests),
849
+ 0o600,
850
+ )
851
+ }
852
+ }
853
+
854
+ async function synchronizeRootInstructions(repo) {
855
+ const agents = path.join(repo, 'AGENTS.md')
856
+ const claude = path.join(repo, 'CLAUDE.md')
857
+ const [agentsInfo, claudeInfo] = await Promise.all([inspect(agents), inspect(claude)])
858
+ if (agentsInfo === null && claudeInfo === null) return
859
+ if (agentsInfo !== null && (!agentsInfo.isFile() || agentsInfo.isSymbolicLink())) {
860
+ throw new Error('AGENTS.md must be a regular file')
861
+ }
862
+ if (claudeInfo !== null && (!claudeInfo.isFile() || claudeInfo.isSymbolicLink())) {
863
+ throw new Error('CLAUDE.md must be a regular file')
864
+ }
865
+
866
+ if (agentsInfo === null) {
867
+ const claudeContents = await readFile(claude, 'utf8')
868
+ if (claudeContents === CLAUDE_IMPORT) {
869
+ throw new Error('CLAUDE.md imports AGENTS.md, but AGENTS.md is missing')
870
+ }
871
+ await atomicWriteText(agents, claudeContents, 0o644)
872
+ await atomicWriteText(claude, CLAUDE_IMPORT, 0o644)
873
+ return
874
+ }
875
+
876
+ if (claudeInfo === null) {
877
+ await atomicWriteText(claude, CLAUDE_IMPORT, 0o644)
878
+ return
879
+ }
880
+ const claudeContents = await readFile(claude, 'utf8')
881
+ if (claudeContents !== CLAUDE_IMPORT) {
882
+ if (isTrivialClaude(claudeContents)) {
883
+ await atomicWriteText(claude, CLAUDE_IMPORT, 0o644)
884
+ return
885
+ }
886
+ throw new Error(
887
+ 'refusing to overwrite nontrivial CLAUDE.md; AGENTS.md and CLAUDE.md both contain instructions, so both were preserved for explicit reconciliation',
888
+ )
889
+ }
890
+ }
891
+
892
+ function isIgnoredCanonicalAsset(repo, assetPath) {
893
+ const skillName = assetPath.split('/')[0]
894
+ return Boolean(skillName) && isGitIgnored(repo, `.agents/skills/${skillName}/SKILL.md`)
895
+ }
896
+
897
+ function trackedSkillAssets(repo) {
898
+ const worktree = runGit(repo, ['rev-parse', '--is-inside-work-tree'])
899
+ if (worktree.status !== 0) {
900
+ const message = worktree.stderr.toString('utf8').trim()
901
+ if (/not a git repository/i.test(message)) return new Set()
902
+ throw new Error(`git rev-parse failed: ${message}`)
903
+ }
904
+
905
+ const tracked = runGit(repo, ['ls-files', '-z', '--', '.claude/skills'])
906
+ if (tracked.status !== 0) {
907
+ throw new Error(`git ls-files failed: ${tracked.stderr.toString('utf8').trim()}`)
908
+ }
909
+
910
+ const assets = new Set(
911
+ tracked.stdout
912
+ .toString('utf8')
913
+ .split('\0')
914
+ .filter(candidate => candidate === '.claude/skills' || candidate.startsWith('.claude/skills/'))
915
+ .map(candidate => candidate.split(path.sep).join('/')),
916
+ )
917
+ assets.delete(OWNERSHIP_MANIFEST_PATH)
918
+ return assets
919
+ }
920
+
921
+ async function trackedMirrorOnlyAssets(repo, canonicalNames, trackedAssets) {
922
+ const prefix = '.claude/skills/'
923
+ const assets = []
924
+ for (const normalized of [...trackedAssets].sort()) {
925
+ if (!normalized.startsWith(prefix)) continue
926
+ const remainder = normalized.slice(prefix.length)
927
+ const skillName = remainder.split('/')[0]
928
+ if (!skillName || canonicalNames.has(skillName)) continue
929
+
930
+ const absolute = path.join(repo, ...normalized.split('/'))
931
+ if ((await inspect(absolute)) !== null) assets.push({ absolute, relative: normalized })
932
+ }
933
+ return assets
934
+ }
935
+
936
+ async function collectTree(root, { rejectSymlinks = false } = {}) {
937
+ const files = new Map()
938
+ const directories = new Set()
939
+
940
+ async function visit(current, currentRelative) {
941
+ const entries = await readdir(current, { withFileTypes: true })
942
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
943
+ const absolute = path.join(current, entry.name)
944
+ const itemRelative = currentRelative ? path.join(currentRelative, entry.name) : entry.name
945
+ const normalized = itemRelative.split(path.sep).join('/')
946
+ const info = await lstat(absolute)
947
+
948
+ if (info.isSymbolicLink()) {
949
+ if (rejectSymlinks) throw new Error(`${normalized}: symbolic links are not portable skill assets`)
950
+ files.set(normalized, { absolute, info, kind: 'symlink' })
951
+ } else if (info.isDirectory()) {
952
+ directories.add(normalized)
953
+ await visit(absolute, itemRelative)
954
+ } else if (info.isFile()) {
955
+ files.set(normalized, { absolute, info, kind: 'file' })
956
+ } else {
957
+ throw new Error(`${normalized}: unsupported filesystem entry`)
958
+ }
959
+ }
960
+ }
961
+
962
+ await visit(root, '')
963
+ return { files, directories }
964
+ }
965
+
966
+ function hasTrackedPath(trackedAssets, candidate) {
967
+ if (trackedAssets.has(candidate)) return true
968
+ const prefix = `${candidate}/`
969
+ return [...trackedAssets].some(tracked => tracked.startsWith(prefix))
970
+ }
971
+
972
+ function hasManifestPath(manifestFiles, candidate) {
973
+ if (manifestFiles.has(candidate)) return true
974
+ const prefix = `${candidate}/`
975
+ return [...manifestFiles.keys()].some(managed => managed.startsWith(prefix))
976
+ }
977
+
978
+ function canonicalPathForMirror(mirrorPath) {
979
+ return mirrorPath.replace(/^\.claude\/skills(?=\/|$)/, '.agents/skills')
980
+ }
981
+
982
+ async function compareSkill(repo, name, sourceRoot, targetRoot, trackedAssets) {
983
+ const sourceSkill = path.join(sourceRoot, name)
984
+ const targetSkill = path.join(targetRoot, name)
985
+ const issues = []
986
+ const targetInfo = await inspect(targetSkill)
987
+ const sourceTree = await collectTree(sourceSkill, { rejectSymlinks: true })
988
+
989
+ if (targetInfo === null || !targetInfo.isDirectory() || targetInfo.isSymbolicLink()) {
990
+ issues.push(`${relative(repo, targetSkill)}: missing mirrored skill directory`)
991
+ return issues
992
+ }
993
+
994
+ const targetTree = await collectTree(targetSkill)
995
+ for (const [file, source] of sourceTree.files) {
996
+ const destination = targetTree.files.get(file)
997
+ const label = relative(repo, path.join(targetSkill, file))
998
+ if (destination === undefined || destination.kind !== 'file') {
999
+ issues.push(`${label}: missing mirrored file`)
1000
+ continue
1001
+ }
1002
+
1003
+ const [sourceBytes, targetBytes] = await Promise.all([
1004
+ readFile(source.absolute),
1005
+ readFile(destination.absolute),
1006
+ ])
1007
+ if (!sourceBytes.equals(targetBytes)) issues.push(`${label}: bytes differ`)
1008
+ if ((source.info.mode & 0o111) !== (destination.info.mode & 0o111)) {
1009
+ issues.push(`${label}: executable bits differ`)
1010
+ }
1011
+ }
1012
+
1013
+ for (const [file] of targetTree.files) {
1014
+ const label = relative(repo, path.join(targetSkill, file))
1015
+ if (!sourceTree.files.has(file) && trackedAssets.has(label)) {
1016
+ issues.push(`${label}: extra tracked mirrored file; migrate to ${canonicalPathForMirror(label)}`)
1017
+ }
1018
+ }
1019
+ for (const directory of targetTree.directories) {
1020
+ const label = relative(repo, path.join(targetSkill, directory))
1021
+ if (!sourceTree.directories.has(directory) && hasTrackedPath(trackedAssets, label)) {
1022
+ issues.push(`${label}: extra tracked mirrored directory; migrate to ${canonicalPathForMirror(label)}`)
1023
+ }
1024
+ }
1025
+
1026
+ return issues
1027
+ }
1028
+
1029
+ async function audit(repo) {
1030
+ const { sharedSkillRoot, sharedInstructions } = await assertSafeRepoLayout(repo)
1031
+ const externalGeneratedInstructions =
1032
+ !sharedInstructions && (await usesExternalGeneratedInstructions(repo))
1033
+ const agents = path.join(repo, 'AGENTS.md')
1034
+ const agentsInfo = await inspect(agents)
1035
+ if (agentsInfo !== null && !sharedInstructions && (!agentsInfo.isFile() || agentsInfo.isSymbolicLink())) {
1036
+ throw new Error('AGENTS.md must be a regular file')
1037
+ }
1038
+ const agentsContents = agentsInfo === null ? '' : await readFile(agents, 'utf8')
1039
+
1040
+ const skills = sharedSkillRoot ? await sharedRootSkills(repo) : await discoverSkills(repo)
1041
+ const trackedAssets = sharedSkillRoot ? new Set() : trackedSkillAssets(repo)
1042
+ const targetRoot = path.join(repo, '.claude', 'skills')
1043
+ const manifest = sharedSkillRoot
1044
+ ? { exists: false, files: new Map(), canonical: true }
1045
+ : await loadOwnershipManifest(repo)
1046
+ const issues = [
1047
+ ...(sharedSkillRoot ? [] : await auditIgnoredSkills(repo)),
1048
+ ...(await auditRuleRouting(repo, agentsContents)),
1049
+ ...(sharedSkillRoot ? [] : await auditOwnershipManifest(repo, skills, targetRoot, manifest)),
1050
+ ]
1051
+ const claude = path.join(repo, 'CLAUDE.md')
1052
+ const claudeInfo = await inspect(claude)
1053
+ if (!sharedInstructions && !externalGeneratedInstructions) {
1054
+ if (agentsInfo === null && claudeInfo !== null) {
1055
+ issues.push('AGENTS.md: missing; migrate the existing CLAUDE.md instructions')
1056
+ } else if (agentsInfo !== null && claudeInfo === null) {
1057
+ issues.push('CLAUDE.md: missing; expected exactly @AGENTS.md')
1058
+ } else if (agentsInfo !== null && (await readFile(claude, 'utf8')) !== CLAUDE_IMPORT) {
1059
+ issues.push('CLAUDE.md: content differs; expected exactly @AGENTS.md')
1060
+ }
1061
+ }
1062
+
1063
+ if (!sharedSkillRoot) {
1064
+ for (const skill of skills) {
1065
+ issues.push(...(await compareSkill(repo, skill.name, skill.sourceRoot, targetRoot, trackedAssets)))
1066
+ }
1067
+ const canonicalNames = new Set(skills.map(skill => skill.name))
1068
+ for (const asset of await trackedMirrorOnlyAssets(repo, canonicalNames, trackedAssets)) {
1069
+ issues.push(
1070
+ `${asset.relative}: tracked mirror-only asset has no canonical source; migrate to ${canonicalPathForMirror(asset.relative)}`,
1071
+ )
1072
+ }
1073
+ }
1074
+ return { issues, skills }
1075
+ }
1076
+
1077
+ function isTrivialClaude(contents) {
1078
+ const meaningful = contents
1079
+ .split(/\r?\n/)
1080
+ .map(line => line.trim())
1081
+ .filter(Boolean)
1082
+ return meaningful.length === 0 || (meaningful.length === 1 && meaningful[0] === '@AGENTS.md')
1083
+ }
1084
+
1085
+ async function atomicCopy(source, destination, mode) {
1086
+ await mkdir(path.dirname(destination), { recursive: true })
1087
+ const temporary = `${destination}.pilot-sync-${process.pid}-${Math.random().toString(16).slice(2)}`
1088
+ try {
1089
+ await copyFile(source, temporary, fsConstants.COPYFILE_FICLONE)
1090
+ await chmod(temporary, mode & 0o777)
1091
+ await rename(temporary, destination)
1092
+ } finally {
1093
+ try {
1094
+ await unlink(temporary)
1095
+ } catch (error) {
1096
+ if (error?.code !== 'ENOENT') throw error
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ async function atomicWriteText(destination, contents, mode) {
1102
+ await mkdir(path.dirname(destination), { recursive: true })
1103
+ const temporary = `${destination}.pilot-sync-${process.pid}-${Math.random().toString(16).slice(2)}`
1104
+ try {
1105
+ await writeFile(temporary, contents, 'utf8')
1106
+ await chmod(temporary, mode)
1107
+ await rename(temporary, destination)
1108
+ } finally {
1109
+ try {
1110
+ await unlink(temporary)
1111
+ } catch (error) {
1112
+ if (error?.code !== 'ENOENT') throw error
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ async function filesMatch(source, target) {
1118
+ if (target.kind !== 'file') return false
1119
+ const [sourceBytes, targetBytes] = await Promise.all([readFile(source.absolute), readFile(target.absolute)])
1120
+ return sourceBytes.equals(targetBytes) && (source.info.mode & 0o111) === (target.info.mode & 0o111)
1121
+ }
1122
+
1123
+ async function sourceAssetInventory(skills) {
1124
+ const files = new Map()
1125
+ const directories = new Set()
1126
+ for (const { name, sourceRoot } of skills) {
1127
+ directories.add(name)
1128
+ const tree = await collectTree(path.join(sourceRoot, name), { rejectSymlinks: true })
1129
+ for (const directory of tree.directories) directories.add(`${name}/${directory}`)
1130
+ for (const [file, asset] of tree.files) {
1131
+ files.set(`${name}/${file}`, { asset, record: await assetRecord(asset) })
1132
+ }
1133
+ }
1134
+ return { files, directories }
1135
+ }
1136
+
1137
+ async function targetAssetInventory(targetRoot) {
1138
+ const info = await inspect(targetRoot)
1139
+ if (info === null) return { files: new Map(), directories: new Set() }
1140
+ if (!info.isDirectory() || info.isSymbolicLink()) {
1141
+ throw new Error('.claude/skills must be a regular directory')
1142
+ }
1143
+ const tree = await collectTree(targetRoot)
1144
+ tree.files.delete(OWNERSHIP_MANIFEST)
1145
+ return tree
1146
+ }
1147
+
1148
+ async function auditOwnershipManifest(repo, skills, targetRoot, manifest) {
1149
+ const issues = []
1150
+ if (!manifest.exists) {
1151
+ if (skills.length === 0) return issues
1152
+ issues.push(`${OWNERSHIP_MANIFEST_PATH}: ownership baseline is missing; run --write to claim an exact legacy mirror`)
1153
+ return issues
1154
+ }
1155
+ if (!manifest.canonical) {
1156
+ issues.push(`${OWNERSHIP_MANIFEST_PATH}: manifest bytes are not deterministic; run --write to normalize it`)
1157
+ }
1158
+
1159
+ const [source, target] = await Promise.all([
1160
+ sourceAssetInventory(skills),
1161
+ targetAssetInventory(targetRoot),
1162
+ ])
1163
+ for (const [assetPath, sourceEntry] of source.files) {
1164
+ const baseline = manifest.files.get(assetPath)
1165
+ if (baseline === undefined) {
1166
+ issues.push(`${OWNERSHIP_MANIFEST_PATH}: missing managed file record for ${assetPath}`)
1167
+ continue
1168
+ }
1169
+ if (!recordsEqual(sourceEntry.record, baseline)) {
1170
+ issues.push(`${OWNERSHIP_MANIFEST_PATH}: canonical asset diverges from baseline: ${assetPath}`)
1171
+ }
1172
+ const targetAsset = target.files.get(assetPath)
1173
+ const targetRecord = await assetRecord(targetAsset)
1174
+ if (targetAsset !== undefined && !recordsEqual(targetRecord, baseline)) {
1175
+ issues.push(`${OWNERSHIP_MANIFEST_PATH}: mirrored asset diverges from baseline: ${assetPath}`)
1176
+ }
1177
+ }
1178
+ for (const assetPath of manifest.files.keys()) {
1179
+ if (!source.files.has(assetPath)) {
1180
+ if (isIgnoredCanonicalAsset(repo, assetPath)) continue
1181
+ issues.push(`${OWNERSHIP_MANIFEST_PATH}: stale managed file record: ${assetPath}`)
1182
+ }
1183
+ }
1184
+ return issues
1185
+ }
1186
+
1187
+ async function writeOwnershipManifest(repo, skills) {
1188
+ const source = await sourceAssetInventory(skills)
1189
+ const files = new Map([...source.files].map(([assetPath, entry]) => [assetPath, entry.record]))
1190
+ const manifestPath = path.join(repo, ...OWNERSHIP_MANIFEST_PATH.split('/'))
1191
+ await atomicWriteText(manifestPath, serializeOwnershipManifest(files), 0o644)
1192
+ }
1193
+
1194
+ async function writeLocalProvenance(repo, skills) {
1195
+ const provenancePath = localProvenancePath(repo)
1196
+ if (provenancePath === null) return
1197
+ const source = await sourceAssetInventory(skills)
1198
+ const files = new Map([...source.files].map(([assetPath, entry]) => [assetPath, entry.record]))
1199
+ await atomicWriteText(provenancePath, serializeOwnershipManifest(files), 0o600)
1200
+ }
1201
+
1202
+ function localConflict(label) {
1203
+ return `${label}: untracked or ignored Claude skill conflicts with the canonical source; migrate or remove the local asset before syncing`
1204
+ }
1205
+
1206
+ function divergentConflict(label) {
1207
+ return `${label}: canonical and mirrored assets do not have a safe ownership direction; preserve the mirror, migrate the intended content, then retry`
1208
+ }
1209
+
1210
+ function trackedExtraConflict(label) {
1211
+ return `${label}: tracked Claude-only asset has no ownership baseline; migrate to ${canonicalPathForMirror(label)} or rerun with --force-migration after preserving intended content`
1212
+ }
1213
+
1214
+ function mutationBaseline(assetPath, tracked, manifest, localProvenance) {
1215
+ return (tracked ? manifest.files : localProvenance.files).get(assetPath)
1216
+ }
1217
+
1218
+ function hasMutationBaseline(assetPath, tracked, manifest, localProvenance) {
1219
+ return hasManifestPath(tracked ? manifest.files : localProvenance.files, assetPath)
1220
+ }
1221
+
1222
+ function replacementDecision(sourceRecord, targetRecord, baseline, tracked) {
1223
+ if (recordsEqual(sourceRecord, targetRecord)) return 'same'
1224
+ if (baseline !== undefined) {
1225
+ const sourceMatches = recordsEqual(sourceRecord, baseline)
1226
+ const targetMatches = recordsEqual(targetRecord, baseline)
1227
+ if (!sourceMatches && targetMatches) return 'replace'
1228
+ if (sourceMatches && !targetMatches) return 'reverse'
1229
+ return 'conflict'
1230
+ }
1231
+ return 'conflict'
1232
+ }
1233
+
1234
+ function staleRemovalDecision(targetRecord, baseline, tracked) {
1235
+ if (baseline !== undefined) return recordsEqual(targetRecord, baseline) ? 'remove' : 'conflict'
1236
+ return tracked ? 'remove' : 'preserve'
1237
+ }
1238
+
1239
+ async function findUntrackedSkillConflicts(
1240
+ repo,
1241
+ skills,
1242
+ targetRoot,
1243
+ trackedAssets,
1244
+ manifest,
1245
+ localProvenance,
1246
+ forceMigration,
1247
+ ) {
1248
+ const conflicts = []
1249
+ for (const { name, sourceRoot } of skills) {
1250
+ const sourceSkill = path.join(sourceRoot, name)
1251
+ const targetSkill = path.join(targetRoot, name)
1252
+ const targetLabel = relative(repo, targetSkill)
1253
+ const targetInfo = await inspect(targetSkill)
1254
+ if (targetInfo === null) continue
1255
+ if (!targetInfo.isDirectory() || targetInfo.isSymbolicLink()) {
1256
+ const tracked = trackedAssets.has(targetLabel)
1257
+ if (hasMutationBaseline(name, tracked, manifest, localProvenance)) {
1258
+ conflicts.push(divergentConflict(targetLabel))
1259
+ } else if (!tracked) {
1260
+ conflicts.push(localConflict(targetLabel))
1261
+ }
1262
+ continue
1263
+ }
1264
+
1265
+ const [sourceTree, targetTree] = await Promise.all([
1266
+ collectTree(sourceSkill, { rejectSymlinks: true }),
1267
+ collectTree(targetSkill),
1268
+ ])
1269
+ for (const [file, target] of targetTree.files) {
1270
+ const label = relative(repo, path.join(targetSkill, file))
1271
+ const source = sourceTree.files.get(file)
1272
+ const assetPath = `${name}/${file}`
1273
+ const tracked = trackedAssets.has(label)
1274
+ const baseline = mutationBaseline(assetPath, tracked, manifest, localProvenance)
1275
+ const targetRecord = await assetRecord(target)
1276
+ if (source !== undefined) {
1277
+ const sourceRecord = await assetRecord(source)
1278
+ if (replacementDecision(sourceRecord, targetRecord, baseline, tracked) === 'conflict') {
1279
+ conflicts.push(baseline === undefined ? localConflict(label) : divergentConflict(label))
1280
+ }
1281
+ } else if (sourceTree.directories.has(file)) {
1282
+ if (baseline === undefined && tracked && !forceMigration) {
1283
+ conflicts.push(trackedExtraConflict(label))
1284
+ continue
1285
+ }
1286
+ const decision = staleRemovalDecision(targetRecord, baseline, tracked)
1287
+ if (decision !== 'remove') {
1288
+ conflicts.push(decision === 'preserve' ? localConflict(label) : divergentConflict(label))
1289
+ }
1290
+ } else if (baseline !== undefined && staleRemovalDecision(targetRecord, baseline, false) === 'conflict') {
1291
+ conflicts.push(divergentConflict(label))
1292
+ }
1293
+ }
1294
+
1295
+ for (const file of sourceTree.files.keys()) {
1296
+ if (!targetTree.directories.has(file)) continue
1297
+ const label = relative(repo, path.join(targetSkill, file))
1298
+ const prefix = `${file}/`
1299
+ const descendants = [...targetTree.files.keys()].filter(target => target.startsWith(prefix))
1300
+ const fullyOwned =
1301
+ descendants.length > 0 &&
1302
+ (
1303
+ await Promise.all(
1304
+ descendants.map(async target => {
1305
+ const descendantLabel = relative(repo, path.join(targetSkill, target))
1306
+ const tracked = trackedAssets.has(descendantLabel)
1307
+ const baseline = mutationBaseline(`${name}/${target}`, tracked, manifest, localProvenance)
1308
+ const targetRecord = await assetRecord(targetTree.files.get(target))
1309
+ return staleRemovalDecision(targetRecord, baseline, tracked) === 'remove'
1310
+ }),
1311
+ )
1312
+ ).every(Boolean)
1313
+ if (!fullyOwned) conflicts.push(divergentConflict(label))
1314
+ }
1315
+ }
1316
+ return [...new Set(conflicts)].sort()
1317
+ }
1318
+
1319
+ async function findStaleManifestConflicts(
1320
+ repo,
1321
+ skills,
1322
+ targetRoot,
1323
+ trackedAssets,
1324
+ manifest,
1325
+ localProvenance,
1326
+ ) {
1327
+ const [source, target] = await Promise.all([
1328
+ sourceAssetInventory(skills),
1329
+ targetAssetInventory(targetRoot),
1330
+ ])
1331
+ const conflicts = []
1332
+ const candidates = new Set([...manifest.files.keys(), ...localProvenance.files.keys()])
1333
+ for (const assetPath of candidates) {
1334
+ if (source.files.has(assetPath)) continue
1335
+ if (isIgnoredCanonicalAsset(repo, assetPath)) continue
1336
+ const targetAsset = target.files.get(assetPath)
1337
+ if (targetAsset !== undefined) {
1338
+ const label = relative(repo, path.join(targetRoot, ...assetPath.split('/')))
1339
+ const baseline = mutationBaseline(assetPath, trackedAssets.has(label), manifest, localProvenance)
1340
+ if (baseline !== undefined && !recordsEqual(await assetRecord(targetAsset), baseline)) {
1341
+ conflicts.push(divergentConflict(label))
1342
+ }
1343
+ }
1344
+ }
1345
+ return conflicts
1346
+ }
1347
+
1348
+ async function removeStaleManifestAssets(repo, skills, targetRoot, trackedAssets, manifest, localProvenance) {
1349
+ const [source, target] = await Promise.all([
1350
+ sourceAssetInventory(skills),
1351
+ targetAssetInventory(targetRoot),
1352
+ ])
1353
+ const removed = []
1354
+ const candidates = new Set([...manifest.files.keys(), ...localProvenance.files.keys()])
1355
+ for (const assetPath of candidates) {
1356
+ if (source.files.has(assetPath)) continue
1357
+ if (isIgnoredCanonicalAsset(repo, assetPath)) continue
1358
+ const targetAsset = target.files.get(assetPath)
1359
+ if (targetAsset !== undefined) {
1360
+ const label = relative(repo, path.join(targetRoot, ...assetPath.split('/')))
1361
+ const baseline = mutationBaseline(assetPath, trackedAssets.has(label), manifest, localProvenance)
1362
+ if (baseline !== undefined && recordsEqual(await assetRecord(targetAsset), baseline)) {
1363
+ await unlink(targetAsset.absolute)
1364
+ removed.push(targetAsset.absolute)
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ const parents = new Set()
1370
+ for (const removedAsset of removed) {
1371
+ let parent = path.dirname(removedAsset)
1372
+ while (parent.startsWith(`${targetRoot}${path.sep}`)) {
1373
+ parents.add(parent)
1374
+ parent = path.dirname(parent)
1375
+ }
1376
+ }
1377
+ for (const parent of [...parents].sort((left, right) => right.length - left.length)) {
1378
+ try {
1379
+ await rmdir(parent)
1380
+ } catch (error) {
1381
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ async function syncSkill(repo, name, sourceRoot, targetRoot, trackedAssets, manifest, localProvenance) {
1387
+ const sourceSkill = path.join(sourceRoot, name)
1388
+ const targetSkill = path.join(targetRoot, name)
1389
+ const targetLabel = relative(repo, targetSkill)
1390
+ const targetInfo = await inspect(targetSkill)
1391
+ if (targetInfo !== null && (!targetInfo.isDirectory() || targetInfo.isSymbolicLink())) {
1392
+ const tracked = trackedAssets.has(targetLabel)
1393
+ if (hasMutationBaseline(name, tracked, manifest, localProvenance)) {
1394
+ throw new Error(divergentConflict(targetLabel))
1395
+ }
1396
+ if (!tracked) throw new Error(localConflict(targetLabel))
1397
+ await unlink(targetSkill)
1398
+ }
1399
+ await mkdir(targetSkill, { recursive: true })
1400
+
1401
+ const sourceTree = await collectTree(sourceSkill, { rejectSymlinks: true })
1402
+ const targetTree = await collectTree(targetSkill)
1403
+
1404
+ for (const [file, target] of targetTree.files) {
1405
+ const label = relative(repo, path.join(targetSkill, file))
1406
+ const source = sourceTree.files.get(file)
1407
+ const assetPath = `${name}/${file}`
1408
+ const tracked = trackedAssets.has(label)
1409
+ const baseline = mutationBaseline(assetPath, tracked, manifest, localProvenance)
1410
+ const targetRecord = await assetRecord(target)
1411
+ let remove = false
1412
+ if (source !== undefined) {
1413
+ const sourceRecord = await assetRecord(source)
1414
+ const decision = replacementDecision(sourceRecord, targetRecord, baseline, tracked)
1415
+ if (decision === 'reverse') {
1416
+ await atomicCopy(target.absolute, source.absolute, target.info.mode)
1417
+ }
1418
+ remove = decision === 'replace'
1419
+ } else {
1420
+ if (baseline === undefined) {
1421
+ await atomicCopy(target.absolute, path.join(sourceSkill, file), target.info.mode)
1422
+ } else {
1423
+ remove = staleRemovalDecision(targetRecord, baseline, tracked) === 'remove'
1424
+ }
1425
+ }
1426
+ if (remove) {
1427
+ await unlink(target.absolute)
1428
+ }
1429
+ }
1430
+
1431
+ for (const [file, source] of sourceTree.files) {
1432
+ const destination = path.join(targetSkill, file)
1433
+ const destinationInfo = await inspect(destination)
1434
+ if (destinationInfo?.isDirectory() && !destinationInfo.isSymbolicLink()) {
1435
+ await rmdir(destination)
1436
+ } else if (destinationInfo?.isSymbolicLink()) {
1437
+ throw new Error(localConflict(relative(repo, destination)))
1438
+ } else if (destinationInfo?.isFile()) {
1439
+ const target = { absolute: destination, info: destinationInfo, kind: 'file' }
1440
+ if (await filesMatch(source, target)) continue
1441
+ }
1442
+ await atomicCopy(source.absolute, destination, source.info.mode)
1443
+ }
1444
+
1445
+ const currentTree = await collectTree(targetSkill)
1446
+ const extraDirectories = [...currentTree.directories]
1447
+ .filter(directory => {
1448
+ const label = relative(repo, path.join(targetSkill, directory))
1449
+ const tracked = hasTrackedPath(trackedAssets, label)
1450
+ return (
1451
+ !sourceTree.directories.has(directory) &&
1452
+ (tracked ||
1453
+ hasManifestPath(
1454
+ tracked ? manifest.files : localProvenance.files,
1455
+ `${name}/${directory}`,
1456
+ ))
1457
+ )
1458
+ })
1459
+ .sort((left, right) => right.split('/').length - left.split('/').length || right.localeCompare(left))
1460
+ for (const directory of extraDirectories) {
1461
+ try {
1462
+ await rmdir(path.join(targetSkill, directory))
1463
+ } catch (error) {
1464
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error
1465
+ }
1466
+ }
1467
+ }
1468
+
1469
+ async function removeTrackedMirrorOnlyAssets(repo, canonicalNames, targetRoot, trackedAssets) {
1470
+ const assets = await trackedMirrorOnlyAssets(repo, canonicalNames, trackedAssets)
1471
+ for (const asset of assets) {
1472
+ const info = await inspect(asset.absolute)
1473
+ if (info !== null && (!info.isDirectory() || info.isSymbolicLink())) await unlink(asset.absolute)
1474
+ }
1475
+
1476
+ const parents = new Set()
1477
+ for (const asset of assets) {
1478
+ let parent = path.dirname(asset.absolute)
1479
+ while (parent.startsWith(`${targetRoot}${path.sep}`)) {
1480
+ parents.add(parent)
1481
+ parent = path.dirname(parent)
1482
+ }
1483
+ }
1484
+ for (const parent of [...parents].sort((left, right) => right.length - left.length)) {
1485
+ try {
1486
+ await rmdir(parent)
1487
+ } catch (error) {
1488
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ async function write(repo, { forceMigration = false } = {}) {
1494
+ const { sharedSkillRoot, sharedInstructions } = await assertSafeRepoLayout(repo)
1495
+ const externalGeneratedInstructions =
1496
+ !sharedInstructions && (await usesExternalGeneratedInstructions(repo))
1497
+ // Validate both managed and local skill inputs before the first write so an
1498
+ // invalid skill cannot leave a partially migrated root contract behind.
1499
+ if (!sharedSkillRoot) {
1500
+ await discoverSkills(repo)
1501
+ await validatePairedMirrorSkills(repo)
1502
+ await planIgnoredSkillSync(repo)
1503
+ }
1504
+ if (!sharedInstructions && !externalGeneratedInstructions) {
1505
+ await synchronizeRootInstructions(repo)
1506
+ }
1507
+ const agents = path.join(repo, 'AGENTS.md')
1508
+ const agentsInfo = await inspect(agents)
1509
+ if (agentsInfo !== null && !sharedInstructions && (!agentsInfo.isFile() || agentsInfo.isSymbolicLink())) {
1510
+ throw new Error('AGENTS.md must be a regular file')
1511
+ }
1512
+ const agentsContents = agentsInfo === null ? '' : await readFile(agents, 'utf8')
1513
+ const routingIssues = await auditRuleRouting(repo, agentsContents)
1514
+ if (routingIssues.length > 0) {
1515
+ throw new Error(`rule routing parity failed:\n${routingIssues.map(issue => `- ${issue}`).join('\n')}`)
1516
+ }
1517
+
1518
+ if (sharedSkillRoot) {
1519
+ const result = await audit(repo)
1520
+ if (result.issues.length > 0) {
1521
+ throw new Error(`synchronization did not converge:\n${result.issues.map(issue => `- ${issue}`).join('\n')}`)
1522
+ }
1523
+ return result.skills.length
1524
+ }
1525
+
1526
+ await mkdir(path.join(repo, '.agents', 'skills'), { recursive: true })
1527
+ await mkdir(path.join(repo, '.claude', 'skills'), { recursive: true })
1528
+ await synchronizeIgnoredSkills(repo)
1529
+
1530
+ const skills = await discoverSkills(repo)
1531
+ const trackedAssets = trackedSkillAssets(repo)
1532
+ const targetRoot = path.join(repo, '.claude', 'skills')
1533
+ const manifest = await loadOwnershipManifest(repo)
1534
+ const localProvenance = await loadLocalProvenance(repo)
1535
+ const canonicalNames = new Set(skills.map(skill => skill.name))
1536
+ const mirrorOnlyAssets = await trackedMirrorOnlyAssets(repo, canonicalNames, trackedAssets)
1537
+ if (mirrorOnlyAssets.length > 0 && !forceMigration) {
1538
+ throw new Error(
1539
+ `tracked Claude-only skill requires explicit migration; preserved:\n${mirrorOnlyAssets.map(asset => `- ${asset.relative} -> ${canonicalPathForMirror(asset.relative)}`).join('\n')}\nRerun with --force-migration only after preserving any intended content.`,
1540
+ )
1541
+ }
1542
+ const localConflicts = [
1543
+ ...(await findUntrackedSkillConflicts(
1544
+ repo,
1545
+ skills,
1546
+ targetRoot,
1547
+ trackedAssets,
1548
+ manifest,
1549
+ localProvenance,
1550
+ forceMigration,
1551
+ )),
1552
+ ...(await findStaleManifestConflicts(
1553
+ repo,
1554
+ skills,
1555
+ targetRoot,
1556
+ trackedAssets,
1557
+ manifest,
1558
+ localProvenance,
1559
+ )),
1560
+ ]
1561
+ if (localConflicts.length > 0) {
1562
+ throw new Error(`local Claude skill migration required:\n${localConflicts.map(issue => `- ${issue}`).join('\n')}`)
1563
+ }
1564
+ const claude = path.join(repo, 'CLAUDE.md')
1565
+ if (agentsInfo !== null && (await inspect(claude)) === null) {
1566
+ await atomicWriteText(claude, CLAUDE_IMPORT, 0o644)
1567
+ }
1568
+ await mkdir(targetRoot, { recursive: true })
1569
+ for (const skill of skills) {
1570
+ await syncSkill(
1571
+ repo,
1572
+ skill.name,
1573
+ skill.sourceRoot,
1574
+ targetRoot,
1575
+ trackedAssets,
1576
+ manifest,
1577
+ localProvenance,
1578
+ )
1579
+ }
1580
+ await removeStaleManifestAssets(
1581
+ repo,
1582
+ skills,
1583
+ targetRoot,
1584
+ trackedAssets,
1585
+ manifest,
1586
+ localProvenance,
1587
+ )
1588
+ if (forceMigration) {
1589
+ await removeTrackedMirrorOnlyAssets(repo, canonicalNames, targetRoot, trackedAssets)
1590
+ }
1591
+ await writeOwnershipManifest(repo, skills)
1592
+ await writeLocalProvenance(repo, skills)
1593
+
1594
+ const result = await audit(repo)
1595
+ if (result.issues.length > 0) {
1596
+ throw new Error(`synchronization did not converge:\n${result.issues.map(issue => `- ${issue}`).join('\n')}`)
1597
+ }
1598
+ return skills.length
1599
+ }
1600
+
1601
+ async function install(repo) {
1602
+ await assertSafeRepoLayout(repo, { includeInstallerDestination: true })
1603
+ const source = fileURLToPath(import.meta.url)
1604
+ const destination = path.join(repo, INSTALLED_PATH)
1605
+ const repoInfo = await inspect(repo)
1606
+ if (repoInfo === null || !repoInfo.isDirectory()) throw new Error(`repository path does not exist: ${repo}`)
1607
+
1608
+ if (path.resolve(source) !== path.resolve(destination)) {
1609
+ await atomicCopy(source, destination, 0o755)
1610
+ } else {
1611
+ await chmod(destination, 0o755)
1612
+ }
1613
+ return destination
1614
+ }
1615
+
1616
+ function shellQuote(value) {
1617
+ if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) return value
1618
+ return `'${value.replaceAll("'", "'\\''")}'`
1619
+ }
1620
+
1621
+ function modeCommand(mode, repo) {
1622
+ return [
1623
+ shellQuote(process.execPath),
1624
+ shellQuote(fileURLToPath(import.meta.url)),
1625
+ `--${mode}`,
1626
+ '--repo',
1627
+ shellQuote(repo),
1628
+ ].join(' ')
1629
+ }
1630
+
1631
+ async function main() {
1632
+ let options
1633
+ try {
1634
+ options = parseArgs(process.argv.slice(2))
1635
+ } catch (error) {
1636
+ if (!(error instanceof UsageError)) throw error
1637
+ console.error(`Error: ${error.message}\n\n${usage()}`)
1638
+ process.exitCode = 2
1639
+ return
1640
+ }
1641
+
1642
+ if (options.help) {
1643
+ console.log(usage())
1644
+ return
1645
+ }
1646
+
1647
+ if (options.mode === 'install') {
1648
+ const count = await write(options.repo, { forceMigration: options.forceMigration })
1649
+ const destination = await install(options.repo)
1650
+ console.log(`Installed ${relative(options.repo, destination)}`)
1651
+ console.log(`Synchronized CLAUDE.md and ${count} skill${count === 1 ? '' : 's'}.`)
1652
+ console.log(`Check: node ${INSTALLED_PATH.split(path.sep).join('/')} --check`)
1653
+ return
1654
+ }
1655
+
1656
+ if (options.mode === 'write') {
1657
+ const count = await write(options.repo, { forceMigration: options.forceMigration })
1658
+ console.log(`Synchronized CLAUDE.md and ${count} skill${count === 1 ? '' : 's'}.`)
1659
+ return
1660
+ }
1661
+
1662
+ const { issues, skills } = await audit(options.repo)
1663
+ if (issues.length === 0) {
1664
+ console.log(`Agent assets are synchronized (${skills.length} skill${skills.length === 1 ? '' : 's'}).`)
1665
+ return
1666
+ }
1667
+
1668
+ console.error('Agent asset drift detected:')
1669
+ for (const issue of issues) console.error(`- ${issue}`)
1670
+ if (issues.some(issue => issue.startsWith('AGENTS.md '))) {
1671
+ console.error(`Verify after correcting AGENTS.md: ${modeCommand('check', options.repo)}`)
1672
+ } else {
1673
+ console.error(`Recovery: ${modeCommand('write', options.repo)}`)
1674
+ }
1675
+ process.exitCode = 1
1676
+ }
1677
+
1678
+ main().catch(error => {
1679
+ console.error(`Error: ${error.message}`)
1680
+ process.exitCode = 1
1681
+ })