@gregiteen/ssss-cli 0.7.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to the SSSS (Structured Semantic Syntax System) spec will be
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.1] - 2026-07-08
9
+ ### Fixed
10
+ - Reduced `resource_bound` primitives to requirement declarations in `template`
11
+ and `sale` exports, preventing seller-bound domain, phone, or integration
12
+ values from leaking into bundles.
13
+ - Rejected malformed bundles with missing `files` arrays and unknown
14
+ `required_extensions`.
15
+ - Preserved nested frontmatter maps and arrays during patch serialization.
16
+ - Corrected `ssss import --dry-run` accounting to report `would commit` counts
17
+ while leaving target vaults empty.
18
+ - Generated starter and reference task files now use the valid `pending` task
19
+ status.
20
+
21
+ ### Added
22
+ - Conformance coverage for resource-bound export reduction, strict bundle
23
+ validation, lease conflict/expiry/unreadable-state behavior, nested
24
+ frontmatter patching, `ssss new` scaffold validation, and dry-run import
25
+ behavior.
26
+ - `docs/help/leases.md` and completed `IMPROVEMENTS` project documentation.
27
+ - Full package content for the `push` skill so skill conformance passes.
28
+
8
29
  ## [0.7.0] - 2026-07-02
9
30
  ### Changed
10
31
  - **BREAKING: universal frontmatter enforcement** — every SSSS document primitive
package/README.md CHANGED
@@ -22,15 +22,15 @@ SSSS is the vendor-neutral standard and reference implementation shared by
22
22
  - **The `.ucw` bundle format** (§16) and **provisioning contract** (§17) — package a
23
23
  vault, then `export → provision → import` it into a fresh tenant, deterministically.
24
24
 
25
- This package, `@ssss/cli`, is **dependency-free**: a host needs neither Zod nor a YAML
25
+ This package, `@gregiteen/ssss-cli`, is **dependency-free**: a host needs neither Zod nor a YAML
26
26
  library to be conformant. The reference engine *is* the validator.
27
27
 
28
28
  ## Install
29
29
 
30
30
  ```bash
31
- npm install -g @ssss/cli # the `ssss` command
31
+ npm install -g @gregiteen/ssss-cli # the `ssss` command
32
32
  # or use it as a library:
33
- npm install @ssss/cli
33
+ npm install @gregiteen/ssss-cli
34
34
  ```
35
35
 
36
36
  Requires Node 18+. No runtime dependencies.
@@ -79,16 +79,16 @@ ssss import festival.ucw.json --vault ./new-tenant \
79
79
  | `ssss import <bundle>` | Replay a bundle/plan into a vault via the engine (idempotent). |
80
80
  | `ssss autolink [dir]` | Generate OKF wiki-links across a vault. |
81
81
  | `ssss conformance` | Run the canonical conformance suite (§12). |
82
- | `ssss help [topic]` | Local docs: `runtime`, `portability`, `bundle`, `provisioning`, … |
82
+ | `ssss help [topic]` | Local docs: `runtime`, `portability`, `bundle`, `provisioning`, `leases`, … |
83
83
 
84
84
  Run `ssss <command> --help` for flags, or `ssss help <topic>` for concepts.
85
85
 
86
86
  ## Use as a library
87
87
 
88
88
  ```js
89
- import { createEngine } from '@ssss/cli/engine';
90
- import { exportBundle, validateBundle, provisionBundle, importBundle } from '@ssss/cli/bundle';
91
- import { planWorkflowTrigger } from '@ssss/cli/runtime';
89
+ import { createEngine } from '@gregiteen/ssss-cli/engine';
90
+ import { exportBundle, validateBundle, provisionBundle, importBundle } from '@gregiteen/ssss-cli/bundle';
91
+ import { planWorkflowTrigger } from '@gregiteen/ssss-cli/runtime';
92
92
 
93
93
  const bundle = exportBundle('./my-vault', { profile: 'sale', name: 'Festival in a Box' });
94
94
  const { valid, errors } = validateBundle(bundle);
@@ -115,9 +115,9 @@ const runtimePlan = planWorkflowTrigger({
115
115
  });
116
116
  ```
117
117
 
118
- Exports: `@ssss/cli` / `@ssss/cli/engine` (Operation Contract engine),
119
- `@ssss/cli/bundle` (export/provision/import), `@ssss/cli/registry`,
120
- `@ssss/cli/runtime`, `@ssss/cli/frontmatter`.
118
+ Exports: `@gregiteen/ssss-cli` / `@gregiteen/ssss-cli/engine` (Operation Contract engine),
119
+ `@gregiteen/ssss-cli/bundle` (export/provision/import), `@gregiteen/ssss-cli/registry`,
120
+ `@gregiteen/ssss-cli/runtime`, `@gregiteen/ssss-cli/frontmatter`.
121
121
 
122
122
  ## Portability — why a vault is safe to sell
123
123
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.7.0
1
+ 0.7.1
@@ -31,12 +31,14 @@ npx ssss conformance --endpoint <url> --token <pat> # run fixtures against a l
31
31
 
32
32
  The runner validates that the fixture file is well-formed and that every
33
33
  `expected_response` is internally consistent with the envelope rules. With
34
- `--engine`, it replays every fixture through the reference engine (`src/engine.mjs`)
35
- checks workflow runtime planning (`src/runtime.mjs`, spec §11.8), and round-trips
36
- the reference bundle (validate → provision → import → re-import, spec §16/§17)
37
- against a temp vault — proving idempotency and that no `tenant_private` file ever
38
- lands. With `--endpoint`, it POSTs each fixture's `request` to a host's Operation
39
- Contract endpoint and diffs the response.
34
+ `--engine`, it replays every fixture through the reference engine (`src/engine.mjs`),
35
+ checks workflow runtime planning (`src/runtime.mjs`, spec §11.8), runs regression
36
+ coverage for patch serialization and leases (§6/§7), and round-trips the
37
+ reference bundle (validate → provision → import → re-import, spec §16/§17)
38
+ against a temp vault — proving idempotency, strict bundle shape validation, and
39
+ that no `tenant_private` or seller-bound `resource_bound` value ever lands. With
40
+ `--endpoint`, it POSTs each fixture's `request` to a host's Operation Contract
41
+ endpoint and diffs the response.
40
42
 
41
43
  ## Core vs. extension registry
42
44
 
@@ -39,6 +39,14 @@
39
39
  "system": "email",
40
40
  "mode": "provision",
41
41
  "required": false
42
+ },
43
+ {
44
+ "id": "bind-domain",
45
+ "label": "Bind domain",
46
+ "system": "domains",
47
+ "mode": "provision",
48
+ "required": true,
49
+ "notes": "owns_domain"
42
50
  }
43
51
  ],
44
52
  "parameters": [
@@ -66,11 +74,20 @@
66
74
  "scope": "workspace",
67
75
  "source": "user",
68
76
  "required": false
77
+ },
78
+ {
79
+ "key": "domain_registrar",
80
+ "label": "domain registrar",
81
+ "type": "string",
82
+ "scope": "workspace",
83
+ "source": "user",
84
+ "required": false,
85
+ "description": "Satisfies the domain resource requirement field 'registrar'."
69
86
  }
70
87
  ],
71
88
  "file_count": 14,
72
89
  "provenance": {
73
- "content_hash": "sha256:96380f618ee1e178331beca9507e1b3cd28a3d12cfa7f9898317f98da8295218",
90
+ "content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
74
91
  "exporter": "@ssss/cli@0.7.0"
75
92
  }
76
93
  },
@@ -85,7 +102,7 @@
85
102
  },
86
103
  {
87
104
  "path": "domains/festival.md",
88
- "content": "---\ntype: domain\ntitle: Festival Domain Requirement\ndescription: Domain resource requirement that the buyer binds during provisioning.\ntimestamp: 2026-07-02T00:00:00Z\ndomain_name: REQUIREMENT\nregistrar: REQUIREMENT\nstatus: required\npayment_status: unbound\nx_portability: resource_bound\n---\nThis festival needs a domain. The buyer binds their own at provision (owns_domain)."
105
+ "content": "---\ntype: domain\ntitle: Festival Domain Requirement\ndescription: Domain resource requirement that the buyer binds during provisioning.\ntimestamp: \"2026-07-02T00:00:00Z\"\ndomain_name: REQUIREMENT\nregistrar: REQUIREMENT\nstatus: required\npayment_status: unbound\nx_portability: resource_bound\n---\nThis festival needs a domain. The buyer binds their own at provision (owns_domain)."
89
106
  },
90
107
  {
91
108
  "path": "domains/index.md",
@@ -12,8 +12,10 @@ ssss import --plan plan.json --vault <dir> # replay a plan from `ssss prov
12
12
  - `--vault <dir>` is required and is created if missing.
13
13
  - Without `--plan`, import provisions inline first (resolving `--param` flags); a
14
14
  failed provision (unresolved params / dangling links) aborts before any write.
15
- - `--dry-run` validates every envelope without committing.
16
- - The summary reports `committed`, `unchanged (idempotent)`, and `failed` counts.
15
+ - `--dry-run` validates every envelope without committing and reports
16
+ `would commit` instead of `committed`.
17
+ - The normal summary reports `committed`, `unchanged (idempotent)`, and `failed`
18
+ counts.
17
19
 
18
20
  Run `ssss import --help` for the full flag list.
19
21
  See also: `ssss help provisioning`, `ssss provision`.
@@ -0,0 +1,31 @@
1
+ # Leases (spec §7)
2
+
3
+ Leases are file-level write locks checked by the Operation Contract before a
4
+ write commits. They protect paths that are being edited by another actor or host
5
+ process.
6
+
7
+ The reference engine expects lease state in a host-provided `leaseStore`:
8
+
9
+ ```text
10
+ <leaseStore>/<workspace_id>/<sha256(path)>.lease.json
11
+ ```
12
+
13
+ Each lease file contains:
14
+
15
+ ```json
16
+ {
17
+ "lease_id": "lease-123",
18
+ "expires_at": "2026-07-08T23:00:00.000Z"
19
+ }
20
+ ```
21
+
22
+ Behavior:
23
+
24
+ - If no lease exists, the write can continue.
25
+ - If a non-expired lease exists, the envelope must provide the matching
26
+ `lease_id`.
27
+ - A mismatched or missing `lease_id` fails validation before commit.
28
+ - Expired leases are removed and do not block the write.
29
+ - Unreadable lease state fails closed; the engine refuses to guess.
30
+
31
+ See also: `ssss help roles`, `ssss help conformance`.
@@ -4,7 +4,7 @@ The **Structured Semantic Syntax System** is a database-free, Markdown-first sch
4
4
  and mutation contract for AI-agent state. A vault is a directory of Markdown files
5
5
  with YAML frontmatter; every file declares a `type` defined in the registry.
6
6
 
7
- `@ssss/cli` is the reference implementation: a dependency-free Operation Contract
7
+ `@gregiteen/ssss-cli` is the reference implementation: a dependency-free Operation Contract
8
8
  engine, a registry-driven validator, and the `.ucw` bundle/provisioning tooling.
9
9
 
10
10
  ## The pieces
@@ -20,8 +20,16 @@ restrictive of (type default, override) wins.
20
20
  | `template` | structural + resource_bound | A reusable template, private data stripped. |
21
21
  | `sale` | structural + resource_bound | A tradeable business, private data stripped. |
22
22
 
23
- > `template` and `sale` exports MUST drop every `tenant_private` primitive. The
24
- > reference bundle (`conformance/reference-bundle.ucw.json`) proves this: its
25
- > `task` ticket is dropped from the `sale` export.
23
+ > `template` and `sale` exports MUST drop every `tenant_private` primitive and
24
+ > reduce every `resource_bound` primitive to a requirement declaration. The
25
+ > seller's actual domain, phone number, provider, or connection value must not
26
+ > appear in the bundle.
27
+
28
+ For resource-bound files, the reference exporter uses the extension registry's
29
+ `resource.binds` metadata to replace bound fields with `REQUIREMENT` and add
30
+ matching provisioning requirements. The reference bundle
31
+ (`conformance/reference-bundle.ucw.json`) proves both guarantees: its `task`
32
+ ticket is dropped from the `sale` export, and its `domain` file carries only
33
+ requirement placeholders.
26
34
 
27
35
  See also: `ssss help bundle`, `ssss help export`.
@@ -54,11 +54,11 @@ database canonical.
54
54
 
55
55
  ## Reference helpers
56
56
 
57
- Use `@ssss/cli/runtime` when building a host daemon:
57
+ Use `@gregiteen/ssss-cli/runtime` when building a host daemon:
58
58
 
59
59
  ```js
60
- import { createEngine } from '@ssss/cli/engine';
61
- import { planWorkflowTrigger } from '@ssss/cli/runtime';
60
+ import { createEngine } from '@gregiteen/ssss-cli/engine';
61
+ import { planWorkflowTrigger } from '@gregiteen/ssss-cli/runtime';
62
62
 
63
63
  const plan = planWorkflowTrigger({
64
64
  workflowPath: 'workflows/daily-digest/WORKFLOW.md',
@@ -13,7 +13,7 @@ ssss new my-app --with-total-recall --install
13
13
 
14
14
  ```
15
15
  my-app/
16
- package.json @ssss/cli pinned to this CLI's version tag; npm test = node --test
16
+ package.json @gregiteen/ssss-cli pinned to this CLI's version tag; npm test = node --test
17
17
  vault/ Starter SSSS documents:
18
18
  rules/welcome.md (structural)
19
19
  workflows/onboarding.md (structural, links [[welcome]])
@@ -38,7 +38,7 @@ provisions, and re-imports idempotently as a `sale` bundle (§16/§17) — with
38
38
  | `--with-total-recall` | Add `total-recall-brain` + memory wiring to CLAUDE.md/README. |
39
39
  | `--install` | Run `npm install`, Total Recall `init` (if requested), then `npm test`. |
40
40
  | `--no-git` | Skip `git init`. |
41
- | `--ref <git-ref>` | Pin `@ssss/cli` to a specific tag/branch/SHA (default: this CLI's version tag). |
41
+ | `--ref <git-ref>` | Pin `@gregiteen/ssss-cli` to a specific tag/branch/SHA (default: this CLI's version tag). |
42
42
  | `--force` | Scaffold into a non-empty directory. |
43
43
 
44
44
  Shelling out (git/npm/npx) uses `execFileSync` with fixed argument vectors — no shell,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregiteen/ssss-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "CLI tools and the reference engine for the Structured Semantic Syntax System",
5
5
  "main": "src/engine.mjs",
6
6
  "exports": {
@@ -104,7 +104,7 @@ description: Private support task for an attendee seat-change request.
104
104
  timestamp: 2026-07-02T00:00:00Z
105
105
  priority: high
106
106
  category: support
107
- status: open
107
+ status: pending
108
108
  ---
109
109
  Attendee jane@example.com requested a seat change.`,
110
110
  };
@@ -63,7 +63,11 @@ export async function run(argv) {
63
63
  const engine = createEngine({ registryDir: flags.registry });
64
64
  const result = importBundle(envelopes, vault, engine);
65
65
  const failed = result.results.filter((r) => !r.success);
66
- console.log(`${result.ok ? '✓' : '✗'} import — ${result.committed} committed, ${envelopes.length - result.committed} unchanged (idempotent), ${failed.length} failed → ${vault}`);
66
+ if (flags['dry-run']) {
67
+ console.log(`${result.ok ? '✓' : '✗'} import dry-run — ${result.wouldCommit || 0} would commit, ${envelopes.length - (result.wouldCommit || 0)} unchanged (idempotent), ${failed.length} failed → ${vault}`);
68
+ } else {
69
+ console.log(`${result.ok ? '✓' : '✗'} import — ${result.committed} committed, ${envelopes.length - result.committed} unchanged (idempotent), ${failed.length} failed → ${vault}`);
70
+ }
67
71
  if (failed.length) {
68
72
  for (const r of failed) console.error(` ✗ ${r.path}: ${(r.validation?.errors || []).join('; ')}`);
69
73
  process.exit(1);
@@ -2,7 +2,7 @@
2
2
  * `ssss new` — scaffold a fresh repo wired for SSSS (and, optionally, Total Recall).
3
3
  *
4
4
  * Produces a ready-to-run project: a starter `vault/` of core primitives, the
5
- * `@ssss/cli` dependency pinned to this CLI's own version tag, a dependency-free
5
+ * `@gregiteen/ssss-cli` dependency pinned to this CLI's own version tag, a dependency-free
6
6
  * conformance test (`node --test`) that replays the canonical fixtures through the
7
7
  * engine AND round-trips the vault as a `sale` bundle, plus a CLAUDE.md describing
8
8
  * the conventions. With `--with-total-recall` it also wires the Total Recall
@@ -31,7 +31,7 @@ const HELP = usage({
31
31
  ['--with-total-recall', 'Also wire the Total Recall memory OS (total-recall-brain).'],
32
32
  ['--install', 'Run `npm install` after scaffolding (and Total Recall init if requested).'],
33
33
  ['--no-git', 'Do not run `git init`.'],
34
- ['--ref <git-ref>', `Pin @ssss/cli to this ref (default: v${VERSION}).`],
34
+ ['--ref <git-ref>', `Pin @gregiteen/ssss-cli to this ref (default: v${VERSION}).`],
35
35
  ['--force', 'Allow scaffolding into a non-empty directory.'],
36
36
  ],
37
37
  examples: [
@@ -81,7 +81,7 @@ export async function run(argv) {
81
81
  export: 'ssss export vault --profile sale --out dist/bundle.ucw.json',
82
82
  validate: 'ssss conformance --engine',
83
83
  },
84
- dependencies: { '@ssss/cli': `github:gregiteen/ssss#${ref}` },
84
+ dependencies: { '@gregiteen/ssss-cli': `github:gregiteen/ssss#${ref}` },
85
85
  ...(withTR ? { devDependencies: { 'total-recall-brain': 'latest' } } : {}),
86
86
  };
87
87
  W(root, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
@@ -94,7 +94,7 @@ export async function run(argv) {
94
94
  W(root, 'vault/assistants/concierge.md',
95
95
  '---\ntype: assistant\ntitle: Concierge\ndescription: Starter assistant definition for the project.\ntimestamp: 2026-07-02T00:00:00Z\nname: Concierge\n---\n\n# Concierge\n\nA starter assistant definition.\n');
96
96
  W(root, 'vault/tasks/first-task.md',
97
- '---\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: open\n---\n\n# First task\n\nThis is `tenant_private` (§5.5) — it is **dropped** from `template`/`sale` exports.\n');
97
+ '---\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
98
 
99
99
  // ── conformance test (zero-dependency: node --test) ─────────────────────
100
100
  W(root, 'test/ssss-conformance.test.mjs', CONFORMANCE_TEST);
@@ -107,7 +107,7 @@ export async function run(argv) {
107
107
  // ── git + install (best-effort, flag-gated, no shell) ───────────────────
108
108
  const log = (m) => console.error(m);
109
109
  log(`Scaffolded SSSS project → ${root}`);
110
- log(` @ssss/cli pinned to github:gregiteen/ssss#${ref}`);
110
+ log(` @gregiteen/ssss-cli pinned to github:gregiteen/ssss#${ref}`);
111
111
  if (withTR) log(' Total Recall wired (total-recall-brain).');
112
112
 
113
113
  if (!flags['no-git'] && !fs.existsSync(path.join(root, '.git'))) {
@@ -153,11 +153,11 @@ import fs from 'node:fs';
153
153
  import os from 'node:os';
154
154
  import path from 'node:path';
155
155
  import { fileURLToPath } from 'node:url';
156
- import { createEngine } from '@ssss/cli/engine';
157
- import { exportBundle, validateBundle, provisionBundle, importBundle } from '@ssss/cli/bundle';
156
+ import { createEngine } from '@gregiteen/ssss-cli/engine';
157
+ import { exportBundle, validateBundle, provisionBundle, importBundle } from '@gregiteen/ssss-cli/bundle';
158
158
 
159
159
  const require = createRequire(import.meta.url);
160
- const { fixtures } = require('@ssss/cli/conformance/fixtures.json');
160
+ const { fixtures } = require('@gregiteen/ssss-cli/conformance/fixtures.json');
161
161
  const VAULT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'vault');
162
162
 
163
163
  test('engine implements the canonical Operation Contract (spec §6)', () => {
@@ -199,7 +199,7 @@ function readme(name, withTR) {
199
199
  return `# ${name}
200
200
 
201
201
  An [SSSS](https://github.com/gregiteen/ssss)-native project: a Markdown **vault** is
202
- the source of truth, validated and packaged by the dependency-free \`@ssss/cli\`.
202
+ the source of truth, validated and packaged by the dependency-free \`@gregiteen/ssss-cli\`.
203
203
 
204
204
  ## Layout
205
205
 
@@ -240,9 +240,9 @@ This repo uses **SSSS** (Structured Semantic Syntax System) as its state contrac
240
240
 
241
241
  ## Source of truth
242
242
  - \`vault/\` is the only source of truth — typed Markdown documents with YAML
243
- frontmatter. Every file declares a \`type\` defined in the \`@ssss/cli\` core registry.
243
+ frontmatter. Every file declares a \`type\` defined in the \`@gregiteen/ssss-cli\` core registry.
244
244
  - Mutate the vault through the standard, not by hand-writing files in bulk:
245
- \`npx ssss\` for bundle/tenant tooling, or the \`@ssss/cli/engine\` Operation Contract
245
+ \`npx ssss\` for bundle/tenant tooling, or the \`@gregiteen/ssss-cli/engine\` Operation Contract
246
246
  in code.
247
247
 
248
248
  ## Portability (§5.5) — the rule that matters
@@ -14,6 +14,8 @@
14
14
  import fs from 'node:fs';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
+ import crypto from 'node:crypto';
18
+ import { execFileSync } from 'node:child_process';
17
19
  import { fileURLToPath } from 'node:url';
18
20
  import { createEngine } from '../src/engine.mjs';
19
21
  import { validateBundle, provisionBundle, importBundle } from '../src/bundle.mjs';
@@ -194,6 +196,26 @@ function runBundleConformance() {
194
196
  check('reference bundle is schema- and hash-valid', valid, errors.join('; '));
195
197
  check('sale profile carries no tenant_private file',
196
198
  bundle.files.every((f) => parseDocument(f.content).data.type !== 'task' && parseDocument(f.content).data.type !== 'conversation'));
199
+ check('sale profile reduces resource_bound fields to requirement declarations',
200
+ bundle.files.every((f) => {
201
+ const data = parseDocument(f.content).data;
202
+ if (data.type !== 'domain') return true;
203
+ return data.domain_name === 'REQUIREMENT' && data.registrar === 'REQUIREMENT';
204
+ }));
205
+
206
+ const missingFilesBundle = JSON.parse(JSON.stringify(bundle));
207
+ delete missingFilesBundle.files;
208
+ const missingFilesValidation = validateBundle(missingFilesBundle, { registryDir: REGISTRY_DIR });
209
+ check('bundle validation rejects missing files array',
210
+ !missingFilesValidation.valid && missingFilesValidation.errors.some((e) => e.includes('bundle.files must be an array')),
211
+ missingFilesValidation.errors.join('; '));
212
+
213
+ const unknownExtensionBundle = JSON.parse(JSON.stringify(bundle));
214
+ unknownExtensionBundle.manifest.required_extensions = ['missing-extension'];
215
+ const unknownExtensionValidation = validateBundle(unknownExtensionBundle, { registryDir: REGISTRY_DIR });
216
+ check('bundle validation rejects unknown required_extensions entries',
217
+ !unknownExtensionValidation.valid && unknownExtensionValidation.errors.some((e) => e.includes("required extension 'missing-extension'")),
218
+ unknownExtensionValidation.errors.join('; '));
197
219
 
198
220
  const prov = provisionBundle(bundle, { workspaceId: 'ws-conf', parameters: { business_name: 'Demo', domain: 'demo.example' } });
199
221
  check('provision resolves params + link integrity (no dangling [[links]])', prov.ok,
@@ -225,6 +247,180 @@ function runBundleConformance() {
225
247
  return pass === checks.length;
226
248
  }
227
249
 
250
+ function runOperationContractRegressionConformance() {
251
+ const checks = [];
252
+ const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
253
+
254
+ const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-op-reg-'));
255
+ const leaseStore = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-lease-'));
256
+ try {
257
+ const engine = createEngine({ registryDir: REGISTRY_DIR, leaseStore });
258
+ const workflowPath = 'workflows/nested.md';
259
+ const createRes = engine.processOperation({
260
+ type: 'operation',
261
+ idempotency_key: 'nested-create',
262
+ workspace_id: 'ws-reg',
263
+ path: workflowPath,
264
+ actor: { role: 'system' },
265
+ content: [
266
+ '---',
267
+ 'type: workflow',
268
+ 'title: Nested Workflow',
269
+ 'description: Exercises nested frontmatter patch serialization.',
270
+ 'timestamp: 2026-07-02T00:00:00Z',
271
+ 'name: Nested Workflow',
272
+ 'triggers:',
273
+ ' - type: cron',
274
+ ' id: morning',
275
+ ' cron: "0 8 * * *"',
276
+ ' timezone: "America/Denver"',
277
+ '---',
278
+ '',
279
+ 'Body.',
280
+ '',
281
+ ].join('\n'),
282
+ }, vault);
283
+ check('nested frontmatter fixture commits before patch', createRes.success === true, JSON.stringify(createRes.validation?.errors || []));
284
+
285
+ const patchRes = engine.processOperation({
286
+ type: 'patch',
287
+ idempotency_key: 'nested-patch',
288
+ workspace_id: 'ws-reg',
289
+ path: workflowPath,
290
+ actor: { role: 'system' },
291
+ patches: { description: 'Patched without dropping triggers.' },
292
+ }, vault);
293
+ const patched = fs.readFileSync(path.join(vault, workflowPath), 'utf8');
294
+ const patchedData = parseDocument(patched).data;
295
+ check('patch preserves nested frontmatter arrays of maps',
296
+ patchRes.success === true && Array.isArray(patchedData.triggers) && patchedData.triggers[0]?.cron === '0 8 * * *',
297
+ patched);
298
+
299
+ const leaseTarget = 'rules/leased.md';
300
+ const leasePathKey = crypto.createHash('sha256').update(leaseTarget).digest('hex');
301
+ const leaseDir = path.join(leaseStore, 'ws-reg');
302
+ fs.mkdirSync(leaseDir, { recursive: true });
303
+ fs.writeFileSync(path.join(leaseDir, `${leasePathKey}.lease.json`), JSON.stringify({
304
+ lease_id: 'lease-ok',
305
+ expires_at: '2999-01-01T00:00:00.000Z',
306
+ }));
307
+ const leasedEnvelope = {
308
+ type: 'operation',
309
+ idempotency_key: 'lease-missing',
310
+ workspace_id: 'ws-reg',
311
+ path: leaseTarget,
312
+ actor: { role: 'system' },
313
+ content: '---\ntype: rule\ntitle: Leased\ndescription: Lease test.\ntimestamp: 2026-07-02T00:00:00Z\nname: Leased\n---\nBody.\n',
314
+ };
315
+ const missingLease = engine.processOperation(leasedEnvelope, vault);
316
+ check('lease conflict rejects missing lease_id', missingLease.success === false && missingLease.validation.errors.some((e) => e.includes('leased')));
317
+
318
+ const mismatchLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-mismatch', lease_id: 'wrong' }, vault);
319
+ check('lease conflict rejects mismatched lease_id', mismatchLease.success === false && mismatchLease.validation.errors.some((e) => e.includes('Lease mismatch')));
320
+
321
+ const matchedLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-match', lease_id: 'lease-ok' }, vault);
322
+ check('lease allows matching lease_id', matchedLease.success === true, JSON.stringify(matchedLease.validation?.errors || []));
323
+
324
+ const expiredTarget = 'rules/expired.md';
325
+ const expiredPathKey = crypto.createHash('sha256').update(expiredTarget).digest('hex');
326
+ fs.writeFileSync(path.join(leaseDir, `${expiredPathKey}.lease.json`), JSON.stringify({
327
+ lease_id: 'expired',
328
+ expires_at: '2000-01-01T00:00:00.000Z',
329
+ }));
330
+ const expiredLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-expired', path: expiredTarget }, vault);
331
+ check('expired lease does not block writes', expiredLease.success === true, JSON.stringify(expiredLease.validation?.errors || []));
332
+
333
+ const unreadableTarget = 'rules/unreadable.md';
334
+ const unreadablePathKey = crypto.createHash('sha256').update(unreadableTarget).digest('hex');
335
+ fs.writeFileSync(path.join(leaseDir, `${unreadablePathKey}.lease.json`), '{not json');
336
+ const unreadableLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-unreadable', path: unreadableTarget }, vault);
337
+ check('unreadable lease state fails closed', unreadableLease.success === false && unreadableLease.validation.errors.some((e) => e.includes('unreadable')));
338
+ } finally {
339
+ fs.rmSync(vault, { recursive: true, force: true });
340
+ fs.rmSync(leaseStore, { recursive: true, force: true });
341
+ }
342
+
343
+ let pass = 0;
344
+ for (const c of checks) {
345
+ if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
346
+ else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
347
+ }
348
+ console.log(`\n ${pass}/${checks.length} operation regression checks passed (§6/§7)`);
349
+ return pass === checks.length;
350
+ }
351
+
352
+ function runCliSmokeConformance() {
353
+ const checks = [];
354
+ const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
355
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-cli-smoke-'));
356
+ try {
357
+ const starter = path.join(tmp, 'starter');
358
+ execFileSync('node', [path.join(__dirname, 'ssss.mjs'), 'new', starter, '--no-git'], { stdio: 'ignore' });
359
+ const vault = path.join(starter, 'vault');
360
+ const files = [];
361
+ (function walk(dir) {
362
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
363
+ const abs = path.join(dir, entry.name);
364
+ if (entry.isDirectory()) walk(abs);
365
+ else if (entry.name.endsWith('.md')) files.push(abs);
366
+ }
367
+ })(vault);
368
+ const engine = createEngine({ registryDir: REGISTRY_DIR });
369
+ const failures = [];
370
+ for (const abs of files) {
371
+ const rel = path.relative(vault, abs);
372
+ const res = engine.processOperation({
373
+ type: 'operation',
374
+ idempotency_key: `scaffold-${rel.replace(/[^A-Za-z0-9._-]/g, '_')}`,
375
+ workspace_id: 'ws-scaffold',
376
+ path: rel,
377
+ content: fs.readFileSync(abs, 'utf8'),
378
+ dry_run: true,
379
+ actor: { role: 'system' },
380
+ }, vault);
381
+ if (!res.success) failures.push({ rel, errors: res.validation?.errors || [] });
382
+ }
383
+ check('ssss new starter vault files validate against the registry', files.length > 0 && failures.length === 0, JSON.stringify(failures));
384
+
385
+ const dryRunVault = path.join(tmp, 'dry-run-vault');
386
+ const dryRunOut = execFileSync('node', [
387
+ path.join(__dirname, 'ssss.mjs'),
388
+ 'import',
389
+ REFERENCE_BUNDLE,
390
+ '--vault',
391
+ dryRunVault,
392
+ '--param',
393
+ 'business_name=Demo',
394
+ '--param',
395
+ 'domain=demo.example',
396
+ '--dry-run',
397
+ ], { encoding: 'utf8' });
398
+ const dryRunFiles = [];
399
+ if (fs.existsSync(dryRunVault)) {
400
+ (function walkDryRun(dir) {
401
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
402
+ const abs = path.join(dir, entry.name);
403
+ if (entry.isDirectory()) walkDryRun(abs);
404
+ else if (entry.isFile()) dryRunFiles.push(abs);
405
+ }
406
+ })(dryRunVault);
407
+ }
408
+ check('ssss import --dry-run reports would-commit counts and leaves target empty',
409
+ dryRunOut.includes('would commit') && dryRunFiles.length === 0,
410
+ dryRunOut.trim());
411
+ } finally {
412
+ fs.rmSync(tmp, { recursive: true, force: true });
413
+ }
414
+
415
+ let pass = 0;
416
+ for (const c of checks) {
417
+ if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
418
+ else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
419
+ }
420
+ console.log(`\n ${pass}/${checks.length} CLI smoke checks passed`);
421
+ return pass === checks.length;
422
+ }
423
+
228
424
  /**
229
425
  * Runtime conformance (spec §11.8). Proves that a conformant daemon can treat
230
426
  * workflow frontmatter as the canonical schedule source, then derive trigger,
@@ -395,9 +591,13 @@ async function main() {
395
591
  const engineOk = runAgainstEngine(doc);
396
592
  console.log('\nRunning workflow runtime conformance (src/runtime.mjs, §11.8) ...');
397
593
  const runtimeOk = runRuntimeConformance();
594
+ console.log('\nRunning operation regression conformance (src/engine.mjs, §6/§7) ...');
595
+ const operationRegressionOk = runOperationContractRegressionConformance();
398
596
  console.log('\nRunning bundle/provisioning conformance (src/bundle.mjs, §16/§17) ...');
399
597
  const bundleOk = runBundleConformance();
400
- process.exit(engineOk && runtimeOk && bundleOk ? 0 : 1);
598
+ console.log('\nRunning CLI smoke conformance (scripts/ssss.mjs) ...');
599
+ const cliSmokeOk = runCliSmokeConformance();
600
+ process.exit(engineOk && runtimeOk && operationRegressionOk && bundleOk && cliSmokeOk ? 0 : 1);
401
601
  } else {
402
602
  console.log('\nℹ No --endpoint/--engine given; ran structural + registry validation only.');
403
603
  console.log(' Reference engine: ssss conformance --engine');
package/scripts/ssss.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @ssss/cli dispatcher — the `ssss` command.
3
+ * @gregiteen/ssss-cli dispatcher — the `ssss` command.
4
4
  *
5
5
  * Routes the first positional argument to a subcommand. The bundle/provisioning
6
6
  * commands (export/validate/inspect/provision/import/help) export a `run(argv)`
@@ -21,7 +21,7 @@ const VERSION = (() => {
21
21
  catch { return '0.0.0'; }
22
22
  })();
23
23
 
24
- const TOP_LEVEL_HELP = `@ssss/cli v${VERSION} — Structured Semantic Syntax System
24
+ const TOP_LEVEL_HELP = `@gregiteen/ssss-cli v${VERSION} — Structured Semantic Syntax System
25
25
 
26
26
  Usage: ssss <command> [options]
27
27
 
package/src/bundle.mjs CHANGED
@@ -17,7 +17,7 @@ import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import crypto from 'node:crypto';
19
19
  import { loadRegistries, resolvePortability } from './registry.mjs';
20
- import { parseDocument } from './frontmatter.mjs';
20
+ import { parseDocument, serializeDocument } from './frontmatter.mjs';
21
21
 
22
22
  const PROFILE_INCLUDES = {
23
23
  backup: new Set(['structural', 'tenant_private', 'resource_bound']),
@@ -27,6 +27,13 @@ const PROFILE_INCLUDES = {
27
27
  const STEP_SYSTEMS = new Set(['workspace', 'branding', 'email', 'phone', 'domains', 'accounts', 'deployments', 'marketplace', 'tabs', 'automation']);
28
28
  const STEP_MODES = new Set(['existing', 'provision', 'install', 'generate', 'configure']);
29
29
  const PARAM_SOURCES = new Set(['user', 'llm', 'graph', 'system']);
30
+ const RESOURCE_SYSTEM_BY_KIND = {
31
+ api_connection: 'automation',
32
+ domain: 'domains',
33
+ email: 'email',
34
+ mailbox: 'email',
35
+ phone: 'phone',
36
+ };
30
37
 
31
38
  function isSafeBundlePath(p) {
32
39
  if (typeof p !== 'string' || !p || p.includes('\0')) return false;
@@ -41,6 +48,62 @@ function canonicalFiles(files) {
41
48
  return sorted.map((f) => ({ path: f.path, content: f.content }));
42
49
  }
43
50
 
51
+ function requirementParameterKey(type, field) {
52
+ return `${type}_${field}`.replace(/[^A-Za-z0-9_]+/g, '_');
53
+ }
54
+
55
+ function resourceFieldParameterAliases(type, field) {
56
+ const aliases = [requirementParameterKey(type, field), field];
57
+ if (type === 'domain' && field === 'domain_name') aliases.push('domain');
58
+ if (type === 'phone_number' && field === 'phone_number') aliases.push('phone');
59
+ return aliases;
60
+ }
61
+
62
+ function ensureRequirementDeclarations(manifest, typeDef, resourceFields) {
63
+ const type = typeDef.type;
64
+ const resource = typeDef.resource || {};
65
+ const existingParams = new Set((manifest.parameters || []).map((p) => p.key));
66
+ for (const field of resourceFields) {
67
+ if (resourceFieldParameterAliases(type, field).some((key) => existingParams.has(key))) continue;
68
+ const key = requirementParameterKey(type, field);
69
+ if (!existingParams.has(key)) {
70
+ manifest.parameters.push({
71
+ key,
72
+ label: `${resource.kind || type} ${field}`.replace(/_/g, ' '),
73
+ type: 'string',
74
+ scope: 'workspace',
75
+ source: 'user',
76
+ required: false,
77
+ description: `Satisfies the ${type} resource requirement field '${field}'.`,
78
+ });
79
+ existingParams.add(key);
80
+ }
81
+ }
82
+
83
+ const stepId = `bind-${type}`.replace(/[^A-Za-z0-9_-]+/g, '-');
84
+ if (!(manifest.provisioning || []).some((s) => s.id === stepId)) {
85
+ manifest.provisioning.push({
86
+ id: stepId,
87
+ label: `Bind ${resource.kind || type}`,
88
+ system: RESOURCE_SYSTEM_BY_KIND[resource.kind] || 'accounts',
89
+ mode: 'provision',
90
+ required: true,
91
+ notes: resource.provision_relation || 'resource_bound requirement',
92
+ });
93
+ }
94
+ }
95
+
96
+ function reduceResourceBoundFile(file, typeDef, profile) {
97
+ const binds = typeDef?.resource?.binds;
98
+ if (!Array.isArray(binds) || binds.length === 0) {
99
+ throw new Error(`Cannot export resource_bound '${file.path}' in ${profile}: registry type '${typeDef?.type || '(unknown)'}' does not declare resource.binds.`);
100
+ }
101
+ const { data, body } = parseDocument(file.content);
102
+ const reduced = { ...data, x_portability: 'resource_bound' };
103
+ for (const field of binds) reduced[field] = 'REQUIREMENT';
104
+ return { path: file.path, content: serializeDocument(reduced, body), resourceFields: binds };
105
+ }
106
+
44
107
  /** sha256 over the canonical file serialization (§16.3). */
45
108
  export function contentHash(files) {
46
109
  const pre = JSON.stringify(canonicalFiles(files));
@@ -75,10 +138,13 @@ export function exportBundle(vaultRoot, opts = {}) {
75
138
  const includes = PROFILE_INCLUDES[profile];
76
139
  if (!includes) throw new Error(`Unknown export profile '${profile}'. Use backup | template | sale.`);
77
140
 
78
- const { types } = loadRegistries(registryDir);
141
+ const { types, core } = loadRegistries(registryDir);
79
142
  const dropped = [];
80
143
  const kept = [];
81
144
  const inventory = {};
145
+ const manifestParameters = [...parameters];
146
+ const manifestProvisioning = [...provisioning];
147
+ const manifestDraft = { parameters: manifestParameters, provisioning: manifestProvisioning };
82
148
 
83
149
  for (const file of walkVault(vaultRoot)) {
84
150
  const { data } = parseDocument(file.content);
@@ -86,7 +152,13 @@ export function exportBundle(vaultRoot, opts = {}) {
86
152
  const typeDef = types.get(type);
87
153
  const klass = resolvePortability(typeDef, data);
88
154
  if (!includes.has(klass)) { dropped.push({ path: file.path, type, portability: klass }); continue; }
89
- kept.push(file);
155
+ if ((profile === 'sale' || profile === 'template') && klass === 'resource_bound') {
156
+ const reduced = reduceResourceBoundFile(file, typeDef, profile);
157
+ kept.push({ path: reduced.path, content: reduced.content });
158
+ ensureRequirementDeclarations(manifestDraft, typeDef, reduced.resourceFields);
159
+ } else {
160
+ kept.push(file);
161
+ }
90
162
  if (type) inventory[type] = (inventory[type] || 0) + 1;
91
163
  }
92
164
 
@@ -94,12 +166,12 @@ export function exportBundle(vaultRoot, opts = {}) {
94
166
  const manifest = {
95
167
  name, description, version,
96
168
  exported_at: exportedAt,
97
- ssss_core_version: loadRegistries(registryDir).core.spec_version,
169
+ ssss_core_version: core.spec_version,
98
170
  required_extensions: requiredExtensions,
99
171
  export_profile: profile,
100
172
  primitive_inventory: inventory,
101
- provisioning,
102
- parameters,
173
+ provisioning: manifestProvisioning,
174
+ parameters: manifestParameters,
103
175
  file_count: files.length,
104
176
  provenance: { content_hash: contentHash(files), exporter },
105
177
  };
@@ -114,22 +186,27 @@ export function exportBundle(vaultRoot, opts = {}) {
114
186
  */
115
187
  export function validateBundle(bundle, opts = {}) {
116
188
  const errors = [];
117
- const { types, core } = loadRegistries(opts.registryDir);
189
+ const { types, core, extensions } = loadRegistries(opts.registryDir);
118
190
  const m = bundle && bundle.manifest;
119
191
  if (!m) return { valid: false, errors: ['bundle has no manifest'] };
192
+ const files = Array.isArray(bundle.files) ? bundle.files : [];
120
193
 
121
194
  for (const f of core.bundle.manifest_required_fields) {
122
195
  if (m[f] === undefined || m[f] === null) errors.push(`manifest missing required field '${f}'`);
123
196
  }
197
+ if (!Array.isArray(bundle.files)) errors.push('bundle.files must be an array');
198
+ for (const ext of m.required_extensions || []) {
199
+ if (!extensions.has(ext)) errors.push(`required extension '${ext}' is not loaded`);
200
+ }
124
201
  if (!PROFILE_INCLUDES[m.export_profile]) errors.push(`invalid export_profile '${m.export_profile}'`);
125
202
  if (m.ssss_core_version && m.ssss_core_version !== core.spec_version) {
126
203
  errors.push(`bundle targets ssss_core_version ${m.ssss_core_version}; host is ${core.spec_version}`);
127
204
  }
128
- if (Array.isArray(bundle.files) && m.file_count !== bundle.files.length) {
129
- errors.push(`file_count ${m.file_count} ≠ actual ${bundle.files.length}`);
205
+ if (m.file_count !== files.length) {
206
+ errors.push(`file_count ${m.file_count} ≠ actual ${files.length}`);
130
207
  }
131
208
  const seenPaths = new Set();
132
- for (const file of bundle.files || []) {
209
+ for (const file of files) {
133
210
  if (!isSafeBundlePath(file.path)) {
134
211
  errors.push(`file '${file.path || '(missing)'}' has an unsafe bundle path`);
135
212
  continue;
@@ -142,7 +219,7 @@ export function validateBundle(bundle, opts = {}) {
142
219
  for (const f of core.bundle.provenance_required_fields) {
143
220
  if (m.provenance[f] === undefined) errors.push(`provenance missing '${f}'`);
144
221
  }
145
- const recomputed = contentHash(bundle.files || []);
222
+ const recomputed = contentHash(files);
146
223
  if (m.provenance.content_hash && m.provenance.content_hash !== recomputed) {
147
224
  errors.push(`content_hash mismatch: manifest ${m.provenance.content_hash} ≠ recomputed ${recomputed}`);
148
225
  }
@@ -150,7 +227,7 @@ export function validateBundle(bundle, opts = {}) {
150
227
  // Portability conformance: no file may exceed what the profile permits (§5.5).
151
228
  const includes = PROFILE_INCLUDES[m.export_profile];
152
229
  if (includes) {
153
- for (const file of bundle.files || []) {
230
+ for (const file of files) {
154
231
  if (!isSafeBundlePath(file.path)) continue;
155
232
  const base = path.basename(file.path || '');
156
233
  if (base === 'index.md' || base === 'log.md') continue;
@@ -175,6 +252,17 @@ export function validateBundle(bundle, opts = {}) {
175
252
  if (!includes.has(klass)) {
176
253
  errors.push(`file '${file.path}' is ${klass}, excluded by profile '${m.export_profile}'`);
177
254
  }
255
+ if ((m.export_profile === 'sale' || m.export_profile === 'template') && klass === 'resource_bound') {
256
+ const binds = def.resource?.binds || [];
257
+ if (!binds.length) {
258
+ errors.push(`file '${file.path}' is resource_bound but registry type '${data.type}' declares no resource.binds`);
259
+ }
260
+ for (const field of binds) {
261
+ if (data[field] !== 'REQUIREMENT') {
262
+ errors.push(`file '${file.path}' leaks resource_bound field '${field}' in ${m.export_profile} bundle`);
263
+ }
264
+ }
265
+ }
178
266
  }
179
267
  }
180
268
  // Provisioning step + parameter vocabularies (§17.2 / §16.5).
@@ -256,6 +344,7 @@ export function provisionBundle(bundle, opts = {}) {
256
344
  export function importBundle(plan, vaultRoot, engine) {
257
345
  const results = [];
258
346
  for (const env of plan) results.push(engine.processOperation(env, vaultRoot));
259
- const committed = results.filter((r) => r.success && !r.replay).length;
260
- return { ok: results.every((r) => r.success), committed, results };
347
+ const committed = results.filter((r) => r.success && !r.replay && !r.dry_run).length;
348
+ const wouldCommit = results.filter((r) => r.success && !r.replay && r.dry_run).length;
349
+ return { ok: results.every((r) => r.success), committed, wouldCommit, results };
261
350
  }
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * SSSS files are Markdown with a leading `---`-fenced YAML frontmatter block
5
5
  * (spec §4.1). The canonical engine avoids a YAML dependency: it parses the
6
- * subset SSSS actually uses — top-level scalars, inline `[a, b]` arrays, block
7
- * `- item` arrays, and block scalars — which is sufficient to resolve a file's
8
- * `type` and validate field presence against the registry (§5, §9).
6
+ * subset SSSS actually uses — scalars, inline `[a, b]` arrays, block `- item`
7
+ * arrays, nested maps, and arrays of shallow maps — which is sufficient to
8
+ * resolve a file's `type`, validate registry fields (§5, §9), and round-trip
9
+ * operation patches without dropping nested frontmatter.
9
10
  */
10
11
 
11
12
  /** Split a document into its raw frontmatter block and body. */
@@ -42,43 +43,107 @@ function coerce(raw) {
42
43
  return v;
43
44
  }
44
45
 
45
- /**
46
- * Parse a frontmatter block (the inner YAML, no fences) into an object.
47
- * Returns top-level keys only; nested maps are returned as `{}` placeholders
48
- * (presence is what the registry validator needs).
49
- */
50
- export function parseFrontmatter(fm) {
46
+ function countIndent(line) {
47
+ const m = line.match(/^ */);
48
+ return m ? m[0].length : 0;
49
+ }
50
+
51
+ function splitKeyValue(text) {
52
+ const m = text.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
53
+ return m ? { key: m[1], rest: m[2] } : null;
54
+ }
55
+
56
+ function nextSignificant(lines, start) {
57
+ for (let i = start; i < lines.length; i++) {
58
+ if (lines[i].trim() && !lines[i].trim().startsWith('#')) return i;
59
+ }
60
+ return -1;
61
+ }
62
+
63
+ function parseMap(lines, start, indent) {
51
64
  const data = {};
52
- if (!fm) return data;
53
- const lines = fm.split('\n');
54
- for (let i = 0; i < lines.length; i++) {
65
+ let i = start;
66
+ while (i < lines.length) {
55
67
  const line = lines[i];
56
- if (!line.trim() || line.trim().startsWith('#')) continue;
57
- if (/^\s/.test(line)) continue; // sub-entry of a block handled below
58
- const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
59
- if (!m) continue;
60
- const key = m[1];
61
- const rest = m[2];
62
- if (rest === '') {
63
- // Could open a block list or a nested map. Peek ahead.
64
- const items = [];
65
- let isList = false;
66
- let j = i + 1;
67
- for (; j < lines.length; j++) {
68
- const sub = lines[j];
69
- if (!sub.trim()) continue;
70
- if (!/^\s/.test(sub)) break; // dedent → block ended
71
- const li = sub.match(/^\s*-\s*(.*)$/);
72
- if (li) { isList = true; items.push(coerce(li[1])); }
73
- else break; // nested map; we only need presence
68
+ if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
69
+ const currentIndent = countIndent(line);
70
+ if (currentIndent < indent) break;
71
+ if (currentIndent > indent) { i++; continue; }
72
+ const kv = splitKeyValue(line.slice(indent));
73
+ if (!kv) { i++; continue; }
74
+ if (kv.rest !== '') {
75
+ data[kv.key] = coerce(kv.rest);
76
+ i++;
77
+ continue;
78
+ }
79
+
80
+ const next = nextSignificant(lines, i + 1);
81
+ if (next < 0 || countIndent(lines[next]) <= indent) {
82
+ data[kv.key] = {};
83
+ i++;
84
+ continue;
85
+ }
86
+ const childIndent = countIndent(lines[next]);
87
+ if (lines[next].slice(childIndent).startsWith('- ')) {
88
+ const parsed = parseList(lines, next, childIndent);
89
+ data[kv.key] = parsed.value;
90
+ i = parsed.next;
91
+ } else {
92
+ const parsed = parseMap(lines, next, childIndent);
93
+ data[kv.key] = parsed.value;
94
+ i = parsed.next;
95
+ }
96
+ }
97
+ return { value: data, next: i };
98
+ }
99
+
100
+ function parseList(lines, start, indent) {
101
+ const items = [];
102
+ let i = start;
103
+ while (i < lines.length) {
104
+ const line = lines[i];
105
+ if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
106
+ const currentIndent = countIndent(line);
107
+ if (currentIndent < indent) break;
108
+ if (currentIndent > indent) { i++; continue; }
109
+ const item = line.slice(indent).match(/^-\s*(.*)$/);
110
+ if (!item) break;
111
+ const raw = item[1];
112
+ const kv = splitKeyValue(raw);
113
+ if (kv) {
114
+ const obj = { [kv.key]: kv.rest === '' ? {} : coerce(kv.rest) };
115
+ i++;
116
+ const next = nextSignificant(lines, i);
117
+ if (next >= 0 && countIndent(lines[next]) > indent) {
118
+ const parsed = parseMap(lines, next, countIndent(lines[next]));
119
+ Object.assign(obj, parsed.value);
120
+ i = parsed.next;
121
+ }
122
+ items.push(obj);
123
+ } else if (raw === '') {
124
+ const next = nextSignificant(lines, i + 1);
125
+ if (next >= 0 && countIndent(lines[next]) > indent) {
126
+ const parsed = parseMap(lines, next, countIndent(lines[next]));
127
+ items.push(parsed.value);
128
+ i = parsed.next;
129
+ } else {
130
+ items.push('');
131
+ i++;
74
132
  }
75
- data[key] = isList ? items : {};
76
- i = isList ? j - 1 : i;
77
133
  } else {
78
- data[key] = coerce(rest);
134
+ items.push(coerce(raw));
135
+ i++;
79
136
  }
80
137
  }
81
- return data;
138
+ return { value: items, next: i };
139
+ }
140
+
141
+ /**
142
+ * Parse a frontmatter block (the inner YAML, no fences) into an object.
143
+ */
144
+ export function parseFrontmatter(fm) {
145
+ if (!fm) return {};
146
+ return parseMap(fm.split('\n'), 0, 0).value;
82
147
  }
83
148
 
84
149
  /** Parse a full document (with fences) into { data, body }. */
@@ -96,13 +161,51 @@ function serializeValue(v) {
96
161
  return s;
97
162
  }
98
163
 
164
+ function serializeEntry(lines, key, value, indent = 0) {
165
+ const pad = ' '.repeat(indent);
166
+ if (Array.isArray(value)) {
167
+ if (!value.length) {
168
+ lines.push(`${pad}${key}: []`);
169
+ return;
170
+ }
171
+ lines.push(`${pad}${key}:`);
172
+ for (const item of value) {
173
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
174
+ const entries = Object.entries(item);
175
+ if (!entries.length) {
176
+ lines.push(`${pad} - {}`);
177
+ continue;
178
+ }
179
+ const [first, ...rest] = entries;
180
+ if (first[1] && typeof first[1] === 'object' && !Array.isArray(first[1])) {
181
+ lines.push(`${pad} - ${first[0]}:`);
182
+ serializeObject(lines, first[1], indent + 6);
183
+ } else {
184
+ lines.push(`${pad} - ${first[0]}: ${serializeValue(first[1])}`);
185
+ }
186
+ for (const [childKey, childValue] of rest) serializeEntry(lines, childKey, childValue, indent + 4);
187
+ } else {
188
+ lines.push(`${pad} - ${serializeValue(item)}`);
189
+ }
190
+ }
191
+ return;
192
+ }
193
+ if (value && typeof value === 'object') {
194
+ lines.push(`${pad}${key}:`);
195
+ serializeObject(lines, value, indent + 2);
196
+ return;
197
+ }
198
+ lines.push(`${pad}${key}: ${serializeValue(value)}`);
199
+ }
200
+
201
+ function serializeObject(lines, obj, indent) {
202
+ for (const [k, v] of Object.entries(obj)) serializeEntry(lines, k, v, indent);
203
+ }
204
+
99
205
  /** Serialize a frontmatter object + body back into an SSSS document. */
100
206
  export function serializeDocument(data, body) {
101
207
  const lines = ['---'];
102
- for (const [k, v] of Object.entries(data)) {
103
- if (v && typeof v === 'object' && !Array.isArray(v)) continue; // skip nested maps we can't round-trip
104
- lines.push(`${k}: ${serializeValue(v)}`);
105
- }
208
+ serializeObject(lines, data, 0);
106
209
  lines.push('---');
107
210
  const b = body == null ? '' : (body.startsWith('\n') ? body : `\n${body}`);
108
211
  return lines.join('\n') + b;
package/src/registry.mjs CHANGED
@@ -20,11 +20,12 @@ const APPEND_TYPES = new Set(['conversation', 'run']);
20
20
  /**
21
21
  * Load the core registry and merge extension registries.
22
22
  * @param {string} [registryDir] - directory containing core.json + extensions/
23
- * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object }}
23
+ * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
24
24
  */
25
25
  export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
26
26
  const corePath = path.join(registryDir, 'core.json');
27
27
  const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
28
+ const extensions = new Set();
28
29
 
29
30
  const types = new Map();
30
31
  for (const [name, def] of Object.entries(core.document_primitives || {})) {
@@ -35,6 +36,7 @@ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
35
36
  if (fs.existsSync(extDir)) {
36
37
  for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json'))) {
37
38
  const ext = JSON.parse(fs.readFileSync(path.join(extDir, file), 'utf8'));
39
+ if (ext.registry) extensions.add(ext.registry);
38
40
  for (const [name, def] of Object.entries(ext.document_primitives || {})) {
39
41
  if (types.has(name) && types.get(name).registry === 'core') continue; // core wins; extensions MUST NOT redefine
40
42
  types.set(name, { ...def, type: name, registry: ext.registry || file });
@@ -44,7 +46,7 @@ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
44
46
 
45
47
  const contractTypes = new Set(Object.keys(core.contract_primitives || {}));
46
48
  const portabilityClasses = Object.keys(core.portability?.classes || {});
47
- return { types, contractTypes, portabilityClasses, core };
49
+ return { types, contractTypes, portabilityClasses, core, extensions };
48
50
  }
49
51
 
50
52
  /** Is this document type append-only (events appended, never rewritten)? */