@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.1792022492092.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.
Files changed (124) hide show
  1. package/README.md +548 -98
  2. package/config/portal-route-roots.json +47 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +32901 -5934
  4. package/dist/base-skills/notis-apps/SKILL.md +76 -51
  5. package/dist/base-skills/notis-apps/references/architecture.md +116 -164
  6. package/dist/base-skills/notis-apps/references/design.md +26 -107
  7. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  8. package/dist/base-skills/notis-apps/references/reading.md +52 -74
  9. package/dist/base-skills/notis-apps/references/release.md +105 -96
  10. package/dist/base-skills/notis-apps/references/sdk.md +115 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +145 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +29 -12
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  16. package/dist/base-skills/notis-cli/references/native-databases.md +6 -5
  17. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  18. package/dist/base-skills/notis-query/SKILL.md +34 -60
  19. package/dist/base-skills/notis-query/references/database-discovery.md +60 -59
  20. package/dist/base-skills/notis-query/references/documents.md +53 -39
  21. package/dist/base-skills/notis-query/references/query.md +57 -524
  22. package/dist/sdk/src/bundleModule.ts +79 -0
  23. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  24. package/dist/sdk/src/components/DocumentPage.tsx +144 -0
  25. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  26. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  27. package/dist/sdk/src/config.ts +4 -26
  28. package/dist/sdk/src/documents.ts +2 -0
  29. package/dist/sdk/src/hooks/useAction.ts +28 -0
  30. package/dist/sdk/src/hooks/useBackend.ts +3 -2
  31. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  32. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  33. package/dist/sdk/src/hooks/useCollectionInteractions.ts +4 -2
  34. package/dist/sdk/src/hooks/useHandover.ts +7 -7
  35. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  36. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  37. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  38. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  39. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  40. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  41. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  42. package/dist/sdk/src/index.ts +36 -0
  43. package/dist/sdk/src/interactions/shortcuts.tsx +42 -5
  44. package/dist/sdk/src/nativeData.ts +39 -0
  45. package/dist/sdk/src/presentation.tsx +82 -0
  46. package/dist/sdk/src/provider.tsx +9 -4
  47. package/dist/sdk/src/queryCache.ts +5 -3
  48. package/dist/sdk/src/queryErrors.ts +51 -0
  49. package/dist/sdk/src/runtime.ts +163 -13
  50. package/dist/sdk/src/space.ts +425 -0
  51. package/dist/sdk/src/styles.css +96 -72
  52. package/dist/sdk/src/vite.ts +11 -1
  53. package/dist/skill-sync/index.js +571 -80
  54. package/dist/skill-sync/index.js.map +4 -4
  55. package/dist/skill-sync-worker.mjs +858 -233
  56. package/dist/space-harness/host.js +66 -0
  57. package/dist/view-renderer/dist/host.css +1 -0
  58. package/dist/view-renderer/dist/host.js +1028 -0
  59. package/dist/view-renderer/dist/theme.css +73 -0
  60. package/dist/view-renderer/src/dom.js +180 -0
  61. package/dist/view-renderer/src/errors.js +8 -0
  62. package/dist/view-renderer/src/host.tsx +175 -0
  63. package/dist/view-renderer/src/index.js +4 -0
  64. package/dist/view-renderer/src/pool.js +76 -0
  65. package/dist/view-renderer/src/remote-broker.js +59 -0
  66. package/dist/view-renderer/src/renderer.js +201 -0
  67. package/package.json +16 -5
  68. package/skills/notis-apps/cli.md +323 -163
  69. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
  70. package/skills/notis-onboarding/BRIEF.md +7 -3
  71. package/src/command-specs/apps.js +2 -1
  72. package/src/command-specs/auth.js +2 -2
  73. package/src/command-specs/files.js +29 -0
  74. package/src/command-specs/handover.js +331 -79
  75. package/src/command-specs/helpers.js +2 -0
  76. package/src/command-specs/index.js +11 -5
  77. package/src/command-specs/profile.js +42 -1
  78. package/src/command-specs/skills.js +242 -1
  79. package/src/command-specs/spaces.js +645 -0
  80. package/src/command-specs/tools.js +0 -6
  81. package/src/command-specs/views.js +69 -0
  82. package/src/runtime/agent-browser.js +92 -29
  83. package/src/runtime/app-boundary-validator.js +7 -4
  84. package/src/runtime/app-data-literals.js +82 -0
  85. package/src/runtime/app-platform.js +321 -253
  86. package/src/runtime/computer-identity.d.ts +30 -0
  87. package/src/runtime/computer-identity.js +151 -0
  88. package/src/runtime/config-lock.js +135 -0
  89. package/src/runtime/confined-space-build.js +74 -0
  90. package/src/runtime/confined-space-verify.js +49 -0
  91. package/src/runtime/delegated-context.js +5 -21
  92. package/src/runtime/native-record-files.js +47 -0
  93. package/src/runtime/oauth.js +18 -1
  94. package/src/runtime/profiles.js +5 -107
  95. package/src/runtime/retired-sdk-files.js +20 -0
  96. package/src/runtime/screenshot-framing.js +85 -0
  97. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  98. package/src/runtime/skill-sync/index.ts +230 -35
  99. package/src/runtime/skill-sync/local-scanner.ts +188 -29
  100. package/src/runtime/skill-sync/native-identity.ts +102 -0
  101. package/src/runtime/skill-sync/native-plan.ts +70 -0
  102. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  103. package/src/runtime/skill-sync/types.ts +32 -6
  104. package/src/runtime/source-root.js +32 -0
  105. package/src/runtime/space-collection-scaffold.js +66 -0
  106. package/src/runtime/space-database-structures.js +229 -0
  107. package/src/runtime/space-fixture-broker.js +50 -0
  108. package/src/runtime/space-lock.js +337 -0
  109. package/src/runtime/space-platform.js +105 -0
  110. package/src/runtime/space-source.js +281 -0
  111. package/src/runtime/space-test-server.js +327 -0
  112. package/src/runtime/space-verification.js +89 -0
  113. package/src/runtime/space-verify-browser-worker.js +51 -0
  114. package/src/runtime/space-view-manifest.js +254 -0
  115. package/src/runtime/store-screenshot.js +10 -7
  116. package/src/runtime/sync-skills.d.ts +3 -1
  117. package/src/runtime/sync-skills.js +10 -2
  118. package/src/runtime/transport.js +8 -6
  119. package/src/runtime/view-render.js +49 -0
  120. package/src/skill-sync-worker.js +14 -2
  121. package/src/space-harness-host.ts +103 -0
  122. package/template/.harness/index.html.tmpl +7 -10
  123. package/template/app/globals.css +1 -0
  124. package/src/command-specs/reports.js +0 -97
@@ -6,10 +6,13 @@ import os from "os";
6
6
  import path from "path";
7
7
  import { promisify } from "util";
8
8
 
9
- import type { CloudSkill, LocalSkill, NotisSyncState, SkillWriteOutcome } from "./types";
9
+ import type { BundleFile, CloudSkill, LocalSkill, NotisSyncState, SkillWriteOutcome } from "./types";
10
+ import { NATIVE_IDENTITY_FILE, assertNativePull, checkedNativeIdentity, isNativeSkill,
11
+ nativeFileDigest, nativeFrontmatterName, type NativeSkillIdentity } from './native-identity';
10
12
 
11
13
  const HOME_DIR = os.homedir();
12
14
  const execFileAsync = promisify(execFile);
15
+ class NativeSkillIdentityError extends Error {}
13
16
 
14
17
  export const AGENTS_DIR = path.join(HOME_DIR, ".agents");
15
18
  export const LEGACY_AGENTS_SKILLS_DIR = path.join(AGENTS_DIR, "skills");
@@ -45,6 +48,9 @@ const EXCLUDED_TOP_LEVEL_ROOT_NAMES = new Set([
45
48
  ]);
46
49
 
47
50
  export interface SkillSyncPaths {
51
+ /** Explicit process-independent home for a confined sync invocation. */
52
+ homeDir?: string;
53
+ isolatedNamespace?: string;
48
54
  agentsDir: string;
49
55
  syncRoot: string;
50
56
  legacySkillsDir: string;
@@ -96,14 +102,21 @@ function sanitizePathSegment(value: string): string {
96
102
  export function getSkillSyncPathsForUser(
97
103
  authUserId: string,
98
104
  agentsDir: string = AGENTS_DIR,
105
+ namespace?: string | null,
106
+ syncHome?: string,
99
107
  ): SkillSyncPaths {
100
108
  const safeUserId = sanitizePathSegment(authUserId);
101
- const resolvedAgentsDir = path.resolve(agentsDir);
102
- const syncRoot = getDefaultSyncRootForAgentsDir(resolvedAgentsDir);
103
- const userRoot = path.join(syncRoot, "users", safeUserId);
109
+ const homeDir = syncHome ? path.resolve(syncHome) : undefined;
110
+ const resolvedAgentsDir = homeDir ? path.join(homeDir, '.agents') : path.resolve(agentsDir);
111
+ const syncRoot = homeDir ? path.join(homeDir, '.notis', 'skills') : getDefaultSyncRootForAgentsDir(resolvedAgentsDir);
112
+ const userRoot = namespace
113
+ ? path.join(syncRoot, 'environments', sanitizePathSegment(namespace), 'users', safeUserId)
114
+ : path.join(syncRoot, "users", safeUserId);
104
115
  const legacyUserRoot = path.join(resolvedAgentsDir, "notis", "users", safeUserId);
105
116
 
106
117
  return {
118
+ ...(homeDir ? { homeDir } : {}),
119
+ ...(namespace ? { isolatedNamespace: sanitizePathSegment(namespace) } : {}),
107
120
  agentsDir: resolvedAgentsDir,
108
121
  syncRoot,
109
122
  legacySkillsDir: path.join(resolvedAgentsDir, "skills"),
@@ -163,17 +176,29 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
163
176
  }
164
177
  }
165
178
 
166
- async function listFilesRecursive(dirPath: string): Promise<string[]> {
179
+ // Interpreter caches a script run leaves beside itself are not skill content: they
180
+ // must neither mark a skill as changed nor ship in its bundle (app bundling applies
181
+ // the same rule).
182
+ const IGNORED_SKILL_DIRECTORIES = new Set(["__pycache__", ".pytest_cache"]);
183
+
184
+ function isIgnoredSkillEntry(entry: Dirent): boolean {
185
+ if (entry.name === ".DS_Store") return true;
186
+ if (entry.isDirectory()) return IGNORED_SKILL_DIRECTORIES.has(entry.name);
187
+ return /\.py[co]$/i.test(entry.name);
188
+ }
189
+
190
+ async function listFilesRecursive(dirPath: string, rootPath = dirPath, strictLinks = false): Promise<string[]> {
167
191
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
168
192
  const nested = await Promise.all(
169
193
  entries.map(async (entry) => {
170
- if (entry.name === ".DS_Store") {
194
+ if (isIgnoredSkillEntry(entry) || (dirPath === rootPath && entry.name === NATIVE_IDENTITY_FILE)) {
171
195
  return [];
172
196
  }
173
197
 
174
198
  const fullPath = path.join(dirPath, entry.name);
199
+ if (strictLinks && entry.isSymbolicLink()) throw new Error('Native Skill bundles cannot contain symbolic links.');
175
200
  if (entry.isDirectory()) {
176
- return listFilesRecursive(fullPath);
201
+ return listFilesRecursive(fullPath, rootPath, strictLinks);
177
202
  }
178
203
 
179
204
  if (entry.isFile()) {
@@ -326,17 +351,17 @@ function defaultTopLevelSkillSources(
326
351
  { label: "agents", root: paths.legacySkillsDir, priority: 2 },
327
352
  {
328
353
  label: "codex",
329
- root: path.join(HOME_DIR, ".codex", "skills"),
354
+ root: path.join(paths.homeDir || HOME_DIR, ".codex", "skills"),
330
355
  priority: 3,
331
356
  },
332
357
  {
333
358
  label: "cursor",
334
- root: path.join(HOME_DIR, ".cursor", "skills"),
359
+ root: path.join(paths.homeDir || HOME_DIR, ".cursor", "skills"),
335
360
  priority: 4,
336
361
  },
337
362
  {
338
363
  label: "claude",
339
- root: path.join(HOME_DIR, ".claude", "skills"),
364
+ root: path.join(paths.homeDir || HOME_DIR, ".claude", "skills"),
340
365
  priority: 5,
341
366
  },
342
367
  );
@@ -680,6 +705,7 @@ export async function scanLocalSkills(
680
705
  await ensureCanonicalSkillsDir(paths);
681
706
 
682
707
  const sourceUrls = await readSkillLock(paths);
708
+ const previousState = await readSyncState(paths);
683
709
  const entries = await fs.readdir(paths.skillsDir, { withFileTypes: true });
684
710
  const skills: Array<LocalSkill | null> = await Promise.all(
685
711
  entries.map(async (entry) => {
@@ -692,7 +718,17 @@ export async function scanLocalSkills(
692
718
 
693
719
  const skillDir = path.join(paths.skillsDir, entry.name);
694
720
  const skillMdPath = path.join(skillDir, "SKILL.md");
695
-
721
+ let marker: string | null = null;
722
+ // Classify provenance BEFORE reading potentially unavailable content.
723
+ // Failure to scan a managed folder is not evidence that it is absent.
724
+ try { marker = await fs.readFile(path.join(skillDir, NATIVE_IDENTITY_FILE), 'utf8'); }
725
+ catch (error) {
726
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
727
+ throw new NativeSkillIdentityError('Native Skill provenance could not be read; existing files were retained.');
728
+ }
729
+ }
730
+ const knownNative = marker !== null || Boolean(previousState.skills[entry.name]?.nativeReference)
731
+ || Object.values(previousState.nativeHistory || {}).some(row => row.names.includes(entry.name));
696
732
  try {
697
733
  const skillMd = await fs.readFile(skillMdPath, "utf8");
698
734
  const { description } = parseFrontMatter(skillMd);
@@ -704,11 +740,27 @@ export async function scanLocalSkills(
704
740
  folderHash,
705
741
  directoryPath: skillDir,
706
742
  };
743
+ if (marker !== null) {
744
+ let identity: NativeSkillIdentity;
745
+ try { identity = checkedNativeIdentity(JSON.parse(marker)); }
746
+ catch { throw new NativeSkillIdentityError('Native Skill provenance is unreadable; do not upload it as a new Skill.'); }
747
+ let current = true;
748
+ for (const [name, expected] of Object.entries(identity.files)) {
749
+ try { if (nativeFileDigest(await fs.readFile(resolveSkillBundleFilePath(skillDir, name))) !== expected) current = false; }
750
+ catch { current = false; }
751
+ }
752
+ const editedName = nativeFrontmatterName(skillMd);
753
+ Object.assign(skill, { cloudId: identity.cloudId, nativeReference: identity.reference,
754
+ nativeScope: identity.scope, nativeBaseFolderHash: identity.folderHash, nativeFilesCurrent: current,
755
+ displayName: editedName && editedName !== identity.frontmatterName ? editedName : identity.displayName });
756
+ }
707
757
  if (sourceUrls[entry.name]) {
708
758
  skill.sourceUrl = sourceUrls[entry.name];
709
759
  }
710
760
  return skill;
711
- } catch {
761
+ } catch (error) {
762
+ if (error instanceof NativeSkillIdentityError) throw error;
763
+ if (knownNative) throw new NativeSkillIdentityError('Native Skill files could not be read; existing files were retained.');
712
764
  return null;
713
765
  }
714
766
  }),
@@ -725,6 +777,11 @@ function normalizeSyncState(
725
777
  if (!state || state.version !== 1 || typeof state.skills !== "object") {
726
778
  return null;
727
779
  }
780
+ if (state.nativeHistory && (typeof state.nativeHistory !== 'object' || Array.isArray(state.nativeHistory)
781
+ || Object.values(state.nativeHistory).some(row => !row || !Array.isArray(row.names)
782
+ || row.names.some(name => typeof name !== 'string')))) {
783
+ throw new Error('Native Skill sync provenance is unreadable; existing files were retained.');
784
+ }
728
785
  const normalizeStoredAgentTargets = (
729
786
  targets: Partial<NotisSyncState["skills"][string]["agentTargets"]> | null | undefined,
730
787
  ): NotisSyncState["skills"][string]["agentTargets"] => ({
@@ -736,6 +793,7 @@ function normalizeSyncState(
736
793
 
737
794
  return {
738
795
  version: 1,
796
+ ...(state.nativeHistory ? { nativeHistory: state.nativeHistory } : {}),
739
797
  lastSyncedAt:
740
798
  typeof state.lastSyncedAt === "string" ? state.lastSyncedAt : null,
741
799
  skills: Object.fromEntries(
@@ -839,22 +897,59 @@ async function createZipFromDirectory(directoryPath: string): Promise<string> {
839
897
  os.tmpdir(),
840
898
  `notis-skill-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
841
899
  );
842
- const parentDir = path.dirname(directoryPath);
843
900
  const directoryName = path.basename(directoryPath);
901
+ // Zip a copy holding exactly the files the folder hash covers, so ignored
902
+ // caches never ship.
903
+ const stagingRoot = await fs.mkdtemp(path.join(os.tmpdir(), "notis-skill-bundle-"));
904
+ try {
905
+ const stagedDir = path.join(stagingRoot, directoryName);
906
+ for (const filePath of await listFilesRecursive(directoryPath)) {
907
+ const target = path.join(stagedDir, path.relative(directoryPath, filePath));
908
+ await fs.mkdir(path.dirname(target), { recursive: true });
909
+ await fs.copyFile(filePath, target);
910
+ }
911
+ await fs.mkdir(stagedDir, { recursive: true });
912
+
913
+ if (process.platform === "win32") {
914
+ // Windows PowerShell's Compress-Archive writes entry names with
915
+ // backslashes, which unzip on the cloud computer as literal file names
916
+ // ("cached\\SKILL.md"). bsdtar, shipped in System32 since Windows 10
917
+ // 1803, writes the forward slashes the zip format requires. Use the
918
+ // system copy explicitly: on a Git Bash PATH, "tar" resolves to GNU tar,
919
+ // which cannot write zip archives.
920
+ const systemTar = path.join(
921
+ process.env.SystemRoot || "C:\\Windows",
922
+ "System32",
923
+ "tar.exe",
924
+ );
925
+ if (await pathExists(systemTar)) {
926
+ await execFileAsync(systemTar, [
927
+ "-c",
928
+ "--format",
929
+ "zip",
930
+ "-f",
931
+ zipPath,
932
+ "-C",
933
+ stagingRoot,
934
+ directoryName,
935
+ ]);
936
+ return zipPath;
937
+ }
938
+ await execFileAsync("powershell.exe", [
939
+ "-NoProfile",
940
+ "-Command",
941
+ `Compress-Archive -Path '${stagedDir.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`,
942
+ ]);
943
+ return zipPath;
944
+ }
844
945
 
845
- if (process.platform === "win32") {
846
- await execFileAsync("powershell.exe", [
847
- "-NoProfile",
848
- "-Command",
849
- `Compress-Archive -Path '${directoryPath.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`,
850
- ]);
946
+ await execFileAsync("zip", ["-qry", zipPath, directoryName], {
947
+ cwd: stagingRoot,
948
+ });
851
949
  return zipPath;
950
+ } finally {
951
+ await fs.rm(stagingRoot, { recursive: true, force: true });
852
952
  }
853
-
854
- await execFileAsync("zip", ["-qry", zipPath, directoryName], {
855
- cwd: parentDir,
856
- });
857
- return zipPath;
858
953
  }
859
954
 
860
955
  async function extractZipToDirectory(
@@ -927,13 +1022,19 @@ async function preserveUnmanagedFiles(
927
1022
  existingDir: string,
928
1023
  stagingDir: string,
929
1024
  previouslyApplied?: readonly string[],
1025
+ strictConflicts = false,
930
1026
  ): Promise<void> {
931
1027
  const managed = previouslyApplied ? new Set(previouslyApplied) : null;
932
1028
  for (const existingFile of await listFilesRecursive(existingDir)) {
933
1029
  const relativePath = toPosixRelativePath(existingDir, existingFile);
934
1030
  if (managed?.has(relativePath)) continue;
935
1031
  const stagedPath = path.join(stagingDir, ...relativePath.split("/"));
936
- if (await pathExists(stagedPath)) continue;
1032
+ if (await pathExists(stagedPath)) {
1033
+ if (strictConflicts && !(await fs.readFile(existingFile)).equals(await fs.readFile(stagedPath))) {
1034
+ throw new Error('An incoming native Skill file conflicts with a local file. Local files were retained.');
1035
+ }
1036
+ continue;
1037
+ }
937
1038
  await fs.mkdir(path.dirname(stagedPath), { recursive: true });
938
1039
  await fs.copyFile(existingFile, stagedPath);
939
1040
  }
@@ -951,7 +1052,7 @@ async function pathExists(targetPath: string): Promise<boolean> {
951
1052
  async function replaceSkillDirectoryAtomically(
952
1053
  skillDir: string,
953
1054
  populateDir: (stagingDir: string) => Promise<void>,
954
- preserve?: { previouslyApplied?: readonly string[] },
1055
+ preserve?: { previouslyApplied?: readonly string[]; expectedFolderHash?: string; native?: boolean; legacyBaseline?: boolean },
955
1056
  ): Promise<void> {
956
1057
  const parentDir = path.dirname(skillDir);
957
1058
  const skillName = path.basename(skillDir);
@@ -970,15 +1071,44 @@ async function replaceSkillDirectoryAtomically(
970
1071
  let cleanupError: Error | null = null;
971
1072
 
972
1073
  try {
1074
+ if (preserve?.native && preserve.expectedFolderHash === undefined) {
1075
+ let existing = false;
1076
+ try { await fs.lstat(skillDir); existing = true; }
1077
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
1078
+ if (existing) throw new Error('Existing native Skill files need a readable baseline before replacement.');
1079
+ }
1080
+ if (preserve?.expectedFolderHash !== undefined
1081
+ && (!await pathExists(skillDir) || await computeFolderHash(skillDir) !== preserve.expectedFolderHash)) {
1082
+ throw new Error('Local Skill files changed before sync; the newer local edits were retained.');
1083
+ }
973
1084
  await populateDir(stagingDir);
974
1085
 
975
1086
  if (preserve && (await pathExists(skillDir))) {
976
- await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied);
1087
+ await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied,
1088
+ preserve.native && !preserve.legacyBaseline);
1089
+ }
1090
+
1091
+ if (preserve?.native) {
1092
+ const markerPath = path.join(stagingDir, NATIVE_IDENTITY_FILE);
1093
+ const marker = checkedNativeIdentity(JSON.parse(await fs.readFile(markerPath, 'utf8')));
1094
+ marker.folderHash = await computeFolderHash(stagingDir);
1095
+ await fs.writeFile(markerPath, JSON.stringify(marker), { mode: 0o600 });
1096
+ }
1097
+
1098
+ if (preserve?.expectedFolderHash !== undefined
1099
+ && (!await pathExists(skillDir) || await computeFolderHash(skillDir) !== preserve.expectedFolderHash)) {
1100
+ throw new Error('Local Skill files changed during sync; the newer local edits were retained.');
977
1101
  }
978
1102
 
979
1103
  if (await pathExists(skillDir)) {
980
1104
  await fs.rename(skillDir, backupDir);
981
1105
  movedExisting = true;
1106
+ if (preserve?.native && preserve.expectedFolderHash === undefined) {
1107
+ throw new Error('Native Skill files appeared during sync; existing files were retained.');
1108
+ }
1109
+ if (preserve?.expectedFolderHash !== undefined && await computeFolderHash(backupDir) !== preserve.expectedFolderHash) {
1110
+ throw new Error('Local Skill files changed before publication; the newer local edits were retained.');
1111
+ }
982
1112
  }
983
1113
 
984
1114
  await fs.rename(stagingDir, skillDir);
@@ -1032,17 +1162,38 @@ export async function createSkillBundleBase64(
1032
1162
  }
1033
1163
  }
1034
1164
 
1165
+ export async function captureNativeSkillFiles(skill: LocalSkill): Promise<BundleFile[]> {
1166
+ const hash = createHash('sha256');
1167
+ const files: BundleFile[] = [];
1168
+ for (const filePath of await listFilesRecursive(skill.directoryPath, skill.directoryPath, true)) {
1169
+ const relative = path.relative(skill.directoryPath, filePath);
1170
+ const bytes = await fs.readFile(filePath);
1171
+ hash.update(relative); hash.update('\0'); hash.update(bytes); hash.update('\0');
1172
+ files.push({ path: relative.split(path.sep).join('/'), content_b64: bytes.toString('base64') });
1173
+ }
1174
+ if (hash.digest('hex') !== skill.folderHash) throw new Error('Local Skill files changed before upload. Sync again with the current files.');
1175
+ return files;
1176
+ }
1177
+
1035
1178
  export async function writeCloudSkillToDisk(
1036
1179
  skill: CloudSkill,
1037
1180
  bundleBytes?: Buffer,
1038
1181
  paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
1039
- options: { previouslyApplied?: readonly string[] } = {},
1182
+ options: { previouslyApplied?: readonly string[]; expectedFolderHash?: string; legacyBaseline?: boolean } = {},
1040
1183
  ): Promise<SkillWriteOutcome | false> {
1184
+ if (isNativeSkill(skill)) {
1185
+ assertNativePull({ skills: [{ ...skill, name: 'native-' + skill.id }], skill_sync_protocol: 2, sync_scope: skill.native_scope,
1186
+ sync_enabled: true, last_synced_at: null });
1187
+ if (bundleBytes?.length) throw new Error('Native Skills require their verified complete file response.');
1188
+ }
1041
1189
  const skillDir = path.join(
1042
1190
  paths.skillsDir,
1043
1191
  safeName(skill.name, paths.skillsDir),
1044
1192
  );
1045
- const preserve = { previouslyApplied: options.previouslyApplied };
1193
+ const preserve = { previouslyApplied: options.previouslyApplied, expectedFolderHash: options.expectedFolderHash,
1194
+ native: isNativeSkill(skill),
1195
+ // An unchanged legacy folder upgrading in place: shared files take the cloud version.
1196
+ legacyBaseline: isNativeSkill(skill) && options.legacyBaseline === true && options.expectedFolderHash !== undefined };
1046
1197
 
1047
1198
  if (bundleBytes?.length) {
1048
1199
  const bundlePath = path.join(
@@ -1088,6 +1239,14 @@ export async function writeCloudSkillToDisk(
1088
1239
  );
1089
1240
  appliedFiles.push(toPosixRelativePath(stagingDir, filePath));
1090
1241
  }
1242
+ if (skill.native_reference) {
1243
+ const identity: NativeSkillIdentity = { schema: 1, cloudId: skill.id, reference: skill.native_reference,
1244
+ displayName: skill.display_name || skill.name, frontmatterName: nativeFrontmatterName(skill.skill_md || ''),
1245
+ scope: skill.native_scope!, folderHash: await computeFolderHash(stagingDir),
1246
+ files: Object.fromEntries((skill.bundle_files || []).map(file => [file.path, nativeFileDigest(Buffer.from(file.content_b64, 'base64'))])) };
1247
+ await fs.writeFile(path.join(stagingDir, NATIVE_IDENTITY_FILE), JSON.stringify(identity), { mode: 0o600 });
1248
+ appliedFiles.push(NATIVE_IDENTITY_FILE);
1249
+ }
1091
1250
  }, preserve);
1092
1251
  return { written: true, appliedFiles: appliedFiles.sort() };
1093
1252
  }
@@ -0,0 +1,102 @@
1
+ import type { CloudSkill, NativeSkillReference, SyncedSkill, SyncPullResponse } from './types';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ export const NATIVE_IDENTITY_FILE = '.notis-native-skill.json';
5
+ export interface NativeSkillIdentity {
6
+ schema: 1;
7
+ cloudId: string;
8
+ reference: NativeSkillReference;
9
+ displayName: string;
10
+ frontmatterName: string;
11
+ scope: string;
12
+ folderHash: string;
13
+ files: Record<string, string>;
14
+ }
15
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
16
+
17
+ export function checkedNativeReference(value: unknown, cloudId?: string): NativeSkillReference {
18
+ const reference = value as NativeSkillReference;
19
+ if (!reference || typeof reference !== 'object' || Array.isArray(reference)
20
+ || Object.keys(reference).sort().join(',') !== 'access_revision,kind,skill_id,version'
21
+ || reference.kind !== 'native_standalone_skill' || !UUID.test(reference.skill_id)
22
+ || (cloudId !== undefined && reference.skill_id !== cloudId)
23
+ || !Number.isSafeInteger(reference.access_revision) || reference.access_revision < 0
24
+ || !reference.version || Object.keys(reference.version).sort().join(',') !== 'digest,revision'
25
+ || !Number.isSafeInteger(reference.version.revision) || reference.version.revision < 1
26
+ || !/^[a-f0-9]{64}$/.test(reference.version.digest)) {
27
+ throw new Error('Native Skill identity is unavailable. Refresh before syncing.');
28
+ }
29
+ return structuredClone(reference);
30
+ }
31
+
32
+ export function nativeDirectory(reference: NativeSkillReference): string {
33
+ return 'native-' + checkedNativeReference(reference).skill_id;
34
+ }
35
+
36
+ export function sameNativeReference(left?: NativeSkillReference, right?: NativeSkillReference): boolean {
37
+ return Boolean(left && right && left.skill_id === right.skill_id
38
+ && left.access_revision === right.access_revision && left.version.revision === right.version.revision
39
+ && left.version.digest === right.version.digest);
40
+ }
41
+
42
+ export function isNativeSkill(skill: CloudSkill): boolean {
43
+ return skill.source === 'native' || skill.native_reference !== undefined;
44
+ }
45
+
46
+ export function assertNativePull(response: SyncPullResponse): void {
47
+ const ids = new Set<string>();
48
+ for (const skill of response.skills) {
49
+ if (!isNativeSkill(skill)) continue;
50
+ const reference = checkedNativeReference(skill.native_reference, skill.id);
51
+ if (response.skill_sync_protocol !== 2 || skill.name !== nativeDirectory(reference) || ids.has(skill.id)
52
+ || !/^[a-f0-9]{64}$/.test(response.sync_scope || '') || skill.native_scope !== response.sync_scope
53
+ || !Array.isArray(skill.bundle_files) || skill.bundle_hydration_failed || typeof skill.skill_md !== 'string') {
54
+ throw new Error('Native Skill inventory is incomplete. Existing local files were retained.');
55
+ }
56
+ const paths = new Set<string>();
57
+ let entrypoint = false;
58
+ for (const file of skill.bundle_files) {
59
+ if (typeof file.path !== 'string' || typeof file.content_b64 !== 'string'
60
+ || file.path.startsWith('/') || file.path.includes('\\') || file.path.includes('\0')
61
+ || file.path.split('/').some(part => !part || part === '.' || part === '..')
62
+ || paths.has(file.path.toLowerCase()) || file.path.toLowerCase() === NATIVE_IDENTITY_FILE) {
63
+ throw new Error('Native Skill bundle has a conflicting managed path.');
64
+ }
65
+ paths.add(file.path.toLowerCase());
66
+ if (file.path === 'SKILL.md') {
67
+ entrypoint = Buffer.from(file.content_b64, 'base64').equals(Buffer.from(skill.skill_md, 'utf8'));
68
+ }
69
+ }
70
+ if (!entrypoint) throw new Error('Native Skill instructions and files do not match.');
71
+ ids.add(skill.id);
72
+ }
73
+ }
74
+
75
+ export function nativeFileDigest(bytes: Buffer): string {
76
+ return createHash('sha256').update(bytes).digest('hex');
77
+ }
78
+
79
+ export function nativeFrontmatterName(markdown: string): string {
80
+ const header = markdown.match(/^---\s*\n([\s\S]*?)\n---/);
81
+ return header?.[1].match(/^name\s*:\s*(.+)$/m)?.[1].trim().replace(/^['"]|['"]$/g, '') || '';
82
+ }
83
+
84
+ export function checkedNativeIdentity(value: unknown): NativeSkillIdentity {
85
+ const identity = value as NativeSkillIdentity;
86
+ if (!identity || identity.schema !== 1 || typeof identity.cloudId !== 'string'
87
+ || typeof identity.displayName !== 'string' || typeof identity.frontmatterName !== 'string'
88
+ || !/^[a-f0-9]{64}$/.test(identity.scope) || !/^[a-f0-9]{64}$/.test(identity.folderHash)
89
+ || !identity.files || !Object.keys(identity.files).includes('SKILL.md')
90
+ || Object.entries(identity.files).some(([name, hash]) => name.startsWith('/') || name.includes('\\')
91
+ || name.split('/').some(part => !part || part === '.' || part === '..') || !/^[a-f0-9]{64}$/.test(hash))) {
92
+ throw new Error('Native Skill provenance is incomplete. Existing files were retained.');
93
+ }
94
+ return { ...identity, reference: checkedNativeReference(identity.reference, identity.cloudId) };
95
+ }
96
+
97
+ export function nativeState(skill: CloudSkill): Partial<SyncedSkill> {
98
+ if (!isNativeSkill(skill)) return {};
99
+ return { nativeReference: checkedNativeReference(skill.native_reference, skill.id),
100
+ ...(skill.native_settings ? { nativeSettingsRevision: skill.native_settings.revision } : {}),
101
+ nativeDisplayName: skill.display_name || skill.name, nativeScope: skill.native_scope, nativeUnavailable: false };
102
+ }
@@ -0,0 +1,70 @@
1
+ import type { CloudSkill, LocalSkill, NotisSyncState, SyncPullResponse } from './types';
2
+ import { checkedNativeReference, isNativeSkill, sameNativeReference } from './native-identity';
3
+
4
+ /** Folder names may change; existing managed cloud identity may not. */
5
+ export function bindNativeFolders(response: SyncPullResponse, locals: LocalSkill[], state: NotisSyncState): SyncPullResponse {
6
+ const nativeIds = new Set(response.skills.filter(isNativeSkill).map(skill => skill.id));
7
+ for (const entry of Object.values(state.skills)) if (entry.nativeReference || entry.nativeUnavailable) nativeIds.add(entry.cloudId);
8
+ const byId = new Map<string, LocalSkill>();
9
+ for (const local of locals) {
10
+ const id = local.cloudId || state.skills[local.name]?.cloudId;
11
+ if (!id || (!nativeIds.has(id) && !local.nativeReference)) continue;
12
+ if (local.nativeReference) checkedNativeReference(local.nativeReference, id);
13
+ if (byId.has(id)) throw new Error('Two local folders claim the same native Skill. Keep both copies and resolve the conflict.');
14
+ byId.set(id, local);
15
+ }
16
+ const skills = response.skills.map(skill => isNativeSkill(skill) && byId.has(skill.id)
17
+ ? { ...skill, name: byId.get(skill.id)!.name } : skill);
18
+ for (const skill of skills.filter(isNativeSkill)) {
19
+ if (skills.some(other => other.id !== skill.id && other.name === skill.name)) {
20
+ throw new Error('A native Skill and another Skill share a local folder. Existing files were retained.');
21
+ }
22
+ }
23
+ return { ...response, skills };
24
+ }
25
+
26
+ export function previousNativeState(state: NotisSyncState, skill: CloudSkill) {
27
+ const named = state.skills[skill.name];
28
+ return named?.cloudId === skill.id ? named : Object.values(state.skills).find(entry => entry.cloudId === skill.id);
29
+ }
30
+
31
+ export function nativeLocalReady(skill: CloudSkill, local?: LocalSkill): boolean {
32
+ return Boolean(local?.nativeFilesCurrent && local.folderHash === local.nativeBaseFolderHash
33
+ && local.cloudId === skill.id && local.nativeScope === skill.native_scope
34
+ && sameNativeReference(local.nativeReference, skill.native_reference));
35
+ }
36
+
37
+ /** A pre-native folder (no identity marker, no native state) the cloud now serves
38
+ * as a native Skill. It stays usable as before until its upgrade write lands. */
39
+ export function pendingLegacyUpgrade(skill: CloudSkill, local: LocalSkill | undefined, state: NotisSyncState): boolean {
40
+ if (!isNativeSkill(skill) || !local || local.nativeReference || local.cloudId) return false;
41
+ const previous = previousNativeState(state, skill);
42
+ return Boolean(previous && !previous.nativeReference);
43
+ }
44
+
45
+ export function nativeLinkRows(response: SyncPullResponse, locals: LocalSkill[], state?: NotisSyncState): CloudSkill[] {
46
+ return response.skills.map(skill => {
47
+ const local = locals.find(row => row.name === skill.name);
48
+ return isNativeSkill(skill) && !nativeLocalReady(skill, local) && !(state && pendingLegacyUpgrade(skill, local, state))
49
+ ? { ...skill, status: 'disabled' } : skill;
50
+ });
51
+ }
52
+
53
+ export function retainMissingNativeState(previous: NotisSyncState, next: NotisSyncState): NotisSyncState {
54
+ const present = new Set(Object.values(next.skills).map(row => row.cloudId));
55
+ const retained = Object.fromEntries(Object.entries(previous.skills)
56
+ .filter(([, row]) => (row.nativeReference || row.nativeUnavailable) && !present.has(row.cloudId))
57
+ .map(([name, row]) => [name, { ...row, nativeUnavailable: true, verifiedAgentLinks: {} }]));
58
+ const history = structuredClone(previous.nativeHistory || {});
59
+ for (const [name, row] of [...Object.entries(previous.skills), ...Object.entries(next.skills)]) {
60
+ if (!row.nativeReference && !row.nativeUnavailable) continue;
61
+ const names = history[row.cloudId]?.names || [];
62
+ history[row.cloudId] = { names: [...new Set([...names, name])] };
63
+ }
64
+ return { ...next, skills: { ...retained, ...next.skills },
65
+ ...(Object.keys(history).length ? { nativeHistory: history } : {}) };
66
+ }
67
+
68
+ export function hasNativeFolderHistory(state: NotisSyncState, name: string): boolean {
69
+ return Object.values(state.nativeHistory || {}).some(row => row.names.includes(name));
70
+ }
@@ -1,9 +1,11 @@
1
1
  import type { CloudSkill, LocalSkill, NotisSyncState } from "./types";
2
+ import { isNativeSkill } from './native-identity';
3
+ import { hasNativeFolderHistory } from './native-plan';
2
4
 
3
5
  /**
4
6
  * One cloud row per local folder. Several active rows can carry the same skill name
5
- * (an app's source skill plus its installed clone, or duplicates created by earlier
6
- * pushes), and every one of them writes to the same directory. Applying them all in
7
+ * (duplicates created by earlier pushes), and every one of them writes to the same
8
+ * directory. Applying them all in
7
9
  * one pass replaced that directory several times per sync and left whichever row came
8
10
  * last on disk. The newest revision wins, deterministically.
9
11
  */
@@ -23,25 +25,30 @@ export function getPushCandidates(
23
25
  syncState: NotisSyncState,
24
26
  cloudCuratedSkillNames: ReadonlySet<string> = new Set(),
25
27
  cloudSkillNames?: ReadonlySet<string>,
26
- cloudAppOwnedSkillNames: ReadonlySet<string> = new Set(),
28
+ cloudSkills: CloudSkill[] = [],
27
29
  ): LocalSkill[] {
28
30
  return localSkills.filter((skill) => {
29
31
  if (cloudCuratedSkillNames.has(skill.name)) {
30
32
  return false;
31
33
  }
32
- // App-owned content belongs to the installed app's release, not to this account.
33
- // Pushing it created a second row with the same name on every local difference,
34
- // and each new row then fought over the same folder.
35
- if (cloudAppOwnedSkillNames.has(skill.name)) {
36
- return false;
34
+ const previous = syncState.skills[skill.name]
35
+ || (skill.cloudId ? Object.values(syncState.skills).find(row => row.cloudId === skill.cloudId) : undefined);
36
+ const native = cloudSkills.find(row => isNativeSkill(row) && row.id === (skill.cloudId || previous?.cloudId));
37
+ if (native || skill.nativeReference || previous?.nativeReference || previous?.nativeUnavailable) {
38
+ return Boolean(native && skill.nativeReference && skill.nativeScope === native.native_scope
39
+ && (previous ? previous.folderHash !== skill.folderHash : skill.nativeBaseFolderHash !== skill.folderHash));
37
40
  }
38
- const previous = syncState.skills[skill.name];
39
41
  if (!previous) {
42
+ if (hasNativeFolderHistory(syncState, skill.name) || /^native-[a-f0-9-]{36}$/.test(skill.name)) return false;
40
43
  return true;
41
44
  }
42
45
  if (cloudSkillNames && !cloudSkillNames.has(skill.name)) {
43
46
  return false;
44
47
  }
45
48
  return previous.folderHash !== skill.folderHash;
49
+ }).map(skill => {
50
+ const previous = syncState.skills[skill.name]
51
+ || Object.values(syncState.skills).find(row => row.cloudId === skill.cloudId);
52
+ return { ...skill, ...(skill.cloudId || previous?.cloudId ? { cloudId: skill.cloudId || previous!.cloudId } : {}) };
46
53
  });
47
54
  }