@empirical-gauzza/cli 1.0.9

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,508 @@
1
+ 'use strict';
2
+
3
+ // US-011: core sync logic shared by `empirical kit use`, `empirical kit sync`, and
4
+ // `empirical --install`. Fetches the device-scoped resolved Skill set from the server and
5
+ // writes each Skill's SKILL.md into every detected agent's skill directory, tracking a local
6
+ // manifest for the local-edit guard and additive/removal diffing.
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const matter = require('gray-matter');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const { httpJson } = require('./http');
13
+ const { log, warn, createPrompter } = require('./ui');
14
+ const { detectInstallTargets } = require('./installers/detect');
15
+ const {
16
+ readDeviceConfig,
17
+ writeDeviceConfig,
18
+ ensureDeviceId,
19
+ readSyncManifest,
20
+ writeSyncManifest,
21
+ readPendingReview,
22
+ writePendingReview
23
+ } = require('./deviceConfig');
24
+
25
+ const SYNC_READ_MAX_ATTEMPTS = 2;
26
+ const SYNC_READ_RETRY_DELAY_MS = 250;
27
+
28
+ function isRetryableSyncReadError(error) {
29
+ if (error?.statusCode) return false;
30
+ return /socket hang up|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|ENOTFOUND|network/i.test(error?.message || '');
31
+ }
32
+
33
+ async function fetchSyncRead(url, headers, label) {
34
+ let lastError;
35
+ for (let attempt = 1; attempt <= SYNC_READ_MAX_ATTEMPTS; attempt += 1) {
36
+ try {
37
+ return await httpJson(url, 'GET', undefined, headers);
38
+ } catch (error) {
39
+ lastError = error;
40
+ if (attempt >= SYNC_READ_MAX_ATTEMPTS || !isRetryableSyncReadError(error)) break;
41
+ await new Promise((resolve) => setTimeout(resolve, SYNC_READ_RETRY_DELAY_MS));
42
+ }
43
+ }
44
+
45
+ const detail = lastError?.message || 'unknown request error';
46
+ const wrapped = new Error(`${label} request failed: ${detail}`);
47
+ if (lastError?.code) wrapped.code = lastError.code;
48
+ if (lastError?.statusCode) wrapped.statusCode = lastError.statusCode;
49
+ wrapped.cause = lastError;
50
+ throw wrapped;
51
+ }
52
+
53
+ // Per-agent skill file path conventions, mirrored exactly from the hardcoded paths already
54
+ // used by installers/claude.js, installers/codex.js, installers/copilot.js (for the single
55
+ // empirical-memory skill) — just parameterized by skill name instead of hardcoded to it.
56
+ function skillFilePath(targetId, skillName) {
57
+ const home = os.homedir();
58
+ if (targetId === 'claude') return path.join(home, '.claude', 'skills', skillName, 'SKILL.md');
59
+ if (targetId === 'codex') return path.join(home, '.agents', 'skills', skillName, 'SKILL.md');
60
+ if (targetId === 'copilot') return path.join(home, '.copilot', 'skills', skillName, 'SKILL.md');
61
+ return null;
62
+ }
63
+
64
+ function hashContent(content) {
65
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
66
+ }
67
+
68
+ // Builds the SKILL.md file content for a resolved Skill. The server stores name/description
69
+ // as structured fields and bodyMarkdown as frontmatter-stripped content (see gitResolver in
70
+ // sourceResolver.service.ts, which parses the original SKILL.md's YAML frontmatter via
71
+ // gray-matter and keeps only parsed.content) — so the frontmatter block must be reconstructed
72
+ // here, or every agent that requires a well-formed SKILL.md (Codex CLI validates this strictly)
73
+ // fails to load the skill at all.
74
+ // Skills resolved from a git_custom Source carry license/authorName/authorUrl/upstreamRepoUrl —
75
+ // append a short attribution footer for those. Skills with none of those fields set (authored
76
+ // Skills, or the builtin ones this module never writes — see the CRITICAL exclusion below) get
77
+ // no fabricated attribution block, just the frontmatter + bodyMarkdown as-is.
78
+ function buildSkillFileContent(skill) {
79
+ const body = String(skill.bodyMarkdown || '');
80
+ const attributionParts = [];
81
+ if (skill.upstreamRepoUrl) attributionParts.push(`Source: ${skill.upstreamRepoUrl}.`);
82
+ if (skill.license) attributionParts.push(`License: ${skill.license}.`);
83
+ if (skill.authorName && skill.authorUrl) {
84
+ attributionParts.push(`Author: ${skill.authorName} (${skill.authorUrl}).`);
85
+ } else if (skill.authorName) {
86
+ attributionParts.push(`Author: ${skill.authorName}.`);
87
+ } else if (skill.authorUrl) {
88
+ attributionParts.push(`Author URL: ${skill.authorUrl}.`);
89
+ }
90
+
91
+ const withAttribution = attributionParts.length === 0
92
+ ? body
93
+ : `${body.replace(/\s+$/, '')}\n\n---\n\n*${attributionParts.join(' ')}*\n`;
94
+
95
+ // js-yaml's dump throws on `undefined` values, so default rather than pass them through raw.
96
+ return matter.stringify(withAttribution, {
97
+ name: skill.name || '',
98
+ description: skill.description || ''
99
+ });
100
+ }
101
+
102
+ // ────────────────────────────────────────────────────────────────────────────────────────
103
+ // CRITICAL TRAP TO AVOID (see PRD US-011): the server's resolveBuiltin() (see
104
+ // packages/server/src/services/sourceResolver.service.ts) currently returns ONE hardcoded
105
+ // PLACEHOLDER Skill named `empirical-memory`, whose bodyMarkdown is literally
106
+ // "Placeholder body — real content wiring is a later story." — it is NOT the native
107
+ // empirical-memory plugin content. The plugin repository owns the real Skill, and native
108
+ // agent plugin installers are responsible for installing it.
109
+ //
110
+ // If this generalized writer were allowed to write a resolved Skill named `empirical-memory`,
111
+ // it would silently overwrite that real plugin file with the server's placeholder stub text on
112
+ // every sync — a real regression for every existing user. So: any
113
+ // resolved Skill named exactly `empirical-memory` is filtered out here, unconditionally,
114
+ // before any writing/tracking/removal-consideration happens. A future story that finally
115
+ // wires up real builtin content server-side (not this one) needs to revisit this filter.
116
+ function filterOutEmpiricalMemory(skills) {
117
+ return (skills || []).filter((skill) => skill && skill.name !== 'empirical-memory');
118
+ }
119
+ // ────────────────────────────────────────────────────────────────────────────────────────
120
+
121
+ function readOnDiskHash(filePath) {
122
+ if (!fs.existsSync(filePath)) return null;
123
+ try {
124
+ return hashContent(fs.readFileSync(filePath, 'utf8'));
125
+ } catch (_error) {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function removeFileAndMaybeDir(filePath) {
131
+ try {
132
+ fs.rmSync(filePath, { force: true });
133
+ } catch (_error) {
134
+ // best-effort
135
+ }
136
+ try {
137
+ const dir = path.dirname(filePath);
138
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
139
+ fs.rmdirSync(dir);
140
+ }
141
+ } catch (_error) {
142
+ // best-effort cleanup only, never fatal
143
+ }
144
+ }
145
+
146
+ // Picks the four attribution fields off a resolved Skill, omitting any that aren't set.
147
+ // Returns null if none of the fields are present (nothing to attribute — e.g. authored Skills).
148
+ function attributionFieldsFor(skill) {
149
+ const fields = {};
150
+ if (skill.license) fields.license = skill.license;
151
+ if (skill.authorName) fields.authorName = skill.authorName;
152
+ if (skill.authorUrl) fields.authorUrl = skill.authorUrl;
153
+ if (skill.upstreamRepoUrl) fields.upstreamRepoUrl = skill.upstreamRepoUrl;
154
+ return Object.keys(fields).length > 0 ? fields : null;
155
+ }
156
+
157
+ // Best-effort identifying label for a git_custom-sourced skill, used both in the human-facing
158
+ // review prompt and as the `source` field persisted to pending-review.json.
159
+ function sourceLabelFor(attribution) {
160
+ if (!attribution) return null;
161
+ return attribution.upstreamRepoUrl || attribution.authorUrl || attribution.authorName || 'git_custom source';
162
+ }
163
+
164
+ // US-016: review-gate prompts. Both print a short human-facing summary line and, when
165
+ // interactive, block on askYesNo. Never called when quiet/dryRun — callers gate that.
166
+ async function confirmFirstSync(prompter, skill, attribution) {
167
+ const kind = attribution ? 'git_custom' : 'authored';
168
+ warn(` new: ${skill.name} (${kind})`);
169
+ return prompter.askYesNo(` Install new skill "${skill.name}"?`, true);
170
+ }
171
+
172
+ async function confirmContentChange(prompter, skill, attribution) {
173
+ warn(` changed: ${skill.name} (git_custom, source: ${sourceLabelFor(attribution)})`);
174
+ return prompter.askYesNo(` Apply upstream content change to "${skill.name}"?`, true);
175
+ }
176
+
177
+ // Computes (and, unless dryRun, applies) the sync diff for a single agent target.
178
+ // Returns { added, updated, removed, skippedLocalEdit, declinedNew, declinedChange } (arrays of
179
+ // { name, filePath }) and, via the `nextEntries` out-param behavior, the manifest entries to
180
+ // persist for this target.
181
+ //
182
+ // US-016 review gates (see PRD US-016):
183
+ // - First-ever sync of ANY skill (authored or git_custom): interactive runs require an
184
+ // explicit y/n via askYesNo before writing. Declining leaves it unwritten/untracked (it's
185
+ // simply offered again next sync, since there's no manifest entry either way).
186
+ // - Content change (resolved hash differs from the manifest, and the file was NOT locally
187
+ // edited) to a git_custom-sourced skill (attributionFieldsFor() != null): interactive runs
188
+ // require y/n before overwriting; declining leaves the OLD file untouched and the manifest
189
+ // entry unchanged. Non-interactive (--quiet) runs NEVER block — they apply the change
190
+ // immediately and instead record it in pending-review.json via the `flagPendingReview`
191
+ // callback (so the device never silently falls behind), to be reviewed later through an
192
+ // interactive `kit sync`.
193
+ // - Authored skills are exempt from the content-change gate entirely (only the first-sync
194
+ // gate applies to them) — the push confirmation in `skill push` already is the review.
195
+ // - The existing local-edit guard (US-011) is checked FIRST and wins outright: a locally
196
+ // edited file is skipped before either review gate is ever considered, so a local edit
197
+ // always takes precedence over an upstream content change being applied/flagged.
198
+ async function syncTarget({ targetId, skills, previousEntries, dryRun, force, quiet, prompter, clearPendingReview, flagPendingReview }) {
199
+ const added = [];
200
+ const updated = [];
201
+ const removed = [];
202
+ const skippedLocalEdit = [];
203
+ const declinedNew = [];
204
+ const declinedChange = [];
205
+ const nextEntries = {};
206
+
207
+ const newNames = new Set(skills.map((s) => s.name));
208
+
209
+ for (const skill of skills) {
210
+ const filePath = skillFilePath(targetId, skill.name);
211
+ if (!filePath) continue;
212
+ const content = buildSkillFileContent(skill);
213
+ const newHash = hashContent(content);
214
+ const prevEntry = previousEntries[skill.name];
215
+ const attribution = attributionFieldsFor(skill);
216
+
217
+ if (!prevEntry) {
218
+ // AC1: first-ever sync of this skill name — gate interactively, never in quiet/dry-run.
219
+ if (!dryRun && !quiet) {
220
+ const accepted = await confirmFirstSync(prompter, skill, attribution);
221
+ if (!accepted) {
222
+ declinedNew.push({ name: skill.name, action: 'declined_new', filePath });
223
+ // Left unwritten and untracked — offered again next sync.
224
+ continue;
225
+ }
226
+ }
227
+ added.push({ name: skill.name, action: 'added', filePath, ...attribution });
228
+ if (!dryRun) {
229
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
230
+ fs.writeFileSync(filePath, content, 'utf8');
231
+ nextEntries[skill.name] = { filePath, contentHash: newHash, resolvedVersion: skill.resolvedVersion };
232
+ }
233
+ continue;
234
+ }
235
+
236
+ const onDiskHash = readOnDiskHash(filePath);
237
+ const locallyEdited = onDiskHash !== null && onDiskHash !== prevEntry.contentHash;
238
+
239
+ if (locallyEdited && !force) {
240
+ // Local-edit guard (US-011) wins outright over either review gate below.
241
+ skippedLocalEdit.push({ name: skill.name, action: 'skipped_local_edit', filePath });
242
+ if (!dryRun) {
243
+ nextEntries[skill.name] = prevEntry;
244
+ }
245
+ continue;
246
+ }
247
+
248
+ if (onDiskHash === newHash) {
249
+ // Nothing changed — carry the entry forward untouched, no write needed.
250
+ if (!dryRun) {
251
+ nextEntries[skill.name] = prevEntry;
252
+ }
253
+ continue;
254
+ }
255
+
256
+ // AC2/AC3: content changed upstream (and not a local edit — that was ruled out above).
257
+ // Only git_custom-sourced skills (attribution present) are gated; authored skills apply
258
+ // like any other update, no prompt, no pending-review tracking.
259
+ if (attribution && !dryRun) {
260
+ if (quiet) {
261
+ // Never block under --quiet: apply immediately, but flag for later human review.
262
+ if (flagPendingReview) {
263
+ flagPendingReview(skill.name, { from: prevEntry.contentHash, source: sourceLabelFor(attribution) });
264
+ }
265
+ } else {
266
+ const accepted = await confirmContentChange(prompter, skill, attribution);
267
+ if (!accepted) {
268
+ declinedChange.push({ name: skill.name, action: 'declined_change', filePath });
269
+ // Leave the old file content in place, untouched; manifest entry unchanged.
270
+ nextEntries[skill.name] = prevEntry;
271
+ continue;
272
+ }
273
+ // Accepted: applying now IS the review — clear any stale pending-review flag.
274
+ if (clearPendingReview) clearPendingReview(skill.name);
275
+ }
276
+ }
277
+
278
+ updated.push({ name: skill.name, action: 'updated', filePath, ...attribution });
279
+ if (!dryRun) {
280
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
281
+ fs.writeFileSync(filePath, content, 'utf8');
282
+ nextEntries[skill.name] = { filePath, contentHash: newHash, resolvedVersion: skill.resolvedVersion };
283
+ }
284
+ }
285
+
286
+ for (const name of Object.keys(previousEntries)) {
287
+ if (newNames.has(name)) continue;
288
+ const prevEntry = previousEntries[name];
289
+ removed.push({ name, action: 'removed', filePath: prevEntry.filePath });
290
+ if (!dryRun) {
291
+ removeFileAndMaybeDir(prevEntry.filePath);
292
+ // omitted from nextEntries -> removed from manifest
293
+ }
294
+ }
295
+
296
+ return { added, updated, removed, skippedLocalEdit, declinedNew, declinedChange, nextEntries };
297
+ }
298
+
299
+ // US-016 AC6: a dedicated review pass for skills that were already applied unattended (during a
300
+ // prior `--quiet` sync) and are still sitting in pending-review.json. This is deliberately
301
+ // separate from syncTarget's own content-change gate, because by the time this runs again
302
+ // interactively the on-disk content already matches the resolved content (the quiet run already
303
+ // wrote it) — so syncTarget's hash-diff would see "no change" and never re-surface it. Mutates
304
+ // `pendingReview` in place (deleting resolved entries) and returns the list of names reviewed.
305
+ //
306
+ // Design choice (see AC "declining an already-applied change" ambiguity): on decline we
307
+ // ACKNOWLEDGE AND CLEAR the flag WITHOUT reverting the file. Two reasons: (1) pending-review.json
308
+ // intentionally stores only a `from` hash/description, not the full prior file content, so an
309
+ // exact revert isn't reliably possible from the data this story's shape defines; and (2) the
310
+ // device may have already been running with the new content for a while (that's the whole
311
+ // premise of --quiet applying immediately) — silently flipping it back on a later, unrelated
312
+ // `kit sync` would itself be an unattended content change, the exact failure mode this story
313
+ // exists to prevent. Declining here is recorded as "reviewed, not re-approved for next time"
314
+ // rather than "undo".
315
+ async function reviewPendingEntries(pendingReview, quiet, dryRun, prompter) {
316
+ const names = Object.keys(pendingReview);
317
+ if (names.length === 0 || quiet || dryRun) return [];
318
+
319
+ warn('The following skill(s) were updated by an unattended sync and are awaiting your review:');
320
+ const reviewed = [];
321
+ for (const name of names) {
322
+ const entry = pendingReview[name];
323
+ warn(` ${name} (source: ${entry.source || 'unknown'}, applied ${entry.firstDetectedAt || 'unknown time'})`);
324
+ const acknowledged = await prompter.askYesNo(` Mark "${name}" as reviewed? (content already applied; declining just clears the flag, it does not revert)`, true);
325
+ warn(acknowledged ? ` ${name}: reviewed, keeping applied content.` : ` ${name}: acknowledged without re-approval; content left as-is.`);
326
+ delete pendingReview[name];
327
+ reviewed.push(name);
328
+ }
329
+ return reviewed;
330
+ }
331
+
332
+ // Core sync entrypoint. Never calls process.exit — callers (kitCommands.js, installWizard.js)
333
+ // own exit-code/printing behavior. Throws on HTTP failure (callers use printApiError/warn).
334
+ async function performSync({ accessToken, baseUrl, dryRun = false, force = false, quiet = false }) {
335
+ const deviceId = ensureDeviceId();
336
+ const config = readDeviceConfig();
337
+
338
+ // US-016: a SINGLE shared prompter (readline interface) for the whole sync, only opened when
339
+ // prompting is even possible (interactive, not dry-run) and closed exactly once at the very
340
+ // end — see createPrompter()'s doc comment in ui.js for why per-question interfaces break
341
+ // against piped/non-TTY stdin.
342
+ const prompter = (!quiet && !dryRun) ? createPrompter(process.stderr) : null;
343
+
344
+ try {
345
+ // US-016: surface + clear any previously-flagged unattended content changes before anything
346
+ // else. Runs regardless of whether a new version is available, since pending-review is local
347
+ // state independent of the server's version counter. No-ops under --quiet/--dry-run.
348
+ const pendingReview = readPendingReview();
349
+ const reviewed = await reviewPendingEntries(pendingReview, quiet, dryRun, prompter);
350
+ if (reviewed.length > 0 && !dryRun) {
351
+ writePendingReview(pendingReview);
352
+ }
353
+
354
+ const versionUrl = new URL(`/api/v1/devices/${encodeURIComponent(deviceId)}/version`, baseUrl).toString();
355
+ const versionResponse = await fetchSyncRead(versionUrl, {
356
+ Authorization: `Bearer ${accessToken}`,
357
+ 'x-empirical-surface': 'cli'
358
+ }, 'Kit version');
359
+ const versionData = (versionResponse && versionResponse.data) ? versionResponse.data : versionResponse;
360
+ const version = versionData.version;
361
+
362
+ if (!force && config.lastSyncedVersion && config.lastSyncedVersion === version) {
363
+ return { upToDate: true, dryRun, version, deviceId, targets: {}, tierSkipped: [], reviewed };
364
+ }
365
+
366
+ const resolvedUrl = new URL(`/api/v1/devices/${encodeURIComponent(deviceId)}/resolved`, baseUrl).toString();
367
+ const resolvedResponse = await fetchSyncRead(resolvedUrl, {
368
+ Authorization: `Bearer ${accessToken}`,
369
+ 'x-empirical-surface': 'cli'
370
+ }, 'Resolved Kit skills');
371
+ const resolvedData = (resolvedResponse && resolvedResponse.data) ? resolvedResponse.data : resolvedResponse;
372
+ const skills = filterOutEmpiricalMemory(resolvedData.skills);
373
+ const tierSkipped = resolvedData.skipped || [];
374
+
375
+ const detectedTargets = detectInstallTargets();
376
+ const manifest = readSyncManifest();
377
+ const targets = {};
378
+ const nextManifest = { ...manifest };
379
+
380
+ // US-016: pending-review mutations collected across all targets in this sync, written once
381
+ // at the end (mirrors nextManifest's accumulate-then-write pattern). A skill written for
382
+ // multiple detected targets (e.g. claude + codex) is only asked about once per performSync
383
+ // call — the flag/clear closures are keyed by skill name, not by target.
384
+ const nextPendingReview = { ...pendingReview };
385
+ const flagPendingReview = (name, info) => {
386
+ const existing = nextPendingReview[name];
387
+ nextPendingReview[name] = {
388
+ from: info.from,
389
+ firstDetectedAt: existing ? existing.firstDetectedAt : new Date().toISOString(),
390
+ source: info.source,
391
+ status: 'content_changed_unreviewed'
392
+ };
393
+ };
394
+ const clearPendingReview = (name) => {
395
+ delete nextPendingReview[name];
396
+ };
397
+
398
+ for (const target of detectedTargets) {
399
+ const previousEntries = manifest[target.id] || {};
400
+ const result = await syncTarget({
401
+ targetId: target.id,
402
+ skills,
403
+ previousEntries,
404
+ dryRun,
405
+ force,
406
+ quiet,
407
+ prompter,
408
+ flagPendingReview,
409
+ clearPendingReview
410
+ });
411
+ targets[target.id] = {
412
+ added: result.added,
413
+ updated: result.updated,
414
+ removed: result.removed,
415
+ skippedLocalEdit: result.skippedLocalEdit,
416
+ declinedNew: result.declinedNew,
417
+ declinedChange: result.declinedChange
418
+ };
419
+ if (!dryRun) {
420
+ nextManifest[target.id] = result.nextEntries;
421
+ }
422
+ }
423
+
424
+ if (!dryRun) {
425
+ writeSyncManifest(nextManifest);
426
+ writeDeviceConfig({ ...config, lastSyncedVersion: version });
427
+ writePendingReview(nextPendingReview);
428
+ }
429
+
430
+ return {
431
+ upToDate: false,
432
+ dryRun,
433
+ version,
434
+ deviceId,
435
+ detectedTargetIds: detectedTargets.map((t) => t.id),
436
+ targets,
437
+ tierSkipped,
438
+ reviewed
439
+ };
440
+ } finally {
441
+ if (prompter) prompter.close();
442
+ }
443
+ }
444
+
445
+ // Shared stdout/stderr reporting for a performSync() result, used by `kit sync`, `kit use`,
446
+ // and `empirical --install`. JSON summary (added/updated/removed/skippedLocalEdit per target,
447
+ // with counts) goes to stdout via log() for machine parsing; the /resolved response's
448
+ // cross-tier `skipped` collisions go to stderr via warn() as human-readable warnings, matching
449
+ // the existing CLI convention (structured JSON results on stdout, human warnings on stderr).
450
+ // Fully suppressed when quiet is true (still writes files/manifest — this only controls
451
+ // printing).
452
+ function printSyncSummary(result, quiet) {
453
+ if (quiet) return;
454
+
455
+ if (result.upToDate) {
456
+ log(JSON.stringify({ status: 'up_to_date', version: result.version }));
457
+ return;
458
+ }
459
+
460
+ const targetsSummary = {};
461
+ const attributionByName = new Map();
462
+ for (const [targetId, t] of Object.entries(result.targets)) {
463
+ targetsSummary[targetId] = {
464
+ added: t.added,
465
+ updated: t.updated,
466
+ removed: t.removed,
467
+ skippedLocalEdit: t.skippedLocalEdit,
468
+ declinedNew: t.declinedNew || [],
469
+ declinedChange: t.declinedChange || [],
470
+ counts: {
471
+ added: t.added.length,
472
+ updated: t.updated.length,
473
+ removed: t.removed.length,
474
+ skippedLocalEdit: t.skippedLocalEdit.length,
475
+ declinedNew: (t.declinedNew || []).length,
476
+ declinedChange: (t.declinedChange || []).length
477
+ }
478
+ };
479
+ for (const entry of [...t.added, ...t.updated]) {
480
+ const { license, authorName, authorUrl, upstreamRepoUrl } = entry;
481
+ if (!license && !authorName && !authorUrl && !upstreamRepoUrl) continue;
482
+ if (attributionByName.has(entry.name)) continue;
483
+ attributionByName.set(entry.name, { name: entry.name, license: license || null, authorName: authorName || null, authorUrl: authorUrl || null, upstreamRepoUrl: upstreamRepoUrl || null });
484
+ }
485
+ }
486
+
487
+ log(JSON.stringify({
488
+ status: result.dryRun ? 'dry_run' : 'synced',
489
+ version: result.version,
490
+ deviceId: result.deviceId,
491
+ targets: targetsSummary,
492
+ attribution: Array.from(attributionByName.values()),
493
+ reviewed: result.reviewed || []
494
+ }));
495
+
496
+ for (const skip of result.tierSkipped || []) {
497
+ warn(`Skipped "${skip.name}" (from ${skip.from}, lost to ${skip.lostTo})`);
498
+ }
499
+ }
500
+
501
+ module.exports = {
502
+ skillFilePath,
503
+ hashContent,
504
+ buildSkillFileContent,
505
+ filterOutEmpiricalMemory,
506
+ performSync,
507
+ printSyncSummary
508
+ };