@gregiteen/ssss-cli 0.7.0

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 (43) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/LICENSE +15 -0
  3. package/README.md +162 -0
  4. package/VERSION +1 -0
  5. package/conformance/README.md +72 -0
  6. package/conformance/fixtures.json +763 -0
  7. package/conformance/reference-bundle.ucw.json +135 -0
  8. package/docs/help/autolink.md +15 -0
  9. package/docs/help/bundle.md +47 -0
  10. package/docs/help/conformance.md +34 -0
  11. package/docs/help/export.md +20 -0
  12. package/docs/help/import.md +19 -0
  13. package/docs/help/overview.md +33 -0
  14. package/docs/help/portability.md +27 -0
  15. package/docs/help/provision.md +19 -0
  16. package/docs/help/provisioning.md +43 -0
  17. package/docs/help/roles.md +61 -0
  18. package/docs/help/runtime.md +78 -0
  19. package/docs/help/scaffold.md +47 -0
  20. package/docs/ssss-spec.md +1325 -0
  21. package/package.json +62 -0
  22. package/registry/core.json +225 -0
  23. package/registry/extensions/festech.json +134 -0
  24. package/scripts/audit-registry-field-usage.mjs +136 -0
  25. package/scripts/autolink.mjs +341 -0
  26. package/scripts/build-reference-bundle.mjs +169 -0
  27. package/scripts/cmd-export.mjs +72 -0
  28. package/scripts/cmd-help.mjs +60 -0
  29. package/scripts/cmd-import.mjs +71 -0
  30. package/scripts/cmd-inspect.mjs +78 -0
  31. package/scripts/cmd-new.mjs +262 -0
  32. package/scripts/cmd-provision.mjs +68 -0
  33. package/scripts/cmd-validate.mjs +43 -0
  34. package/scripts/conformance.mjs +408 -0
  35. package/scripts/lib/cli.mjs +92 -0
  36. package/scripts/release.sh +35 -0
  37. package/scripts/ssss.mjs +93 -0
  38. package/scripts/validate-skills.mjs +115 -0
  39. package/src/bundle.mjs +261 -0
  40. package/src/engine.mjs +452 -0
  41. package/src/frontmatter.mjs +109 -0
  42. package/src/registry.mjs +67 -0
  43. package/src/runtime.mjs +212 -0
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ssss autolink — OKF wiki-autolinker for an SSSS vault.
4
+ *
5
+ * Two layers, by design (mirrors the SSSS thesis: semantics enriches at
6
+ * authoring time; the deterministic contract is never touched):
7
+ *
8
+ * 1. Deterministic core (default, no network): links exact surface forms —
9
+ * every primitive's `title`, `aliases`, `name`, and `slug` — using
10
+ * absolute bundle-relative paths (spec §4.3). Code spans/blocks, existing
11
+ * links, headings, and URLs are protected. First occurrence per target
12
+ * only. Idempotent: re-running adds nothing.
13
+ *
14
+ * 2. Semantic layer (--semantic, uses the Anthropic API): links by MEANING —
15
+ * a body that talks about "writing files atomically" links to the
16
+ * `prefer-atomic-writes` memory even with no exact title match. This is the
17
+ * core SSSS promise (meaning over surface tokens) applied to linking.
18
+ *
19
+ * SAFE BY DEFAULT: prints a plan and writes nothing unless --write is given.
20
+ *
21
+ * Usage:
22
+ * ssss autolink [dir] [--write] [--semantic] [--model <id>]
23
+ * [--max-per-target N] [--min-len N] [--index]
24
+ * [--include-headings] [--concurrency N]
25
+ */
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ // ─── args ────────────────────────────────────────────────────────────────────
31
+ function parseArgs() {
32
+ let a = process.argv.slice(2);
33
+ if (a[0] === 'autolink') a = a.slice(1); // reached via the `ssss` dispatcher
34
+ const o = {
35
+ dir: process.cwd(), write: false, semantic: false,
36
+ model: 'claude-haiku-4-5', maxPerTarget: 1, minLen: 3,
37
+ index: false, includeHeadings: false, concurrency: 4
38
+ };
39
+ const pos = [];
40
+ for (let i = 0; i < a.length; i++) {
41
+ const x = a[i];
42
+ if (x === '--write') o.write = true;
43
+ else if (x === '--semantic') o.semantic = true;
44
+ else if (x === '--index') o.index = true;
45
+ else if (x === '--include-headings') o.includeHeadings = true;
46
+ else if (x === '--model') o.model = a[++i];
47
+ else if (x === '--max-per-target') o.maxPerTarget = parseInt(a[++i], 10);
48
+ else if (x === '--min-len') o.minLen = parseInt(a[++i], 10);
49
+ else if (x === '--concurrency') o.concurrency = parseInt(a[++i], 10);
50
+ else if (!x.startsWith('--')) pos.push(x);
51
+ }
52
+ if (pos[0]) o.dir = path.resolve(pos[0]);
53
+ return o;
54
+ }
55
+
56
+ // Common words we never auto-link even if they appear as a single-word title.
57
+ const STOPWORDS = new Set([
58
+ 'type', 'name', 'status', 'task', 'run', 'model', 'rule', 'page', 'event',
59
+ 'memory', 'skill', 'note', 'data', 'index', 'title', 'tags', 'the', 'and'
60
+ ]);
61
+
62
+ // ─── vault walk + glossary ───────────────────────────────────────────────────
63
+ function walk(dir, out = []) {
64
+ if (!fs.existsSync(dir)) return out;
65
+ for (const name of fs.readdirSync(dir)) {
66
+ if (name === 'node_modules' || name === '.git' || name.startsWith('.')) continue;
67
+ const p = path.join(dir, name);
68
+ const st = fs.lstatSync(p);
69
+ if (st.isSymbolicLink()) continue;
70
+ if (st.isDirectory()) walk(p, out);
71
+ else if (p.endsWith('.md')) out.push(p);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function splitFrontmatter(content) {
77
+ if (!content.startsWith('---')) return { fm: '', body: content, hasFm: false };
78
+ const end = content.indexOf('\n---', 3);
79
+ if (end < 0) return { fm: '', body: content, hasFm: false };
80
+ const fmEnd = content.indexOf('\n', end + 1);
81
+ return {
82
+ fm: content.slice(0, fmEnd + 1),
83
+ body: content.slice(fmEnd + 1),
84
+ hasFm: true
85
+ };
86
+ }
87
+
88
+ /** Pull title / name / slug / aliases[] out of a YAML frontmatter block (no dep). */
89
+ function readMeta(fm) {
90
+ const scalar = (k) => {
91
+ const m = fm.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
92
+ return m ? m[1].trim().replace(/^["']|["']$/g, '') : null;
93
+ };
94
+ const meta = { title: scalar('title'), name: scalar('name'), slug: scalar('slug'), aliases: [] };
95
+ // aliases: inline [a, b] or YAML block list
96
+ const inline = fm.match(/^aliases:\s*\[(.*)\]\s*$/m);
97
+ if (inline) meta.aliases = inline[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
98
+ else {
99
+ const block = fm.match(/^aliases:\s*\n((?:\s*-\s*.+\n?)+)/m);
100
+ if (block) meta.aliases = block[1].split('\n').map(l => l.replace(/^\s*-\s*/, '').trim().replace(/^["']|["']$/g, '')).filter(Boolean);
101
+ }
102
+ return meta;
103
+ }
104
+
105
+ function buildGlossary(files, root) {
106
+ const entries = [];
107
+ for (const file of files) {
108
+ const { fm } = splitFrontmatter(fs.readFileSync(file, 'utf8'));
109
+ if (!fm) continue;
110
+ const meta = readMeta(fm);
111
+ const target = '/' + path.relative(root, file).split(path.sep).join('/');
112
+ const surfaces = new Set();
113
+ for (const s of [meta.title, meta.name, ...meta.aliases]) {
114
+ if (s && s.length >= 1) surfaces.add(s);
115
+ }
116
+ if (surfaces.size === 0) continue;
117
+ entries.push({ file, target, surfaces: [...surfaces] });
118
+ }
119
+ return entries;
120
+ }
121
+
122
+ // ─── deterministic linking ───────────────────────────────────────────────────
123
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
124
+
125
+ /** Replace term matches in a single plain-text chunk (no code/links inside). */
126
+ function linkChunk(chunk, regex, byForm, selfTarget, used, opts, stats) {
127
+ return chunk.replace(regex, (m) => {
128
+ const entry = byForm.get(m.toLowerCase());
129
+ if (!entry || entry.target === selfTarget) return m;
130
+ const count = used.get(entry.target) || 0;
131
+ if (count >= opts.maxPerTarget) return m;
132
+ used.set(entry.target, count + 1);
133
+ stats.links++;
134
+ return `[${m}](${entry.target})`;
135
+ });
136
+ }
137
+
138
+ /** Link a line of body text, protecting inline code and existing links. */
139
+ function linkLine(line, regex, byForm, selfTarget, used, opts, stats) {
140
+ // Split out protected spans: `inline code`, [text](url), [[wikilink]], <url>
141
+ const protect = /(`[^`]*`|\[[^\]]*\]\([^)]*\)|\[\[[^\]]*\]\]|<[^>]+>|https?:\/\/\S+)/g;
142
+ let out = '', last = 0, m;
143
+ while ((m = protect.exec(line)) !== null) {
144
+ out += linkChunk(line.slice(last, m.index), regex, byForm, selfTarget, used, opts, stats);
145
+ out += m[0]; // protected span, untouched
146
+ last = m.index + m[0].length;
147
+ }
148
+ out += linkChunk(line.slice(last), regex, byForm, selfTarget, used, opts, stats);
149
+ return out;
150
+ }
151
+
152
+ function linkBody(body, glossary, selfTarget, opts, stats) {
153
+ // Build a longest-first surface→entry map and one combined regex.
154
+ const byForm = new Map();
155
+ const forms = [];
156
+ for (const e of glossary) {
157
+ for (const s of e.surfaces) {
158
+ const key = s.toLowerCase();
159
+ const single = !/\s/.test(s);
160
+ if (single && (s.length < opts.minLen || STOPWORDS.has(key))) continue;
161
+ if (!byForm.has(key)) { byForm.set(key, e); forms.push(s); }
162
+ }
163
+ }
164
+ if (forms.length === 0) return body;
165
+ forms.sort((a, b) => b.length - a.length);
166
+ const regex = new RegExp(`\\b(${forms.map(esc).join('|')})\\b`, 'gi');
167
+
168
+ // Seed the per-target budget from links already present so re-runs are
169
+ // idempotent: a target that already has maxPerTarget links gets no more.
170
+ const used = new Map();
171
+ for (const m of body.matchAll(/\]\((\/[^)]+)\)/g)) {
172
+ used.set(m[1], (used.get(m[1]) || 0) + 1);
173
+ }
174
+
175
+ const lines = body.split('\n');
176
+ let inFence = false;
177
+ for (let i = 0; i < lines.length; i++) {
178
+ const t = lines[i].trimStart();
179
+ if (t.startsWith('```') || t.startsWith('~~~')) { inFence = !inFence; continue; }
180
+ if (inFence) continue;
181
+ if (!opts.includeHeadings && /^#{1,6}\s/.test(t)) continue;
182
+ lines[i] = linkLine(lines[i], regex, byForm, selfTarget, used, opts, stats);
183
+ }
184
+ return lines.join('\n');
185
+ }
186
+
187
+ // ─── semantic linking (Anthropic API, optional) ──────────────────────────────
188
+ async function semanticLinks(body, glossary, selfTarget, opts) {
189
+ const key = process.env.ANTHROPIC_API_KEY;
190
+ if (!key) return [];
191
+ const items = glossary.filter(e => e.target !== selfTarget)
192
+ .map(e => ({ path: e.target, names: e.surfaces.slice(0, 4) }));
193
+ const sys = 'You insert wiki links into Markdown. You are given a glossary of concepts (each with a path and names) and a document body. Find places where the body references a glossary concept BY MEANING — including paraphrases and synonyms, not just exact name matches. Return ONLY a JSON array of {"path","anchor"} where "anchor" is an EXACT substring of the body to turn into a link to "path". Rules: never link inside code; never link the document to its own path; pick natural noun-phrase anchors; max one anchor per concept; prefer precision over recall. Return [] if nothing fits.';
194
+ const user = `GLOSSARY:\n${JSON.stringify(items)}\n\nDOCUMENT BODY:\n${body.slice(0, 8000)}`;
195
+ try {
196
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
197
+ method: 'POST',
198
+ headers: { 'content-type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01' },
199
+ body: JSON.stringify({ model: opts.model, max_tokens: 1024, system: sys, messages: [{ role: 'user', content: user }] })
200
+ });
201
+ if (!res.ok) { console.error(` ⚠ semantic API ${res.status}: ${(await res.text()).slice(0, 120)}`); return []; }
202
+ const data = await res.json();
203
+ const text = (data.content || []).map(c => c.text || '').join('');
204
+ const json = text.slice(text.indexOf('['), text.lastIndexOf(']') + 1);
205
+ const out = JSON.parse(json);
206
+ return Array.isArray(out) ? out.filter(x => x && x.path && x.anchor) : [];
207
+ } catch (err) {
208
+ console.error(` ⚠ semantic link failed: ${err.message}`);
209
+ return [];
210
+ }
211
+ }
212
+
213
+ /** Apply semantic anchors as links to the first plain-text occurrence of each. */
214
+ function applySemantic(body, links, selfTarget, stats) {
215
+ let inFence = false;
216
+ const lines = body.split('\n');
217
+ // Seed from existing links so a path already linked is not linked again (idempotent).
218
+ const done = new Set();
219
+ for (const m of body.matchAll(/\]\((\/[^)]+)\)/g)) done.add(m[1]);
220
+ for (let i = 0; i < lines.length; i++) {
221
+ const t = lines[i].trimStart();
222
+ if (t.startsWith('```') || t.startsWith('~~~')) { inFence = !inFence; continue; }
223
+ if (inFence) continue;
224
+ for (const l of links) {
225
+ if (done.has(l.path) || l.path === selfTarget) continue;
226
+ const idx = lines[i].indexOf(l.anchor);
227
+ if (idx < 0) continue;
228
+ // Don't relink inside an existing link/code span on this line.
229
+ const before = lines[i].slice(0, idx);
230
+ if ((before.match(/`/g) || []).length % 2 === 1) continue;
231
+ if (/\]\([^)]*$/.test(before) || /\[[^\]]*$/.test(before)) continue;
232
+ lines[i] = before + `[${l.anchor}](${l.path})` + lines[i].slice(idx + l.anchor.length);
233
+ done.add(l.path); stats.semantic++;
234
+ }
235
+ }
236
+ return lines.join('\n');
237
+ }
238
+
239
+ // ─── index.md generation (spec §4.3) ─────────────────────────────────────────
240
+ /**
241
+ * Generate one index.md per directory for progressive disclosure.
242
+ *
243
+ * Covers every ANCESTOR directory of every tracked file (not just directories
244
+ * that directly contain a .md file) — a nested primitive like
245
+ * `roles/admin/ROLE.md` needs `roles/admin/index.md` (listing ROLE.md) AND
246
+ * `roles/index.md` (listing the `admin/` subdirectory) for the chain to be
247
+ * navigable end to end. Subdirectories that themselves get an index are
248
+ * listed as `[name/](./name/index.md)` entries alongside sibling files.
249
+ */
250
+ export function generateIndexes(files, root, opts, summary) {
251
+ const dirs = new Set();
252
+ for (const f of files) {
253
+ if (path.basename(f) === 'index.md') continue;
254
+ // Stop before `root` itself: a bundle-relative path of bare "index.md"
255
+ // (no leading directory) doesn't match the engine's `*/index.md` special
256
+ // case and would fail normal frontmatter validation. Vault-root indexing
257
+ // is intentionally out of scope here, matching prior behavior.
258
+ let d = path.dirname(f);
259
+ while (d !== root) {
260
+ dirs.add(d);
261
+ const parent = path.dirname(d);
262
+ if (parent === d) break;
263
+ d = parent;
264
+ }
265
+ }
266
+
267
+ for (const dir of dirs) {
268
+ const lines = [`# Index — ${path.relative(root, dir) || '.'}`, ''];
269
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
270
+ .filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
271
+ .sort((a, b) => a.name.localeCompare(b.name));
272
+ for (const e of entries) {
273
+ if (e.isSymbolicLink()) continue;
274
+ if (e.isDirectory()) {
275
+ if (dirs.has(path.join(dir, e.name))) lines.push(`- [${e.name}/](./${e.name}/index.md)`);
276
+ } else if (e.name.endsWith('.md') && e.name !== 'index.md') {
277
+ const { fm } = splitFrontmatter(fs.readFileSync(path.join(dir, e.name), 'utf8'));
278
+ const meta = readMeta(fm);
279
+ const label = meta.title || meta.name || path.basename(e.name, '.md');
280
+ lines.push(`- [${label}](./${e.name})`);
281
+ }
282
+ }
283
+ const target = path.join(dir, 'index.md');
284
+ summary.indexes.push(path.relative(root, target));
285
+ if (opts.write) fs.writeFileSync(target, lines.join('\n') + '\n', 'utf8');
286
+ }
287
+ }
288
+
289
+ // ─── main ────────────────────────────────────────────────────────────────────
290
+ export async function main() {
291
+ const opts = parseArgs();
292
+ console.log(`\n=== ssss autolink ===`);
293
+ console.log(` dir: ${opts.dir}`);
294
+ console.log(` mode: ${opts.write ? 'WRITE' : 'dry-run (use --write to apply)'}${opts.semantic ? ' + semantic AI' : ''}\n`);
295
+
296
+ const files = walk(opts.dir);
297
+ if (files.length === 0) { console.log('No .md files found.'); return; }
298
+ const glossary = buildGlossary(files, opts.dir);
299
+ console.log(` ${files.length} files scanned · ${glossary.length} linkable concepts in glossary`);
300
+ if (opts.semantic && !process.env.ANTHROPIC_API_KEY) {
301
+ console.log(' ⚠ --semantic given but ANTHROPIC_API_KEY is not set; running deterministic only.\n');
302
+ }
303
+
304
+ const summary = { changed: 0, links: 0, semantic: 0, indexes: [] };
305
+ // Limit concurrency for the semantic API calls.
306
+ const queue = [...files];
307
+ async function worker() {
308
+ while (queue.length) {
309
+ const file = queue.shift();
310
+ const original = fs.readFileSync(file, 'utf8');
311
+ const { fm, body } = splitFrontmatter(original);
312
+ const selfTarget = '/' + path.relative(opts.dir, file).split(path.sep).join('/');
313
+ const stats = { links: 0, semantic: 0 };
314
+ let newBody = linkBody(body, glossary, selfTarget, opts, stats);
315
+ if (opts.semantic && process.env.ANTHROPIC_API_KEY) {
316
+ const links = await semanticLinks(newBody, glossary, selfTarget, opts);
317
+ if (links.length) newBody = applySemantic(newBody, links, selfTarget, stats);
318
+ }
319
+ if (stats.links + stats.semantic > 0) {
320
+ summary.changed++; summary.links += stats.links; summary.semantic += stats.semantic;
321
+ const rel = path.relative(opts.dir, file);
322
+ console.log(` ~ ${rel} (+${stats.links} exact${stats.semantic ? `, +${stats.semantic} semantic` : ''})`);
323
+ if (opts.write) fs.writeFileSync(file, fm + newBody, 'utf8');
324
+ }
325
+ }
326
+ }
327
+ await Promise.all(Array.from({ length: Math.max(1, opts.semantic ? opts.concurrency : 1) }, worker));
328
+
329
+ if (opts.index) generateIndexes(files, opts.dir, opts, summary);
330
+
331
+ console.log(`\n ${summary.changed} files ${opts.write ? 'updated' : 'would change'} · ${summary.links} exact + ${summary.semantic} semantic links` +
332
+ (opts.index ? ` · ${summary.indexes.length} index.md` : ''));
333
+ if (!opts.write && summary.changed > 0) console.log(` Re-run with --write to apply.`);
334
+ }
335
+
336
+ // Only self-execute when run directly (`node scripts/autolink.mjs ...` or the
337
+ // `ssss.mjs` dispatcher explicitly calling `main()`), never as a side effect
338
+ // of another module importing `generateIndexes` for reuse.
339
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
340
+ main();
341
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build the canonical reference `.ucw` bundle (spec §16, Track G — "Festival in a
4
+ * Box"). Writes conformance/reference-bundle.ucw.json deterministically so the
5
+ * content hash is stable and the bundle is a fixed conformance artifact.
6
+ *
7
+ * The reference vault deliberately contains all three portability classes so the
8
+ * exported `sale` bundle proves the keystone (§5.5):
9
+ * - structural → INCLUDED (the sellable festival model)
10
+ * - resource_bound → INCLUDED as a requirement declaration (buyer binds a domain)
11
+ * - tenant_private → DROPPED (the operator's ticket records never ship)
12
+ */
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { exportBundle, validateBundle } from '../src/bundle.mjs';
18
+ import { generateIndexes } from './autolink.mjs';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const OUT = path.resolve(__dirname, '..', 'conformance', 'reference-bundle.ucw.json');
22
+
23
+ // ─── The reference vault ────────────────────────────────────────────────────
24
+ const VAULT = {
25
+ // structural — the festival's reusable model
26
+ 'workflows/box-office.md':
27
+ `---
28
+ type: workflow
29
+ title: Box Office
30
+ description: Ticket-sales workflow for a music festival.
31
+ timestamp: 2026-07-02T00:00:00Z
32
+ name: Box Office
33
+ slug: box-office
34
+ ---
35
+ Sell tickets, then hand the buyer to [[concierge]] for questions.`,
36
+ 'assistants/concierge.md':
37
+ `---
38
+ type: assistant
39
+ title: Festival Concierge
40
+ description: Assistant that answers attendee questions about the lineup and venue.
41
+ timestamp: 2026-07-02T00:00:00Z
42
+ name: Festival Concierge
43
+ slug: concierge
44
+ ---
45
+ Answer attendee questions about the lineup and venue.`,
46
+ 'pages/lineup.md':
47
+ `---
48
+ type: page
49
+ title: Lineup
50
+ description: Public-facing festival lineup page.
51
+ timestamp: 2026-07-02T00:00:00Z
52
+ slug: lineup
53
+ name: Lineup
54
+ sandbox_entry: lineup/index.html
55
+ ---
56
+ The festival lineup page.`,
57
+ 'rules/refund-policy.md':
58
+ `---
59
+ type: rule
60
+ title: Refund Policy
61
+ description: Refund eligibility rule for festival attendees.
62
+ timestamp: 2026-07-02T00:00:00Z
63
+ name: Refund Policy
64
+ slug: refund-policy
65
+ ---
66
+ Refunds are available up to 14 days before the event.`,
67
+ 'roles/admin/ROLE.md':
68
+ `---
69
+ type: security_role
70
+ title: Administrator Role
71
+ description: Security role with full festival operations write access.
72
+ timestamp: 2026-07-02T00:00:00Z
73
+ name: Administrator
74
+ permissions:
75
+ - "write:assistant"
76
+ - "write:workflow"
77
+ - "write:rule"
78
+ - "write:domain"
79
+ - "write:page"
80
+ - "write:security_role"
81
+ - "write:task"
82
+ ---
83
+ Full access to all festival operations.`,
84
+ // resource_bound — declares "this business needs a domain"; the SELLER's domain never ships
85
+ 'domains/festival.md':
86
+ `---
87
+ type: domain
88
+ title: Festival Domain Requirement
89
+ description: Domain resource requirement that the buyer binds during provisioning.
90
+ timestamp: 2026-07-02T00:00:00Z
91
+ domain_name: REQUIREMENT
92
+ registrar: REQUIREMENT
93
+ status: required
94
+ payment_status: unbound
95
+ x_portability: resource_bound
96
+ ---
97
+ This festival needs a domain. The buyer binds their own at provision (owns_domain).`,
98
+ // tenant_private — operator's ticket record; MUST be dropped from a sale bundle
99
+ 'tasks/ticket-4821.md':
100
+ `---
101
+ type: task
102
+ title: Ticket 4821 Seat Change
103
+ description: Private support task for an attendee seat-change request.
104
+ timestamp: 2026-07-02T00:00:00Z
105
+ priority: high
106
+ category: support
107
+ status: open
108
+ ---
109
+ Attendee jane@example.com requested a seat change.`,
110
+ };
111
+
112
+ function buildVault() {
113
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-refvault-'));
114
+ const files = [];
115
+
116
+ for (const [rel, content] of Object.entries(VAULT)) {
117
+ const abs = path.join(root, rel);
118
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
119
+ fs.writeFileSync(abs, content);
120
+ files.push(abs);
121
+ }
122
+
123
+ // Generate index.md for progressive disclosure (§4.3) — reuses the same
124
+ // generator `ssss autolink --index` uses, so there is one implementation.
125
+ generateIndexes(files, root, { write: true }, { indexes: [] });
126
+
127
+ return root;
128
+ }
129
+
130
+ const vault = buildVault();
131
+ try {
132
+ const bundle = exportBundle(vault, {
133
+ profile: 'sale',
134
+ name: 'Festival in a Box',
135
+ description: 'A complete music-festival operation as a tradeable SSSS bundle.',
136
+ version: '1.0.0',
137
+ exporter: '@ssss/cli@0.7.0',
138
+ requiredExtensions: ['festech'],
139
+ parameters: [
140
+ { key: 'business_name', label: 'Festival name', type: 'string', scope: 'workspace', source: 'user', required: true },
141
+ { key: 'domain', label: 'Domain to bind', type: 'string', scope: 'workspace', source: 'user', required: true, description: 'Satisfies the resource_bound domain requirement (owns_domain).' },
142
+ { key: 'support_mailbox', label: 'Support mailbox', type: 'string', scope: 'workspace', source: 'user', required: false },
143
+ ],
144
+ provisioning: [
145
+ { id: 'ws', label: 'Create the workspace', system: 'workspace', mode: 'generate', required: true },
146
+ { id: 'dom', label: 'Bind the festival domain', system: 'domains', mode: 'provision', required: true, notes: 'owns_domain — buyer supplies their own domain.' },
147
+ { id: 'mail', label: 'Provision a support mailbox', system: 'email', mode: 'provision', required: false },
148
+ ],
149
+ });
150
+
151
+ // Drop the internal export trace before persisting the artifact.
152
+ const dropped = bundle._dropped;
153
+ delete bundle._dropped;
154
+
155
+ const { valid, errors } = validateBundle(bundle);
156
+ if (!valid) {
157
+ console.error('❌ Reference bundle failed validation:');
158
+ for (const e of errors) console.error(' • ' + e);
159
+ process.exit(1);
160
+ }
161
+
162
+ fs.writeFileSync(OUT, JSON.stringify(bundle, null, 2) + '\n');
163
+ console.log(`✅ Wrote ${path.relative(path.resolve(__dirname, '..'), OUT)}`);
164
+ console.log(` profile=sale files=${bundle.files.length} hash=${bundle.manifest.provenance.content_hash.slice(0, 23)}…`);
165
+ console.log(` inventory: ${JSON.stringify(bundle.manifest.primitive_inventory)}`);
166
+ console.log(` dropped (tenant_private, not sold): ${dropped.map((d) => `${d.path}[${d.portability}]`).join(', ') || '(none)'}`);
167
+ } finally {
168
+ fs.rmSync(vault, { recursive: true, force: true });
169
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `ssss export` — package a vault directory into a `.ucw` bundle (spec §16).
3
+ *
4
+ * Pure and deterministic: the same vault + profile always yields a byte-identical
5
+ * bundle (frozen `exported_at`, content-hashed). Profile filtering enforces the
6
+ * §5.5 portability rules — `template`/`sale` drop every `tenant_private` file.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { exportBundle } from '../src/bundle.mjs';
11
+ import { parseArgs, usage, die, wantsHelp } from './lib/cli.mjs';
12
+
13
+ const HELP = usage({
14
+ summary: 'Package an SSSS vault directory into a transportable .ucw bundle (spec §16).',
15
+ usage: 'ssss export <vault-dir> [options]',
16
+ options: [
17
+ ['--profile <p>', 'Export profile: backup | template | sale (default: backup).'],
18
+ ['--out <file>', 'Write the bundle here (default: stdout).'],
19
+ ['--name <s>', 'Bundle display name.'],
20
+ ['--description <s>', 'Bundle description.'],
21
+ ['--bundle-version <s>', 'Bundle version string (default: 0.1.0).'],
22
+ ['--exporter <s>', 'Exporter id recorded in provenance (default: @ssss/cli).'],
23
+ ['--ext <name>', 'Declare a required extension registry (repeatable).'],
24
+ ['--exported-at <iso>', 'Override the export timestamp (default: epoch, for determinism).'],
25
+ ['--registry <dir>', 'Registry directory (default: the package core registry).'],
26
+ ['--show-dropped', 'Print the files excluded by the profile to stderr.'],
27
+ ],
28
+ examples: [
29
+ 'ssss export ./my-vault --profile sale --out festival.ucw.json',
30
+ 'ssss export ./my-vault --profile backup --name "Nightly" --out backup.ucw.json',
31
+ ],
32
+ seeAlso: ['ssss validate', 'ssss inspect', 'ssss help portability'],
33
+ });
34
+
35
+ export async function run(argv) {
36
+ if (wantsHelp(argv)) { console.log(HELP); return; }
37
+ const { positionals, flags } = parseArgs(argv, {
38
+ booleans: ['show-dropped'],
39
+ multi: ['ext'],
40
+ });
41
+ const vault = positionals[0];
42
+ if (!vault) die('export needs a vault directory.\n\n' + HELP);
43
+ if (!fs.existsSync(vault) || !fs.statSync(vault).isDirectory()) die(`not a directory: ${vault}`);
44
+
45
+ let bundle;
46
+ try {
47
+ bundle = exportBundle(path.resolve(vault), {
48
+ profile: flags.profile || 'backup',
49
+ registryDir: flags.registry,
50
+ name: flags.name || path.basename(path.resolve(vault)),
51
+ description: flags.description || '',
52
+ version: flags['bundle-version'] || '0.1.0',
53
+ exporter: flags.exporter || '@ssss/cli',
54
+ requiredExtensions: flags.ext || [],
55
+ ...(flags['exported-at'] ? { exportedAt: flags['exported-at'] } : {}),
56
+ });
57
+ } catch (err) { die(err.message); }
58
+
59
+ if (flags['show-dropped'] && bundle._dropped.length) {
60
+ console.error(`Dropped ${bundle._dropped.length} file(s) excluded by profile '${bundle.manifest.export_profile}':`);
61
+ for (const d of bundle._dropped) console.error(` - ${d.path} (${d.type || 'untyped'} · ${d.portability})`);
62
+ }
63
+
64
+ const out = { manifest: bundle.manifest, files: bundle.files };
65
+ const json = JSON.stringify(out, null, 2);
66
+ if (flags.out) {
67
+ fs.writeFileSync(flags.out, json + '\n');
68
+ console.error(`Wrote ${out.files.length} file(s) → ${flags.out} (${bundle.manifest.provenance.content_hash}).`);
69
+ } else {
70
+ process.stdout.write(json + '\n');
71
+ }
72
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `ssss help [topic]` — query the local documentation shipped with the package.
3
+ *
4
+ * Topics are Markdown files under `docs/help/`. With no argument it lists them;
5
+ * with a topic it prints that file. No network, no dependencies — the docs travel
6
+ * with the CLI so they work offline and match the installed version.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { parseArgs } from './lib/cli.mjs';
12
+
13
+ const HELP_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'help');
14
+
15
+ function topics() {
16
+ if (!fs.existsSync(HELP_DIR)) return [];
17
+ return fs.readdirSync(HELP_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')).sort();
18
+ }
19
+
20
+ function firstLineSummary(topic) {
21
+ const lines = fs.readFileSync(path.join(HELP_DIR, `${topic}.md`), 'utf8').split('\n');
22
+ for (const l of lines) {
23
+ const t = l.trim();
24
+ if (t && !t.startsWith('#')) return t.replace(/[*`]/g, '');
25
+ }
26
+ return '';
27
+ }
28
+
29
+ export async function run(argv) {
30
+ const { positionals, flags } = parseArgs(argv, { booleans: ['json'], alias: { j: 'json' } });
31
+ const all = topics();
32
+ const topic = positionals[0];
33
+
34
+ if (flags.json) {
35
+ if (topic) {
36
+ const file = path.join(HELP_DIR, `${topic}.md`);
37
+ if (!fs.existsSync(file)) { console.error(JSON.stringify({ error: `unknown topic '${topic}'`, topics: all })); process.exit(1); }
38
+ console.log(JSON.stringify({ topic, content: fs.readFileSync(file, 'utf8') }));
39
+ } else {
40
+ console.log(JSON.stringify({ topics: all.map((t) => ({ topic: t, summary: firstLineSummary(t) })) }));
41
+ }
42
+ return;
43
+ }
44
+
45
+ if (!topic) {
46
+ console.log('SSSS help topics — run `ssss help <topic>`:\n');
47
+ const w = Math.max(...all.map((t) => t.length));
48
+ for (const t of all) console.log(` ${t.padEnd(w)} ${firstLineSummary(t)}`);
49
+ console.log('\nPer-command flags: `ssss <command> --help`.');
50
+ return;
51
+ }
52
+
53
+ const file = path.join(HELP_DIR, `${topic}.md`);
54
+ if (!fs.existsSync(file)) {
55
+ console.error(`No help topic '${topic}'. Available: ${all.join(', ')}`);
56
+ process.exit(1);
57
+ }
58
+ process.stdout.write(fs.readFileSync(file, 'utf8'));
59
+ if (!fs.readFileSync(file, 'utf8').endsWith('\n')) process.stdout.write('\n');
60
+ }