@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.197.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 (107) hide show
  1. package/README.md +485 -86
  2. package/config/portal-route-roots.json +47 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +32663 -6357
  4. package/dist/base-skills/notis-apps/SKILL.md +88 -67
  5. package/dist/base-skills/notis-apps/references/architecture.md +114 -167
  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 +104 -142
  10. package/dist/base-skills/notis-apps/references/sdk.md +113 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +142 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +4 -4
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  17. package/dist/base-skills/notis-query/SKILL.md +34 -60
  18. package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
  19. package/dist/base-skills/notis-query/references/documents.md +63 -50
  20. package/dist/base-skills/notis-query/references/query.md +57 -524
  21. package/dist/sdk/src/bundleModule.ts +79 -0
  22. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  23. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  24. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  25. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  26. package/dist/sdk/src/config.ts +4 -26
  27. package/dist/sdk/src/documents.ts +2 -0
  28. package/dist/sdk/src/hooks/useAction.ts +28 -0
  29. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  30. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  31. package/dist/sdk/src/hooks/useHandover.ts +5 -4
  32. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  33. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  34. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  35. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  36. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  37. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  38. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  39. package/dist/sdk/src/index.ts +36 -0
  40. package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
  41. package/dist/sdk/src/nativeData.ts +39 -0
  42. package/dist/sdk/src/presentation.tsx +82 -0
  43. package/dist/sdk/src/provider.tsx +9 -4
  44. package/dist/sdk/src/queryCache.ts +5 -3
  45. package/dist/sdk/src/queryErrors.ts +51 -0
  46. package/dist/sdk/src/runtime.ts +146 -8
  47. package/dist/sdk/src/space.ts +425 -0
  48. package/dist/sdk/src/styles.css +96 -72
  49. package/dist/sdk/src/vite.ts +11 -1
  50. package/dist/skill-sync/index.js +458 -68
  51. package/dist/skill-sync/index.js.map +4 -4
  52. package/dist/skill-sync-worker.mjs +451 -77
  53. package/dist/space-harness/host.js +66 -0
  54. package/dist/view-renderer/dist/host.css +1 -0
  55. package/dist/view-renderer/dist/host.js +1042 -0
  56. package/dist/view-renderer/dist/theme.css +73 -0
  57. package/dist/view-renderer/src/dom.js +180 -0
  58. package/dist/view-renderer/src/errors.js +8 -0
  59. package/dist/view-renderer/src/host.tsx +173 -0
  60. package/dist/view-renderer/src/index.js +4 -0
  61. package/dist/view-renderer/src/pool.js +76 -0
  62. package/dist/view-renderer/src/remote-broker.js +59 -0
  63. package/dist/view-renderer/src/renderer.js +201 -0
  64. package/package.json +11 -5
  65. package/skills/notis-apps/cli.md +270 -163
  66. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
  67. package/skills/notis-onboarding/BRIEF.md +7 -3
  68. package/src/command-specs/apps.js +1 -8
  69. package/src/command-specs/files.js +29 -0
  70. package/src/command-specs/handover.js +45 -16
  71. package/src/command-specs/helpers.js +1 -0
  72. package/src/command-specs/index.js +10 -4
  73. package/src/command-specs/skills.js +229 -0
  74. package/src/command-specs/spaces.js +572 -0
  75. package/src/command-specs/views.js +69 -0
  76. package/src/runtime/agent-browser.js +30 -7
  77. package/src/runtime/app-boundary-validator.js +7 -4
  78. package/src/runtime/app-data-literals.js +82 -0
  79. package/src/runtime/app-platform.js +321 -253
  80. package/src/runtime/confined-space-build.js +74 -0
  81. package/src/runtime/confined-space-verify.js +49 -0
  82. package/src/runtime/native-record-files.js +47 -0
  83. package/src/runtime/retired-sdk-files.js +20 -0
  84. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  85. package/src/runtime/skill-sync/index.ts +154 -35
  86. package/src/runtime/skill-sync/local-scanner.ts +125 -17
  87. package/src/runtime/skill-sync/native-identity.ts +102 -0
  88. package/src/runtime/skill-sync/native-plan.ts +59 -0
  89. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  90. package/src/runtime/skill-sync/types.ts +32 -6
  91. package/src/runtime/source-root.js +32 -0
  92. package/src/runtime/space-collection-scaffold.js +66 -0
  93. package/src/runtime/space-database-structures.js +229 -0
  94. package/src/runtime/space-fixture-broker.js +50 -0
  95. package/src/runtime/space-lock.js +337 -0
  96. package/src/runtime/space-platform.js +105 -0
  97. package/src/runtime/space-source.js +281 -0
  98. package/src/runtime/space-test-server.js +327 -0
  99. package/src/runtime/space-verification.js +89 -0
  100. package/src/runtime/space-verify-browser-worker.js +51 -0
  101. package/src/runtime/space-view-manifest.js +254 -0
  102. package/src/runtime/sync-skills.js +1 -1
  103. package/src/runtime/transport.js +8 -1
  104. package/src/runtime/view-render.js +49 -0
  105. package/src/space-harness-host.ts +103 -0
  106. package/template/app/globals.css +1 -0
  107. 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"),
@@ -174,17 +187,18 @@ function isIgnoredSkillEntry(entry: Dirent): boolean {
174
187
  return /\.py[co]$/i.test(entry.name);
175
188
  }
176
189
 
177
- async function listFilesRecursive(dirPath: string): Promise<string[]> {
190
+ async function listFilesRecursive(dirPath: string, rootPath = dirPath, strictLinks = false): Promise<string[]> {
178
191
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
179
192
  const nested = await Promise.all(
180
193
  entries.map(async (entry) => {
181
- if (isIgnoredSkillEntry(entry)) {
194
+ if (isIgnoredSkillEntry(entry) || (dirPath === rootPath && entry.name === NATIVE_IDENTITY_FILE)) {
182
195
  return [];
183
196
  }
184
197
 
185
198
  const fullPath = path.join(dirPath, entry.name);
199
+ if (strictLinks && entry.isSymbolicLink()) throw new Error('Native Skill bundles cannot contain symbolic links.');
186
200
  if (entry.isDirectory()) {
187
- return listFilesRecursive(fullPath);
201
+ return listFilesRecursive(fullPath, rootPath, strictLinks);
188
202
  }
189
203
 
190
204
  if (entry.isFile()) {
@@ -337,17 +351,17 @@ function defaultTopLevelSkillSources(
337
351
  { label: "agents", root: paths.legacySkillsDir, priority: 2 },
338
352
  {
339
353
  label: "codex",
340
- root: path.join(HOME_DIR, ".codex", "skills"),
354
+ root: path.join(paths.homeDir || HOME_DIR, ".codex", "skills"),
341
355
  priority: 3,
342
356
  },
343
357
  {
344
358
  label: "cursor",
345
- root: path.join(HOME_DIR, ".cursor", "skills"),
359
+ root: path.join(paths.homeDir || HOME_DIR, ".cursor", "skills"),
346
360
  priority: 4,
347
361
  },
348
362
  {
349
363
  label: "claude",
350
- root: path.join(HOME_DIR, ".claude", "skills"),
364
+ root: path.join(paths.homeDir || HOME_DIR, ".claude", "skills"),
351
365
  priority: 5,
352
366
  },
353
367
  );
@@ -691,6 +705,7 @@ export async function scanLocalSkills(
691
705
  await ensureCanonicalSkillsDir(paths);
692
706
 
693
707
  const sourceUrls = await readSkillLock(paths);
708
+ const previousState = await readSyncState(paths);
694
709
  const entries = await fs.readdir(paths.skillsDir, { withFileTypes: true });
695
710
  const skills: Array<LocalSkill | null> = await Promise.all(
696
711
  entries.map(async (entry) => {
@@ -703,7 +718,17 @@ export async function scanLocalSkills(
703
718
 
704
719
  const skillDir = path.join(paths.skillsDir, entry.name);
705
720
  const skillMdPath = path.join(skillDir, "SKILL.md");
706
-
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));
707
732
  try {
708
733
  const skillMd = await fs.readFile(skillMdPath, "utf8");
709
734
  const { description } = parseFrontMatter(skillMd);
@@ -715,11 +740,27 @@ export async function scanLocalSkills(
715
740
  folderHash,
716
741
  directoryPath: skillDir,
717
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
+ }
718
757
  if (sourceUrls[entry.name]) {
719
758
  skill.sourceUrl = sourceUrls[entry.name];
720
759
  }
721
760
  return skill;
722
- } 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.');
723
764
  return null;
724
765
  }
725
766
  }),
@@ -736,6 +777,11 @@ function normalizeSyncState(
736
777
  if (!state || state.version !== 1 || typeof state.skills !== "object") {
737
778
  return null;
738
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
+ }
739
785
  const normalizeStoredAgentTargets = (
740
786
  targets: Partial<NotisSyncState["skills"][string]["agentTargets"]> | null | undefined,
741
787
  ): NotisSyncState["skills"][string]["agentTargets"] => ({
@@ -747,6 +793,7 @@ function normalizeSyncState(
747
793
 
748
794
  return {
749
795
  version: 1,
796
+ ...(state.nativeHistory ? { nativeHistory: state.nativeHistory } : {}),
750
797
  lastSyncedAt:
751
798
  typeof state.lastSyncedAt === "string" ? state.lastSyncedAt : null,
752
799
  skills: Object.fromEntries(
@@ -975,13 +1022,19 @@ async function preserveUnmanagedFiles(
975
1022
  existingDir: string,
976
1023
  stagingDir: string,
977
1024
  previouslyApplied?: readonly string[],
1025
+ strictConflicts = false,
978
1026
  ): Promise<void> {
979
1027
  const managed = previouslyApplied ? new Set(previouslyApplied) : null;
980
1028
  for (const existingFile of await listFilesRecursive(existingDir)) {
981
1029
  const relativePath = toPosixRelativePath(existingDir, existingFile);
982
1030
  if (managed?.has(relativePath)) continue;
983
1031
  const stagedPath = path.join(stagingDir, ...relativePath.split("/"));
984
- 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
+ }
985
1038
  await fs.mkdir(path.dirname(stagedPath), { recursive: true });
986
1039
  await fs.copyFile(existingFile, stagedPath);
987
1040
  }
@@ -999,7 +1052,7 @@ async function pathExists(targetPath: string): Promise<boolean> {
999
1052
  async function replaceSkillDirectoryAtomically(
1000
1053
  skillDir: string,
1001
1054
  populateDir: (stagingDir: string) => Promise<void>,
1002
- preserve?: { previouslyApplied?: readonly string[] },
1055
+ preserve?: { previouslyApplied?: readonly string[]; expectedFolderHash?: string; native?: boolean },
1003
1056
  ): Promise<void> {
1004
1057
  const parentDir = path.dirname(skillDir);
1005
1058
  const skillName = path.basename(skillDir);
@@ -1018,15 +1071,43 @@ async function replaceSkillDirectoryAtomically(
1018
1071
  let cleanupError: Error | null = null;
1019
1072
 
1020
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
+ }
1021
1084
  await populateDir(stagingDir);
1022
1085
 
1023
1086
  if (preserve && (await pathExists(skillDir))) {
1024
- await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied);
1087
+ await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied, preserve.native);
1088
+ }
1089
+
1090
+ if (preserve?.native) {
1091
+ const markerPath = path.join(stagingDir, NATIVE_IDENTITY_FILE);
1092
+ const marker = checkedNativeIdentity(JSON.parse(await fs.readFile(markerPath, 'utf8')));
1093
+ marker.folderHash = await computeFolderHash(stagingDir);
1094
+ await fs.writeFile(markerPath, JSON.stringify(marker), { mode: 0o600 });
1095
+ }
1096
+
1097
+ if (preserve?.expectedFolderHash !== undefined
1098
+ && (!await pathExists(skillDir) || await computeFolderHash(skillDir) !== preserve.expectedFolderHash)) {
1099
+ throw new Error('Local Skill files changed during sync; the newer local edits were retained.');
1025
1100
  }
1026
1101
 
1027
1102
  if (await pathExists(skillDir)) {
1028
1103
  await fs.rename(skillDir, backupDir);
1029
1104
  movedExisting = true;
1105
+ if (preserve?.native && preserve.expectedFolderHash === undefined) {
1106
+ throw new Error('Native Skill files appeared during sync; existing files were retained.');
1107
+ }
1108
+ if (preserve?.expectedFolderHash !== undefined && await computeFolderHash(backupDir) !== preserve.expectedFolderHash) {
1109
+ throw new Error('Local Skill files changed before publication; the newer local edits were retained.');
1110
+ }
1030
1111
  }
1031
1112
 
1032
1113
  await fs.rename(stagingDir, skillDir);
@@ -1080,17 +1161,36 @@ export async function createSkillBundleBase64(
1080
1161
  }
1081
1162
  }
1082
1163
 
1164
+ export async function captureNativeSkillFiles(skill: LocalSkill): Promise<BundleFile[]> {
1165
+ const hash = createHash('sha256');
1166
+ const files: BundleFile[] = [];
1167
+ for (const filePath of await listFilesRecursive(skill.directoryPath, skill.directoryPath, true)) {
1168
+ const relative = path.relative(skill.directoryPath, filePath);
1169
+ const bytes = await fs.readFile(filePath);
1170
+ hash.update(relative); hash.update('\0'); hash.update(bytes); hash.update('\0');
1171
+ files.push({ path: relative.split(path.sep).join('/'), content_b64: bytes.toString('base64') });
1172
+ }
1173
+ if (hash.digest('hex') !== skill.folderHash) throw new Error('Local Skill files changed before upload. Sync again with the current files.');
1174
+ return files;
1175
+ }
1176
+
1083
1177
  export async function writeCloudSkillToDisk(
1084
1178
  skill: CloudSkill,
1085
1179
  bundleBytes?: Buffer,
1086
1180
  paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
1087
- options: { previouslyApplied?: readonly string[] } = {},
1181
+ options: { previouslyApplied?: readonly string[]; expectedFolderHash?: string } = {},
1088
1182
  ): Promise<SkillWriteOutcome | false> {
1183
+ if (isNativeSkill(skill)) {
1184
+ assertNativePull({ skills: [{ ...skill, name: 'native-' + skill.id }], skill_sync_protocol: 2, sync_scope: skill.native_scope,
1185
+ sync_enabled: true, last_synced_at: null });
1186
+ if (bundleBytes?.length) throw new Error('Native Skills require their verified complete file response.');
1187
+ }
1089
1188
  const skillDir = path.join(
1090
1189
  paths.skillsDir,
1091
1190
  safeName(skill.name, paths.skillsDir),
1092
1191
  );
1093
- const preserve = { previouslyApplied: options.previouslyApplied };
1192
+ const preserve = { previouslyApplied: options.previouslyApplied, expectedFolderHash: options.expectedFolderHash,
1193
+ native: isNativeSkill(skill) };
1094
1194
 
1095
1195
  if (bundleBytes?.length) {
1096
1196
  const bundlePath = path.join(
@@ -1136,6 +1236,14 @@ export async function writeCloudSkillToDisk(
1136
1236
  );
1137
1237
  appliedFiles.push(toPosixRelativePath(stagingDir, filePath));
1138
1238
  }
1239
+ if (skill.native_reference) {
1240
+ const identity: NativeSkillIdentity = { schema: 1, cloudId: skill.id, reference: skill.native_reference,
1241
+ displayName: skill.display_name || skill.name, frontmatterName: nativeFrontmatterName(skill.skill_md || ''),
1242
+ scope: skill.native_scope!, folderHash: await computeFolderHash(stagingDir),
1243
+ files: Object.fromEntries((skill.bundle_files || []).map(file => [file.path, nativeFileDigest(Buffer.from(file.content_b64, 'base64'))])) };
1244
+ await fs.writeFile(path.join(stagingDir, NATIVE_IDENTITY_FILE), JSON.stringify(identity), { mode: 0o600 });
1245
+ appliedFiles.push(NATIVE_IDENTITY_FILE);
1246
+ }
1139
1247
  }, preserve);
1140
1248
  return { written: true, appliedFiles: appliedFiles.sort() };
1141
1249
  }
@@ -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,59 @@
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
+ export function nativeLinkRows(response: SyncPullResponse, locals: LocalSkill[]): CloudSkill[] {
38
+ return response.skills.map(skill => isNativeSkill(skill) && !nativeLocalReady(skill, locals.find(row => row.name === skill.name))
39
+ ? { ...skill, status: 'disabled' } : skill);
40
+ }
41
+
42
+ export function retainMissingNativeState(previous: NotisSyncState, next: NotisSyncState): NotisSyncState {
43
+ const present = new Set(Object.values(next.skills).map(row => row.cloudId));
44
+ const retained = Object.fromEntries(Object.entries(previous.skills)
45
+ .filter(([, row]) => (row.nativeReference || row.nativeUnavailable) && !present.has(row.cloudId))
46
+ .map(([name, row]) => [name, { ...row, nativeUnavailable: true, verifiedAgentLinks: {} }]));
47
+ const history = structuredClone(previous.nativeHistory || {});
48
+ for (const [name, row] of [...Object.entries(previous.skills), ...Object.entries(next.skills)]) {
49
+ if (!row.nativeReference && !row.nativeUnavailable) continue;
50
+ const names = history[row.cloudId]?.names || [];
51
+ history[row.cloudId] = { names: [...new Set([...names, name])] };
52
+ }
53
+ return { ...next, skills: { ...retained, ...next.skills },
54
+ ...(Object.keys(history).length ? { nativeHistory: history } : {}) };
55
+ }
56
+
57
+ export function hasNativeFolderHistory(state: NotisSyncState, name: string): boolean {
58
+ return Object.values(state.nativeHistory || {}).some(row => row.names.includes(name));
59
+ }
@@ -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
  }
@@ -5,8 +5,21 @@ export interface AgentTargets {
5
5
  codex: boolean;
6
6
  }
7
7
 
8
+ export interface NativeSkillReference {
9
+ kind: 'native_standalone_skill';
10
+ skill_id: string;
11
+ access_revision: number;
12
+ version: { revision: number; digest: string };
13
+ }
14
+
8
15
  export interface SyncedSkill {
9
16
  cloudId: string;
17
+ nativeReference?: NativeSkillReference;
18
+ nativeDisplayName?: string;
19
+ nativeScope?: string;
20
+ nativeSettingsRevision?: number;
21
+ /** Retain managed provenance even when its cloud access is removed. */
22
+ nativeUnavailable?: boolean;
10
23
  folderHash: string;
11
24
  agentTargets: AgentTargets;
12
25
  /** Actual readable managed links, never inferred from desired targets. */
@@ -20,7 +33,7 @@ export interface SyncedSkill {
20
33
  appliedFiles?: string[];
21
34
  /** `skill_folder_hash` of the cloud revision a successful write actually applied.
22
35
  * The server may compute that hash with a different construction than the local
23
- * folder hash (app-published skills do), so only cloud-to-cloud comparison tells
36
+ * folder hash (bundle-entry hashes do), so only cloud-to-cloud comparison tells
24
37
  * "the cloud moved" apart from "the two sides hash differently". */
25
38
  appliedCloudFolderHash?: string;
26
39
  syncedAt: string;
@@ -30,6 +43,8 @@ export interface NotisSyncState {
30
43
  version: 1;
31
44
  lastSyncedAt: string | null;
32
45
  skills: Record<string, SyncedSkill>;
46
+ /** Historical folder aliases are provenance, never authority or write pins. */
47
+ nativeHistory?: Record<string, { names: string[] }>;
33
48
  }
34
49
 
35
50
  /** A successful write, plus the paths it put on disk so the next one can tell
@@ -47,6 +62,12 @@ export interface LocalSkill {
47
62
  directoryPath: string;
48
63
  sourceUrl?: string;
49
64
  bundleFiles?: BundleFile[];
65
+ cloudId?: string;
66
+ nativeReference?: NativeSkillReference;
67
+ displayName?: string;
68
+ nativeScope?: string;
69
+ nativeFilesCurrent?: boolean;
70
+ nativeBaseFolderHash?: string;
50
71
  }
51
72
 
52
73
  export interface BundleFile {
@@ -65,16 +86,18 @@ export interface CloudSkill {
65
86
  skill_source_url?: string | null;
66
87
  bundle_files?: BundleFile[] | null;
67
88
  bundle_hydration_failed?: boolean | null;
68
- /** Set when the skill's content is owned by a Notis app. Its folder is republished
69
- * by the app, never by a local push. `app_owned` is what the server sends; the raw
70
- * association id stays server-side. */
71
- app_owned?: boolean | null;
72
- owner_app_id?: string | null;
73
89
  source: string;
74
90
  status: string;
91
+ native_reference?: NativeSkillReference;
92
+ display_name?: string;
93
+ native_scope?: string;
94
+ native_settings?: { revision: number; enabled: boolean; agent_targets: AgentTargets; updated_at: string };
75
95
  }
76
96
 
77
97
  export interface SyncSettings {
98
+ skill_sync_protocol?: number;
99
+ sync_scope?: string;
100
+ sync_namespace?: string | null;
78
101
  agent_targets_conditional_updates?: boolean;
79
102
  /** Server-verified canonical Notis account id used to scope local mirrors. */
80
103
  user_id?: string;
@@ -94,6 +117,9 @@ export interface EntitlementAccessDetail {
94
117
  }
95
118
 
96
119
  export interface SyncPullResponse {
120
+ skill_sync_protocol?: number;
121
+ sync_scope?: string;
122
+ sync_namespace?: string | null;
97
123
  skills: CloudSkill[];
98
124
  sync_enabled: boolean;
99
125
  last_synced_at: string | null;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Commands that write inside a source folder first prove the folder is one.
3
+ *
4
+ * 2026-10-02: `notis spaces build .` run from the Notis repository root treated
5
+ * the repository as a Space source, refreshed "its" embedded SDK and deleted the
6
+ * canonical packages/sdk/src/presentation.tsx. A source folder is identified by
7
+ * a marker at its root, checked before any file is read for writing, staged,
8
+ * refreshed or deleted.
9
+ */
10
+ import { statSync } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+
13
+ import { usageError } from './errors.js';
14
+
15
+ // The workspace Space index (also a legacy App's config) or a pulled Space's lock.
16
+ export const SOURCE_ROOT_MARKERS = Object.freeze(['notis.config.ts', 'notis.config.js', 'notis.config.mjs', '.notis/space-lock.json']);
17
+
18
+ export function isSourceRoot(directory) {
19
+ return SOURCE_ROOT_MARKERS.some((name) => {
20
+ try { return statSync(join(directory, name)).isFile(); } catch { return false; }
21
+ });
22
+ }
23
+
24
+ /** Return the resolved folder, or refuse before anything inside it is touched. */
25
+ export function assertSourceRoot(directory) {
26
+ const root = resolve(directory);
27
+ if (!isSourceRoot(root)) {
28
+ throw usageError(`${root} is not a Space source: it has no notis.config.ts (or .js, .mjs) and no .notis/space-lock.json `
29
+ + 'at its root, so nothing was changed. Run the command in a Space source folder, or pass that folder.');
30
+ }
31
+ return root;
32
+ }