@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.
- package/CHANGELOG.md +124 -0
- package/LICENSE +15 -0
- package/README.md +162 -0
- package/VERSION +1 -0
- package/conformance/README.md +72 -0
- package/conformance/fixtures.json +763 -0
- package/conformance/reference-bundle.ucw.json +135 -0
- package/docs/help/autolink.md +15 -0
- package/docs/help/bundle.md +47 -0
- package/docs/help/conformance.md +34 -0
- package/docs/help/export.md +20 -0
- package/docs/help/import.md +19 -0
- package/docs/help/overview.md +33 -0
- package/docs/help/portability.md +27 -0
- package/docs/help/provision.md +19 -0
- package/docs/help/provisioning.md +43 -0
- package/docs/help/roles.md +61 -0
- package/docs/help/runtime.md +78 -0
- package/docs/help/scaffold.md +47 -0
- package/docs/ssss-spec.md +1325 -0
- package/package.json +62 -0
- package/registry/core.json +225 -0
- package/registry/extensions/festech.json +134 -0
- package/scripts/audit-registry-field-usage.mjs +136 -0
- package/scripts/autolink.mjs +341 -0
- package/scripts/build-reference-bundle.mjs +169 -0
- package/scripts/cmd-export.mjs +72 -0
- package/scripts/cmd-help.mjs +60 -0
- package/scripts/cmd-import.mjs +71 -0
- package/scripts/cmd-inspect.mjs +78 -0
- package/scripts/cmd-new.mjs +262 -0
- package/scripts/cmd-provision.mjs +68 -0
- package/scripts/cmd-validate.mjs +43 -0
- package/scripts/conformance.mjs +408 -0
- package/scripts/lib/cli.mjs +92 -0
- package/scripts/release.sh +35 -0
- package/scripts/ssss.mjs +93 -0
- package/scripts/validate-skills.mjs +115 -0
- package/src/bundle.mjs +261 -0
- package/src/engine.mjs +452 -0
- package/src/frontmatter.mjs +109 -0
- package/src/registry.mjs +67 -0
- 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
|
+
}
|