@volter/world 3.0.138 → 3.0.139

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 (80) hide show
  1. package/dist/examples/customer-onboarding/.env.example +3 -0
  2. package/dist/examples/customer-onboarding/LICENSE +202 -0
  3. package/dist/examples/customer-onboarding/README.md +99 -0
  4. package/dist/examples/customer-onboarding/demo.mjs +30 -0
  5. package/dist/examples/customer-onboarding/gitignore.template +8 -0
  6. package/dist/examples/customer-onboarding/onboarding.mjs +55 -0
  7. package/dist/examples/customer-onboarding/package.json +21 -0
  8. package/dist/examples/customer-onboarding/prepare-ui.mjs +20 -0
  9. package/dist/examples/customer-onboarding/public-files.json +14 -0
  10. package/dist/examples/customer-onboarding/run.mjs +22 -0
  11. package/dist/examples/customer-onboarding/server.mjs +32 -0
  12. package/dist/examples/customer-onboarding/style.css +8 -0
  13. package/dist/examples/index.json +52 -0
  14. package/dist/examples/integration-branches/.env.example +1 -0
  15. package/dist/examples/integration-branches/LICENSE +202 -0
  16. package/dist/examples/integration-branches/README.md +102 -0
  17. package/dist/examples/integration-branches/customer.mjs +11 -0
  18. package/dist/examples/integration-branches/gitignore.template +8 -0
  19. package/dist/examples/integration-branches/package.json +18 -0
  20. package/dist/examples/integration-branches/public-files.json +10 -0
  21. package/dist/examples/integration-branches/receipt.mjs +3 -0
  22. package/dist/examples/workplace-agent/.env.example +3 -0
  23. package/dist/examples/workplace-agent/LICENSE +202 -0
  24. package/dist/examples/workplace-agent/README.md +106 -0
  25. package/dist/examples/workplace-agent/clients.mjs +7 -0
  26. package/dist/examples/workplace-agent/create-organization.mjs +11 -0
  27. package/dist/examples/workplace-agent/gitignore.template +8 -0
  28. package/dist/examples/workplace-agent/handoff.mjs +30 -0
  29. package/dist/examples/workplace-agent/operate.mjs +21 -0
  30. package/dist/examples/workplace-agent/package.json +22 -0
  31. package/dist/examples/workplace-agent/public-files.json +14 -0
  32. package/dist/examples/workplace-agent/reset-receipts.mjs +5 -0
  33. package/dist/examples/workplace-agent/seed.mjs +16 -0
  34. package/dist/src/agent-prompt.js +5 -5
  35. package/dist/src/cli.d.ts +1 -1
  36. package/dist/src/cli.js +56 -11
  37. package/dist/src/examples.d.ts +8 -0
  38. package/dist/src/examples.js +30 -0
  39. package/dist/src/mcp.js +2 -2
  40. package/dist/src/world.d.ts +4 -3
  41. package/dist/src/world.js +27 -14
  42. package/examples/customer-onboarding/.env.example +3 -0
  43. package/examples/customer-onboarding/LICENSE +202 -0
  44. package/examples/customer-onboarding/README.md +99 -0
  45. package/examples/customer-onboarding/demo.mjs +30 -0
  46. package/examples/customer-onboarding/gitignore.template +8 -0
  47. package/examples/customer-onboarding/onboarding.mjs +55 -0
  48. package/examples/customer-onboarding/package.json +21 -0
  49. package/examples/customer-onboarding/prepare-ui.mjs +20 -0
  50. package/examples/customer-onboarding/public-files.json +14 -0
  51. package/examples/customer-onboarding/run.mjs +22 -0
  52. package/examples/customer-onboarding/server.mjs +32 -0
  53. package/examples/customer-onboarding/style.css +8 -0
  54. package/examples/index.json +52 -0
  55. package/examples/integration-branches/.env.example +1 -0
  56. package/examples/integration-branches/LICENSE +202 -0
  57. package/examples/integration-branches/README.md +102 -0
  58. package/examples/integration-branches/customer.mjs +11 -0
  59. package/examples/integration-branches/gitignore.template +8 -0
  60. package/examples/integration-branches/package.json +18 -0
  61. package/examples/integration-branches/public-files.json +10 -0
  62. package/examples/integration-branches/receipt.mjs +3 -0
  63. package/examples/workplace-agent/.env.example +3 -0
  64. package/examples/workplace-agent/LICENSE +202 -0
  65. package/examples/workplace-agent/README.md +106 -0
  66. package/examples/workplace-agent/clients.mjs +7 -0
  67. package/examples/workplace-agent/create-organization.mjs +11 -0
  68. package/examples/workplace-agent/gitignore.template +8 -0
  69. package/examples/workplace-agent/handoff.mjs +30 -0
  70. package/examples/workplace-agent/operate.mjs +21 -0
  71. package/examples/workplace-agent/package.json +22 -0
  72. package/examples/workplace-agent/public-files.json +14 -0
  73. package/examples/workplace-agent/reset-receipts.mjs +5 -0
  74. package/examples/workplace-agent/seed.mjs +16 -0
  75. package/package.json +9 -8
  76. package/src/agent-prompt.ts +5 -5
  77. package/src/cli.ts +45 -7
  78. package/src/examples.ts +24 -0
  79. package/src/mcp.ts +2 -2
  80. package/src/world.ts +27 -15
@@ -0,0 +1,106 @@
1
+ ---
2
+ title: GitHub issue to Linear ticket and Slack update
3
+ ---
4
+
5
+ # GitHub issue to Linear ticket and Slack update
6
+
7
+ Give a tool-using agent an isolated workplace: a GitHub repository and source issue, a Linear team and existing runbook task, and a Slack triage channel with a policy message. The starter reads an issue, creates a linked ticket and posts the result to the channel through the real SDKs.
8
+
9
+ This application uses a labeled fixed policy: titles beginning with `P1:` receive priority 1; other titles receive priority 3. It exercises tools and stored outcomes. To assess an agent's planning or judgment, replace that decision with your own agent and configure its model twin's scripted answers separately. A successful fixed-rule handoff does not assess a model.
10
+
11
+ You need Node 22.6+, npm and registry access during installation. No platform account, private application checkout or real vendor key is required.
12
+
13
+ ## Prepare an ordinary workplace
14
+
15
+ ```console
16
+ $ npx @volter/world@3.0.139 example workplace-agent --dir workplace
17
+ $ cd workplace
18
+ $ npm install
19
+ $ npx volter world init --name workplace --twins github,linear,slack
20
+ ```
21
+
22
+ Read [package.json](./package.json), [package-lock.json](./package-lock.json) and [clients.mjs](./clients.mjs) for exact tool, twin and SDK versions. Keep the lockfile created by your installation. Review the three configured vendors and synthetic credentials before booting. No model vendor is selected by this starter.
23
+
24
+ ```console
25
+ $ npx volter world up
26
+ $ npx volter world clock set 2026-01-15T12:00:00Z
27
+ $ npx volter world run -- node seed.mjs
28
+ ```
29
+
30
+ [seed.mjs](./seed.mjs) creates stored records through each vendor's API. Run it once per fresh or reset World. It writes `company.json` with the returned repository owner, issue number, team and channel IDs. Existing runbook and policy records give your agent something to read before acting.
31
+
32
+ ## Complete a handoff
33
+
34
+ ```console
35
+ $ npx volter world run -- node handoff.mjs
36
+ $ npx volter world run -- node operate.mjs read
37
+ $ npx volter world log
38
+ $ npx volter world diff
39
+ ```
40
+
41
+ Read the ticket's title, priority and team, then the channel and posted source/ticket links. [handoff.mjs](./handoff.mjs) writes an application receipt under `outcomes/` as soon as the ticket exists. A repeated completed handoff prints that receipt; it does not create another ticket. [operate.mjs](./operate.mjs) reads the vendor's stored result rather than trusting the receipt alone.
42
+
43
+ ## Give the task to your coding agent
44
+
45
+ Install the World skill and MCP tools into your agent's configuration:
46
+
47
+ ```console
48
+ $ npx volter agents install
49
+ ```
50
+
51
+ If you already completed the example handoff, create another source issue with `world run -- node operate.mjs issue`. Give your agent that returned issue number and this task:
52
+
53
+ > Read this starter's README and `company.json`. Work in its existing World. Read the source GitHub issue, the Linear backlog and the Slack policy message using the installed real SDKs inside `volter world run`. Apply the supplied priority policy, create a ticket with the source link, then notify the channel with both links. Preserve an application receipt as soon as the ticket exists. If notification is refused, report the stored ticket and pending notification separately. Read the resulting vendor records and show the World log; do not claim success from the script's exit alone. Inspect existing receipts before repeating a mutation.
54
+
55
+ The agent uses the same public SDKs as [clients.mjs](./clients.mjs); the [coding-agent guide](https://world.volter.ai/docs/guides/use-with-a-coding-agent.html) owns CLI/MCP setup. You can leave the fixed policy in place to exercise tool use, or supply your own agent decision and model scenario. The requested outcomes are stored links, team, priority and completion state; a scripted response still does not measure model judgment.
56
+
57
+ ## Keep a partial result and retry it
58
+
59
+ Archive the channel, then create another source issue:
60
+
61
+ ```console
62
+ $ npx volter world run -- node operate.mjs archive
63
+ $ npx volter world run -- node operate.mjs issue
64
+ ```
65
+
66
+ Use the returned issue number in both commands below:
67
+
68
+ ```console
69
+ $ npx volter world run -- node handoff.mjs <issue-number>
70
+ $ npx volter world run -- node operate.mjs read <issue-number>
71
+ ```
72
+
73
+ Slack's archived-channel refusal leaves the Linear ticket stored. The application reports `partial-ticket-created-notification-failed` and preserves the vendor error in its receipt. Unarchive the channel and resume that same handoff:
74
+
75
+ ```console
76
+ $ npx volter world run -- node operate.mjs unarchive
77
+ $ npx volter world run -- node handoff.mjs <issue-number>
78
+ $ npx volter world run -- node operate.mjs read <issue-number>
79
+ ```
80
+
81
+ The retry uses the recorded ticket and attempts only the notification. An uncertain process exit between a successful vendor call and writing its receipt requires inspecting the vendor before retrying; this example does not claim exactly-once external execution.
82
+
83
+ `company.json` and `outcomes/` are application files outside declared World services. They are not branched or reset with vendor history. After explicitly resetting this disposable World, clear only this example's receipts with `node reset-receipts.mjs`, restore the frozen clock, then seed again. Do not reuse receipts from one World with another.
84
+
85
+ ## Create a synthetic GitHub organization
86
+
87
+ Personal repositories use GitHub's ordinary create-repository API. Organization creation is a settings-page operation; this twin provides the declared `POST /_twin/orgs` endpoint for synthetic setup. [create-organization.mjs](./create-organization.mjs) discovers the authenticated account and sends:
88
+
89
+ ```json
90
+ {
91
+ "login": "example-payments",
92
+ "name": "Example Payments",
93
+ "owner": "<login returned by GitHub users.getAuthenticated()>",
94
+ "billing_email": "owner@example.invalid"
95
+ }
96
+ ```
97
+
98
+ It expects HTTP 201 with the organization's `login` and `id`, then creates a private repository using `github.repos.createInOrg`. Run it once in this World:
99
+
100
+ ```console
101
+ $ npx volter world run -- node create-organization.mjs
102
+ ```
103
+
104
+ Without the owner or signed-in browser identity, the settings operation can redirect to sign-in. Inspect `GET /twin` for its declared setup endpoints rather than guessing a request body or inserting state files.
105
+
106
+ Stop with `npx volter world down` to retain the workplace. [Inspect a World](https://world.volter.ai/docs/guides/inspect-a-world.html) shows its vendor screens, semantic relations and event history. [Failure handoff](https://world.volter.ai/docs/guides/reproduce-a-failure.html) describes how to share source, clock, handlers and application receipt conditions. [Source files](./public-files.json) identify the starter. The source instructions carry no new execution claim.
@@ -0,0 +1,7 @@
1
+ import { Octokit } from '@octokit/rest';
2
+ import { LinearClient } from '@linear/sdk';
3
+ import { WebClient } from '@slack/web-api';
4
+ if (!process.env.VOLTER_WORLD) throw new Error('Run this application with volter world run');
5
+ export const github = new Octokit({ auth: process.env.GITHUB_TOKEN, request: { timeout: 1500 } });
6
+ export const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
7
+ export const slack = new WebClient(process.env.SLACK_BOT_TOKEN, { retryConfig: { retries: 0 }, timeout: 1500 });
@@ -0,0 +1,11 @@
1
+ import { github } from './clients.mjs';
2
+ const owner = (await github.users.getAuthenticated()).data.login;
3
+ const response = await fetch(`${process.env.GITHUB_TWIN_URL}/_twin/orgs`, {
4
+ method: 'POST', headers: { 'content-type': 'application/json' },
5
+ body: JSON.stringify({ login: 'example-payments', name: 'Example Payments', owner, billing_email: 'owner@example.invalid' }),
6
+ signal: AbortSignal.timeout(1500),
7
+ });
8
+ if (!response.ok) throw new Error(`Organization setup: ${response.status} ${await response.text()}`);
9
+ console.log(await response.json());
10
+ // GitHub's API exposes repository creation once its organization exists; org creation itself is a settings-page door.
11
+ console.log((await github.repos.createInOrg({ org: 'example-payments', name: 'service', private: true })).data.full_name);
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ .volter/worlds/
3
+ .volter/world.env
4
+ .volter/*.world.env
5
+ .volter/token*
6
+ company.json
7
+ outcomes/
8
+ brand/
@@ -0,0 +1,30 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { github, linear, slack } from './clients.mjs';
3
+ const company = JSON.parse(readFileSync('company.json', 'utf8'));
4
+ const number = Number(process.argv[2] ?? company.sourceIssue);
5
+ const path = `outcomes/issue-${number}.json`;
6
+ const source = (await github.issues.get({ owner: company.owner, repo: company.repo, issue_number: number })).data;
7
+ // A labeled fixed policy, not an assessment of a model's judgment. Bring your own agent here.
8
+ const priority = source.title.startsWith('P1:') ? 1 : 3;
9
+ let outcome;
10
+ try { outcome = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
11
+ if (outcome && outcome.world !== process.env.VOLTER_WORLD) throw new Error('This receipt belongs to another World; inspect its vendor state before proceeding');
12
+ if (outcome?.completion === 'complete') { console.log(JSON.stringify(outcome, null, 2)); process.exit(0); }
13
+ if (!outcome) {
14
+ const payload = await linear.createIssue({ teamId: company.teamId, title: source.title, description: `Source: ${source.html_url}\n\n${source.body}`, priority });
15
+ const ticket = await payload.issue;
16
+ if (!payload.success || !ticket) throw new Error('Linear did not create a ticket');
17
+ outcome = { world: process.env.VOLTER_WORLD, source: { number, url: source.html_url }, ticket: { id: ticket.id, identifier: ticket.identifier, url: ticket.url, title: ticket.title, priority }, message: null, completion: 'ticket-created-notification-pending' };
18
+ writeFileSync(path, JSON.stringify(outcome, null, 2) + '\n');
19
+ }
20
+ // Resume a refused notification from its application receipt; never create a second ticket on that retry.
21
+ try {
22
+ const posted = await slack.chat.postMessage({ channel: company.channelId, text: `${outcome.ticket.identifier}: ${source.title}. Ticket: ${outcome.ticket.url}. Source: ${source.html_url}. Team: Reliability. Priority: ${priority}.` });
23
+ outcome.message = { channel: posted.channel, ts: posted.ts };
24
+ outcome.completion = 'complete'; delete outcome.error;
25
+ } catch (error) {
26
+ outcome.completion = 'partial-ticket-created-notification-failed';
27
+ outcome.error = { code: error.code, vendorError: error.data?.error, message: error.message };
28
+ }
29
+ writeFileSync(path, JSON.stringify(outcome, null, 2) + '\n');
30
+ console.log(JSON.stringify(outcome, null, 2));
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { github, linear, slack } from './clients.mjs';
3
+ const company = JSON.parse(readFileSync('company.json', 'utf8'));
4
+ switch (process.argv[2] ?? 'read') {
5
+ case 'issue': {
6
+ const made = await github.issues.create({ owner: company.owner, repo: company.repo, title: 'P1: Duplicate receipt after a retry', body: 'Synthetic second issue: deduplicate receipts while keeping history.' });
7
+ console.log(`New source issue ${made.data.number}`); break;
8
+ }
9
+ case 'archive': console.log(await slack.conversations.archive({ channel: company.channelId })); break;
10
+ case 'unarchive': console.log(await slack.conversations.unarchive({ channel: company.channelId })); break;
11
+ case 'read': {
12
+ const number = Number(process.argv[3] ?? company.sourceIssue);
13
+ const outcome = JSON.parse(readFileSync(`outcomes/issue-${number}.json`, 'utf8'));
14
+ const ticket = await linear.issue(outcome.ticket.id);
15
+ const team = await ticket.team;
16
+ const channel = await slack.conversations.info({ channel: company.channelId });
17
+ const messages = await slack.conversations.history({ channel: company.channelId, limit: 20 });
18
+ console.log(JSON.stringify({ completion: outcome.completion, ticket: { id: ticket.id, identifier: ticket.identifier, title: ticket.title, description: ticket.description, priority: ticket.priority, team: { id: team.id, name: team.name } }, channel: channel.channel, messages: messages.messages }, null, 2)); break;
19
+ }
20
+ default: throw new Error('Use issue, archive, unarchive or read [issue-number]');
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "world-workplace-agent",
3
+ "private": true,
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=22.6"
7
+ },
8
+ "dependencies": {
9
+ "@octokit/rest": "22.0.1",
10
+ "@linear/sdk": "86.0.0",
11
+ "@slack/web-api": "7.19.0"
12
+ },
13
+ "devDependencies": {
14
+ "@volter/world": "3.0.139",
15
+ "@volter/world-core": "3.0.139",
16
+ "@volter/world-runtime": "3.0.139",
17
+ "@volter/world-console": "3.0.134",
18
+ "@volter/twin-github": "3.0.6",
19
+ "@volter/twin-linear": "3.0.4",
20
+ "@volter/twin-slack": "3.0.5"
21
+ }
22
+ }
@@ -0,0 +1,14 @@
1
+ [
2
+ ".env.example",
3
+ ".gitignore",
4
+ "README.md",
5
+ "clients.mjs",
6
+ "create-organization.mjs",
7
+ "handoff.mjs",
8
+ "operate.mjs",
9
+ "package-lock.json",
10
+ "package.json",
11
+ "public-files.json",
12
+ "reset-receipts.mjs",
13
+ "seed.mjs"
14
+ ]
@@ -0,0 +1,5 @@
1
+ // Only this example's named application receipts. Never a World, credential or unknown directory.
2
+ import { existsSync, readdirSync, unlinkSync } from 'node:fs';
3
+ if (existsSync('company.json')) unlinkSync('company.json');
4
+ if (existsSync('outcomes')) for (const name of readdirSync('outcomes')) if (/^issue-\d+\.json$/.test(name)) unlinkSync('outcomes/' + name);
5
+ console.log('Application receipts cleared; vendor state was not changed');
@@ -0,0 +1,16 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { github, linear, slack } from './clients.mjs';
3
+ const owner = (await github.users.getAuthenticated()).data.login;
4
+ await github.repos.createForAuthenticatedUser({ name: 'payments', private: true, description: 'Synthetic payments app' });
5
+ const source = await github.issues.create({ owner, repo: 'payments', title: 'P1: Retried webhook creates duplicate orders', body: 'Deliver the same provider event twice. Expected: one order and one receipt. Preserve audit history; deduplicate by provider event ID.' });
6
+ const teamPayload = await linear.createTeam({ name: 'Reliability', key: 'REL' });
7
+ const team = await teamPayload.team;
8
+ if (!teamPayload.success || !team) throw new Error('Linear did not create the Reliability team');
9
+ await linear.createIssue({ teamId: team.id, title: 'Document the on-call runbook', priority: 3 });
10
+ const channel = await slack.conversations.create({ name: 'payments-triage', is_private: false });
11
+ if (!channel.channel?.id) throw new Error('Slack did not create the triage channel');
12
+ await slack.chat.postMessage({ channel: channel.channel.id, text: 'Payment integrity issues go to Reliability. Every update includes the source and ticket links.' });
13
+ mkdirSync('outcomes', { recursive: true });
14
+ const company = { name: 'Example Payments', owner, repo: 'payments', sourceIssue: source.data.number, teamId: team.id, channelId: channel.channel.id, policy: 'P1 payment integrity → Reliability, priority 1' };
15
+ writeFileSync('company.json', JSON.stringify(company, null, 2) + '\n');
16
+ console.log(JSON.stringify(company, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world",
3
- "version": "3.0.138",
3
+ "version": "3.0.139",
4
4
  "description": "Run your app against a world of twins. `World` and `Repo` — one method per verb: init, up, run, log, diff, reset, branch, checkout, clone, fetch, changeset, push — and the `volter` command, one client of them.",
5
5
  "keywords": [
6
6
  "volter",
@@ -26,16 +26,17 @@
26
26
  "files": [
27
27
  "src",
28
28
  "dist",
29
- "skills"
29
+ "skills",
30
+ "examples"
30
31
  ],
31
32
  "dependencies": {
32
33
  "@modelcontextprotocol/sdk": "^1.30.1",
33
- "@volter/world-core": "3.0.138",
34
- "@volter/world-runtime": "3.0.138",
34
+ "@volter/world-core": "3.0.139",
35
+ "@volter/world-runtime": "3.0.139",
35
36
  "zod": "^4.6.5"
36
37
  },
37
38
  "peerDependencies": {
38
- "@volter/world-console": "3.0.133"
39
+ "@volter/world-console": "3.0.134"
39
40
  },
40
41
  "peerDependenciesMeta": {
41
42
  "@volter/world-console": {
@@ -43,14 +44,14 @@
43
44
  }
44
45
  },
45
46
  "devDependencies": {
46
- "@volter/world-console": "3.0.133"
47
+ "@volter/world-console": "3.0.134"
47
48
  },
48
49
  "bin": {
49
50
  "volter": "dist/src/cli.js"
50
51
  },
51
52
  "scripts": {
52
- "build": "node ../../scripts/publish/build.mjs",
53
- "prepack": "node ./scripts/ship-skill.mjs in && node ../../scripts/publish/prepare-publish.mjs prepack",
53
+ "build": "node ../../scripts/prepare-world-examples.mjs && node ../../scripts/publish/build.mjs",
54
+ "prepack": "node ../../scripts/prepare-world-examples.mjs && node ./scripts/ship-skill.mjs in && node ../../scripts/publish/prepare-publish.mjs prepack",
54
55
  "postpack": "node ../../scripts/publish/prepare-publish.mjs postpack && node ./scripts/ship-skill.mjs out"
55
56
  },
56
57
  "homepage": "https://world.volter.ai/docs/getting-started"
@@ -15,13 +15,13 @@ export type AgentPromptOptions = {
15
15
  export function agentPrompt(o: AgentPromptOptions = {}): string {
16
16
  const cmd = (shell: string, tool: string): string => (o.tools ? `${tool} (\`${shell}\`)` : `\`${shell}\``);
17
17
  const steps = [
18
- 'Set up Volter World for this app, so its calls to Stripe, Slack, GitHub, OpenAI and the other services it uses go to local twins instead of the real APIs. The app keeps its real SDKs and code.',
18
+ 'Set up Volter World for the workflow I want to run in this app. Select only the vendors that workflow deliberately substitutes with local twins. Keep the app\'s real SDKs.',
19
19
  '',
20
20
  o.tools
21
- ? '1. You have the Volter World tools (volter mcp). If the `volter` command is missing, install it: `npm install -g @volter/world`.'
22
- : '1. Install the command and your agent tooling: `npm install -g @volter/world`, then `volter agents install --yes`.',
23
- `2. In the app's folder, ${cmd('volter world init --install', 'world_init')}: it detects the vendors the app calls and installs their twins. Show me the vendors it detected and why, before keeping them.`,
24
- `3. ${o.tools ? 'Start it with world_up' : 'Start it: `volter world up`'}, then run the tests inside it: ${cmd('volter world run -- <the test command>', 'world_run')}. Fix anything that fails because a call went to the real service.`,
21
+ ? '1. You have the Volter World tools (volter mcp). Keep an app\'s existing pinned World tools. If the `volter` command is missing, install it: `npm install -g @volter/world`.'
22
+ : '1. Keep the app\'s pinned World tools if present, using its local CLI through `npx`. Otherwise install the command: `npm install -g @volter/world`. Install the agent tooling with `volter agents install --yes`.',
23
+ `2. Read the app's manifests, env names and any .volter/world.json. Show me the likely vendors and why, before keeping them. Preserve a saved World and its state; do not initialize it again to resume. For a new World, choose the exact catalog releases and initialize only the selected vendors with ${cmd('volter world init --twins <selected-vendors> --source <vendor>=<package>', 'world_init with vendors and sources')}.`,
24
+ `3. ${o.tools ? 'Start it with world_up' : 'Start it: `volter world up`'}, then run my usual app command inside it: ${cmd('volter world run -- <the app command>', 'world_run')}. Use throwaway credentials. Diagnose a failure as routing, data, scenario, unsupported operation or runtime; preserve the refusal's evidence rather than bypassing it.`,
25
25
  `4. Tell me what the app did at each vendor (${cmd('volter world log', 'world_log')}), and give me the dashboard link (${o.tools ? 'world_view' : '`volter world view --no-open`, started in the background: it keeps running'}).`,
26
26
  ];
27
27
  if (o.platform) {
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import { existsSync } from 'node:fs';
14
14
  import { join, relative, resolve } from 'node:path';
15
15
  import { forgetPlatform, forgetToken, platformOrigin, requireToken, storePlatform, storeToken, tokenFor } from './credentials.ts';
16
16
  import { parseOriginUrl, World } from './world.ts';
17
+ import { copyExample, examples } from './examples.ts';
17
18
  import { waitForServeShutdown } from './serve-shutdown.ts';
18
19
  import { startUpdateCheck } from './update-notice.ts';
19
20
  import type { ViewConsole } from '@volter/world-runtime';
@@ -37,6 +38,8 @@ world — the twins your app needs, running together (the world of the current
37
38
  volter world activate eval "$(volter world activate)": vendor CLIs and curl in this shell reach the twins
38
39
  volter world shell a subshell with the world active
39
40
  volter world init --bare <org>/<world> --twins a,b a world with no app: a shared world for a team, or one standing in for a vendor
41
+ volter example list ready application starters included in this CLI release
42
+ volter example <name> [--dir <path>] copy a cookbook starter into an empty directory; does not install or start it
40
43
  volter world serve [--port <p>] [--console-port <c>] [--origin <url>] serve this world on a URL under /<org>/<world>/ for apps; prints the token and the console (on loopback: view's front, no token in the browser)
41
44
  volter world view [--port <p>] [--host <h>] [--no-open] [--no-origins] [--origin <url>] step into this world in a browser: its twins' own UIs, timeline, clock, branches (a non-loopback --host serves them on one origin)
42
45
  volter world console [<https://host/org/world>] [--port <p>] the console on this machine for a World served elsewhere (default: this world's origin); needs @volter/world-console
@@ -55,7 +58,7 @@ world — the twins your app needs, running together (the world of the current
55
58
  volter world origin where this world clones from and pushes to
56
59
  volter world changeset [<name>] -m "<message>" cut the unpushed changes into a reviewable changeset
57
60
  volter world pull [--token <token>] fetch, then move this branch onto what came in (git's pull)
58
- volter world push [<name>] [--token <token>] [--force] send entries to the remote; vendor deployment follows its root policy
61
+ volter world push [<name>] [--remote <remote>] [--token <token>] [--force] send entries to the selected remote (default origin); vendor deployment follows its root policy
59
62
  volter world rebase [<changeset>] rebase this branch onto its moved base, or a changeset onto the moved remote
60
63
  volter world verify <changeset> run the world's checks over a changeset and record the result
61
64
  volter world approve <changeset> --as <principal> sign a changeset's current hash
@@ -106,7 +109,19 @@ function positionals(args: string[], valueFlags: string[]): string[] {
106
109
  return out;
107
110
  }
108
111
  function afterDashes(args: string[]): string[] { const i = args.indexOf('--'); return i < 0 ? [] : args.slice(i + 1); }
109
- const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--source', '--host', '--origin', '--scope', '--refresh', '--at-most', '--at'];
112
+ const VALUE_FLAGS = ['--world', '--branch', '--name', '--token', '--admin-token', '-m', '--message', '--base', '--org', '--vendors', '--into', '--deploy', '--as', '--note', '--port', '--twins', '--source', '--host', '--origin', '--remote', '--dir', '--scope', '--refresh', '--at-most', '--at'];
113
+
114
+ /** Help is resolved before opening a World; the child after `run --` owns its own flags. */
115
+ function wantsHelp(noun: string | undefined, verb: string | undefined, args: string[]): boolean {
116
+ const boundary = args.indexOf('--');
117
+ const options = boundary < 0 ? args : args.slice(0, boundary);
118
+ return !noun || noun === 'help' || noun === '--help' || noun === '-h'
119
+ || verb === '--help' || verb === '-h' || options.some((arg) => arg === '--help' || arg === '-h');
120
+ }
121
+ function printHelp(noun?: string, verb?: string): void {
122
+ const lines = noun && verb ? HELP.split('\n').filter((line) => line.startsWith(` volter ${noun} ${verb} `)) : [];
123
+ out(lines.length ? `${lines.join('\n')}\n\n --world <dir> select the app's World\n --branch <name> select a branch\n --json machine-readable output where offered` : HELP);
124
+ }
110
125
 
111
126
  /** A subject as a person names it: an id that already carries its context (`acme/web#1`, `C-ops/ts`) stands alone, with an embedded `<type>:` dropped; a bare id gets its type. */
112
127
  function subjectLabel(subject: { type: string; id: string }): string {
@@ -272,8 +287,9 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
272
287
  }
273
288
  return { positions, views };
274
289
  })();
290
+ const parent = world.name;
275
291
  const made = await world.branch(name, at ? { at } : {});
276
- out(`branch ${made.name} from ${world.isMain() ? 'main' : world.name}${at ? ` at ${atText}` : ''}`);
292
+ out(`branch ${made.name} from ${parent}${at ? ` at ${atText}` : ''}`);
277
293
  return;
278
294
  }
279
295
  case 'checkout': {
@@ -419,8 +435,16 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
419
435
  return;
420
436
  }
421
437
  case 'push': {
422
- const remoteName = 'origin';
423
- const outcomes = await world.push({ ...(pos[0] ? { name: pos[0] } : {}), ...(value(args, '--token') ? { token: value(args, '--token')! } : {}), force: flag(args, '--force') });
438
+ if (pos.length > 1) throw new Error('volter world push [<changeset>] [--remote <name>]: choose the destination with --remote');
439
+ for (let i = 0; i < args.length; i += 1) {
440
+ const arg = args[i]!;
441
+ if (['--remote', '--token', '--world', '--branch'].includes(arg)) {
442
+ if (!args[i + 1] || args[i + 1]!.startsWith('-')) throw new Error(`volter world push: ${arg} needs a value`);
443
+ i += 1;
444
+ } else if (arg.startsWith('-') && !['--force', '--json'].includes(arg)) throw new Error(`volter world push: unknown option ${arg}`);
445
+ }
446
+ const remoteName = value(args, '--remote') ?? 'origin';
447
+ const outcomes = await world.push({ remote: remoteName, ...(pos[0] ? { name: pos[0] } : {}), ...(value(args, '--token') ? { token: value(args, '--token')! } : {}), force: flag(args, '--force') });
424
448
  if (asJson) { json(outcomes); return; }
425
449
  if (outcomes.length === 0) { out('nothing to push'); return; }
426
450
  for (const o of outcomes) {
@@ -799,16 +823,30 @@ function ownVersion(): string {
799
823
 
800
824
  async function main(): Promise<void> {
801
825
  const [noun, verb, ...args] = process.argv.slice(2);
826
+ if (wantsHelp(noun, verb, args)) { printHelp(noun, verb); return; }
802
827
  const notice = startUpdateCheck(process.argv.slice(2));
803
828
  await dispatch(noun, verb, args);
804
829
  await notice();
805
830
  }
806
831
 
807
832
  async function dispatch(noun: string | undefined, verb: string | undefined, args: string[]): Promise<void> {
808
- if (!noun || noun === '--help' || noun === '-h' || noun === 'help') { out(HELP); return; }
809
- if (verb === '--help' || verb === '-h') { out(HELP); return; }
833
+ if (wantsHelp(noun, verb, args)) { printHelp(noun, verb); return; }
810
834
  if (noun === '--version' || noun === '-v' || noun === 'version') { out(`volter ${ownVersion()}`); return; }
811
835
  switch (noun) {
836
+ case 'example': {
837
+ if (!verb || verb === 'list') {
838
+ if (args.length) throw new Error('volter example list takes no arguments');
839
+ for (const e of examples()) out(`${e.id} ${e.title}`); return;
840
+ }
841
+ for (let i = 0; i < args.length; i++) {
842
+ if (args[i] !== '--dir' || !args[i + 1] || args[i + 1]!.startsWith('-')) throw new Error('volter example <name> [--dir <path>]');
843
+ i++;
844
+ }
845
+ const target = copyExample(verb, value(args, '--dir') ?? verb);
846
+ out(`example ${verb} ${target}`);
847
+ out(`Read ${target}/README.md, install its dependencies, then review the vendors before world init.`);
848
+ return;
849
+ }
812
850
  case 'world': {
813
851
  if (!verb) throw new Error(`volter world: which verb?\n\n${HELP}`);
814
852
  await worldCommand(verb, args);
@@ -0,0 +1,24 @@
1
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ type Example = { id: string; title: string; files: string[] };
5
+ const packaged = () => fileURLToPath(new URL('../examples/', import.meta.url));
6
+ export function examples(): Example[] {
7
+ const index = join(packaged(), 'index.json');
8
+ if (!existsSync(index)) throw new Error('Starter files are absent from this CLI build; build @volter/world or install a release containing examples');
9
+ return JSON.parse(readFileSync(index, 'utf8')) as Example[];
10
+ }
11
+ export function copyExample(id: string, directory: string): string {
12
+ const example = examples().find(e => e.id === id);
13
+ if (!example) throw new Error(`Unknown example ${id}; use volter example list`);
14
+ const target = resolve(directory);
15
+ if (existsSync(target) && (!lstatSync(target).isDirectory() || lstatSync(target).isSymbolicLink() || readdirSync(target).length)) throw new Error(`Example destination must be an empty directory: ${target}`);
16
+ const source = join(packaged(), example.id);
17
+ const sourceFile = (file: string): string => join(source, file === '.gitignore' ? 'gitignore.template' : file);
18
+ for (const file of example.files) {
19
+ if (!file || file.includes('..') || file.includes('\\') || file.startsWith('/') || file === '.env' || !lstatSync(sourceFile(file)).isFile() || lstatSync(sourceFile(file)).isSymbolicLink()) throw new Error(`Invalid packaged example file ${file}`);
20
+ }
21
+ mkdirSync(target, { recursive: true });
22
+ for (const file of example.files) { mkdirSync(dirname(join(target, file)), { recursive: true }); copyFileSync(sourceFile(file), join(target, file)); }
23
+ return target;
24
+ }
package/src/mcp.ts CHANGED
@@ -165,8 +165,8 @@ export async function serveMcp(version: string): Promise<void> {
165
165
 
166
166
  server.registerTool('world_push', {
167
167
  title: 'Push to the remote', description: "Send this World's unpushed changes to its remote (the platform's World after remote_link); the remote answers with a receipt for each change.",
168
- inputSchema: { ...dirArg, changeset: z.string().optional().describe('Push one changeset by name; default all unpushed.') },
169
- }, async ({ dir, changeset }) => ran(await volter(['world', 'push', ...(changeset ? [changeset] : [])], here(dir))));
168
+ inputSchema: { ...dirArg, changeset: z.string().optional().describe('Push one changeset by name; default all unpushed.'), remote: z.string().optional().describe('Named destination; default origin.') },
169
+ }, async ({ dir, changeset, remote }) => ran(await volter(['world', 'push', ...(changeset ? [changeset] : []), ...(remote ? ['--remote', remote] : [])], here(dir))));
170
170
 
171
171
  server.registerPrompt('get-started', {
172
172
  title: 'Set up Volter World for this app', description: 'Walks through setting up a World for the app in this folder, running its tests in it, and optionally sharing it on a platform.',
package/src/world.ts CHANGED
@@ -61,10 +61,10 @@ export type WorldStatus = {
61
61
  };
62
62
 
63
63
  /** `https://host/org/world` → the remote's base URL and the namespace it addresses. */
64
- export function parseOriginUrl(url: string): { url: string; namespace: string } {
64
+ export function parseOriginUrl(url: string, worldRoot = process.cwd()): { url: string; namespace: string } {
65
65
  // a PATH is a world on this machine (git's file transport): its directory, and its served name
66
66
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
67
- const root = resolve(url);
67
+ const root = resolve(worldRoot, url);
68
68
  const configPath = join(root, '.volter', 'world.json');
69
69
  if (!existsSync(configPath)) throw new Error(`Not a remote URL or world directory: ${url} (want https://<host>/<org>/<world>, or a directory holding .volter/world.json)`);
70
70
  const config = loadWorldConfig(configPath, root).config;
@@ -134,7 +134,7 @@ export class World {
134
134
  const existing = this.instance();
135
135
  if (existing?.running) return existing; // already up: nothing to do
136
136
  const instance = await upWorld(worldConfigRelative(), {
137
- name: this.name, root: this.root, envFile: worldEnvPath(this.root),
137
+ name: this.name, root: this.root, envFile: existing?.envFile ?? worldEnvPath(this.root),
138
138
  ...(opts.mode ? { mode: opts.mode } : existing ? { mode: existing.mode } : {}),
139
139
  ...(existing ? { keepState: true } : {}),
140
140
  });
@@ -182,7 +182,7 @@ export class World {
182
182
  // pushed to it has not fetched, but it has an origin all the same)
183
183
  origin: (() => { const o = this.origin(); return o ? { url: o.url, namespace: o.namespace, ...(o.fetchedAt ? { fetchedAt: o.fetchedAt } : {}) } : null; })(),
184
184
  unpushed: instance ? this.unpushed().length : 0,
185
- changesets: { total: changesets.length, pushed: changesets.filter((c) => c.changeset.applied?.outcome === 'applied').length },
185
+ changesets: { total: changesets.length, pushed: changesets.filter(({ changeset: c }) => c.applied?.outcome === 'applied' && (!c.applied.destination || (c.applied.destination.url === this.origin()?.url && c.applied.destination.namespace === this.origin()?.namespace))).length },
186
186
  services,
187
187
  ...(instance ? { envFile: instance.envFile } : {}),
188
188
  };
@@ -198,7 +198,7 @@ export class World {
198
198
  }
199
199
 
200
200
  // ── the log ─────────────────────────────────────────────────────────────────────────────
201
- /** Every write the app made, across the twins, oldest first, with the receipt against each pushed one. */
201
+ /** Every recorded write grouped by vendor in its position order, with delivery receipts where available. */
202
202
  log(): LogEntry[] {
203
203
  const receipts = new Map<string, { receipt: ApplyReceipt; changeset: string }>();
204
204
  for (const located of this.changesets()) for (const receipt of located.changeset.applied?.receipts ?? []) receipts.set(receipt.actionId, { receipt, changeset: located.changeset.name });
@@ -227,7 +227,10 @@ export class World {
227
227
  rows.push(l ? { ...row, landed: l } : r ? { ...row, receipt: r.receipt, changeset: r.changeset } : row);
228
228
  }
229
229
  }
230
- return rows.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.id.localeCompare(b.id));
230
+ // Each vendor has its own position; timestamps (including a frozen clock) never shuffle it.
231
+ // Group vendors rather than inventing a global sequence from independent positions.
232
+ return rows.sort((a, b) => a.service.localeCompare(b.service) ||
233
+ (a.position !== undefined && b.position !== undefined ? a.position - b.position : a.occurredAt.localeCompare(b.occurredAt)));
231
234
  }
232
235
  /** Changes not yet pushed (`log origin..HEAD`), across the twins. */
233
236
  unpushed(): TwinAction[] { return this.repos().flatMap((r) => r.unpushed({ pushable: true })).sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); }
@@ -363,7 +366,7 @@ export class World {
363
366
  const { path, config } = loadWorldConfig(this.configRef(), this.root);
364
367
  const remotes = { ...(config.remotes ?? {}), [name]: target };
365
368
  writeWorldConfig(path, { ...config, remotes });
366
- if (opts.token) storeToken(parseOriginUrl(target).url, opts.token);
369
+ if (opts.token) storeToken(parseOriginUrl(target, this.root).url, opts.token);
367
370
  }
368
371
  removeRemote(name: string): void {
369
372
  const { path, config } = loadWorldConfig(this.configRef(), this.root);
@@ -376,7 +379,7 @@ export class World {
376
379
  const instance = name === 'origin' ? this.instance() : null;
377
380
  if (instance && instance.name !== instance.config) return instance.origin ? { url: instance.origin.url, namespace: instance.origin.namespace } : null;
378
381
  const target = this.remotes()[name];
379
- if (target) return parseOriginUrl(target);
382
+ if (target) return parseOriginUrl(target, this.root);
380
383
  if (name !== 'origin') return null;
381
384
  const recorded = this.recordedOrigin();
382
385
  return recorded ? { url: recorded.url, namespace: recorded.namespace } : null;
@@ -398,7 +401,7 @@ export class World {
398
401
  * the world's, which is what `fetch` and `push` use afterwards.
399
402
  */
400
403
  async clone(url: string, opts: { token?: string; adminToken?: string } = {}): Promise<ReturnType<typeof fetchFromOrigin>> {
401
- const origin = parseOriginUrl(url);
404
+ const origin = parseOriginUrl(url, this.root);
402
405
  if (opts.token !== undefined && opts.token !== '') storeToken(origin.url, opts.token);
403
406
  if (opts.adminToken !== undefined && opts.adminToken !== '') {
404
407
  if (opts.token === undefined || opts.token === '') storeToken(origin.url, opts.adminToken);
@@ -448,23 +451,32 @@ export class World {
448
451
  }
449
452
  changesets(): ChangesetLocation[] { return listWorldChangesets({ root: this.root, world: this.name }); }
450
453
  /**
451
- * Push to the origin, which deploys to the vendor with its own keys and answers with receipts:
454
+ * Push to a selected remote (origin by default), which answers with receipts under its policy:
452
455
  * the named changeset, or every changeset not yet pushed, oldest first. A local world never
453
456
  * holds a vendor credential.
454
457
  */
455
- async push(opts: { name?: string; token?: string; force?: boolean } = {}): Promise<Array<Awaited<ReturnType<typeof pushWorldChangeset>>>> {
456
- const origin = this.origin();
457
- if (!origin) throw new Error(`World "${this.name}" has no origin to push to — the default data cannot be pushed to. \`volter world clone <url>\` connects a remote.`);
458
+ async push(opts: { name?: string; remote?: string; token?: string; force?: boolean } = {}): Promise<Array<Awaited<ReturnType<typeof pushWorldChangeset>>>> {
459
+ const remoteName = opts.remote ?? 'origin';
460
+ const origin = this.remote(remoteName);
461
+ if (!origin) throw new Error(remoteName === 'origin'
462
+ ? `World "${this.name}" has no origin to push to — the default data cannot be pushed to. \`volter world clone <url>\` connects a remote.`
463
+ : `World "${this.name}" has no remote "${remoteName}" — \`volter remote add ${remoteName} <url|path>\` names one.`);
464
+ const tracked = this.origin();
465
+ const trackOrigin = tracked?.url === origin.url && tracked.namespace === origin.namespace;
458
466
  if (opts.token !== undefined && opts.token !== '') storeToken(origin.url, opts.token);
459
467
  const key = requireToken(origin.url, opts.token);
460
468
  const to = { to: origin.url, namespace: origin.namespace };
461
469
  const queue = opts.name !== undefined
462
470
  ? [findWorldChangeset(opts.name, { root: this.root, world: this.name })]
463
- : this.changesets().filter((c) => c.changeset.applied === null || c.changeset.applied.outcome === 'refused').sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
471
+ : this.changesets().filter((c) => {
472
+ const applied = c.changeset.applied;
473
+ if (!applied || applied.outcome === 'refused') return true;
474
+ return applied.destination ? applied.destination.url !== origin.url || applied.destination.namespace !== origin.namespace : !trackOrigin;
475
+ }).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
464
476
  if (queue.length === 0) throw new Error(this.unpushed().length ? 'Nothing to push: cut a changeset first — `volter world changeset -m "<what and why>"`' : 'Nothing to push: no unpushed changes');
465
477
  const outcomes: Array<Awaited<ReturnType<typeof pushWorldChangeset>>> = [];
466
478
  for (const located of queue) {
467
- const outcome = await pushWorldChangeset(located.changeset.name, { root: this.root, world: this.name, ...to, key, ...(opts.force ? { force: true } : {}) });
479
+ const outcome = await pushWorldChangeset(located.changeset.name, { root: this.root, world: this.name, ...to, key, trackOrigin, ...(opts.force ? { force: true } : {}) });
468
480
  outcomes.push(outcome);
469
481
  if (outcome.application.outcome === 'refused') break;
470
482
  }