@volter/twin-standard 1.0.91 → 1.0.92

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 (41) hide show
  1. package/README.md +13 -3
  2. package/dist/src/assessment.d.ts +43 -0
  3. package/dist/src/assessment.js +10 -2
  4. package/dist/src/cli.js +25 -2
  5. package/dist/src/clients/anthropic.first-use.json +17 -0
  6. package/dist/src/clients/clerk.first-use.json +21 -0
  7. package/dist/src/clients/cloudflare.first-use.json +21 -0
  8. package/dist/src/clients/github.first-use.json +21 -0
  9. package/dist/src/clients/openai.first-use.json +17 -0
  10. package/dist/src/clients/resend.first-use.json +25 -0
  11. package/dist/src/clients/run.js +2 -0
  12. package/dist/src/clients/slack.first-use.json +21 -0
  13. package/dist/src/clients/stripe.first-use.json +21 -0
  14. package/dist/src/clients/supabase.first-use.json +21 -0
  15. package/dist/src/clients/tavily.first-use.json +17 -0
  16. package/dist/src/clients/upstash.first-use.json +21 -0
  17. package/dist/src/conformance.js +2 -0
  18. package/dist/src/consumer.d.ts +67 -0
  19. package/dist/src/consumer.js +190 -0
  20. package/dist/src/index.d.ts +1 -0
  21. package/dist/src/index.js +1 -0
  22. package/dist/src/protocol-3.js +1 -1
  23. package/package.json +2 -2
  24. package/src/assessment.ts +7 -3
  25. package/src/cli.ts +19 -2
  26. package/src/clients/anthropic.first-use.json +17 -0
  27. package/src/clients/clerk.first-use.json +21 -0
  28. package/src/clients/cloudflare.first-use.json +21 -0
  29. package/src/clients/github.first-use.json +21 -0
  30. package/src/clients/openai.first-use.json +17 -0
  31. package/src/clients/resend.first-use.json +25 -0
  32. package/src/clients/run.ts +1 -0
  33. package/src/clients/slack.first-use.json +21 -0
  34. package/src/clients/stripe.first-use.json +21 -0
  35. package/src/clients/supabase.first-use.json +21 -0
  36. package/src/clients/tavily.first-use.json +17 -0
  37. package/src/clients/upstash.first-use.json +21 -0
  38. package/src/conformance.ts +1 -0
  39. package/src/consumer.ts +168 -0
  40. package/src/index.ts +1 -0
  41. package/src/protocol-3.ts +1 -1
@@ -0,0 +1,168 @@
1
+ // The installed customer entry into a pack's life. The CLI owns World lifetime; these are caller steps.
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { standardPackageFile } from './package-file.ts';
9
+
10
+ export type ConsumerRecipe = {
11
+ schemaVersion: 1;
12
+ source: string;
13
+ about: string;
14
+ files: Record<string, string>;
15
+ clock?: { at: string; advanceAfterRun?: string };
16
+ run: string[];
17
+ retention: { readback: string[] } | { none: string };
18
+ };
19
+ export type ConsumerOptions = { appDir: string; cli: string; artifactIntegrity: string };
20
+
21
+ /** Bind behavior inputs on both source and packed layouts; compiled outputs and score caches are not inputs. */
22
+ export function consumerSourceDigest(pack: string): string {
23
+ const entries: Array<[string, string]> = [];
24
+ const cache = new Set(['journeys/score.json', 'journeys/coverage.json']);
25
+ const scan = (path: string): void => {
26
+ if (cache.has(path)) return;
27
+ const at = join(pack, path);
28
+ if (statSync(at).isDirectory()) {
29
+ for (const name of readdirSync(at).sort()) if (name !== 'node_modules') scan(`${path}/${name}`);
30
+ } else entries.push([path, createHash('sha256').update(readFileSync(at)).digest('hex')]);
31
+ };
32
+ for (const name of readdirSync(pack).sort()) {
33
+ const at = join(pack, name);
34
+ if (['src', 'spec', 'journeys', 'vendor', 'assets', 'static'].includes(name) || statSync(at).isDirectory() && existsSync(join(at, 'src/manifest.ts'))) scan(name);
35
+ }
36
+ return createHash('sha256').update(JSON.stringify(entries)).digest('hex');
37
+ }
38
+
39
+ /** A pack supplies its own entry journey; older immutable releases use a versioned standard fixture. */
40
+ export function consumerRecipe(vendor: string, packDir?: string): ConsumerRecipe {
41
+ if (!/^[a-z][a-z0-9-]*$/.test(vendor)) throw new Error('Invalid consumer vendor identity');
42
+ const root = dirname(fileURLToPath(standardPackageFile('package.json')));
43
+ const supplied = packDir ? join(packDir, 'journeys/first-use.json') : undefined;
44
+ const path = supplied && existsSync(supplied) ? supplied : join(root, 'src/clients', `${vendor}.first-use.json`);
45
+ if (!existsSync(path)) throw new Error(`${vendor} has no installed customer workflow in this standard`);
46
+ const recipe = JSON.parse(readFileSync(path, 'utf8')) as ConsumerRecipe;
47
+ const command = (value: unknown): value is string[] => Array.isArray(value) && value.length > 0 && value.every((part) => typeof part === 'string' && part.length > 0);
48
+ if (recipe.schemaVersion !== 1 || typeof recipe.source !== 'string' || !recipe.source || typeof recipe.about !== 'string' || !recipe.about || !recipe.files || typeof recipe.files !== 'object' || Array.isArray(recipe.files) || !recipe.files['package.json'] || typeof recipe.files['.env.example'] !== 'string' || !command(recipe.run)) throw new Error('Incomplete installed customer workflow');
49
+ if (!recipe.retention || ('none' in recipe.retention) === ('readback' in recipe.retention) || ('none' in recipe.retention ? typeof recipe.retention.none !== 'string' || !recipe.retention.none.trim() : !command(recipe.retention.readback))) throw new Error('Workflow needs retained-state readback or a stateless explanation');
50
+ if (recipe.clock && (!Number.isFinite(Date.parse(recipe.clock.at)) || recipe.clock.advanceAfterRun !== undefined && (!/^\d+(?:\.\d+)?[smhd]$/.test(recipe.clock.advanceAfterRun) || !('readback' in recipe.retention)))) throw new Error('Consumer clock needs an instant and, for a stored result, an optional advance before inspection');
51
+ return recipe;
52
+ }
53
+
54
+ /** Materialize only a new caller-owned app. Normal dependency installation happens before offline assessment. */
55
+ export function prepareConsumerApp(packDir: string, appDir: string): { recipeSha256: string; dependencies: Record<string, string> } {
56
+ const facts = JSON.parse(readFileSync(join(packDir, 'generated/pack-facts.json'), 'utf8'));
57
+ const vendor = Object.keys(facts.packs ?? {})[0];
58
+ if (!vendor || Object.keys(facts.packs).length !== 1) throw new Error('Consumer preparation requires one artifact vendor identity');
59
+ const recipe = consumerRecipe(vendor, packDir);
60
+ if (existsSync(appDir)) throw new Error('Consumer app must be a new directory');
61
+ const app = JSON.parse(recipe.files['package.json']!);
62
+ if (app.scripts || app.workspaces) throw new Error('Consumer fixture supplies direct SDK commands, not install scripts or workspace links');
63
+ for (const [name, version] of Object.entries({ ...app.dependencies, ...app.devDependencies, ...app.optionalDependencies })) {
64
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error(`${name}: customer SDK dependencies require exact registry versions`);
65
+ }
66
+ const files = Object.entries(recipe.files).map(([path, content]) => {
67
+ if (isAbsolute(path) || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..') || path.split('/').includes('node_modules') || ['.env', '.npmrc', '.volter/world.json'].includes(path)) throw new Error(`Invalid consumer file: ${path}`);
68
+ if (typeof content !== 'string') throw new Error('Consumer files must contain text');
69
+ return [path, content] as const;
70
+ });
71
+ mkdirSync(appDir, { recursive: true });
72
+ for (const [path, content] of files) {
73
+ mkdirSync(dirname(join(appDir, path)), { recursive: true });
74
+ writeFileSync(join(appDir, path), content);
75
+ }
76
+ return { recipeSha256: createHash('sha256').update(JSON.stringify(recipe)).digest('hex'), dependencies: app.dependencies ?? {} };
77
+ }
78
+
79
+ export async function runConsumerWorkflow(packDir: string, options: ConsumerOptions) {
80
+ if (!isAbsolute(options.appDir) || !isAbsolute(options.cli) || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(options.artifactIntegrity)) throw new Error('Consumer requires absolute prepared paths and candidate SHA-512 integrity');
81
+ if (existsSync(join(options.appDir, '.volter/world.json'))) throw new Error('Consumer app already has a World; prepare a new app rather than reusing prior state');
82
+ const packageVersion = (entry: string, name: string): string => {
83
+ let directory = dirname(entry);
84
+ while (true) {
85
+ const file = join(directory, 'package.json');
86
+ if (existsSync(file)) { const pkg = JSON.parse(readFileSync(file, 'utf8')); if (pkg.name === name) return pkg.version; }
87
+ const parent = dirname(directory);
88
+ if (parent === directory) throw new Error(`${name} has no installed package identity`);
89
+ directory = parent;
90
+ }
91
+ };
92
+ const cliEntry = realpathSync(options.cli);
93
+ const cliRequire = createRequire(cliEntry);
94
+ const tools = {
95
+ '@volter/world': packageVersion(cliEntry, '@volter/world'),
96
+ '@volter/world-core': packageVersion(cliRequire.resolve('@volter/world-core/runtime'), '@volter/world-core'),
97
+ '@volter/world-runtime': packageVersion(cliRequire.resolve('@volter/world-runtime'), '@volter/world-runtime'),
98
+ };
99
+ const metadata = JSON.parse(readFileSync(join(packDir, 'package.json'), 'utf8'));
100
+ const facts = JSON.parse(readFileSync(join(packDir, 'generated/pack-facts.json'), 'utf8'));
101
+ const vendor = Object.keys(facts.packs ?? {})[0];
102
+ if (!vendor || Object.keys(facts.packs).length !== 1) throw new Error('Consumer workflow requires one selected artifact identity');
103
+ const recipe = consumerRecipe(vendor, packDir);
104
+ const commands: Array<{ label: string; argv: string[]; exitCode: number | null; stdout: string; stderr: string }> = [];
105
+ const failures: string[] = [];
106
+ const run = async (label: string, args: string[]) => {
107
+ const result = await new Promise<{ exitCode: number | null; stdout: string; stderr: string }>((done) => {
108
+ const child = spawn(options.cli, ['world', ...args], { cwd: options.appDir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
109
+ let stdout = '', stderr = '';
110
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
111
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
112
+ child.on('error', (error) => done({ exitCode: 1, stdout, stderr: `${stderr}\n${error.message}` }));
113
+ child.on('close', (exitCode) => done({ exitCode, stdout, stderr }));
114
+ });
115
+ commands.push({ label, argv: [options.cli, 'world', ...args], ...result });
116
+ if (result.exitCode !== 0) throw new Error(`${label} exited ${result.exitCode}: ${result.stderr.trim().split('\n')[0] ?? ''}`);
117
+ return result.stdout;
118
+ };
119
+ let upAttempted = false;
120
+ let retainedReadback = false;
121
+ let teardownVerified = false;
122
+ try {
123
+ await run('init', ['init', '--twins', vendor]);
124
+ const config = JSON.parse(readFileSync(join(options.appDir, '.volter/world.json'), 'utf8'));
125
+ if (!config.services?.some((service: { source?: { package?: string; version?: string } }) => service.source?.package === metadata.name && service.source?.version === metadata.version)) throw new Error('World init did not select the installed candidate package and exact version');
126
+ upAttempted = true;
127
+ await run('up', ['up']);
128
+ if (recipe.clock) await run('clock-set', ['clock', 'set', recipe.clock.at]);
129
+ await run('sdk', ['run', '--', ...recipe.run]);
130
+ if ('readback' in recipe.retention) {
131
+ if (recipe.clock?.advanceAfterRun) {
132
+ await run('clock-advance', ['clock', 'advance', recipe.clock.advanceAfterRun]);
133
+ await run('inspect-timed-result', ['run', '--', ...recipe.retention.readback]);
134
+ }
135
+ const before = JSON.parse(await run('log-before-stop', ['log', '--json']));
136
+ await run('stop', ['down']);
137
+ await run('resume', ['up']);
138
+ const resumed = JSON.parse(await run('log-after-resume', ['log', '--json']));
139
+ if (JSON.stringify(before) !== JSON.stringify(resumed)) throw new Error('Resume changed recorded application state before readback');
140
+ await run('retained-readback', ['run', '--', ...recipe.retention.readback]);
141
+ const after = JSON.parse(await run('log-after-readback', ['log', '--json']));
142
+ if (JSON.stringify(before) !== JSON.stringify(after)) throw new Error('Retained readback added mutations; it must only inspect existing state');
143
+ retainedReadback = true;
144
+ }
145
+ } catch (error) { failures.push((error as Error).message); }
146
+ finally {
147
+ if (upAttempted) {
148
+ try {
149
+ await run('final-stop', ['down']);
150
+ const status = JSON.parse(await run('final-status', ['status', '--json']));
151
+ if (status.running || Object.values(status.services ?? {}).some((service) => (service as { running?: boolean }).running)) throw new Error('Consumer World still reports running compute');
152
+ teardownVerified = true;
153
+ } catch (error) { failures.push((error as Error).message); }
154
+ }
155
+ }
156
+ return {
157
+ measured: true, passed: failures.length === 0 && teardownVerified,
158
+ scope: 'Exact installed artifact; normal CLI initialization, unchanged SDK, result assertions and retained-state read-only resume where applicable',
159
+ package: metadata.name, version: metadata.version, integrity: options.artifactIntegrity,
160
+ tools,
161
+ sourceInputsSha256: consumerSourceDigest(packDir), recipeSha256: createHash('sha256').update(JSON.stringify(recipe)).digest('hex'),
162
+ fixture: existsSync(join(packDir, 'journeys/first-use.json')) ? 'journeys/first-use.json' : `standard/clients/${vendor}.first-use.json`, appDir: resolve(options.appDir), appName: basename(options.appDir),
163
+ commands, failures, teardownVerified, retainedReadback,
164
+ clock: recipe.clock ?? { mode: 'wall' },
165
+ ...('none' in recipe.retention ? { retentionNotApplicable: recipe.retention.none } : {}),
166
+ node: process.versions.node, at: new Date().toISOString(),
167
+ };
168
+ }
package/src/index.ts CHANGED
@@ -18,3 +18,4 @@ export { assessPack, type AssessmentOptions } from './assessment.ts';
18
18
 
19
19
  export { createPack } from './create-pack.ts';
20
20
  export { verifyBrowserTransport } from './browser-verification.ts';
21
+ export { consumerRecipe, prepareConsumerApp, runConsumerWorkflow, consumerSourceDigest, type ConsumerRecipe, type ConsumerOptions } from './consumer.ts';
package/src/protocol-3.ts CHANGED
@@ -127,7 +127,7 @@ function srcFileInForm(unit: GradeUnit, name: string): boolean {
127
127
  const SEMANTICS_FIXED = new Set(['answer-views.ts', 'states.ts', 'index.ts', 'shared.ts', 'doors.ts', 'seed.ts', 'around.ts', 'events.ts', 'clock.ts', 'tenant.ts', 'scenario.ts', 'gap.ts', 'graphql.ts', 'sockets.ts', 'board.ts']);
128
128
  /** journeys/'s closed set. */
129
129
  const JOURNEY_FILES = new Set(['customer-life.outline.md', 'customer-life.outline.verdict.json', 'customer-life.json', 'customer-life.verdict.json', 'decisions.json',
130
- 'vendor-examples.json', 'vendor-examples.verdict.json', 'demand.json', 'unreachable.json', 'score.json', 'coverage.json',
130
+ 'vendor-examples.json', 'vendor-examples.verdict.json', 'demand.json', 'unreachable.json', 'score.json', 'coverage.json', 'first-use.json',
131
131
  // a generative pack's life names the World's scenario it walks under (architecture, "Writing a life")
132
132
  'scenario.json']);
133
133
  /** Only scenario basenames the two walks explicitly declare extend the closed layout. */