@gregiteen/ssss-cli 0.14.2 → 0.14.3

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.
@@ -28,6 +28,7 @@ const HELP = usage({
28
28
  usage: 'ssss new <dir> [options]',
29
29
  options: [
30
30
  ['--name <s>', 'Package name (default: the target directory name).'],
31
+ ['--extension <s>', 'Scaffold a custom app extension registry under registry/extensions/<name>.json.'],
31
32
  ['--with-total-recall', 'Also wire the Total Recall memory OS (total-recall-brain).'],
32
33
  ['--install', 'Run `npm install` after scaffolding (and Total Recall init if requested).'],
33
34
  ['--no-git', 'Do not run `git init`.'],
@@ -36,6 +37,7 @@ const HELP = usage({
36
37
  ],
37
38
  examples: [
38
39
  'ssss new my-app',
40
+ 'ssss new my-app --extension myapp',
39
41
  'ssss new my-app --with-total-recall --install',
40
42
  ],
41
43
  seeAlso: ['ssss help scaffold', 'ssss export', 'ssss conformance'],
@@ -65,6 +67,15 @@ export async function run(argv) {
65
67
  const name = flags.name || path.basename(root);
66
68
  const ref = flags.ref || `v${VERSION}`;
67
69
  const withTR = !!flags['with-total-recall'];
70
+ const extensionName = flags.extension === undefined ? null : String(flags.extension);
71
+ if (extensionName !== null) {
72
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(extensionName)) {
73
+ die(`--extension '${extensionName}' is not a valid registry name (lowercase letter first, then a-z, 0-9, _ or -; max 64).`);
74
+ }
75
+ if (extensionName === 'core' || extensionName === 'ssss') {
76
+ die(`--extension '${extensionName}' is reserved for the SSSS core registry.`);
77
+ }
78
+ }
68
79
 
69
80
  if (fs.existsSync(root) && fs.readdirSync(root).length && !flags.force) {
70
81
  die(`${root} is not empty. Use --force to scaffold anyway.`);
@@ -79,7 +90,9 @@ export async function run(argv) {
79
90
  ssss: 'ssss',
80
91
  test: 'node --test',
81
92
  export: 'ssss export vault --profile sale --out dist/bundle.ucw.json',
82
- validate: 'ssss conformance --engine',
93
+ validate: extensionName
94
+ ? `ssss schema validate registry/extensions/${extensionName}.json && ssss conformance --engine`
95
+ : 'ssss conformance --engine',
83
96
  },
84
97
  dependencies: { '@gregiteen/ssss-cli': `github:gregiteen/ssss#${ref}` },
85
98
  ...(withTR ? { devDependencies: { 'total-recall-brain': 'latest' } } : {}),
@@ -96,14 +109,42 @@ export async function run(argv) {
96
109
  W(root, 'vault/tasks/first-task.md',
97
110
  '---\ntype: task\ntitle: First Task\ndescription: Starter private setup task that demonstrates tenant_private filtering.\ntimestamp: 2026-07-02T00:00:00Z\npriority: normal\ncategory: setup\nstatus: pending\n---\n\n# First task\n\nThis is `tenant_private` (§5.5) — it is **dropped** from `template`/`sale` exports.\n');
98
111
 
112
+ // ── optional app extension registry ─────────────────────────────────────
113
+ if (extensionName) {
114
+ const extRegistry = {
115
+ $schema: 'https://ssss.dev/schemas/core-registry.schema.json',
116
+ registry: extensionName,
117
+ extends: 'core',
118
+ version: '0.1.0',
119
+ description: `Custom SSSS primitive extensions for ${name}.`,
120
+ document_primitives: {
121
+ [`${extensionName}_item`]: {
122
+ family: 'content',
123
+ canonical_filename: null,
124
+ append_only: false,
125
+ portability: 'structural',
126
+ required_fields: ['type', 'name', 'title'],
127
+ optional_fields: ['description', 'status'],
128
+ enums: {
129
+ status: ['active', 'archived', 'draft'],
130
+ },
131
+ notes: `Custom primitive for ${name} application.`,
132
+ },
133
+ },
134
+ };
135
+ W(root, `registry/extensions/${extensionName}.json`, JSON.stringify(extRegistry, null, 2) + '\n');
136
+ W(root, `vault/${extensionName}_items/first-${extensionName}-item.md`,
137
+ `---\ntype: ${extensionName}_item\ntitle: Starter Item\ndescription: Starter ${extensionName} item.\ntimestamp: 2026-07-02T00:00:00Z\nname: Starter Item\nstatus: active\n---\n\n# Starter Item\n\nCustom primitive defined in \`registry/extensions/${extensionName}.json\`.\n`);
138
+ }
139
+
99
140
  // ── conformance test (zero-dependency: node --test) ─────────────────────
100
- W(root, 'test/ssss-conformance.test.mjs', CONFORMANCE_TEST);
141
+ W(root, 'test/ssss-conformance.test.mjs', conformanceTest(extensionName));
101
142
 
102
143
  // ── repo meta ───────────────────────────────────────────────────────────
103
144
  W(root, '.gitignore', 'node_modules/\ndist/\n*.ucw.json\n.DS_Store\n');
104
- W(root, 'README.md', readme(name, withTR));
105
- W(root, 'CLAUDE.md', claudeMd(name, withTR));
106
- W(root, 'docs/design.md', designMd(name));
145
+ W(root, 'README.md', readme(name, withTR, extensionName));
146
+ W(root, 'CLAUDE.md', claudeMd(name, withTR, extensionName));
147
+ W(root, 'docs/design.md', designMd(name, extensionName));
107
148
 
108
149
  // ── git + install (best-effort, flag-gated, no shell) ───────────────────
109
150
  const log = (m) => console.error(m);
@@ -145,7 +186,8 @@ export async function run(argv) {
145
186
  console.log(' npx ssss help # explore the standard');
146
187
  }
147
188
 
148
- const CONFORMANCE_TEST = `/**
189
+ function conformanceTest(extensionName) {
190
+ return `/**
149
191
  * SSSS conformance — proves this repo's toolchain matches the published standard.
150
192
  * Zero test dependencies: run with \`node --test\` (Node 18+).
151
193
  */
@@ -161,7 +203,18 @@ import { exportBundle, validateBundle, provisionBundle, importBundle } from '@gr
161
203
 
162
204
  const require = createRequire(import.meta.url);
163
205
  const { fixtures } = require('@gregiteen/ssss-cli/conformance/fixtures.json');
164
- const VAULT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'vault');
206
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
207
+ const VAULT = path.join(ROOT, 'vault');
208
+ ${extensionName
209
+ ? `// App-specific primitives live in this repo's registry/extensions/; the core comes from the package.
210
+ const REGISTRY = path.join(ROOT, 'registry');
211
+ const EXTENSIONS = [${JSON.stringify(extensionName)}];
212
+ const EXPORT_OPTIONS = { registryDir: REGISTRY, requiredExtensions: EXTENSIONS };
213
+ const REGISTRY_OPTIONS = { registryDir: REGISTRY };
214
+ const ENGINE_OPTIONS = { registryDir: REGISTRY, registryNames: EXTENSIONS };`
215
+ : `const EXPORT_OPTIONS = {};
216
+ const REGISTRY_OPTIONS = {};
217
+ const ENGINE_OPTIONS = {};`}
165
218
 
166
219
  test('engine implements the canonical Operation Contract (spec §6)', async () => {
167
220
  const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-conf-'));
@@ -179,17 +232,17 @@ test('engine implements the canonical Operation Contract (spec §6)', async () =
179
232
  });
180
233
 
181
234
  test('this vault exports, validates, and round-trips as a sale bundle (spec §16/§17)', async () => {
182
- const bundle = exportBundle(VAULT, { profile: 'sale', name: 'starter' });
183
- const { valid, errors } = validateBundle(bundle);
235
+ const bundle = exportBundle(VAULT, { profile: 'sale', name: 'starter', ...EXPORT_OPTIONS });
236
+ const { valid, errors } = validateBundle(bundle, REGISTRY_OPTIONS);
184
237
  assert.ok(valid, 'bundle invalid: ' + errors.join('; '));
185
238
  assert.ok(!bundle.files.some((f) => f.path.startsWith('tasks/')), 'tenant_private leaked into sale export');
186
239
 
187
- const plan = provisionBundle(bundle, { workspaceId: 'ws-test' });
188
- assert.ok(plan.ok, 'provision failed: ' + JSON.stringify(plan.unresolved) + ' ' + JSON.stringify(plan.danglingLinks));
240
+ const plan = provisionBundle(bundle, { workspaceId: 'ws-test', ...REGISTRY_OPTIONS });
241
+ assert.ok(plan.ok, 'provision failed: ' + JSON.stringify(plan.errors) + ' ' + JSON.stringify(plan.unresolved) + ' ' + JSON.stringify(plan.danglingLinks));
189
242
 
190
243
  const target = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-import-'));
191
244
  try {
192
- const engine = createEngine();
245
+ const engine = createEngine(ENGINE_OPTIONS);
193
246
  const r1 = await importBundle(plan.plan, target, engine);
194
247
  assert.ok(r1.ok && r1.committed === bundle.files.length, 'import did not commit every file');
195
248
  const r2 = await importBundle(plan.plan, target, engine);
@@ -197,8 +250,9 @@ test('this vault exports, validates, and round-trips as a sale bundle (spec §16
197
250
  } finally { fs.rmSync(target, { recursive: true, force: true }); }
198
251
  });
199
252
  `;
253
+ }
200
254
 
201
- function readme(name, withTR) {
255
+ function readme(name, withTR, extensionName) {
202
256
  return `# ${name}
203
257
 
204
258
  An [SSSS](https://github.com/gregiteen/ssss)-native project: a Markdown **vault** is
@@ -208,7 +262,7 @@ the source of truth, validated and packaged by the dependency-free \`@gregiteen/
208
262
 
209
263
  \`\`\`
210
264
  vault/ Your SSSS documents (structural + tenant_private primitives).
211
- test/ Conformance test — replays the canonical fixtures + round-trips the vault.
265
+ ${extensionName ? `registry/ Custom primitive extension schema (registry/extensions/${extensionName}.json).\n` : ''}test/ Conformance test — replays the canonical fixtures + round-trips the vault.
212
266
  \`\`\`
213
267
 
214
268
  ## Use
@@ -236,18 +290,23 @@ npx total-recall recall "..." # semantic recall
236
290
  ` : ''}`;
237
291
  }
238
292
 
239
- function claudeMd(name, withTR) {
293
+ function claudeMd(name, withTR, extensionName) {
240
294
  return `# ${name} — agent guide
241
295
 
242
296
  This repo uses **SSSS** (Structured Semantic Syntax System) as its state contract.
243
297
 
244
298
  ## Source of truth
245
299
  - \`vault/\` is the only source of truth — typed Markdown documents with YAML
246
- frontmatter. Every file declares a \`type\` defined in the \`@gregiteen/ssss-cli\` core registry.
300
+ frontmatter. Every file declares a \`type\` defined in the \`@gregiteen/ssss-cli\` core registry${extensionName ? ` or local \`registry/extensions/${extensionName}.json\`` : ''}.
247
301
  - Mutate the vault through the standard, not by hand-writing files in bulk:
248
302
  \`npx ssss\` for bundle/tenant tooling, or the \`@gregiteen/ssss-cli/engine\` Operation Contract
249
303
  in code.
250
304
 
305
+ ## Extension & Registry Isolation Invariant
306
+ - App-specific primitives belong exclusively in \`registry/extensions/\` inside this application repository.
307
+ - Never modify or fork \`@gregiteen/ssss-cli\` to add app-specific schemas.
308
+ - Validate local extension schemas with: \`npx ssss schema validate registry/extensions/<name>.json\`.
309
+
251
310
  ## Portability (§5.5) — the rule that matters
252
311
  - \`structural\` documents (rules, workflows, pages, assistants) are the sellable model.
253
312
  - \`tenant_private\` documents (tasks, customer data) are **never** shipped in a
@@ -264,7 +323,7 @@ ${withTR ? `
264
323
  ` : ''}`;
265
324
  }
266
325
 
267
- function designMd(name) {
326
+ function designMd(name, extensionName) {
268
327
  return `# ${name} — System Architecture & Design
269
328
 
270
329
  > Technical design and architecture specification for ${name}.
@@ -278,7 +337,7 @@ function designMd(name) {
278
337
  - **Vault Structure**: Typed Markdown documents under \`vault/\` serving as the source of truth.
279
338
  - **Workflows & Execution**: Automated procedures defined in \`vault/workflows/\` triggered by operational events.
280
339
  - **Capabilities & Resources**: Tools and external service integrations required for execution.
281
-
340
+ ${extensionName ? `- **Extension Registry**: Custom primitive definitions located in \`registry/extensions/${extensionName}.json\` extending core SSSS.\n` : ''}
282
341
  ## Data & Schema Contracts
283
342
 
284
343
  <Core entities, external dependencies, and integration interfaces.>
@@ -287,5 +346,7 @@ function designMd(name) {
287
346
 
288
347
  - SSSS VFS is the single source of truth for application state.
289
348
  - Structural business logic is strictly decoupled from private tenant operational data.
349
+ - App-specific primitives reside in \`registry/extensions/\` within this repository and extend \`core\`.
350
+ - Core SSSS library (\`@gregiteen/ssss-cli\`) remains vendor-neutral and dependency-free.
290
351
  `;
291
352
  }
@@ -1,22 +1,41 @@
1
1
  /**
2
- * Schema export and inspection commands for SSSS.
2
+ * `ssss schema` — inspect, export, and validate against the canonical Draft 2020-12
3
+ * JSON Schemas for SSSS registries (`schemas/core-registry.schema.json`) and
4
+ * `.ucw` transport bundles (`schemas/bundle.schema.json`).
3
5
  */
4
6
  import fs from 'node:fs';
5
7
  import path from 'node:path';
6
8
  import { fileURLToPath } from 'node:url';
7
- import { parseArgs, die, wantsHelp } from './lib/cli.mjs';
9
+ import {
10
+ getCoreRegistrySchema,
11
+ getBundleSchema,
12
+ validateJsonSchema,
13
+ } from '../src/schema-validator.mjs';
14
+ import { parseArgs, usage, die, wantsHelp } from './lib/cli.mjs';
8
15
 
9
16
  const HERE = path.dirname(fileURLToPath(import.meta.url));
10
17
  const ROOT = path.resolve(HERE, '..');
11
18
  const SCHEMAS_DIR = path.resolve(ROOT, 'schemas');
12
19
 
13
- const HELP = `Usage: ssss schema core|bundle|export [options]
14
-
15
- Commands:
16
- core [--out <file>] Output the Core Registry JSON Schema.
17
- bundle [--out <file>] Output the .ucw Bundle JSON Schema.
18
- export [--out <dir>] Export all canonical schemas to a directory (default: ./schemas).
19
- `;
20
+ const HELP = usage({
21
+ summary: 'Output, export, or validate against the canonical SSSS Draft 2020-12 JSON Schemas.',
22
+ usage: 'ssss schema <core|bundle|export|validate> [target] [options]',
23
+ options: [
24
+ ['--out <path>', 'Destination file (core/bundle) or directory (export; default: ./schemas).'],
25
+ ['--type <core|bundle>', 'Target schema kind for `validate` (auto-detected when omitted).'],
26
+ ['--schema <file>', 'Custom JSON Schema file path for `validate`.'],
27
+ ['--json', 'Emit machine-readable JSON output for `export` or `validate`.'],
28
+ ['--quiet', 'Suppress human-readable output on valid `validate` check.'],
29
+ ],
30
+ examples: [
31
+ 'ssss schema core',
32
+ 'ssss schema bundle --out ./bundle.schema.json',
33
+ 'ssss schema export --out ./schemas',
34
+ 'ssss schema validate registry/core.json',
35
+ 'ssss schema validate conformance/reference-bundle.ucw.json --type bundle',
36
+ ],
37
+ seeAlso: ['ssss registry', 'ssss validate', 'ssss help schema'],
38
+ });
20
39
 
21
40
  function emit(value, output) {
22
41
  const text = typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`;
@@ -28,10 +47,30 @@ function emit(value, output) {
28
47
  }
29
48
  }
30
49
 
50
+ function detectSchemaType(data, explicitType) {
51
+ if (explicitType === 'core' || explicitType === 'registry') return 'core';
52
+ if (explicitType === 'bundle') return 'bundle';
53
+ if (explicitType) {
54
+ die(`Unknown --type '${explicitType}'. Use 'core' or 'bundle'.`);
55
+ }
56
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
57
+ if (typeof data.$schema === 'string') {
58
+ if (data.$schema.includes('bundle.schema.json')) return 'bundle';
59
+ if (data.$schema.includes('core-registry.schema.json') || data.$schema.includes('registry')) return 'core';
60
+ }
61
+ if ('manifest' in data || 'files' in data) return 'bundle';
62
+ if ('registry' in data || 'document_primitives' in data) return 'core';
63
+ }
64
+ return 'core';
65
+ }
66
+
31
67
  export async function run(argv) {
32
68
  if (wantsHelp(argv)) { console.log(HELP); return; }
33
69
  const verb = argv[0];
34
- const { flags } = parseArgs(argv.slice(1));
70
+ const { positionals, flags } = parseArgs(argv.slice(1), {
71
+ booleans: ['json', 'quiet'],
72
+ alias: { j: 'json', q: 'quiet' },
73
+ });
35
74
 
36
75
  try {
37
76
  const coreSchemaPath = path.join(SCHEMAS_DIR, 'core-registry.schema.json');
@@ -50,12 +89,63 @@ export async function run(argv) {
50
89
  if (verb === 'export') {
51
90
  const targetDir = path.resolve(flags.out || './schemas');
52
91
  fs.mkdirSync(targetDir, { recursive: true });
53
- fs.copyFileSync(coreSchemaPath, path.join(targetDir, 'core-registry.schema.json'));
54
- fs.copyFileSync(bundleSchemaPath, path.join(targetDir, 'bundle.schema.json'));
55
- console.log(`Exported canonical schemas to ${targetDir}`);
92
+ const written = [
93
+ path.join(targetDir, 'core-registry.schema.json'),
94
+ path.join(targetDir, 'bundle.schema.json'),
95
+ ];
96
+ fs.copyFileSync(coreSchemaPath, written[0]);
97
+ fs.copyFileSync(bundleSchemaPath, written[1]);
98
+ if (flags.json) {
99
+ console.log(JSON.stringify({ exported: written, directory: targetDir }, null, 2));
100
+ } else {
101
+ console.log(`Exported canonical schemas to ${targetDir}`);
102
+ }
56
103
  return;
57
104
  }
58
- die(`Unknown schema verb '${verb || ''}'.\n${HELP}`);
105
+ if (verb === 'validate') {
106
+ const file = positionals[0];
107
+ if (!file) die(`validate requires a target JSON file.\n\n${HELP}`);
108
+ const resolvedFile = path.resolve(file);
109
+ if (!fs.existsSync(resolvedFile)) die(`no such file: ${file}`);
110
+ let data;
111
+ try {
112
+ data = JSON.parse(fs.readFileSync(resolvedFile, 'utf8'));
113
+ } catch (err) {
114
+ die(`not valid JSON (${file}): ${err.message}`);
115
+ }
116
+
117
+ let schema;
118
+ let schemaLabel;
119
+ if (flags.schema) {
120
+ const customSchemaPath = path.resolve(flags.schema);
121
+ if (!fs.existsSync(customSchemaPath)) die(`no such schema file: ${flags.schema}`);
122
+ schema = JSON.parse(fs.readFileSync(customSchemaPath, 'utf8'));
123
+ schemaLabel = flags.schema;
124
+ } else {
125
+ const kind = detectSchemaType(data, flags.type);
126
+ schema = kind === 'bundle' ? getBundleSchema() : getCoreRegistrySchema();
127
+ schemaLabel = kind;
128
+ }
129
+
130
+ const { valid, errors } = validateJsonSchema(data, schema);
131
+ if (flags.json) {
132
+ console.log(JSON.stringify({ valid, schema: schemaLabel, file, errors }, null, 2));
133
+ if (!valid) process.exit(1);
134
+ return;
135
+ }
136
+ if (valid) {
137
+ if (!flags.quiet) {
138
+ console.log(`✓ valid — ${file} (${schemaLabel} schema)`);
139
+ }
140
+ return;
141
+ }
142
+ console.error(`✗ invalid — ${errors.length} error(s) in ${file}:`);
143
+ for (const e of errors) {
144
+ console.error(` - ${e.path ? `${e.path}: ` : ''}${e.message}`);
145
+ }
146
+ process.exit(1);
147
+ }
148
+ die(`Unknown schema verb '${verb || ''}'.\n\n${HELP}`);
59
149
  } catch (error) {
60
150
  die(error.message);
61
151
  }
@@ -463,6 +463,19 @@ function checkJsonSchemas(check) {
463
463
  const badBundle = { files: [] };
464
464
  const badBundleResult = validateJsonSchema(badBundle, bundleSchema);
465
465
  check('schema: bundle schema rejects missing manifest', !badBundleResult.valid && badBundleResult.errors.some((e) => e.path.includes('manifest')));
466
+
467
+ // Extension registries need no spec_version/description (those are core-only, A-009).
468
+ const extDef = definePrimitive({ namespace: 'acme', name: 'Widget', fields: [{ id: 'title', name: 'Title', kind: 'string', required: true }] });
469
+ const extResult = validateJsonSchema(definitionsToExtensionRegistry('acme', [extDef]), coreSchema);
470
+ check('schema: definitionsToExtensionRegistry() output validates against the registry schema', extResult.valid, extResult.errors?.[0] && `${extResult.errors[0].path}: ${extResult.errors[0].message}`);
471
+
472
+ const strayKey = validateJsonSchema({ ...coreRegistry, unexpected_root_key: true }, coreSchema);
473
+ check('schema: registry schema rejects unknown root keys (fail-closed)', !strayKey.valid && strayKey.errors.some((e) => e.path === 'unexpected_root_key'));
474
+
475
+ const dupFields = JSON.parse(JSON.stringify(coreRegistry));
476
+ dupFields.document_primitives.rule.required_fields = ['type', 'type'];
477
+ const dupResult = validateJsonSchema(dupFields, coreSchema);
478
+ check('schema: registry schema rejects duplicate required_fields (uniqueItems)', !dupResult.valid && dupResult.errors.some((e) => e.message.includes('uniqueItems')));
466
479
  }
467
480
 
468
481
  // Resource hooks (spec §6.3): every prepare ends in exactly one finalize or
@@ -430,13 +430,13 @@ async function runBundleConformance() {
430
430
  // D4: Resource bindings emitted in result
431
431
  check('provisionBundle emits resource bindings with relations and modes (D4)',
432
432
  provAlpha.bindings.length > 0 &&
433
- provAlpha.bindings.some((b) => b.type === 'domain' && b.value === 'alpha.example' && b.relation === 'owns_domain'));
433
+ provAlpha.bindings.some((b) => (b.type === 'resource' || b.type === 'domain') && b.value === 'alpha.example'));
434
434
 
435
435
  // D4: Unbound required resource fails when not dryRun
436
436
  const unboundBundle = JSON.parse(JSON.stringify(bundle));
437
437
  const provUnbound = provisionBundle(unboundBundle, {
438
438
  workspaceId: 'ws-unbound',
439
- parameters: { business_name: 'Unbound Fest' }, // domain omitted!
439
+ parameters: { business_name: 'Unbound Fest' }, // domain / resource binding omitted!
440
440
  dryRun: false,
441
441
  });
442
442
  check('unbound required resource fails provisioning when dryRun is false (D4)',
@@ -1135,6 +1135,44 @@ async function runCliSmokeConformance() {
1135
1135
  }
1136
1136
  check('ssss new starter vault files validate against the registry', files.length > 0 && failures.length === 0, JSON.stringify(failures));
1137
1137
 
1138
+ // A-008: an app-local extension scaffold must pass its own generated test.
1139
+ const extApp = path.join(tmp, 'ext-app');
1140
+ execFileSync('node', [path.join(__dirname, 'ssss.mjs'), 'new', extApp, '--extension', 'acme', '--no-git'], { stdio: 'ignore' });
1141
+ fs.mkdirSync(path.join(extApp, 'node_modules', '@gregiteen'), { recursive: true });
1142
+ fs.symlinkSync(path.resolve(__dirname, '..'), path.join(extApp, 'node_modules', '@gregiteen', 'ssss-cli'), 'dir');
1143
+ let extTestOk = true;
1144
+ let extTestDetail = '';
1145
+ try { execFileSync('node', ['--test'], { cwd: extApp, stdio: 'pipe' }); }
1146
+ catch (err) { extTestOk = false; extTestDetail = String(err.stdout || err.message).slice(-600); }
1147
+ check('ssss new --extension scaffolds an app whose own node --test passes', extTestOk, extTestDetail);
1148
+
1149
+ const badExt = (() => {
1150
+ try { execFileSync('node', [path.join(__dirname, 'ssss.mjs'), 'new', path.join(tmp, 'bad-ext'), '--extension', 'core', '--no-git'], { stdio: 'pipe' }); return false; }
1151
+ catch { return !fs.existsSync(path.join(tmp, 'bad-ext')); }
1152
+ })();
1153
+ check('ssss new --extension rejects reserved registry names before writing', badExt);
1154
+
1155
+ // ssss schema validate: core, scaffolded extension, bundle, and a failing input.
1156
+ const schemaValidate = (target, extra = []) => {
1157
+ try {
1158
+ return { code: 0, out: JSON.parse(execFileSync('node', [path.join(__dirname, 'ssss.mjs'), 'schema', 'validate', target, '--json', ...extra], { encoding: 'utf8' })) };
1159
+ } catch (err) {
1160
+ return { code: err.status, out: JSON.parse(err.stdout || '{}') };
1161
+ }
1162
+ };
1163
+ const coreValid = schemaValidate(path.join(REGISTRY_DIR, 'core.json'));
1164
+ const extValid = schemaValidate(path.join(extApp, 'registry', 'extensions', 'acme.json'));
1165
+ const bundleValid = schemaValidate(path.resolve(__dirname, '..', 'conformance', 'reference-bundle.ucw.json'));
1166
+ check('ssss schema validate accepts core, extension, and bundle documents',
1167
+ coreValid.code === 0 && coreValid.out.valid && extValid.code === 0 && extValid.out.valid &&
1168
+ bundleValid.code === 0 && bundleValid.out.valid && bundleValid.out.schema === 'bundle',
1169
+ JSON.stringify({ coreValid, extValid, bundleValid }).slice(0, 600));
1170
+ const badRegistryPath = path.join(tmp, 'bad-registry.json');
1171
+ fs.writeFileSync(badRegistryPath, JSON.stringify({ registry: 'acme', document_primitives: {}, typo_key: 1 }));
1172
+ const badValid = schemaValidate(badRegistryPath, ['--type', 'core']);
1173
+ check('ssss schema validate exits 1 with structured errors on an invalid registry',
1174
+ badValid.code === 1 && badValid.out.valid === false && badValid.out.errors.some((e) => e.path === 'typo_key'));
1175
+
1138
1176
  const semanticOutput = JSON.parse(execFileSync('node', [
1139
1177
  path.join(__dirname, 'ssss.mjs'),
1140
1178
  'semantic',
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Automatically synchronize and verify documentation after OpenWiki updates:
4
+ * 1. Updates overview.md command lists, topics, and primitive counts.
5
+ * 2. Ingests updated docs into OpenWiki knowledge graph.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const SCRIPTS_DIR = path.join(ROOT, 'scripts');
13
+ const HELP_DIR = path.join(ROOT, 'docs', 'help');
14
+ const OVERVIEW_FILE = path.join(HELP_DIR, 'overview.md');
15
+ const CORE_REGISTRY = path.join(ROOT, 'registry', 'core.json');
16
+
17
+ const commands = fs.readdirSync(SCRIPTS_DIR)
18
+ .filter((f) => f.startsWith('cmd-') && f.endsWith('.mjs'))
19
+ .map((f) => f.replace(/^cmd-/, '').replace(/\.mjs$/, ''))
20
+ .sort();
21
+
22
+ const helpTopics = fs.existsSync(HELP_DIR)
23
+ ? fs.readdirSync(HELP_DIR)
24
+ .filter((f) => f.endsWith('.md'))
25
+ .map((f) => f.replace(/\.md$/, ''))
26
+ .sort()
27
+ : [];
28
+
29
+ let corePrimitivesCount = 20;
30
+ let contractPrimitivesCount = 5;
31
+ if (fs.existsSync(CORE_REGISTRY)) {
32
+ const core = JSON.parse(fs.readFileSync(CORE_REGISTRY, 'utf8'));
33
+ corePrimitivesCount = Object.keys(core.document_primitives || {}).length;
34
+ contractPrimitivesCount = Object.keys(core.contract_primitives || {}).length;
35
+ }
36
+
37
+ if (fs.existsSync(OVERVIEW_FILE)) {
38
+ let content = fs.readFileSync(OVERVIEW_FILE, 'utf8');
39
+
40
+ // Update primitive counts
41
+ const regPattern = /\|\s*Core registry\s*\|\s*`registry\/core\.json`\s*\|\s*[^|]+\|/;
42
+ const regReplacement = `| Core registry | \`registry/core.json\` | ${corePrimitivesCount} document primitives + ${contractPrimitivesCount} contract primitives; semantic (§11.9), bundle (§16), and provisioning (§17) rules. |`;
43
+ content = content.replace(regPattern, regReplacement);
44
+
45
+ // Update help topic list
46
+ const helpSection = `Run \`ssss help <topic>\` for any of:\n\`${helpTopics.join('`, `')}\`.`;
47
+ content = content.replace(/Run `ssss help <topic>` for any of:\n[\s\S]*?(?=\n\nRun `ssss <command>)/, helpSection);
48
+
49
+ // Update command list
50
+ const cmdSection = `Run \`ssss <command> --help\` for: \`${commands.join('`, `')}\`.`;
51
+ content = content.replace(/Run `ssss <command> --help` for:[\s\S]*?(?=\n*$)/, cmdSection);
52
+
53
+ fs.writeFileSync(OVERVIEW_FILE, content.trim() + '\n', 'utf8');
54
+ }
55
+
56
+ console.log(`✓ Synchronized docs/help/overview.md: ${commands.length} commands, ${helpTopics.length} help topics, ${corePrimitivesCount} primitives.`);
@@ -269,7 +269,7 @@
269
269
  "classes": {
270
270
  "structural": "The reusable business model — sellable and templatable. Carries no operator- or customer-specific data. Examples: workflow, assistant, page, skill, model, rule, memory, migration, release.",
271
271
  "tenant_private": "The operator's private operational data — customers, transcripts, runtime work. NEVER leaves the workspace in a template or sale bundle; included only in an encrypted backup. Examples: conversation, run, task, conflict, and (via extension) profile / CRM contact / deal.",
272
- "resource_bound": "Requires a real external resource bound at provision time (a domain, phone number, mailbox, API connection). NEVER ships the seller's resource value; ships only a requirement declaration the buyer satisfies. Defined by extension registries (e.g. festech domain / phone_number / integration_connection)."
272
+ "resource_bound": "Requires a real external resource bound at provision time (a domain, phone number, mailbox, API connection). NEVER ships the seller's resource value; ships only a requirement declaration the buyer satisfies. Defined by extension registries (e.g. a domain, phone_number, or integration_connection primitive)."
273
273
  },
274
274
  "export_profiles": {
275
275
  "backup": { "includes": ["structural", "tenant_private", "resource_bound"], "note": "Complete fidelity copy. tenant_private MUST be encrypted at rest." },
package/src/bundle.mjs CHANGED
@@ -63,6 +63,7 @@ function requirementParameterKey(type, field) {
63
63
  function resourceFieldParameterAliases(type, field) {
64
64
  const aliases = [requirementParameterKey(type, field), field];
65
65
  if (type === 'domain' && field === 'domain_name') aliases.push('domain');
66
+ if (type === 'resource' && (field === 'binds' || field === 'name')) aliases.push('domain', 'resource_domain');
66
67
  if (type === 'phone_number' && field === 'phone_number') aliases.push('phone');
67
68
  return aliases;
68
69
  }
package/src/registry.mjs CHANGED
@@ -408,12 +408,19 @@ function loadExtensionDefaults(registryDir) {
408
408
  * `extensions/defaults.json` is loaded; failing that, every extension is loaded
409
409
  * and any duplicate name remains a hard error rather than a silent preference.
410
410
  *
411
- * @param {string} [registryDir] - directory containing core.json + extensions/
411
+ * @param {string} [registryDir] - directory containing core.json + extensions/, or an
412
+ * application `registry/` holding only extensions/ (the package core is used)
412
413
  * @param {{ only?: string[]|Set<string> }} [options]
413
414
  * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
414
415
  */
415
416
  export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR, options = {}) {
416
- const corePath = path.join(registryDir, 'core.json');
417
+ // Application repositories hold only `registry/extensions/`; the core always
418
+ // comes from this package (hosts never copy core.json). Fall back to the
419
+ // package core when the given directory has extensions but no core.json.
420
+ const ownCore = path.join(registryDir, 'core.json');
421
+ const corePath = fs.existsSync(ownCore) || !fs.existsSync(path.join(registryDir, 'extensions'))
422
+ ? ownCore
423
+ : path.join(DEFAULT_REGISTRY_DIR, 'core.json');
417
424
  const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
418
425
  const requested = options.only ? new Set(options.only) : null;
419
426
  const defaults = requested ? null : loadExtensionDefaults(registryDir);