@notis_ai/cli 0.2.0-beta.165.1 → 0.2.0-beta.167.1

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.
@@ -1881,10 +1881,34 @@ async function extractZipToDirectory(zipPath, destinationDir) {
1881
1881
  await fs.rm(destinationDir, { recursive: true, force: true });
1882
1882
  await fs.mkdir(path.dirname(destinationDir), { recursive: true });
1883
1883
  await fs.cp(sourceDir, destinationDir, { recursive: true });
1884
+ return await listRelativeFiles(destinationDir);
1884
1885
  } finally {
1885
1886
  await fs.rm(extractRoot, { recursive: true, force: true });
1886
1887
  }
1887
1888
  }
1889
+ async function listRelativeFiles(dirPath) {
1890
+ try {
1891
+ const files = await listFilesRecursive(dirPath);
1892
+ return files.map((filePath) => toPosixRelativePath(dirPath, filePath)).sort();
1893
+ } catch (error) {
1894
+ if (error?.code === "ENOENT") return [];
1895
+ throw error;
1896
+ }
1897
+ }
1898
+ function toPosixRelativePath(rootDir, filePath) {
1899
+ return path.relative(rootDir, filePath).split(path.sep).join("/");
1900
+ }
1901
+ async function preserveUnmanagedFiles(existingDir, stagingDir, previouslyApplied) {
1902
+ const managed = previouslyApplied ? new Set(previouslyApplied) : null;
1903
+ for (const existingFile of await listFilesRecursive(existingDir)) {
1904
+ const relativePath = toPosixRelativePath(existingDir, existingFile);
1905
+ if (managed?.has(relativePath)) continue;
1906
+ const stagedPath = path.join(stagingDir, ...relativePath.split("/"));
1907
+ if (await pathExists(stagedPath)) continue;
1908
+ await fs.mkdir(path.dirname(stagedPath), { recursive: true });
1909
+ await fs.copyFile(existingFile, stagedPath);
1910
+ }
1911
+ }
1888
1912
  async function pathExists(targetPath) {
1889
1913
  try {
1890
1914
  await fs.access(targetPath);
@@ -1893,7 +1917,7 @@ async function pathExists(targetPath) {
1893
1917
  return false;
1894
1918
  }
1895
1919
  }
1896
- async function replaceSkillDirectoryAtomically(skillDir, populateDir) {
1920
+ async function replaceSkillDirectoryAtomically(skillDir, populateDir, preserve) {
1897
1921
  const parentDir = path.dirname(skillDir);
1898
1922
  const skillName = path.basename(skillDir);
1899
1923
  await fs.mkdir(parentDir, { recursive: true });
@@ -1909,6 +1933,9 @@ async function replaceSkillDirectoryAtomically(skillDir, populateDir) {
1909
1933
  let cleanupError = null;
1910
1934
  try {
1911
1935
  await populateDir(stagingDir);
1936
+ if (preserve && await pathExists(skillDir)) {
1937
+ await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied);
1938
+ }
1912
1939
  if (await pathExists(skillDir)) {
1913
1940
  await fs.rename(skillDir, backupDir);
1914
1941
  movedExisting = true;
@@ -1956,11 +1983,12 @@ async function createSkillBundleBase64(skill) {
1956
1983
  await fs.rm(zipPath, { force: true });
1957
1984
  }
1958
1985
  }
1959
- async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PATHS) {
1986
+ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PATHS, options = {}) {
1960
1987
  const skillDir = path.join(
1961
1988
  paths.skillsDir,
1962
1989
  safeName(skill.name, paths.skillsDir)
1963
1990
  );
1991
+ const preserve = { previouslyApplied: options.previouslyApplied };
1964
1992
  if (bundleBytes?.length) {
1965
1993
  const bundlePath = path.join(
1966
1994
  os.tmpdir(),
@@ -1968,8 +1996,11 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
1968
1996
  );
1969
1997
  try {
1970
1998
  await fs.writeFile(bundlePath, bundleBytes);
1971
- await extractZipToDirectory(bundlePath, skillDir);
1972
- return true;
1999
+ let appliedFiles = [];
2000
+ await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
2001
+ appliedFiles = await extractZipToDirectory(bundlePath, stagingDir);
2002
+ }, preserve);
2003
+ return { written: true, appliedFiles };
1973
2004
  } finally {
1974
2005
  await fs.rm(bundlePath, { force: true });
1975
2006
  }
@@ -1983,6 +2014,7 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
1983
2014
  `Synced bundle for "${skill.name}" is missing SKILL.md or SKILLS.md`
1984
2015
  );
1985
2016
  }
2017
+ const appliedFiles = [];
1986
2018
  await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
1987
2019
  for (const bundleFile of skill.bundle_files || []) {
1988
2020
  const filePath = resolveSkillBundleFilePath(
@@ -1994,9 +2026,10 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
1994
2026
  filePath,
1995
2027
  Buffer.from(bundleFile.content_b64, "base64")
1996
2028
  );
2029
+ appliedFiles.push(toPosixRelativePath(stagingDir, filePath));
1997
2030
  }
1998
- });
1999
- return true;
2031
+ }, preserve);
2032
+ return { written: true, appliedFiles: appliedFiles.sort() };
2000
2033
  }
2001
2034
  await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
2002
2035
  await fs.writeFile(
@@ -2004,8 +2037,8 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
2004
2037
  skill.skill_md ?? "",
2005
2038
  "utf8"
2006
2039
  );
2007
- });
2008
- return true;
2040
+ }, preserve);
2041
+ return { written: true, appliedFiles: ["SKILL.md"] };
2009
2042
  }
2010
2043
  async function requestJson(url, jwt, options = {}) {
2011
2044
  const response = await fetch(url, {
@@ -2399,7 +2432,7 @@ async function writeCloudSkillWithBundleFallback(skill, dependencies) {
2399
2432
  const bundleBytes = await dependencies.downloadSkillBundle(skill.skill_source_url);
2400
2433
  const wroteBundleToDisk = await dependencies.writeCloudSkillToDisk(skill, bundleBytes);
2401
2434
  if (wroteBundleToDisk) {
2402
- return true;
2435
+ return wroteBundleToDisk;
2403
2436
  }
2404
2437
  dependencies.onWarning?.(
2405
2438
  `Bundle sync for "${skill.name}" produced no local changes, falling back to SKILL.md payload.`,
@@ -2511,12 +2544,20 @@ function collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillN
2511
2544
  }
2512
2545
  return applied;
2513
2546
  }
2514
- function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLinks = {}, failedContentNames = /* @__PURE__ */ new Set(), appliedRevisions = {}) {
2547
+ function collectAppliedFiles(pullResponse, previousState, appliedFilesByName) {
2548
+ const applied = {};
2549
+ for (const skill of selectCloudSkillsToApply(pullResponse.skills)) {
2550
+ applied[skill.name] = appliedFilesByName[skill.name] ?? previousState.skills[skill.name]?.appliedFiles;
2551
+ }
2552
+ return applied;
2553
+ }
2554
+ function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLinks = {}, failedContentNames = /* @__PURE__ */ new Set(), appliedRevisions = {}, appliedFiles = {}) {
2515
2555
  const localSkillMap = toSkillMap(localSkills);
2516
2556
  const skills = Object.fromEntries(
2517
2557
  selectCloudSkillsToApply(pullResponse.skills).map((skill) => {
2518
2558
  const localSkill = localSkillMap.get(skill.name);
2519
2559
  const appliedCloudFolderHash = appliedRevisions[skill.name];
2560
+ const appliedFileList = appliedFiles[skill.name];
2520
2561
  return [
2521
2562
  skill.name,
2522
2563
  {
@@ -2527,6 +2568,7 @@ function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLi
2527
2568
  cloudUpdatedAt: skill.updated_at,
2528
2569
  ...!failedContentNames.has(skill.name) && !skill.skill_source_url ? { cloudContentHash: cloudContentHash(skill) } : {},
2529
2570
  ...appliedCloudFolderHash !== void 0 ? { appliedCloudFolderHash } : {},
2571
+ ...appliedFileList !== void 0 ? { appliedFiles: appliedFileList } : {},
2530
2572
  syncedAt: lastSyncedAt || (/* @__PURE__ */ new Date()).toISOString()
2531
2573
  }
2532
2574
  ];
@@ -2582,7 +2624,7 @@ function applyLegacyFirstRunState(localSkills, scopedState, legacyState) {
2582
2624
  skills: migratedSkills
2583
2625
  };
2584
2626
  }
2585
- async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps, failures = [], writtenSkillNames = /* @__PURE__ */ new Set()) {
2627
+ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps, failures = [], writtenSkillNames = /* @__PURE__ */ new Set(), appliedFilesByName = {}) {
2586
2628
  const localSkillMap = toSkillMap(localSkills);
2587
2629
  const warnSkillSync = (message, error) => {
2588
2630
  console.warn(`[Notis] ${message}`, error);
@@ -2592,13 +2634,21 @@ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previo
2592
2634
  if (!shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
2593
2635
  continue;
2594
2636
  }
2595
- if (await writeCloudSkillWithBundleFallback(cloudSkill, {
2637
+ const outcome = await writeCloudSkillWithBundleFallback(cloudSkill, {
2596
2638
  downloadSkillBundle: deps.downloadSkillBundle,
2597
- writeCloudSkillToDisk: (skill, bundleBytes) => deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths),
2639
+ writeCloudSkillToDisk: (skill, bundleBytes) => deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths, {
2640
+ // What the last write put there. Everything else in the folder was
2641
+ // produced locally and survives this one.
2642
+ previouslyApplied: previousState.skills[skill.name]?.appliedFiles
2643
+ }),
2598
2644
  onWarning: warnSkillSync
2599
- })) {
2645
+ });
2646
+ if (outcome) {
2600
2647
  downloaded += 1;
2601
2648
  writtenSkillNames.add(cloudSkill.name);
2649
+ if (typeof outcome === "object" && Array.isArray(outcome.appliedFiles)) {
2650
+ appliedFilesByName[cloudSkill.name] = outcome.appliedFiles;
2651
+ }
2602
2652
  } else {
2603
2653
  failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
2604
2654
  }
@@ -2769,6 +2819,7 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2769
2819
  }
2770
2820
  const failedDownloads = [];
2771
2821
  const writtenSkillNames = /* @__PURE__ */ new Set();
2822
+ const appliedFilesByName = {};
2772
2823
  const downloaded = await writePulledSkillsToScopedMirror(
2773
2824
  pullResponse,
2774
2825
  localSkills,
@@ -2776,7 +2827,8 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2776
2827
  syncPaths,
2777
2828
  deps,
2778
2829
  failedDownloads,
2779
- writtenSkillNames
2830
+ writtenSkillNames,
2831
+ appliedFilesByName
2780
2832
  );
2781
2833
  const finalLocalSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
2782
2834
  const symlinkResult = await deps.syncSymlinks(
@@ -2793,7 +2845,8 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2793
2845
  lastSyncedAt,
2794
2846
  verifiedLinks,
2795
2847
  new Set(failedDownloads.map((item) => item.name)),
2796
- collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames)
2848
+ collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
2849
+ collectAppliedFiles(pullResponse, previousState, appliedFilesByName)
2797
2850
  ),
2798
2851
  syncPaths
2799
2852
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.165.1",
3
+ "version": "0.2.0-beta.167.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -231,6 +231,24 @@ function collectAppliedCloudRevisions(
231
231
  return applied;
232
232
  }
233
233
 
234
+ /**
235
+ * The files each folder's last successful write put on disk: this run's list for the
236
+ * skills we just wrote, the previously recorded one otherwise. Missing means "no
237
+ * record", which keeps local files rather than deleting what cannot be attributed.
238
+ */
239
+ function collectAppliedFiles(
240
+ pullResponse: SyncPullResponse,
241
+ previousState: NotisSyncState,
242
+ appliedFilesByName: Record<string, string[]>,
243
+ ): Record<string, string[] | undefined> {
244
+ const applied: Record<string, string[] | undefined> = {};
245
+ for (const skill of selectCloudSkillsToApply(pullResponse.skills)) {
246
+ applied[skill.name] = appliedFilesByName[skill.name]
247
+ ?? previousState.skills[skill.name]?.appliedFiles;
248
+ }
249
+ return applied;
250
+ }
251
+
234
252
  function buildSyncState(
235
253
  pullResponse: SyncPullResponse,
236
254
  localSkills: LocalSkill[],
@@ -238,6 +256,7 @@ function buildSyncState(
238
256
  verifiedAgentLinks: Record<string, Partial<AgentTargets>> = {},
239
257
  failedContentNames: ReadonlySet<string> = new Set(),
240
258
  appliedRevisions: AppliedCloudRevisions = {},
259
+ appliedFiles: Record<string, string[] | undefined> = {},
241
260
  ): NotisSyncState {
242
261
  const localSkillMap = toSkillMap(localSkills);
243
262
  // The same row selection the writer used, so the state describes the revision that
@@ -246,6 +265,7 @@ function buildSyncState(
246
265
  selectCloudSkillsToApply(pullResponse.skills).map((skill) => {
247
266
  const localSkill = localSkillMap.get(skill.name);
248
267
  const appliedCloudFolderHash = appliedRevisions[skill.name];
268
+ const appliedFileList = appliedFiles[skill.name];
249
269
  return [
250
270
  skill.name,
251
271
  {
@@ -257,6 +277,7 @@ function buildSyncState(
257
277
  ...(!failedContentNames.has(skill.name) && !skill.skill_source_url
258
278
  ? { cloudContentHash: cloudContentHash(skill) } : {}),
259
279
  ...(appliedCloudFolderHash !== undefined ? { appliedCloudFolderHash } : {}),
280
+ ...(appliedFileList !== undefined ? { appliedFiles: appliedFileList } : {}),
260
281
  syncedAt: lastSyncedAt || new Date().toISOString(),
261
282
  },
262
283
  ];
@@ -342,6 +363,7 @@ async function writePulledSkillsToScopedMirror(
342
363
  >,
343
364
  failures: SkillSyncFailure[] = [],
344
365
  writtenSkillNames: Set<string> = new Set(),
366
+ appliedFilesByName: Record<string, string[]> = {},
345
367
  ): Promise<number> {
346
368
  const localSkillMap = toSkillMap(localSkills);
347
369
  const warnSkillSync = (message: string, error: unknown): void => {
@@ -354,16 +376,22 @@ async function writePulledSkillsToScopedMirror(
354
376
  continue;
355
377
  }
356
378
 
357
- if (
358
- await writeCloudSkillWithBundleFallback(cloudSkill, {
359
- downloadSkillBundle: deps.downloadSkillBundle,
360
- writeCloudSkillToDisk: (skill, bundleBytes) =>
361
- deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths),
362
- onWarning: warnSkillSync,
363
- })
364
- ) {
379
+ const outcome = await writeCloudSkillWithBundleFallback(cloudSkill, {
380
+ downloadSkillBundle: deps.downloadSkillBundle,
381
+ writeCloudSkillToDisk: (skill, bundleBytes) =>
382
+ deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths, {
383
+ // What the last write put there. Everything else in the folder was
384
+ // produced locally and survives this one.
385
+ previouslyApplied: previousState.skills[skill.name]?.appliedFiles,
386
+ }),
387
+ onWarning: warnSkillSync,
388
+ });
389
+ if (outcome) {
365
390
  downloaded += 1;
366
391
  writtenSkillNames.add(cloudSkill.name);
392
+ if (typeof outcome === "object" && Array.isArray(outcome.appliedFiles)) {
393
+ appliedFilesByName[cloudSkill.name] = outcome.appliedFiles;
394
+ }
367
395
  } else {
368
396
  failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
369
397
  }
@@ -425,6 +453,7 @@ export async function materializeCloudSkillsForLocalShell(
425
453
  const localSkills = await deps.scanLocalSkills(syncPaths);
426
454
  const failedDownloads: SkillSyncFailure[] = [];
427
455
  const writtenSkillNames = new Set<string>();
456
+ const appliedFilesByName: Record<string, string[]> = {};
428
457
  const downloaded = await writePulledSkillsToScopedMirror(
429
458
  pullResponse,
430
459
  localSkills,
@@ -433,6 +462,7 @@ export async function materializeCloudSkillsForLocalShell(
433
462
  deps,
434
463
  failedDownloads,
435
464
  writtenSkillNames,
465
+ appliedFilesByName,
436
466
  );
437
467
  const finalLocalSkills = await deps.scanLocalSkills(syncPaths);
438
468
  const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
@@ -465,6 +495,7 @@ export async function materializeCloudSkillsForLocalShell(
465
495
  pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks,
466
496
  new Set(failedDownloads.map(item => item.name)),
467
497
  collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
498
+ collectAppliedFiles(pullResponse, previousState, appliedFilesByName),
468
499
  );
469
500
  // Pull-only refresh is not an upload acknowledgement. Keep content baselines
470
501
  // unless we actually wrote cloud content, and retain cloud-missing entries so
@@ -701,6 +732,7 @@ export async function runSkillSync(
701
732
 
702
733
  const failedDownloads: SkillSyncFailure[] = [];
703
734
  const writtenSkillNames = new Set<string>();
735
+ const appliedFilesByName: Record<string, string[]> = {};
704
736
  const downloaded = await writePulledSkillsToScopedMirror(
705
737
  pullResponse,
706
738
  localSkills,
@@ -709,6 +741,7 @@ export async function runSkillSync(
709
741
  deps,
710
742
  failedDownloads,
711
743
  writtenSkillNames,
744
+ appliedFilesByName,
712
745
  );
713
746
 
714
747
  const finalLocalSkills = (await deps.scanLocalSkills(syncPaths))
@@ -729,6 +762,7 @@ export async function runSkillSync(
729
762
  verifiedLinks,
730
763
  new Set(failedDownloads.map(item => item.name)),
731
764
  collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
765
+ collectAppliedFiles(pullResponse, previousState, appliedFilesByName),
732
766
  ),
733
767
  syncPaths,
734
768
  );
@@ -6,7 +6,7 @@ import os from "os";
6
6
  import path from "path";
7
7
  import { promisify } from "util";
8
8
 
9
- import type { CloudSkill, LocalSkill, NotisSyncState } from "./types";
9
+ import type { CloudSkill, LocalSkill, NotisSyncState, SkillWriteOutcome } from "./types";
10
10
 
11
11
  const HOME_DIR = os.homedir();
12
12
  const execFileAsync = promisify(execFile);
@@ -860,7 +860,7 @@ async function createZipFromDirectory(directoryPath: string): Promise<string> {
860
860
  async function extractZipToDirectory(
861
861
  zipPath: string,
862
862
  destinationDir: string,
863
- ): Promise<void> {
863
+ ): Promise<string[]> {
864
864
  const extractRoot = await fs.mkdtemp(
865
865
  path.join(os.tmpdir(), "notis-skill-extract-"),
866
866
  );
@@ -889,11 +889,56 @@ async function extractZipToDirectory(
889
889
  await fs.rm(destinationDir, { recursive: true, force: true });
890
890
  await fs.mkdir(path.dirname(destinationDir), { recursive: true });
891
891
  await fs.cp(sourceDir, destinationDir, { recursive: true });
892
+ return await listRelativeFiles(destinationDir);
892
893
  } finally {
893
894
  await fs.rm(extractRoot, { recursive: true, force: true });
894
895
  }
895
896
  }
896
897
 
898
+ async function listRelativeFiles(dirPath: string): Promise<string[]> {
899
+ try {
900
+ const files = await listFilesRecursive(dirPath);
901
+ return files.map((filePath) => toPosixRelativePath(dirPath, filePath)).sort();
902
+ } catch (error) {
903
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return [];
904
+ throw error;
905
+ }
906
+ }
907
+
908
+ function toPosixRelativePath(rootDir: string, filePath: string): string {
909
+ return path.relative(rootDir, filePath).split(path.sep).join("/");
910
+ }
911
+
912
+ /**
913
+ * Carry local-only files across a cloud write. A skill that writes while it runs
914
+ * (a run ledger under its own folder) would otherwise lose that work every time the
915
+ * cloud content changed, because the write replaces the whole directory.
916
+ *
917
+ * Selective, not a blanket merge:
918
+ * - a path the incoming content provides always wins;
919
+ * - a path the previous write applied and the cloud has now dropped stays deleted,
920
+ * so upstream removals still propagate;
921
+ * - anything else is local and is kept.
922
+ *
923
+ * With no record of the previous write (state written before this existed) nothing
924
+ * can be attributed to the cloud, so local files are kept rather than destroyed.
925
+ */
926
+ async function preserveUnmanagedFiles(
927
+ existingDir: string,
928
+ stagingDir: string,
929
+ previouslyApplied?: readonly string[],
930
+ ): Promise<void> {
931
+ const managed = previouslyApplied ? new Set(previouslyApplied) : null;
932
+ for (const existingFile of await listFilesRecursive(existingDir)) {
933
+ const relativePath = toPosixRelativePath(existingDir, existingFile);
934
+ if (managed?.has(relativePath)) continue;
935
+ const stagedPath = path.join(stagingDir, ...relativePath.split("/"));
936
+ if (await pathExists(stagedPath)) continue;
937
+ await fs.mkdir(path.dirname(stagedPath), { recursive: true });
938
+ await fs.copyFile(existingFile, stagedPath);
939
+ }
940
+ }
941
+
897
942
  async function pathExists(targetPath: string): Promise<boolean> {
898
943
  try {
899
944
  await fs.access(targetPath);
@@ -906,6 +951,7 @@ async function pathExists(targetPath: string): Promise<boolean> {
906
951
  async function replaceSkillDirectoryAtomically(
907
952
  skillDir: string,
908
953
  populateDir: (stagingDir: string) => Promise<void>,
954
+ preserve?: { previouslyApplied?: readonly string[] },
909
955
  ): Promise<void> {
910
956
  const parentDir = path.dirname(skillDir);
911
957
  const skillName = path.basename(skillDir);
@@ -926,6 +972,10 @@ async function replaceSkillDirectoryAtomically(
926
972
  try {
927
973
  await populateDir(stagingDir);
928
974
 
975
+ if (preserve && (await pathExists(skillDir))) {
976
+ await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied);
977
+ }
978
+
929
979
  if (await pathExists(skillDir)) {
930
980
  await fs.rename(skillDir, backupDir);
931
981
  movedExisting = true;
@@ -986,11 +1036,13 @@ export async function writeCloudSkillToDisk(
986
1036
  skill: CloudSkill,
987
1037
  bundleBytes?: Buffer,
988
1038
  paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
989
- ): Promise<boolean> {
1039
+ options: { previouslyApplied?: readonly string[] } = {},
1040
+ ): Promise<SkillWriteOutcome | false> {
990
1041
  const skillDir = path.join(
991
1042
  paths.skillsDir,
992
1043
  safeName(skill.name, paths.skillsDir),
993
1044
  );
1045
+ const preserve = { previouslyApplied: options.previouslyApplied };
994
1046
 
995
1047
  if (bundleBytes?.length) {
996
1048
  const bundlePath = path.join(
@@ -999,8 +1051,11 @@ export async function writeCloudSkillToDisk(
999
1051
  );
1000
1052
  try {
1001
1053
  await fs.writeFile(bundlePath, bundleBytes);
1002
- await extractZipToDirectory(bundlePath, skillDir);
1003
- return true;
1054
+ let appliedFiles: string[] = [];
1055
+ await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
1056
+ appliedFiles = await extractZipToDirectory(bundlePath, stagingDir);
1057
+ }, preserve);
1058
+ return { written: true, appliedFiles };
1004
1059
  } finally {
1005
1060
  await fs.rm(bundlePath, { force: true });
1006
1061
  }
@@ -1019,6 +1074,7 @@ export async function writeCloudSkillToDisk(
1019
1074
  `Synced bundle for "${skill.name}" is missing SKILL.md or SKILLS.md`,
1020
1075
  );
1021
1076
  }
1077
+ const appliedFiles: string[] = [];
1022
1078
  await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
1023
1079
  for (const bundleFile of skill.bundle_files || []) {
1024
1080
  const filePath = resolveSkillBundleFilePath(
@@ -1030,9 +1086,10 @@ export async function writeCloudSkillToDisk(
1030
1086
  filePath,
1031
1087
  Buffer.from(bundleFile.content_b64, "base64"),
1032
1088
  );
1089
+ appliedFiles.push(toPosixRelativePath(stagingDir, filePath));
1033
1090
  }
1034
- });
1035
- return true;
1091
+ }, preserve);
1092
+ return { written: true, appliedFiles: appliedFiles.sort() };
1036
1093
  }
1037
1094
 
1038
1095
  await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
@@ -1041,6 +1098,6 @@ export async function writeCloudSkillToDisk(
1041
1098
  skill.skill_md ?? "",
1042
1099
  "utf8",
1043
1100
  );
1044
- });
1045
- return true;
1101
+ }, preserve);
1102
+ return { written: true, appliedFiles: ["SKILL.md"] };
1046
1103
  }
@@ -14,6 +14,10 @@ export interface SyncedSkill {
14
14
  cloudUpdatedAt?: string;
15
15
  /** Content accepted on disk; independent of server-specific folder hash formats. */
16
16
  cloudContentHash?: string;
17
+ /** Relative paths the last successful write put in this folder. Anything else in
18
+ * the folder was created locally (a running skill's output) and survives the next
19
+ * cloud write; anything listed here that the cloud later drops is deleted. */
20
+ appliedFiles?: string[];
17
21
  /** `skill_folder_hash` of the cloud revision a successful write actually applied.
18
22
  * The server may compute that hash with a different construction than the local
19
23
  * folder hash (app-published skills do), so only cloud-to-cloud comparison tells
@@ -28,6 +32,13 @@ export interface NotisSyncState {
28
32
  skills: Record<string, SyncedSkill>;
29
33
  }
30
34
 
35
+ /** A successful write, plus the paths it put on disk so the next one can tell
36
+ * cloud-managed files apart from whatever ran inside the folder since. */
37
+ export interface SkillWriteOutcome {
38
+ written: true;
39
+ appliedFiles: string[];
40
+ }
41
+
31
42
  export interface LocalSkill {
32
43
  name: string;
33
44
  skillMd: string;
@@ -1,21 +1,21 @@
1
- import type { CloudSkill } from './types';
1
+ import type { CloudSkill, SkillWriteOutcome } from './types';
2
2
 
3
3
  interface WriteCloudSkillDependencies {
4
4
  downloadSkillBundle: (bundleUrl: string) => Promise<Buffer>;
5
- writeCloudSkillToDisk: (skill: CloudSkill, bundleBytes?: Buffer) => Promise<boolean>;
5
+ writeCloudSkillToDisk: (skill: CloudSkill, bundleBytes?: Buffer) => Promise<SkillWriteOutcome | boolean>;
6
6
  onWarning?: (message: string, error: unknown) => void;
7
7
  }
8
8
 
9
9
  export async function writeCloudSkillWithBundleFallback(
10
10
  skill: CloudSkill,
11
11
  dependencies: WriteCloudSkillDependencies,
12
- ): Promise<boolean> {
12
+ ): Promise<SkillWriteOutcome | boolean> {
13
13
  if (skill.skill_source_url) {
14
14
  try {
15
15
  const bundleBytes = await dependencies.downloadSkillBundle(skill.skill_source_url);
16
16
  const wroteBundleToDisk = await dependencies.writeCloudSkillToDisk(skill, bundleBytes);
17
17
  if (wroteBundleToDisk) {
18
- return true;
18
+ return wroteBundleToDisk;
19
19
  }
20
20
 
21
21
  dependencies.onWarning?.(