@venturewild/workspace 0.6.3 → 0.6.4
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/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +85 -85
- package/server/bin/wild-workspace.mjs +1096 -1096
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -468
- package/server/src/bazaar/core.mjs +974 -974
- package/server/src/bazaar/index.mjs +88 -88
- package/server/src/bazaar/mcp-server.mjs +429 -429
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
- package/server/src/canvas/core.mjs +446 -446
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/canvas-rails.mjs +108 -108
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +3332 -3332
- package/server/src/listings-rails.mjs +156 -156
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -145
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -213
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -405
- package/server/src/workspace-registry.mjs +295 -295
- package/server/src/workspaces.mjs +145 -145
|
@@ -1,974 +1,974 @@
|
|
|
1
|
-
// Bazaar core — the shelf, search/ranking, and the transaction ledger.
|
|
2
|
-
//
|
|
3
|
-
// Faithful to UX §3.7: a *network* of producers' recipes that a consumer's agent
|
|
4
|
-
// reaches onto, picks by "which actually works" (measured outcomes), absorbs, and
|
|
5
|
-
// builds — recording a three-way transaction (consumer got it · producer earns ·
|
|
6
|
-
// platform recorded it).
|
|
7
|
-
//
|
|
8
|
-
// State lives ENTIRELY under ~/.wild-workspace/bazaar/ (an absolute path OUTSIDE
|
|
9
|
-
// the user's repo — CLAUDE.md rule #1). One module, imported by BOTH the main
|
|
10
|
-
// server and the spawned MCP server, so there is a single source of truth and no
|
|
11
|
-
// HTTP/port handshake between processes:
|
|
12
|
-
// - events.jsonl append-only event log (use · service-call · publish). Append
|
|
13
|
-
// is multi-writer safe (the MCP process writes use/publish; the
|
|
14
|
-
// main process's preview server writes service-call).
|
|
15
|
-
// - listings.json user-published listings (the flip side). MCP writes, main reads.
|
|
16
|
-
// - preview.json the current preview target {dir,recipeId}. MCP writes, main reads.
|
|
17
|
-
//
|
|
18
|
-
// Seed recipes (our product content — the shelf) ship in-repo under seed-recipes/.
|
|
19
|
-
|
|
20
|
-
import fs from 'node:fs';
|
|
21
|
-
import path from 'node:path';
|
|
22
|
-
import os from 'node:os';
|
|
23
|
-
import crypto from 'node:crypto';
|
|
24
|
-
import { fileURLToPath } from 'node:url';
|
|
25
|
-
|
|
26
|
-
import { normalizeTheme } from '../canvas/core.mjs';
|
|
27
|
-
|
|
28
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
-
const SEED_DIR = path.join(__dirname, 'seed-recipes');
|
|
30
|
-
|
|
31
|
-
// Seed THEMES — the first cross-user marketplace artifact (a theme is a hex-token
|
|
32
|
-
// bundle: pure data, zero execution risk, and its own preview). These ship as a
|
|
33
|
-
// small network of "producers" so the bazaar's Themes shelf is populated out of
|
|
34
|
-
// the box; a user's own published theme lands beside them (listings.json).
|
|
35
|
-
export const SEED_THEMES = [
|
|
36
|
-
{
|
|
37
|
-
id: 'theme-midnight-cyan', kind: 'theme', source: 'theme',
|
|
38
|
-
title: 'Midnight Cyan', pitch: 'The venturewild.llc night look — deep navy, electric cyan.',
|
|
39
|
-
summary: 'A focused dark workspace: near-black navy with a bright cyan accent and cool slate cards.',
|
|
40
|
-
tags: ['dark', 'cyan', 'focus'],
|
|
41
|
-
producer: { name: 'VentureWild', handle: 'venturewild', kind: 'vendor' },
|
|
42
|
-
theme: { mode: 'dark', accent: '#22d3ee', tokens: { bg: '#0a0c10', surface: '#11141a', text: '#e8eaed', textMuted: '#8b95a3', border: '#1f242d', canvas1: '#0b1620', canvas2: '#0c1320', canvas3: '#0a0f14' } },
|
|
43
|
-
outcomeScore: 0.92, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
|
|
44
|
-
rating: { stars: 5, count: 24 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
id: 'theme-sunset', kind: 'theme', source: 'theme',
|
|
48
|
-
title: 'Warm Sunset', pitch: 'Dusty plum to amber — warm, low-glare, easy at night.',
|
|
49
|
-
summary: 'A cosy dark theme: plum background fading to amber, with a glowing orange accent.',
|
|
50
|
-
tags: ['dark', 'warm', 'orange'],
|
|
51
|
-
producer: { name: 'Lina', handle: 'lina', kind: 'maker' },
|
|
52
|
-
theme: { mode: 'dark', accent: '#ff7a3c', tokens: { bg: '#1a1014', surface: '#2a1a1f', text: '#fbeee6', textMuted: '#c7a89a', border: '#43292f', canvas1: '#2d1620', canvas2: '#5a2a2c', canvas3: '#b85c2e' } },
|
|
53
|
-
outcomeScore: 0.84, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
|
|
54
|
-
rating: { stars: 4, count: 11 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
id: 'theme-forest', kind: 'theme', source: 'theme',
|
|
58
|
-
title: 'Forest Light', pitch: 'Soft sage & cream — a calm, bright daytime desk.',
|
|
59
|
-
summary: 'A gentle light theme: sage-green wallpaper, cream cards, a deep forest accent.',
|
|
60
|
-
tags: ['light', 'green', 'calm'],
|
|
61
|
-
producer: { name: 'Mateo', handle: 'mateo', kind: 'maker' },
|
|
62
|
-
theme: { mode: 'light', accent: '#15803d', tokens: { bg: '#fbfdfb', surface: '#ffffff', text: '#1a2b22', textMuted: '#5c7064', border: '#dde9e0', canvas1: '#e3f3e7', canvas2: '#eaf4ec', canvas3: '#fbfdf6' } },
|
|
63
|
-
outcomeScore: 0.79, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
|
|
64
|
-
rating: { stars: 4, count: 8 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
id: 'theme-mono', kind: 'theme', source: 'theme',
|
|
68
|
-
title: 'Mono Slate', pitch: 'Greyscale, no distractions — one quiet blue accent.',
|
|
69
|
-
summary: 'A neutral light theme for heads-down work: warm greys, a single muted-blue accent.',
|
|
70
|
-
tags: ['light', 'minimal', 'mono'],
|
|
71
|
-
producer: { name: 'Priya', handle: 'priya', kind: 'maker' },
|
|
72
|
-
theme: { mode: 'light', accent: '#475569', tokens: { bg: '#fafafa', surface: '#ffffff', text: '#1e2530', textMuted: '#6b7280', border: '#e6e8ec', canvas1: '#eef0f3', canvas2: '#f3f4f6', canvas3: '#fafafa' } },
|
|
73
|
-
outcomeScore: 0.74, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
|
|
74
|
-
rating: { stars: 4, count: 6 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
75
|
-
},
|
|
76
|
-
];
|
|
77
|
-
|
|
78
|
-
// ~/.wild-workspace/bazaar — mirrors logpaths.globalDir() but kept dependency-free
|
|
79
|
-
// here so the MCP child can import this module standalone.
|
|
80
|
-
export function defaultBazaarDir(env = process.env) {
|
|
81
|
-
const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
|
|
82
|
-
return path.join(base, 'bazaar');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function rid() {
|
|
86
|
-
return crypto.randomUUID().slice(0, 12);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function readJsonSafe(file, fallback) {
|
|
90
|
-
try {
|
|
91
|
-
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
92
|
-
} catch {
|
|
93
|
-
return fallback;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function writeJsonAtomic(file, value) {
|
|
98
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
99
|
-
fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
100
|
-
fs.renameSync(tmp, file); // Node rename replaces the destination on all platforms
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// --- recipe loading -------------------------------------------------------
|
|
104
|
-
|
|
105
|
-
function loadSeedRecipes(seedDir = SEED_DIR) {
|
|
106
|
-
let entries = [];
|
|
107
|
-
try {
|
|
108
|
-
entries = fs.readdirSync(seedDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
109
|
-
} catch {
|
|
110
|
-
return [];
|
|
111
|
-
}
|
|
112
|
-
const recipes = [];
|
|
113
|
-
for (const e of entries) {
|
|
114
|
-
const dir = path.join(seedDir, e.name);
|
|
115
|
-
const meta = readJsonSafe(path.join(dir, 'recipe.json'), null);
|
|
116
|
-
if (!meta || !meta.id) continue;
|
|
117
|
-
let knowHow = '';
|
|
118
|
-
try {
|
|
119
|
-
knowHow = fs.readFileSync(path.join(dir, 'know-how.md'), 'utf8');
|
|
120
|
-
} catch {
|
|
121
|
-
/* a recipe with no know-how file is still listable */
|
|
122
|
-
}
|
|
123
|
-
recipes.push({ ...meta, knowHow, source: 'seed' });
|
|
124
|
-
}
|
|
125
|
-
return recipes;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// --- search / ranking -----------------------------------------------------
|
|
129
|
-
// The agent's pick signal is "which recipe actually works" (outcomeScore), NOT
|
|
130
|
-
// marketing or stars (§3.7). Tag relevance gates the candidates; outcomeScore
|
|
131
|
-
// breaks ties and floats proven recipes to the top of the shelf.
|
|
132
|
-
|
|
133
|
-
function normalize(s) {
|
|
134
|
-
return String(s || '')
|
|
135
|
-
.toLowerCase()
|
|
136
|
-
.replace(/[^a-z0-9]+/g, ' ')
|
|
137
|
-
.trim();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function stem(word) {
|
|
141
|
-
// ultra-light: drop a trailing plural 's' so "candidates" matches "candidate"
|
|
142
|
-
return word.length > 3 && word.endsWith('s') ? word.slice(0, -1) : word;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function scoreRecipe(recipe, need) {
|
|
146
|
-
const needNorm = ` ${normalize(need)} `;
|
|
147
|
-
const needWords = new Set(needNorm.trim().split(' ').filter(Boolean).map(stem));
|
|
148
|
-
let score = 0;
|
|
149
|
-
const matched = [];
|
|
150
|
-
for (const tag of recipe.tags || []) {
|
|
151
|
-
const tagNorm = normalize(tag);
|
|
152
|
-
if (!tagNorm) continue;
|
|
153
|
-
if (tagNorm.includes(' ')) {
|
|
154
|
-
// multi-word tag: a phrase hit is a strong signal
|
|
155
|
-
if (needNorm.includes(` ${tagNorm} `) || needNorm.includes(tagNorm)) {
|
|
156
|
-
score += 3;
|
|
157
|
-
matched.push(tag);
|
|
158
|
-
}
|
|
159
|
-
} else if (needWords.has(stem(tagNorm))) {
|
|
160
|
-
score += 1;
|
|
161
|
-
matched.push(tag);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return { score, matched };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// --- Class-B auto-scan (B2) ------------------------------------------------
|
|
168
|
-
// A conservative, pattern-based check that a published recipe carries no baked-in
|
|
169
|
-
// secrets and no obviously destructive / remote-exec / persistence / exfil command.
|
|
170
|
-
// It gates the FAST lane (Class B self-attest), NOT a full audit — Class C/D get
|
|
171
|
-
// human review. High-confidence patterns only, so a clean recipe is rarely
|
|
172
|
-
// false-flagged (the adversarial corpus in bazaar-scan.test.mjs holds it at 0 false
|
|
173
|
-
// positives over a battery of clean recipes). Never throws.
|
|
174
|
-
//
|
|
175
|
-
// ⚠️ HARD LIMIT — this scan catches MECHANICALLY-DETECTABLE threats (literal secrets,
|
|
176
|
-
// known-malicious command shapes). It CANNOT catch natural-language instructions that
|
|
177
|
-
// steer the agent ("include the contents of ~/.aws/credentials in the config", "add a
|
|
178
|
-
// snippet that POSTs the form data to evil.com"). A Class-B recipe's know-how is
|
|
179
|
-
// *instructions an agent reads and acts on*, not inert data, so a pattern scan is
|
|
180
|
-
// NECESSARY-but-NOT-SUFFICIENT to make recipes safe cross-user. See
|
|
181
|
-
// docs/class-b-cross-user-readiness.md — recipes stay own/teammate-only until a
|
|
182
|
-
// structural control (consent-gated review of a stranger's know-how, or a constrained
|
|
183
|
-
// build permission mode) lands. Themes (Class A, pure hex data) are unaffected.
|
|
184
|
-
const MAX_SCAN_CHARS = 256 * 1024; // bound the work on a pathological giant payload
|
|
185
|
-
const UNSAFE_PATTERNS = [
|
|
186
|
-
// --- baked-in secrets ---
|
|
187
|
-
{ kind: 'secret', note: 'private key', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
|
|
188
|
-
{ kind: 'secret', note: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
189
|
-
{ kind: 'secret', note: 'API secret key (Stripe-style)', re: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
|
|
190
|
-
{ kind: 'secret', note: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
|
|
191
|
-
{ kind: 'secret', note: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
192
|
-
{ kind: 'secret', note: 'Google API key', re: /\bAIza[0-9A-Za-z_\-]{20,}/ },
|
|
193
|
-
{ kind: 'secret', note: 'hardcoded JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ },
|
|
194
|
-
{ kind: 'secret', note: 'hardcoded credential', re: /(?:api[_-]?key|secret|password|passwd|access[_-]?token)\s*[:=]\s*['"][^'"\s]{12,}['"]/i },
|
|
195
|
-
// --- destructive commands (only when aimed at root / home / a raw disk — relative
|
|
196
|
-
// build-dir deletes like `rm -rf ./dist` or `rm -rf node_modules` stay clean) ---
|
|
197
|
-
{ kind: 'destructive', note: 'recursive delete of a root/home path', re: /\brm\s+-[a-z]*r[a-z]*f?\s+["']?(?:[~/]|\$\{?HOME|\$env:USERPROFILE|\$env:HOME|%USERPROFILE%)/i },
|
|
198
|
-
{ kind: 'destructive', note: 'fork bomb', re: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;\s*:/ },
|
|
199
|
-
{ kind: 'destructive', note: 'raw write to a disk device', re: /\bdd\b[^\n]*\bof=\/dev\/(?:sd|nvme|hd|vd|disk|mapper)/i },
|
|
200
|
-
{ kind: 'destructive', note: 'format a disk device', re: /\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i },
|
|
201
|
-
{ kind: 'destructive', note: 'overwrite a disk device', re: />\s*\/dev\/(?:sd|nvme|hd|vd|disk)/i },
|
|
202
|
-
{ kind: 'destructive', note: 'recursive 777 on root/home', re: /\bchmod\s+-[a-z]*R[a-z]*\s+[0-7]*777\s+(?:\/|~|\$HOME)/i },
|
|
203
|
-
{ kind: 'destructive', note: 'Windows recursive delete of a system path', re: /\bdel\s+[^\n]*\/s\b[^\n]*(?:[a-z]:\\|system32)/i },
|
|
204
|
-
{ kind: 'destructive', note: 'format a Windows drive', re: /\bformat\s+[a-z]:(?:\s|\/|$)/i },
|
|
205
|
-
{ kind: 'destructive', note: 'PowerShell recursive force-delete of home/drive', re: /Remove-Item(?=[^\n]*-Recurse)(?=[^\n]*-Force)[^\n]*(?:\$HOME|\$env:USERPROFILE|[a-z]:\\)/i },
|
|
206
|
-
// --- remote-exec / reverse-shell / exfiltration ---
|
|
207
|
-
{ kind: 'exfiltrate', note: 'pipe a remote script straight into a shell', re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:(?:ba|z|k|da)?sh|fish|python[0-9.]*|ruby|perl|node|php)\b/i },
|
|
208
|
-
{ kind: 'exfiltrate', note: 'decode a blob straight into a shell', re: /\bbase64\b[^\n|]*\|\s*(?:sudo\s+)?(?:(?:ba|z|k|da)?sh|fish|python[0-9.]*|ruby|perl|node|php)\b/i },
|
|
209
|
-
{ kind: 'exfiltrate', note: 'bash reverse shell via /dev/tcp', re: /\/dev\/tcp\/[^\s/]+\/\d+/i },
|
|
210
|
-
{ kind: 'exfiltrate', note: 'eval of a remote download', re: /\beval\b[^\n]*\$\([^)]*(?:curl|wget)\b/i },
|
|
211
|
-
{ kind: 'exfiltrate', note: 'netcat exec / reverse shell', re: /\bn(?:c|cat|etcat)\b[^\n]*\s-[ec]\b/i },
|
|
212
|
-
{ kind: 'exfiltrate', note: 'pipe the environment to a network tool', re: /\b(?:env|printenv)\b\s*\|\s*(?:curl|wget|nc|ncat|netcat|telnet)\b/i },
|
|
213
|
-
{ kind: 'exfiltrate', note: 'node -e with network/exec/secret access', re: /\bnode\s+(?:-e|--eval|-p|--print)\b[^\n]*(?:require\(\s*['"](?:https?|net|dgram|tls|child_process)['"]|child_process|\.ssh\/|\.aws\/)/i },
|
|
214
|
-
{ kind: 'exfiltrate', note: 'python -c with network/exec/secret access', re: /\bpython[0-9.]*\s+-c\b[^\n]*(?:import\s+(?:socket|urllib|requests|http\.client)|os\.system|subprocess\.|socket\.|\.ssh\/|\.aws\/)/i },
|
|
215
|
-
{ kind: 'exfiltrate', note: 'PowerShell download piped into Invoke-Expression', re: /\b(?:iwr|Invoke-WebRequest|curl|wget|DownloadString|Net\.WebClient)\b[^\n]*\|\s*(?:iex|Invoke-Expression)\b/i },
|
|
216
|
-
{ kind: 'exfiltrate', note: 'PowerShell Invoke-Expression of a download', re: /\b(?:iex|Invoke-Expression)\b[^\n]*(?:DownloadString|Net\.WebClient|iwr|Invoke-WebRequest)/i },
|
|
217
|
-
{ kind: 'exfiltrate', note: 'pipe a secret file into a network tool', re: /(?:\bcat\b|\btype\b|\bGet-Content\b)[^\n|]*(?:\.ssh\/|\.aws\/|id_rsa|id_ed25519|credentials|\.pem|\.netrc)[^\n|]*\|\s*(?:curl|wget|nc|ncat|netcat|scp|Invoke-RestMethod|iwr)\b/i },
|
|
218
|
-
{ kind: 'exfiltrate', note: 'copy a secret file to a remote host', re: /\bscp\s+[^\n]*(?:\.ssh\/|\.aws\/|id_rsa|id_ed25519|credentials|\.pem)[^\n]*\s+\S+@\S+:/i },
|
|
219
|
-
// --- persistence / access to well-known secret files ---
|
|
220
|
-
{ kind: 'persistence', note: 'write to SSH authorized_keys', re: /(?:>>|>|\btee\b|\bcat\b)[^\n]*authorized_keys\b/i },
|
|
221
|
-
{ kind: 'sensitive-file', note: 'reads a private key / cloud credential file', re: /(?:~|\$HOME|\$env:USERPROFILE)?[\/\\]?\.(?:aws[\/\\]credentials|ssh[\/\\]id_(?:rsa|ed25519|ecdsa)|netrc)\b/i },
|
|
222
|
-
];
|
|
223
|
-
export function scanForUnsafe(text) {
|
|
224
|
-
const s = String(text || '').slice(0, MAX_SCAN_CHARS);
|
|
225
|
-
const findings = [];
|
|
226
|
-
const seen = new Set();
|
|
227
|
-
for (const p of UNSAFE_PATTERNS) {
|
|
228
|
-
if (p.re.test(s) && !seen.has(p.note)) {
|
|
229
|
-
seen.add(p.note);
|
|
230
|
-
findings.push({ kind: p.kind, note: p.note });
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return { clean: findings.length === 0, findings };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// --- the bazaar instance --------------------------------------------------
|
|
237
|
-
|
|
238
|
-
// Outcome score = a Bayesian-smoothed build success rate (B1). The recipe's
|
|
239
|
-
// declared outcomeScore is the PRIOR mean; this many pseudo-builds give it weight,
|
|
240
|
-
// so a brand-new listing or a thin sample isn't whipsawed by one result, while a
|
|
241
|
-
// recipe with real volume converges on its true rate.
|
|
242
|
-
const OUTCOME_PRIOR_K = 4;
|
|
243
|
-
|
|
244
|
-
export function createBazaar({ baseDir, seedDir = SEED_DIR, rails = null } = {}) {
|
|
245
|
-
const dir = baseDir || defaultBazaarDir();
|
|
246
|
-
const eventsFile = path.join(dir, 'events.jsonl');
|
|
247
|
-
const listingsFile = path.join(dir, 'listings.json');
|
|
248
|
-
const previewFile = path.join(dir, 'preview.json');
|
|
249
|
-
// The cross-user theme pool, cached locally (next push §N). The MAIN server
|
|
250
|
-
// periodically pulls the global pool from the rails and writes it here; BOTH
|
|
251
|
-
// this process and the spawned MCP child read it SYNCHRONOUSLY in shelf() — the
|
|
252
|
-
// child has no rails access of its own, exactly the canvas "rails=truth → local
|
|
253
|
-
// cache below" model. `rails` (the ListingsRails client) is injected only into
|
|
254
|
-
// the main server's instance, for the immediate push on the human publish path;
|
|
255
|
-
// the MCP child's instance has none (the periodic reconcile pushes its writes).
|
|
256
|
-
const railsThemesFile = path.join(dir, 'rails-themes.json');
|
|
257
|
-
// The cross-user RECIPE pool, cached locally (reframed Class-B cross-user, 2026-06-15).
|
|
258
|
-
// Same shape as the theme cache, but the payload is the producer's `knowHow` (the
|
|
259
|
-
// build instructions the agent absorbs), not a hex bundle. readRailsRecipes() below
|
|
260
|
-
// re-runs the Class-B scan on every pool recipe on read and DROPS any that trip it —
|
|
261
|
-
// a hard, defense-in-depth filter so a stranger's recipe with a baked-in
|
|
262
|
-
// secret/destructive/exfil command never reaches the shelf even if it slipped past
|
|
263
|
-
// publish or the cache was tampered. The remaining (un-scannable) judgment — "do these
|
|
264
|
-
// natural-language instructions look safe?" — is the AGENT's call at open_recipe time
|
|
265
|
-
// (source:'rails' is surfaced to it), NOT a platform consent wall. See
|
|
266
|
-
// docs/class-b-cross-user-readiness.md.
|
|
267
|
-
const railsRecipesFile = path.join(dir, 'rails-recipes.json');
|
|
268
|
-
// Self-seed drafts (cold-start, Producer #1): recipes the agent EXTRACTED from
|
|
269
|
-
// the user's existing work, staged here for REVIEW. Nothing reaches the shelf
|
|
270
|
-
// until the user approves a draft (publishDraft). The safety control is that a
|
|
271
|
-
// draft holds only generalized know-how — never client files/names/secrets.
|
|
272
|
-
const draftsFile = path.join(dir, 'drafts.json');
|
|
273
|
-
|
|
274
|
-
function ensureDir() {
|
|
275
|
-
try {
|
|
276
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
277
|
-
} catch {
|
|
278
|
-
/* read-only fs — degrades to no persistence */
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// --- live outcome telemetry (B1) ----------------------------------------
|
|
283
|
-
// Real build-result events (recorded by record_build_result after a build lands
|
|
284
|
-
// or fails) are the signal behind outcomeScore. Cached on the events file's
|
|
285
|
-
// size+mtime so it stays correct even when the MCP child process appends.
|
|
286
|
-
let _outcomeCache = null;
|
|
287
|
-
let _outcomeCacheSig = null;
|
|
288
|
-
function liveOutcomes() {
|
|
289
|
-
let sig = '0';
|
|
290
|
-
try { const st = fs.statSync(eventsFile); sig = `${st.size}:${st.mtimeMs}`; } catch { /* no file yet */ }
|
|
291
|
-
if (_outcomeCache && _outcomeCacheSig === sig) return _outcomeCache;
|
|
292
|
-
const map = {};
|
|
293
|
-
for (const e of events()) {
|
|
294
|
-
if (e.type !== 'build-result' || !e.recipeId) continue;
|
|
295
|
-
const m = (map[e.recipeId] ??= { builds: 0, working: 0 });
|
|
296
|
-
m.builds += 1;
|
|
297
|
-
if (e.success) m.working += 1;
|
|
298
|
-
}
|
|
299
|
-
_outcomeCache = map;
|
|
300
|
-
_outcomeCacheSig = sig;
|
|
301
|
-
return map;
|
|
302
|
-
}
|
|
303
|
-
// The recipe's accumulated { builds, working }: its seed baseline (missing →
|
|
304
|
-
// {0,0}, the migration for old listings) plus live build-results.
|
|
305
|
-
function liveStats(r) {
|
|
306
|
-
const base = r.outcomeStats && typeof r.outcomeStats === 'object' ? r.outcomeStats : { builds: 0, working: 0 };
|
|
307
|
-
const live = liveOutcomes()[r.id] || { builds: 0, working: 0 };
|
|
308
|
-
return { builds: (base.builds || 0) + live.builds, working: (base.working || 0) + live.working };
|
|
309
|
-
}
|
|
310
|
-
// Bayesian-smoothed success rate. Prior mean = the declared outcomeScore (0.7 for
|
|
311
|
-
// an undeclared listing); with zero live results it equals the declared score, so
|
|
312
|
-
// seed rankings stay stable until real outcomes accumulate.
|
|
313
|
-
function liveScore(r) {
|
|
314
|
-
const prior = typeof r.outcomeScore === 'number' ? r.outcomeScore : 0.7;
|
|
315
|
-
const { builds, working } = liveStats(r);
|
|
316
|
-
const score = (working + OUTCOME_PRIOR_K * prior) / (builds + OUTCOME_PRIOR_K);
|
|
317
|
-
return Math.max(0, Math.min(1, Math.round(score * 1000) / 1000));
|
|
318
|
-
}
|
|
319
|
-
// The agent reports whether a build that used a recipe actually WORKED (B1) — the
|
|
320
|
-
// real signal behind the outcome score. Appended to events.jsonl; liveScore reads
|
|
321
|
-
// it on the next card render. Unknown recipe → a no-op error (keeps events clean).
|
|
322
|
-
function recordBuildResult({ recipeId, success, reason = '' } = {}) {
|
|
323
|
-
const r = getRecipe(recipeId);
|
|
324
|
-
if (!r) return { ok: false, error: `no recipe "${recipeId}" on the shelf` };
|
|
325
|
-
const evt = appendEvent({
|
|
326
|
-
type: 'build-result',
|
|
327
|
-
recipeId: r.id,
|
|
328
|
-
title: r.title,
|
|
329
|
-
success: !!success,
|
|
330
|
-
reason: String(reason || '').slice(0, 200),
|
|
331
|
-
});
|
|
332
|
-
return { ok: true, event: evt, outcome: { ...liveStats(r), score: liveScore(r) } };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// --- trust & provenance (B2) --------------------------------------------
|
|
336
|
-
// The §3 schema (docs/shelf-trust-provenance-design.md). riskClass is the spine:
|
|
337
|
-
// A data (themes) · B local build-recipe · C connected-service · D executable-skill.
|
|
338
|
-
// provenanceFor() also MIGRATES old records on read — a listing with the legacy
|
|
339
|
-
// safetyBadge but no riskClass gets defaults by kind, and a free-text sourceNote
|
|
340
|
-
// becomes a builtFrom[] entry — so an existing listings.json never breaks.
|
|
341
|
-
function riskClassOf(r) {
|
|
342
|
-
if (['A', 'B', 'C', 'D'].includes(r.riskClass)) return r.riskClass;
|
|
343
|
-
if (r.kind === 'theme') return 'A';
|
|
344
|
-
if (r.service || r.hasService) return 'C';
|
|
345
|
-
return 'B';
|
|
346
|
-
}
|
|
347
|
-
function normalizeBuiltFrom(r) {
|
|
348
|
-
if (Array.isArray(r.builtFrom)) {
|
|
349
|
-
return r.builtFrom.filter(Boolean).map((b) => ({ type: b.type || 'recipe', id: b.id ?? null, note: b.note || b.title || '' }));
|
|
350
|
-
}
|
|
351
|
-
if (r.builtFrom && (r.builtFrom.id || r.builtFrom.title)) {
|
|
352
|
-
// the C2 remix pointer { id, title } → the schema's array form
|
|
353
|
-
return [{ type: r.kind === 'theme' ? 'theme' : 'recipe', id: r.builtFrom.id ?? null, note: r.builtFrom.title || '' }];
|
|
354
|
-
}
|
|
355
|
-
if (r.sourceNote) return [{ type: 'scratch', id: null, note: String(r.sourceNote) }]; // migrate free-text
|
|
356
|
-
return [];
|
|
357
|
-
}
|
|
358
|
-
function provenanceFor(r) {
|
|
359
|
-
const riskClass = riskClassOf(r);
|
|
360
|
-
const trusted = r.source === 'seed' || r.source === 'theme'; // VW-shipped → pre-vetted
|
|
361
|
-
const dataTouched = r.dataTouched || (riskClass === 'C'
|
|
362
|
-
? { egress: [], scope: 'external', paid: { model: r.reward?.perUseValue ? 'per-use' : 'none', note: '' } }
|
|
363
|
-
: { egress: [], scope: 'in-workspace', paid: { model: 'none', note: '' } });
|
|
364
|
-
const attestation = r.attestation || {
|
|
365
|
-
privacy: riskClass === 'C' ? 'not-attested' : 'n/a',
|
|
366
|
-
attestedBy: null,
|
|
367
|
-
at: null,
|
|
368
|
-
};
|
|
369
|
-
const defaultVerdict =
|
|
370
|
-
riskClass === 'A' ? 'auto'
|
|
371
|
-
: riskClass === 'B' ? (trusted ? 'scanned' : 'unscanned')
|
|
372
|
-
: riskClass === 'C' ? (trusted ? 'reviewed' : 'unvetted')
|
|
373
|
-
: 'unvetted'; // D never auto-clears (enforcement deferred → own/teammate only)
|
|
374
|
-
const vetting = r.vetting || { verdict: defaultVerdict, stakes: 'ordinary', signature: null, capabilities: null, findings: [] };
|
|
375
|
-
return { riskClass, builtFrom: normalizeBuiltFrom(r), dataTouched, attestation, vetting };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// The cross-user theme pool from the local rails cache, RE-VALIDATED on read —
|
|
379
|
-
// never trust the pool blindly (spike 2: 15/15 hostile bundles neutralized by
|
|
380
|
-
// normalizeTheme). A tampered cache file degrades to a clean bundle, not exec.
|
|
381
|
-
//
|
|
382
|
-
// Trust/ranking/payout fields are NEVER trusted from the pool (audit 2026-06-15):
|
|
383
|
-
// a producer could otherwise publish a theme that renders "Verified" with a
|
|
384
|
-
// perfect outcome score, 5 stars, and an inflated payout. We rebuild a clean card
|
|
385
|
-
// input keeping only display fields + the re-validated bundle, and RESET the
|
|
386
|
-
// trust signals so OUR provenanceFor derives the honest Class-A 'auto' verdict and
|
|
387
|
-
// a neutral outcome (local build-results then accrue). Display text is clamped.
|
|
388
|
-
function str(v, max) {
|
|
389
|
-
return typeof v === 'string' ? v.slice(0, max) : '';
|
|
390
|
-
}
|
|
391
|
-
function readRailsThemes() {
|
|
392
|
-
const raw = readJsonSafe(railsThemesFile, []);
|
|
393
|
-
if (!Array.isArray(raw)) return [];
|
|
394
|
-
return raw
|
|
395
|
-
.filter((r) => r && typeof r === 'object' && r.id && r.kind === 'theme')
|
|
396
|
-
.map((r) => {
|
|
397
|
-
const p = r.producer && typeof r.producer === 'object' ? r.producer : {};
|
|
398
|
-
return {
|
|
399
|
-
id: String(r.id).slice(0, 120),
|
|
400
|
-
kind: 'theme',
|
|
401
|
-
source: 'rails',
|
|
402
|
-
title: str(r.title, 80) || 'Untitled',
|
|
403
|
-
pitch: str(r.pitch, 200),
|
|
404
|
-
summary: str(r.summary, 400),
|
|
405
|
-
tags: Array.isArray(r.tags) ? r.tags.slice(0, 8).map((t) => String(t).slice(0, 24)) : [],
|
|
406
|
-
producer: {
|
|
407
|
-
name: str(p.name, 60) || 'A maker',
|
|
408
|
-
handle: str(p.handle, 40) || 'maker',
|
|
409
|
-
kind: 'maker',
|
|
410
|
-
},
|
|
411
|
-
theme: normalizeTheme(r.theme || {}),
|
|
412
|
-
// Remix lineage is safe to surface (it's just a pointer + label).
|
|
413
|
-
...(r.builtFrom && r.builtFrom.id
|
|
414
|
-
? { builtFrom: { id: String(r.builtFrom.id).slice(0, 120), title: str(r.builtFrom.title, 80) } }
|
|
415
|
-
: {}),
|
|
416
|
-
// RESET publisher-controlled trust/ranking/payout → our derivation governs.
|
|
417
|
-
outcomeScore: 0.7, // the new-listing prior (proven locally over time)
|
|
418
|
-
outcomeStats: { builds: 0, working: 0 },
|
|
419
|
-
rating: { stars: 0, count: 0 }, // no fake stars
|
|
420
|
-
reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
421
|
-
// No vetting/safetyBadge → provenanceFor derives Class-A 'auto'.
|
|
422
|
-
createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
|
|
423
|
-
};
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Overwrite the local cache of the global theme pool (called by the main
|
|
428
|
-
// server's periodic sync). Atomic; best-effort.
|
|
429
|
-
function writeRailsThemes(cards) {
|
|
430
|
-
ensureDir();
|
|
431
|
-
try {
|
|
432
|
-
writeJsonAtomic(railsThemesFile, Array.isArray(cards) ? cards : []);
|
|
433
|
-
} catch {
|
|
434
|
-
/* persistence best-effort */
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// The cross-user RECIPE pool from the local cache. Unlike a theme (inert hex data),
|
|
439
|
-
// a recipe's payload is `knowHow` — instructions the agent will absorb and run. So:
|
|
440
|
-
// 1. HARD read-time scan filter: any pool recipe whose know-how trips scanForUnsafe
|
|
441
|
-
// is DROPPED here (never served). The agent only ever sees scan-clean strangers.
|
|
442
|
-
// 2. Trust/ranking/payout are NEVER trusted from the pool (same rule as themes —
|
|
443
|
-
// a producer can't ship a fake "Verified"/perfect-score/inflated-payout). We
|
|
444
|
-
// rebuild a clean record, reset the trust signals, and stamp source:'rails' so
|
|
445
|
-
// the agent knows it's a maker outside the user's team (the real, judgment-call
|
|
446
|
-
// control — at open_recipe time, the AGENT decides, not a consent wall).
|
|
447
|
-
function readRailsRecipes() {
|
|
448
|
-
const raw = readJsonSafe(railsRecipesFile, []);
|
|
449
|
-
if (!Array.isArray(raw)) return [];
|
|
450
|
-
const out = [];
|
|
451
|
-
for (const r of raw) {
|
|
452
|
-
if (!r || typeof r !== 'object' || !r.id || r.kind !== 'recipe') continue;
|
|
453
|
-
const knowHow = typeof r.knowHow === 'string' ? r.knowHow.slice(0, MAX_SCAN_CHARS) : '';
|
|
454
|
-
if (!scanForUnsafe(knowHow).clean) continue; // hard filter — unsafe know-how never reaches the shelf
|
|
455
|
-
const p = r.producer && typeof r.producer === 'object' ? r.producer : {};
|
|
456
|
-
out.push({
|
|
457
|
-
id: String(r.id).slice(0, 120),
|
|
458
|
-
kind: 'recipe',
|
|
459
|
-
source: 'rails',
|
|
460
|
-
riskClass: 'B',
|
|
461
|
-
title: str(r.title, 80) || 'Untitled',
|
|
462
|
-
pitch: str(r.pitch, 200),
|
|
463
|
-
summary: str(r.summary, 400),
|
|
464
|
-
tags: Array.isArray(r.tags) ? r.tags.slice(0, 8).map((t) => String(t).slice(0, 24)) : [],
|
|
465
|
-
knowHow,
|
|
466
|
-
producer: {
|
|
467
|
-
name: str(p.name, 60) || 'A maker',
|
|
468
|
-
handle: str(p.handle, 40) || 'maker',
|
|
469
|
-
kind: 'maker',
|
|
470
|
-
},
|
|
471
|
-
...(r.builtFrom && r.builtFrom.id
|
|
472
|
-
? { builtFrom: { id: String(r.builtFrom.id).slice(0, 120), title: str(r.builtFrom.title, 80) } }
|
|
473
|
-
: {}),
|
|
474
|
-
// RESET publisher-controlled trust/ranking/payout → our derivation governs.
|
|
475
|
-
outcomeScore: 0.7, // the new-listing prior (proven locally over time)
|
|
476
|
-
outcomeStats: { builds: 0, working: 0 },
|
|
477
|
-
rating: { stars: 0, count: 0 }, // no fake stars
|
|
478
|
-
reward: { model: 'one-time', unit: 'per build', oneTimeValue: 5.0, perUseValue: 0 },
|
|
479
|
-
// We re-scanned it clean → an honest 'scanned' verdict; source:'rails' is what
|
|
480
|
-
// tells the agent (and the badge) it's a stranger's recipe, not a vetted one.
|
|
481
|
-
vetting: { verdict: 'scanned', stakes: 'ordinary', signature: null, capabilities: null, findings: [] },
|
|
482
|
-
dataTouched: { egress: [], scope: 'in-workspace', paid: { model: 'none', note: '' } },
|
|
483
|
-
attestation: { privacy: 'n/a', attestedBy: str(p.handle, 40) || 'maker', at: null },
|
|
484
|
-
createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
return out;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// Overwrite the local cache of the global recipe pool (periodic sync). Atomic.
|
|
491
|
-
function writeRailsRecipes(cards) {
|
|
492
|
-
ensureDir();
|
|
493
|
-
try {
|
|
494
|
-
writeJsonAtomic(railsRecipesFile, Array.isArray(cards) ? cards : []);
|
|
495
|
-
} catch {
|
|
496
|
-
/* persistence best-effort */
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// The user's OWN recipe listings as public records to reconcile up to the pool
|
|
501
|
-
// (mirrors ownThemeListings, but recipes carry knowHow — the payload — and only
|
|
502
|
-
// SCAN-CLEAN recipes are ever pushed: the flagged ones stay strictly local).
|
|
503
|
-
function ownRecipeListings() {
|
|
504
|
-
const listings = readJsonSafe(listingsFile, []);
|
|
505
|
-
return (Array.isArray(listings) ? listings : [])
|
|
506
|
-
.filter((l) => l && l.kind !== 'theme') // recipes (a listing with no kind defaults to recipe)
|
|
507
|
-
.filter((l) => scanForUnsafe(l.knowHow || '').clean) // hard publish filter — never push unsafe know-how
|
|
508
|
-
.map((l) => ({ ...card(l), knowHow: l.knowHow || '' }));
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function shelf() {
|
|
512
|
-
const seed = loadSeedRecipes(seedDir);
|
|
513
|
-
const listings = readJsonSafe(listingsFile, []);
|
|
514
|
-
const local = Array.isArray(listings) ? listings : [];
|
|
515
|
-
// Seeds + the user's OWN listings come first and SHADOW the pool: a theme the
|
|
516
|
-
// user published locally wins over its copy in the global pool (same id).
|
|
517
|
-
const base = [...seed, ...SEED_THEMES, ...local];
|
|
518
|
-
const seen = new Set(base.map((r) => r.id));
|
|
519
|
-
// Cross-user pools (themes + recipes), de-duped against base and each other; a
|
|
520
|
-
// listing the user published locally always shadows its copy in the pool.
|
|
521
|
-
const themePool = readRailsThemes().filter((r) => !seen.has(r.id));
|
|
522
|
-
for (const t of themePool) seen.add(t.id);
|
|
523
|
-
const recipePool = readRailsRecipes().filter((r) => !seen.has(r.id));
|
|
524
|
-
return [...base, ...themePool, ...recipePool];
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// The user's own theme listings, as public cards — what the periodic sync
|
|
528
|
-
// reconciles up to the rails (catches themes the agent published via the MCP
|
|
529
|
-
// child, which has no rails of its own).
|
|
530
|
-
function ownThemeListings() {
|
|
531
|
-
const listings = readJsonSafe(listingsFile, []);
|
|
532
|
-
return (Array.isArray(listings) ? listings : [])
|
|
533
|
-
.filter((l) => l && l.kind === 'theme')
|
|
534
|
-
.map((l) => card(l));
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function getRecipe(id) {
|
|
538
|
-
return shelf().find((r) => r.id === id) || null;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Public, agent-facing card for a recipe (no know-how dump until opened).
|
|
542
|
-
function card(r, extra = {}) {
|
|
543
|
-
return {
|
|
544
|
-
id: r.id,
|
|
545
|
-
kind: r.kind || 'recipe',
|
|
546
|
-
// Provenance category: 'seed'|'theme' (VW-shipped) · 'listing' (own) · 'rails'
|
|
547
|
-
// (another user's, from the global pool). The shelf UI keys the Report action
|
|
548
|
-
// + the "from the community" labelling on this. Defaults to 'listing'.
|
|
549
|
-
source: r.source || 'listing',
|
|
550
|
-
title: r.title,
|
|
551
|
-
pitch: r.pitch,
|
|
552
|
-
producer: r.producer,
|
|
553
|
-
summary: r.summary,
|
|
554
|
-
vendorDescription: r.vendorDescription,
|
|
555
|
-
outcomeScore: liveScore(r),
|
|
556
|
-
outcomeStats: liveStats(r),
|
|
557
|
-
safetyBadge: r.safetyBadge, // legacy field kept for back-compat; web derives from `vetting`
|
|
558
|
-
...provenanceFor(r), // B2: riskClass · builtFrom · dataTouched · attestation · vetting
|
|
559
|
-
rating: r.rating,
|
|
560
|
-
reward: r.reward,
|
|
561
|
-
hasService: Boolean(r.service),
|
|
562
|
-
buildDir: r.buildDir,
|
|
563
|
-
// A theme card carries its own bundle — it IS its own preview (just hex), so
|
|
564
|
-
// the storefront can render a live swatch and apply it without a fetch.
|
|
565
|
-
...(r.kind === 'theme' && r.theme ? { theme: r.theme } : {}),
|
|
566
|
-
...extra,
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function search(need, { limit = 4 } = {}) {
|
|
571
|
-
const ranked = shelf()
|
|
572
|
-
.map((r) => {
|
|
573
|
-
const { score, matched } = scoreRecipe(r, need);
|
|
574
|
-
return { r, score, matched };
|
|
575
|
-
})
|
|
576
|
-
.filter((x) => x.score > 0)
|
|
577
|
-
.sort((a, b) => b.score - a.score || liveScore(b.r) - liveScore(a.r))
|
|
578
|
-
.slice(0, limit);
|
|
579
|
-
return ranked.map((x) => card(x.r, { relevance: x.score, matched: x.matched }));
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function appendEvent(evt) {
|
|
583
|
-
ensureDir();
|
|
584
|
-
const enriched = { id: rid(), ts: Date.now(), ...evt };
|
|
585
|
-
try {
|
|
586
|
-
fs.appendFileSync(eventsFile, `${JSON.stringify(enriched)}\n`);
|
|
587
|
-
} catch {
|
|
588
|
-
/* persistence best-effort */
|
|
589
|
-
}
|
|
590
|
-
return enriched;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function events() {
|
|
594
|
-
let raw = '';
|
|
595
|
-
try {
|
|
596
|
-
raw = fs.readFileSync(eventsFile, 'utf8');
|
|
597
|
-
} catch {
|
|
598
|
-
return [];
|
|
599
|
-
}
|
|
600
|
-
return raw
|
|
601
|
-
.split('\n')
|
|
602
|
-
.map((l) => l.trim())
|
|
603
|
-
.filter(Boolean)
|
|
604
|
-
.map((l) => {
|
|
605
|
-
try {
|
|
606
|
-
return JSON.parse(l);
|
|
607
|
-
} catch {
|
|
608
|
-
return null;
|
|
609
|
-
}
|
|
610
|
-
})
|
|
611
|
-
.filter(Boolean);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// A consumer's agent absorbed a recipe and built it: the three-way moment.
|
|
615
|
-
function recordUse({ recipeId, summary, consumer = 'you' }) {
|
|
616
|
-
const r = getRecipe(recipeId);
|
|
617
|
-
if (!r) return { ok: false, error: `no recipe "${recipeId}" on the shelf` };
|
|
618
|
-
const recurring = r.reward?.perUseValue || 0;
|
|
619
|
-
// A recurring SERVICE recipe (tool/API) earns per-use, NOT a build fee — so the
|
|
620
|
-
// build itself credits 0, and the meter ticks up per match. This keeps the live
|
|
621
|
-
// meter ("$X · N matches") reconciled with the "earns per match, ongoing" story.
|
|
622
|
-
// Pure know-how recipes earn their one-time build-time slice here.
|
|
623
|
-
const oneTime = r.service ? 0 : (r.reward?.oneTimeValue || 0);
|
|
624
|
-
const evt = appendEvent({
|
|
625
|
-
type: 'use',
|
|
626
|
-
recipeId: r.id,
|
|
627
|
-
title: r.title,
|
|
628
|
-
producer: r.producer,
|
|
629
|
-
consumer,
|
|
630
|
-
summary: summary || r.title,
|
|
631
|
-
value: oneTime,
|
|
632
|
-
unit: r.service ? 'recurring-start' : 'one-time',
|
|
633
|
-
});
|
|
634
|
-
const threeWay = {
|
|
635
|
-
you: `You got: ${summary || r.title}`,
|
|
636
|
-
producer: r.service
|
|
637
|
-
? `${r.producer?.name || 'The producer'} just gained a customer — your site uses their matching on every candidate, ongoing.`
|
|
638
|
-
: `${r.producer?.name || 'The producer'} earns a one-time share for the know-how you built on.`,
|
|
639
|
-
platform: 'VentureWild matched you to a proven recipe — and recorded it.',
|
|
640
|
-
};
|
|
641
|
-
return {
|
|
642
|
-
ok: true,
|
|
643
|
-
event: evt,
|
|
644
|
-
recipe: card(r),
|
|
645
|
-
threeWay,
|
|
646
|
-
credit: { producer: r.producer, oneTime, recurring, unit: r.reward?.unit },
|
|
647
|
-
service: r.service || null,
|
|
648
|
-
};
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// TickUp's matching service ran once — recurring credit to the producer.
|
|
652
|
-
function recordServiceCall({ recipeId, serviceId, matches = 0 }) {
|
|
653
|
-
const r = getRecipe(recipeId) || shelf().find((x) => x.service?.id === serviceId);
|
|
654
|
-
const per = r?.reward?.perUseValue || 0;
|
|
655
|
-
return appendEvent({
|
|
656
|
-
type: 'service-call',
|
|
657
|
-
recipeId: r?.id || recipeId || null,
|
|
658
|
-
serviceId: serviceId || r?.service?.id || null,
|
|
659
|
-
producer: r?.producer || null,
|
|
660
|
-
matches,
|
|
661
|
-
value: per,
|
|
662
|
-
unit: 'per-use',
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// The flip side (§3.7): the user's agent packaged a build into a listing.
|
|
667
|
-
function publishListing({ id, title, pitch, summary, tags = [], knowHow = '', producer, buildDir, builtFrom = null }) {
|
|
668
|
-
ensureDir();
|
|
669
|
-
const listings = readJsonSafe(listingsFile, []);
|
|
670
|
-
const arr = Array.isArray(listings) ? listings : [];
|
|
671
|
-
const slug =
|
|
672
|
-
id ||
|
|
673
|
-
`${normalize(title).split(' ').slice(0, 4).join('-') || 'listing'}-${rid().slice(0, 4)}`;
|
|
674
|
-
// Class-B vetting (B2): a recipe is local build know-how → self-attest + an
|
|
675
|
-
// automated scan. A clean scan earns the 'scanned' verdict; a hit is recorded
|
|
676
|
-
// (verdict 'flagged' + findings) so the badge tells the truth — publishing isn't
|
|
677
|
-
// blocked here (single-user/local shelf), but the listing carries its verdict.
|
|
678
|
-
const scan = scanForUnsafe(knowHow);
|
|
679
|
-
const listing = {
|
|
680
|
-
id: slug,
|
|
681
|
-
kind: 'recipe',
|
|
682
|
-
title: title || 'Untitled',
|
|
683
|
-
pitch: pitch || '',
|
|
684
|
-
summary: summary || pitch || '',
|
|
685
|
-
vendorDescription: summary || pitch || '',
|
|
686
|
-
producer: producer || { name: 'You', handle: 'you', kind: 'maker' },
|
|
687
|
-
tags,
|
|
688
|
-
knowHow,
|
|
689
|
-
buildDir: buildDir || null,
|
|
690
|
-
outcomeScore: 0.7, // new listing: a starting, unproven score (earns its place by working)
|
|
691
|
-
outcomeStats: { builds: 0, working: 0 },
|
|
692
|
-
safetyBadge: 'new', // legacy field; the real signal is `vetting` below
|
|
693
|
-
riskClass: 'B',
|
|
694
|
-
...(builtFrom && builtFrom.id ? { builtFrom: { id: String(builtFrom.id), title: String(builtFrom.title || '') } } : {}),
|
|
695
|
-
dataTouched: { egress: [], scope: 'in-workspace', paid: { model: 'none', note: '' } },
|
|
696
|
-
attestation: { privacy: 'n/a', attestedBy: (producer || {}).handle || 'you', at: new Date().toISOString() },
|
|
697
|
-
vetting: {
|
|
698
|
-
verdict: scan.clean ? 'scanned' : 'flagged',
|
|
699
|
-
stakes: 'ordinary',
|
|
700
|
-
signature: null,
|
|
701
|
-
capabilities: null,
|
|
702
|
-
findings: scan.findings,
|
|
703
|
-
},
|
|
704
|
-
rating: { stars: 0, count: 0 },
|
|
705
|
-
reward: { model: 'one-time', unit: 'per build', perUseValue: 0, oneTimeValue: 5.0 },
|
|
706
|
-
source: 'listing',
|
|
707
|
-
createdAt: Date.now(),
|
|
708
|
-
};
|
|
709
|
-
// de-dupe by id (re-publish overwrites)
|
|
710
|
-
const next = arr.filter((l) => l.id !== listing.id);
|
|
711
|
-
next.push(listing);
|
|
712
|
-
writeJsonAtomic(listingsFile, next);
|
|
713
|
-
appendEvent({
|
|
714
|
-
type: 'publish',
|
|
715
|
-
recipeId: listing.id,
|
|
716
|
-
title: listing.title,
|
|
717
|
-
producer: listing.producer,
|
|
718
|
-
});
|
|
719
|
-
const publicCard = card(listing);
|
|
720
|
-
// Push to the GLOBAL pool ONLY if the scan is clean — the hard publish-time filter:
|
|
721
|
-
// a flagged recipe stays strictly local and never enters the cross-user pool.
|
|
722
|
-
// Recipes carry their know-how (the payload other agents absorb). Best-effort +
|
|
723
|
-
// fire-and-forget; a down rail (or the MCP child, which has no rails) never fails
|
|
724
|
-
// the local publish — the periodic sync reconciles via ownRecipeListings.
|
|
725
|
-
if (scan.clean && rails && typeof rails.publish === 'function') {
|
|
726
|
-
Promise.resolve(rails.publish({ ...publicCard, knowHow })).catch(() => {});
|
|
727
|
-
}
|
|
728
|
-
return {
|
|
729
|
-
ok: true,
|
|
730
|
-
listing: publicCard,
|
|
731
|
-
earning: {
|
|
732
|
-
model: listing.reward.model,
|
|
733
|
-
oneTimeValue: listing.reward.oneTimeValue,
|
|
734
|
-
note: 'Represented earnings — you earn each time someone builds on this. (Money is for later.)',
|
|
735
|
-
},
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// --- themes on the shelf (the safest cross-user artifact) -----------------
|
|
740
|
-
|
|
741
|
-
// Publish a theme as a listing. The payload is a hex-token bundle (validated by
|
|
742
|
-
// normalizeTheme — same security boundary as set_theme: data, never CSS). It
|
|
743
|
-
// lands in listings.json with kind:'theme' and shows on the Themes shelf.
|
|
744
|
-
function publishTheme({ title, pitch, summary, tags = [], producer, theme, builtFrom = null } = {}) {
|
|
745
|
-
ensureDir();
|
|
746
|
-
const bundle = normalizeTheme(theme || {});
|
|
747
|
-
const listings = readJsonSafe(listingsFile, []);
|
|
748
|
-
const arr = Array.isArray(listings) ? listings : [];
|
|
749
|
-
const slug = `theme-${normalize(title).split(' ').slice(0, 3).join('-') || 'custom'}-${rid().slice(0, 4)}`;
|
|
750
|
-
// A remix records what it was tweaked from (the source theme on the shelf) so the
|
|
751
|
-
// network shows lineage — "built on each other's work", not a flat catalogue.
|
|
752
|
-
const provenance =
|
|
753
|
-
builtFrom && builtFrom.id
|
|
754
|
-
? { id: String(builtFrom.id), title: String(builtFrom.title || '') }
|
|
755
|
-
: null;
|
|
756
|
-
const listing = {
|
|
757
|
-
id: slug,
|
|
758
|
-
kind: 'theme',
|
|
759
|
-
source: 'theme',
|
|
760
|
-
title: title || bundle.name || 'Custom theme',
|
|
761
|
-
pitch: pitch || '',
|
|
762
|
-
summary: summary || pitch || `A ${bundle.mode} theme.`,
|
|
763
|
-
vendorDescription: summary || pitch || '',
|
|
764
|
-
producer: producer || { name: 'You', handle: 'you', kind: 'maker' },
|
|
765
|
-
tags,
|
|
766
|
-
theme: bundle,
|
|
767
|
-
...(provenance ? { builtFrom: provenance } : {}),
|
|
768
|
-
outcomeScore: 0.7,
|
|
769
|
-
outcomeStats: { builds: 0, working: 0 },
|
|
770
|
-
safetyBadge: 'new',
|
|
771
|
-
rating: { stars: 0, count: 0 },
|
|
772
|
-
reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
773
|
-
createdAt: Date.now(),
|
|
774
|
-
};
|
|
775
|
-
const next = arr.filter((l) => l.id !== listing.id);
|
|
776
|
-
next.push(listing);
|
|
777
|
-
writeJsonAtomic(listingsFile, next);
|
|
778
|
-
appendEvent({ type: 'publish', recipeId: listing.id, title: listing.title, producer: listing.producer, ...(provenance ? { builtFrom: provenance } : {}) });
|
|
779
|
-
const publicCard = card(listing);
|
|
780
|
-
// Push to the GLOBAL pool (next push §N) so every VW user can discover it.
|
|
781
|
-
// Best-effort + fire-and-forget: a down rail (or the MCP child, which has no
|
|
782
|
-
// rails) NEVER fails the local publish — the periodic sync reconciles it up.
|
|
783
|
-
if (rails && typeof rails.publish === 'function') {
|
|
784
|
-
Promise.resolve(rails.publish(publicCard)).catch(() => {});
|
|
785
|
-
}
|
|
786
|
-
return {
|
|
787
|
-
ok: true,
|
|
788
|
-
listing: publicCard,
|
|
789
|
-
earning: { model: 'one-time', oneTimeValue: listing.reward.oneTimeValue, note: 'You earn each time someone applies it. (Money is for later.)' },
|
|
790
|
-
};
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// A consumer applied a theme from the shelf: record the three-way moment (they
|
|
794
|
-
// got the look · the producer earns · platform recorded it) and hand back the
|
|
795
|
-
// validated bundle for the browser to apply.
|
|
796
|
-
function recordThemeApply({ themeId, consumer = 'you' } = {}) {
|
|
797
|
-
const r = getRecipe(themeId);
|
|
798
|
-
if (!r || r.kind !== 'theme') return { ok: false, error: `no theme "${themeId}" on the shelf` };
|
|
799
|
-
const oneTime = r.reward?.oneTimeValue || 0;
|
|
800
|
-
const evt = appendEvent({
|
|
801
|
-
type: 'use',
|
|
802
|
-
recipeId: r.id,
|
|
803
|
-
title: r.title,
|
|
804
|
-
producer: r.producer,
|
|
805
|
-
consumer,
|
|
806
|
-
summary: `Applied the "${r.title}" theme`,
|
|
807
|
-
value: oneTime,
|
|
808
|
-
unit: 'one-time',
|
|
809
|
-
});
|
|
810
|
-
return {
|
|
811
|
-
ok: true,
|
|
812
|
-
event: evt,
|
|
813
|
-
theme: normalizeTheme(r.theme || {}),
|
|
814
|
-
title: r.title,
|
|
815
|
-
threeWay: {
|
|
816
|
-
you: `Your workspace now wears "${r.title}".`,
|
|
817
|
-
producer: `${r.producer?.name || 'The producer'} earns a one-time share for the look you adopted.`,
|
|
818
|
-
platform: 'VentureWild matched you to a proven theme — and recorded it.',
|
|
819
|
-
},
|
|
820
|
-
credit: { producer: r.producer, oneTime, unit: r.reward?.unit },
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// --- self-seed drafts (review-gated) --------------------------------------
|
|
825
|
-
|
|
826
|
-
function listDrafts() {
|
|
827
|
-
const d = readJsonSafe(draftsFile, []);
|
|
828
|
-
return Array.isArray(d) ? d : [];
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function getDraft(id) {
|
|
832
|
-
return listDrafts().find((d) => d.id === id) || null;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// Stage a recipe the agent extracted from the user's existing work — does NOT
|
|
836
|
-
// publish. `sourceNote` records (for the user's review) what it was generalized
|
|
837
|
-
// from. Returns a review-shaped object the UI renders for approval.
|
|
838
|
-
function stageDraft({ title, pitch, summary, tags = [], knowHow = '', sourceNote = '' }) {
|
|
839
|
-
ensureDir();
|
|
840
|
-
const drafts = listDrafts();
|
|
841
|
-
const draft = {
|
|
842
|
-
id: `draft-${rid()}`,
|
|
843
|
-
title: title || 'Untitled',
|
|
844
|
-
pitch: pitch || '',
|
|
845
|
-
summary: summary || pitch || '',
|
|
846
|
-
tags: Array.isArray(tags) ? tags : [],
|
|
847
|
-
knowHow: knowHow || '',
|
|
848
|
-
sourceNote: sourceNote || '',
|
|
849
|
-
createdAt: Date.now(),
|
|
850
|
-
};
|
|
851
|
-
drafts.push(draft);
|
|
852
|
-
writeJsonAtomic(draftsFile, drafts);
|
|
853
|
-
return draft;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
function discardDraft(id) {
|
|
857
|
-
const drafts = listDrafts();
|
|
858
|
-
const next = drafts.filter((d) => d.id !== id);
|
|
859
|
-
writeJsonAtomic(draftsFile, next);
|
|
860
|
-
return { ok: next.length !== drafts.length };
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// Promote a reviewed draft onto the shelf — publishes EXACTLY what was reviewed
|
|
864
|
-
// (you publish what you saw), then removes it from the draft queue.
|
|
865
|
-
function publishDraft(id, { producer } = {}) {
|
|
866
|
-
const draft = getDraft(id);
|
|
867
|
-
if (!draft) return { ok: false, error: `no draft "${id}"` };
|
|
868
|
-
const res = publishListing({
|
|
869
|
-
title: draft.title,
|
|
870
|
-
pitch: draft.pitch,
|
|
871
|
-
summary: draft.summary,
|
|
872
|
-
tags: draft.tags,
|
|
873
|
-
knowHow: draft.knowHow,
|
|
874
|
-
producer: producer || { name: 'You', handle: 'you', kind: 'maker' },
|
|
875
|
-
});
|
|
876
|
-
discardDraft(id);
|
|
877
|
-
return res;
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function setPreview({ dir: buildDir, recipeId, port = 4000 }) {
|
|
881
|
-
ensureDir();
|
|
882
|
-
const payload = { dir: buildDir, recipeId: recipeId || null, port, ts: Date.now() };
|
|
883
|
-
writeJsonAtomic(previewFile, payload);
|
|
884
|
-
return payload;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function getPreview() {
|
|
888
|
-
return readJsonSafe(previewFile, null);
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
// Aggregate everything the UI needs: transactions, per-producer earnings,
|
|
892
|
-
// per-service usage meters, and the user's own listings.
|
|
893
|
-
function computeState() {
|
|
894
|
-
const evs = events();
|
|
895
|
-
const earnings = {}; // handle -> { name, total, uses, serviceCalls, matches }
|
|
896
|
-
const meters = {}; // serviceId -> { calls, matches, value, producer }
|
|
897
|
-
const transactions = [];
|
|
898
|
-
for (const e of evs) {
|
|
899
|
-
const h = e.producer?.handle || 'unknown';
|
|
900
|
-
const bucket = (earnings[h] ??= {
|
|
901
|
-
name: e.producer?.name || h,
|
|
902
|
-
handle: h,
|
|
903
|
-
total: 0,
|
|
904
|
-
uses: 0,
|
|
905
|
-
serviceCalls: 0,
|
|
906
|
-
matches: 0,
|
|
907
|
-
});
|
|
908
|
-
if (e.type === 'use') {
|
|
909
|
-
bucket.total += e.value || 0;
|
|
910
|
-
bucket.uses += 1;
|
|
911
|
-
transactions.push(e);
|
|
912
|
-
} else if (e.type === 'service-call') {
|
|
913
|
-
bucket.total += e.value || 0;
|
|
914
|
-
bucket.serviceCalls += 1;
|
|
915
|
-
bucket.matches += e.matches || 0;
|
|
916
|
-
const sid = e.serviceId || 'service';
|
|
917
|
-
const m = (meters[sid] ??= { calls: 0, matches: 0, value: 0, producer: e.producer || null });
|
|
918
|
-
m.calls += 1;
|
|
919
|
-
m.matches += e.matches || 0;
|
|
920
|
-
m.value += e.value || 0;
|
|
921
|
-
} else if (e.type === 'publish') {
|
|
922
|
-
transactions.push(e);
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
return {
|
|
926
|
-
transactions: transactions.slice(-50),
|
|
927
|
-
earnings: Object.values(earnings).sort((a, b) => b.total - a.total),
|
|
928
|
-
meters,
|
|
929
|
-
listings: (readJsonSafe(listingsFile, []) || []).map((l) => card(l)),
|
|
930
|
-
totals: {
|
|
931
|
-
events: evs.length,
|
|
932
|
-
creditPaid: Object.values(earnings).reduce((s, b) => s + b.total, 0),
|
|
933
|
-
},
|
|
934
|
-
};
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
return {
|
|
938
|
-
dir,
|
|
939
|
-
eventsFile,
|
|
940
|
-
listingsFile,
|
|
941
|
-
previewFile,
|
|
942
|
-
draftsFile,
|
|
943
|
-
railsThemesFile,
|
|
944
|
-
railsRecipesFile,
|
|
945
|
-
writeRailsThemes,
|
|
946
|
-
writeRailsRecipes,
|
|
947
|
-
ownThemeListings,
|
|
948
|
-
ownRecipeListings,
|
|
949
|
-
shelf,
|
|
950
|
-
getRecipe,
|
|
951
|
-
card,
|
|
952
|
-
search,
|
|
953
|
-
recordUse,
|
|
954
|
-
recordBuildResult,
|
|
955
|
-
recordServiceCall,
|
|
956
|
-
publishListing,
|
|
957
|
-
publishTheme,
|
|
958
|
-
recordThemeApply,
|
|
959
|
-
listDrafts,
|
|
960
|
-
getDraft,
|
|
961
|
-
stageDraft,
|
|
962
|
-
discardDraft,
|
|
963
|
-
publishDraft,
|
|
964
|
-
setPreview,
|
|
965
|
-
getPreview,
|
|
966
|
-
computeState,
|
|
967
|
-
events,
|
|
968
|
-
};
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
function money(n) {
|
|
972
|
-
const v = Number(n) || 0;
|
|
973
|
-
return `$${v.toFixed(2)}`;
|
|
974
|
-
}
|
|
1
|
+
// Bazaar core — the shelf, search/ranking, and the transaction ledger.
|
|
2
|
+
//
|
|
3
|
+
// Faithful to UX §3.7: a *network* of producers' recipes that a consumer's agent
|
|
4
|
+
// reaches onto, picks by "which actually works" (measured outcomes), absorbs, and
|
|
5
|
+
// builds — recording a three-way transaction (consumer got it · producer earns ·
|
|
6
|
+
// platform recorded it).
|
|
7
|
+
//
|
|
8
|
+
// State lives ENTIRELY under ~/.wild-workspace/bazaar/ (an absolute path OUTSIDE
|
|
9
|
+
// the user's repo — CLAUDE.md rule #1). One module, imported by BOTH the main
|
|
10
|
+
// server and the spawned MCP server, so there is a single source of truth and no
|
|
11
|
+
// HTTP/port handshake between processes:
|
|
12
|
+
// - events.jsonl append-only event log (use · service-call · publish). Append
|
|
13
|
+
// is multi-writer safe (the MCP process writes use/publish; the
|
|
14
|
+
// main process's preview server writes service-call).
|
|
15
|
+
// - listings.json user-published listings (the flip side). MCP writes, main reads.
|
|
16
|
+
// - preview.json the current preview target {dir,recipeId}. MCP writes, main reads.
|
|
17
|
+
//
|
|
18
|
+
// Seed recipes (our product content — the shelf) ship in-repo under seed-recipes/.
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
import { normalizeTheme } from '../canvas/core.mjs';
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const SEED_DIR = path.join(__dirname, 'seed-recipes');
|
|
30
|
+
|
|
31
|
+
// Seed THEMES — the first cross-user marketplace artifact (a theme is a hex-token
|
|
32
|
+
// bundle: pure data, zero execution risk, and its own preview). These ship as a
|
|
33
|
+
// small network of "producers" so the bazaar's Themes shelf is populated out of
|
|
34
|
+
// the box; a user's own published theme lands beside them (listings.json).
|
|
35
|
+
export const SEED_THEMES = [
|
|
36
|
+
{
|
|
37
|
+
id: 'theme-midnight-cyan', kind: 'theme', source: 'theme',
|
|
38
|
+
title: 'Midnight Cyan', pitch: 'The venturewild.llc night look — deep navy, electric cyan.',
|
|
39
|
+
summary: 'A focused dark workspace: near-black navy with a bright cyan accent and cool slate cards.',
|
|
40
|
+
tags: ['dark', 'cyan', 'focus'],
|
|
41
|
+
producer: { name: 'VentureWild', handle: 'venturewild', kind: 'vendor' },
|
|
42
|
+
theme: { mode: 'dark', accent: '#22d3ee', tokens: { bg: '#0a0c10', surface: '#11141a', text: '#e8eaed', textMuted: '#8b95a3', border: '#1f242d', canvas1: '#0b1620', canvas2: '#0c1320', canvas3: '#0a0f14' } },
|
|
43
|
+
outcomeScore: 0.92, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
|
|
44
|
+
rating: { stars: 5, count: 24 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'theme-sunset', kind: 'theme', source: 'theme',
|
|
48
|
+
title: 'Warm Sunset', pitch: 'Dusty plum to amber — warm, low-glare, easy at night.',
|
|
49
|
+
summary: 'A cosy dark theme: plum background fading to amber, with a glowing orange accent.',
|
|
50
|
+
tags: ['dark', 'warm', 'orange'],
|
|
51
|
+
producer: { name: 'Lina', handle: 'lina', kind: 'maker' },
|
|
52
|
+
theme: { mode: 'dark', accent: '#ff7a3c', tokens: { bg: '#1a1014', surface: '#2a1a1f', text: '#fbeee6', textMuted: '#c7a89a', border: '#43292f', canvas1: '#2d1620', canvas2: '#5a2a2c', canvas3: '#b85c2e' } },
|
|
53
|
+
outcomeScore: 0.84, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
|
|
54
|
+
rating: { stars: 4, count: 11 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'theme-forest', kind: 'theme', source: 'theme',
|
|
58
|
+
title: 'Forest Light', pitch: 'Soft sage & cream — a calm, bright daytime desk.',
|
|
59
|
+
summary: 'A gentle light theme: sage-green wallpaper, cream cards, a deep forest accent.',
|
|
60
|
+
tags: ['light', 'green', 'calm'],
|
|
61
|
+
producer: { name: 'Mateo', handle: 'mateo', kind: 'maker' },
|
|
62
|
+
theme: { mode: 'light', accent: '#15803d', tokens: { bg: '#fbfdfb', surface: '#ffffff', text: '#1a2b22', textMuted: '#5c7064', border: '#dde9e0', canvas1: '#e3f3e7', canvas2: '#eaf4ec', canvas3: '#fbfdf6' } },
|
|
63
|
+
outcomeScore: 0.79, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
|
|
64
|
+
rating: { stars: 4, count: 8 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'theme-mono', kind: 'theme', source: 'theme',
|
|
68
|
+
title: 'Mono Slate', pitch: 'Greyscale, no distractions — one quiet blue accent.',
|
|
69
|
+
summary: 'A neutral light theme for heads-down work: warm greys, a single muted-blue accent.',
|
|
70
|
+
tags: ['light', 'minimal', 'mono'],
|
|
71
|
+
producer: { name: 'Priya', handle: 'priya', kind: 'maker' },
|
|
72
|
+
theme: { mode: 'light', accent: '#475569', tokens: { bg: '#fafafa', surface: '#ffffff', text: '#1e2530', textMuted: '#6b7280', border: '#e6e8ec', canvas1: '#eef0f3', canvas2: '#f3f4f6', canvas3: '#fafafa' } },
|
|
73
|
+
outcomeScore: 0.74, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
|
|
74
|
+
rating: { stars: 4, count: 6 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// ~/.wild-workspace/bazaar — mirrors logpaths.globalDir() but kept dependency-free
|
|
79
|
+
// here so the MCP child can import this module standalone.
|
|
80
|
+
export function defaultBazaarDir(env = process.env) {
|
|
81
|
+
const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
|
|
82
|
+
return path.join(base, 'bazaar');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rid() {
|
|
86
|
+
return crypto.randomUUID().slice(0, 12);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readJsonSafe(file, fallback) {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
92
|
+
} catch {
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeJsonAtomic(file, value) {
|
|
98
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
99
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
100
|
+
fs.renameSync(tmp, file); // Node rename replaces the destination on all platforms
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- recipe loading -------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function loadSeedRecipes(seedDir = SEED_DIR) {
|
|
106
|
+
let entries = [];
|
|
107
|
+
try {
|
|
108
|
+
entries = fs.readdirSync(seedDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const recipes = [];
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
const dir = path.join(seedDir, e.name);
|
|
115
|
+
const meta = readJsonSafe(path.join(dir, 'recipe.json'), null);
|
|
116
|
+
if (!meta || !meta.id) continue;
|
|
117
|
+
let knowHow = '';
|
|
118
|
+
try {
|
|
119
|
+
knowHow = fs.readFileSync(path.join(dir, 'know-how.md'), 'utf8');
|
|
120
|
+
} catch {
|
|
121
|
+
/* a recipe with no know-how file is still listable */
|
|
122
|
+
}
|
|
123
|
+
recipes.push({ ...meta, knowHow, source: 'seed' });
|
|
124
|
+
}
|
|
125
|
+
return recipes;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- search / ranking -----------------------------------------------------
|
|
129
|
+
// The agent's pick signal is "which recipe actually works" (outcomeScore), NOT
|
|
130
|
+
// marketing or stars (§3.7). Tag relevance gates the candidates; outcomeScore
|
|
131
|
+
// breaks ties and floats proven recipes to the top of the shelf.
|
|
132
|
+
|
|
133
|
+
function normalize(s) {
|
|
134
|
+
return String(s || '')
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
137
|
+
.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stem(word) {
|
|
141
|
+
// ultra-light: drop a trailing plural 's' so "candidates" matches "candidate"
|
|
142
|
+
return word.length > 3 && word.endsWith('s') ? word.slice(0, -1) : word;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function scoreRecipe(recipe, need) {
|
|
146
|
+
const needNorm = ` ${normalize(need)} `;
|
|
147
|
+
const needWords = new Set(needNorm.trim().split(' ').filter(Boolean).map(stem));
|
|
148
|
+
let score = 0;
|
|
149
|
+
const matched = [];
|
|
150
|
+
for (const tag of recipe.tags || []) {
|
|
151
|
+
const tagNorm = normalize(tag);
|
|
152
|
+
if (!tagNorm) continue;
|
|
153
|
+
if (tagNorm.includes(' ')) {
|
|
154
|
+
// multi-word tag: a phrase hit is a strong signal
|
|
155
|
+
if (needNorm.includes(` ${tagNorm} `) || needNorm.includes(tagNorm)) {
|
|
156
|
+
score += 3;
|
|
157
|
+
matched.push(tag);
|
|
158
|
+
}
|
|
159
|
+
} else if (needWords.has(stem(tagNorm))) {
|
|
160
|
+
score += 1;
|
|
161
|
+
matched.push(tag);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { score, matched };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Class-B auto-scan (B2) ------------------------------------------------
|
|
168
|
+
// A conservative, pattern-based check that a published recipe carries no baked-in
|
|
169
|
+
// secrets and no obviously destructive / remote-exec / persistence / exfil command.
|
|
170
|
+
// It gates the FAST lane (Class B self-attest), NOT a full audit — Class C/D get
|
|
171
|
+
// human review. High-confidence patterns only, so a clean recipe is rarely
|
|
172
|
+
// false-flagged (the adversarial corpus in bazaar-scan.test.mjs holds it at 0 false
|
|
173
|
+
// positives over a battery of clean recipes). Never throws.
|
|
174
|
+
//
|
|
175
|
+
// ⚠️ HARD LIMIT — this scan catches MECHANICALLY-DETECTABLE threats (literal secrets,
|
|
176
|
+
// known-malicious command shapes). It CANNOT catch natural-language instructions that
|
|
177
|
+
// steer the agent ("include the contents of ~/.aws/credentials in the config", "add a
|
|
178
|
+
// snippet that POSTs the form data to evil.com"). A Class-B recipe's know-how is
|
|
179
|
+
// *instructions an agent reads and acts on*, not inert data, so a pattern scan is
|
|
180
|
+
// NECESSARY-but-NOT-SUFFICIENT to make recipes safe cross-user. See
|
|
181
|
+
// docs/class-b-cross-user-readiness.md — recipes stay own/teammate-only until a
|
|
182
|
+
// structural control (consent-gated review of a stranger's know-how, or a constrained
|
|
183
|
+
// build permission mode) lands. Themes (Class A, pure hex data) are unaffected.
|
|
184
|
+
const MAX_SCAN_CHARS = 256 * 1024; // bound the work on a pathological giant payload
|
|
185
|
+
const UNSAFE_PATTERNS = [
|
|
186
|
+
// --- baked-in secrets ---
|
|
187
|
+
{ kind: 'secret', note: 'private key', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
|
|
188
|
+
{ kind: 'secret', note: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
189
|
+
{ kind: 'secret', note: 'API secret key (Stripe-style)', re: /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
|
|
190
|
+
{ kind: 'secret', note: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
|
|
191
|
+
{ kind: 'secret', note: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
192
|
+
{ kind: 'secret', note: 'Google API key', re: /\bAIza[0-9A-Za-z_\-]{20,}/ },
|
|
193
|
+
{ kind: 'secret', note: 'hardcoded JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ },
|
|
194
|
+
{ kind: 'secret', note: 'hardcoded credential', re: /(?:api[_-]?key|secret|password|passwd|access[_-]?token)\s*[:=]\s*['"][^'"\s]{12,}['"]/i },
|
|
195
|
+
// --- destructive commands (only when aimed at root / home / a raw disk — relative
|
|
196
|
+
// build-dir deletes like `rm -rf ./dist` or `rm -rf node_modules` stay clean) ---
|
|
197
|
+
{ kind: 'destructive', note: 'recursive delete of a root/home path', re: /\brm\s+-[a-z]*r[a-z]*f?\s+["']?(?:[~/]|\$\{?HOME|\$env:USERPROFILE|\$env:HOME|%USERPROFILE%)/i },
|
|
198
|
+
{ kind: 'destructive', note: 'fork bomb', re: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;\s*:/ },
|
|
199
|
+
{ kind: 'destructive', note: 'raw write to a disk device', re: /\bdd\b[^\n]*\bof=\/dev\/(?:sd|nvme|hd|vd|disk|mapper)/i },
|
|
200
|
+
{ kind: 'destructive', note: 'format a disk device', re: /\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i },
|
|
201
|
+
{ kind: 'destructive', note: 'overwrite a disk device', re: />\s*\/dev\/(?:sd|nvme|hd|vd|disk)/i },
|
|
202
|
+
{ kind: 'destructive', note: 'recursive 777 on root/home', re: /\bchmod\s+-[a-z]*R[a-z]*\s+[0-7]*777\s+(?:\/|~|\$HOME)/i },
|
|
203
|
+
{ kind: 'destructive', note: 'Windows recursive delete of a system path', re: /\bdel\s+[^\n]*\/s\b[^\n]*(?:[a-z]:\\|system32)/i },
|
|
204
|
+
{ kind: 'destructive', note: 'format a Windows drive', re: /\bformat\s+[a-z]:(?:\s|\/|$)/i },
|
|
205
|
+
{ kind: 'destructive', note: 'PowerShell recursive force-delete of home/drive', re: /Remove-Item(?=[^\n]*-Recurse)(?=[^\n]*-Force)[^\n]*(?:\$HOME|\$env:USERPROFILE|[a-z]:\\)/i },
|
|
206
|
+
// --- remote-exec / reverse-shell / exfiltration ---
|
|
207
|
+
{ kind: 'exfiltrate', note: 'pipe a remote script straight into a shell', re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:(?:ba|z|k|da)?sh|fish|python[0-9.]*|ruby|perl|node|php)\b/i },
|
|
208
|
+
{ kind: 'exfiltrate', note: 'decode a blob straight into a shell', re: /\bbase64\b[^\n|]*\|\s*(?:sudo\s+)?(?:(?:ba|z|k|da)?sh|fish|python[0-9.]*|ruby|perl|node|php)\b/i },
|
|
209
|
+
{ kind: 'exfiltrate', note: 'bash reverse shell via /dev/tcp', re: /\/dev\/tcp\/[^\s/]+\/\d+/i },
|
|
210
|
+
{ kind: 'exfiltrate', note: 'eval of a remote download', re: /\beval\b[^\n]*\$\([^)]*(?:curl|wget)\b/i },
|
|
211
|
+
{ kind: 'exfiltrate', note: 'netcat exec / reverse shell', re: /\bn(?:c|cat|etcat)\b[^\n]*\s-[ec]\b/i },
|
|
212
|
+
{ kind: 'exfiltrate', note: 'pipe the environment to a network tool', re: /\b(?:env|printenv)\b\s*\|\s*(?:curl|wget|nc|ncat|netcat|telnet)\b/i },
|
|
213
|
+
{ kind: 'exfiltrate', note: 'node -e with network/exec/secret access', re: /\bnode\s+(?:-e|--eval|-p|--print)\b[^\n]*(?:require\(\s*['"](?:https?|net|dgram|tls|child_process)['"]|child_process|\.ssh\/|\.aws\/)/i },
|
|
214
|
+
{ kind: 'exfiltrate', note: 'python -c with network/exec/secret access', re: /\bpython[0-9.]*\s+-c\b[^\n]*(?:import\s+(?:socket|urllib|requests|http\.client)|os\.system|subprocess\.|socket\.|\.ssh\/|\.aws\/)/i },
|
|
215
|
+
{ kind: 'exfiltrate', note: 'PowerShell download piped into Invoke-Expression', re: /\b(?:iwr|Invoke-WebRequest|curl|wget|DownloadString|Net\.WebClient)\b[^\n]*\|\s*(?:iex|Invoke-Expression)\b/i },
|
|
216
|
+
{ kind: 'exfiltrate', note: 'PowerShell Invoke-Expression of a download', re: /\b(?:iex|Invoke-Expression)\b[^\n]*(?:DownloadString|Net\.WebClient|iwr|Invoke-WebRequest)/i },
|
|
217
|
+
{ kind: 'exfiltrate', note: 'pipe a secret file into a network tool', re: /(?:\bcat\b|\btype\b|\bGet-Content\b)[^\n|]*(?:\.ssh\/|\.aws\/|id_rsa|id_ed25519|credentials|\.pem|\.netrc)[^\n|]*\|\s*(?:curl|wget|nc|ncat|netcat|scp|Invoke-RestMethod|iwr)\b/i },
|
|
218
|
+
{ kind: 'exfiltrate', note: 'copy a secret file to a remote host', re: /\bscp\s+[^\n]*(?:\.ssh\/|\.aws\/|id_rsa|id_ed25519|credentials|\.pem)[^\n]*\s+\S+@\S+:/i },
|
|
219
|
+
// --- persistence / access to well-known secret files ---
|
|
220
|
+
{ kind: 'persistence', note: 'write to SSH authorized_keys', re: /(?:>>|>|\btee\b|\bcat\b)[^\n]*authorized_keys\b/i },
|
|
221
|
+
{ kind: 'sensitive-file', note: 'reads a private key / cloud credential file', re: /(?:~|\$HOME|\$env:USERPROFILE)?[\/\\]?\.(?:aws[\/\\]credentials|ssh[\/\\]id_(?:rsa|ed25519|ecdsa)|netrc)\b/i },
|
|
222
|
+
];
|
|
223
|
+
export function scanForUnsafe(text) {
|
|
224
|
+
const s = String(text || '').slice(0, MAX_SCAN_CHARS);
|
|
225
|
+
const findings = [];
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
for (const p of UNSAFE_PATTERNS) {
|
|
228
|
+
if (p.re.test(s) && !seen.has(p.note)) {
|
|
229
|
+
seen.add(p.note);
|
|
230
|
+
findings.push({ kind: p.kind, note: p.note });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { clean: findings.length === 0, findings };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- the bazaar instance --------------------------------------------------
|
|
237
|
+
|
|
238
|
+
// Outcome score = a Bayesian-smoothed build success rate (B1). The recipe's
|
|
239
|
+
// declared outcomeScore is the PRIOR mean; this many pseudo-builds give it weight,
|
|
240
|
+
// so a brand-new listing or a thin sample isn't whipsawed by one result, while a
|
|
241
|
+
// recipe with real volume converges on its true rate.
|
|
242
|
+
const OUTCOME_PRIOR_K = 4;
|
|
243
|
+
|
|
244
|
+
export function createBazaar({ baseDir, seedDir = SEED_DIR, rails = null } = {}) {
|
|
245
|
+
const dir = baseDir || defaultBazaarDir();
|
|
246
|
+
const eventsFile = path.join(dir, 'events.jsonl');
|
|
247
|
+
const listingsFile = path.join(dir, 'listings.json');
|
|
248
|
+
const previewFile = path.join(dir, 'preview.json');
|
|
249
|
+
// The cross-user theme pool, cached locally (next push §N). The MAIN server
|
|
250
|
+
// periodically pulls the global pool from the rails and writes it here; BOTH
|
|
251
|
+
// this process and the spawned MCP child read it SYNCHRONOUSLY in shelf() — the
|
|
252
|
+
// child has no rails access of its own, exactly the canvas "rails=truth → local
|
|
253
|
+
// cache below" model. `rails` (the ListingsRails client) is injected only into
|
|
254
|
+
// the main server's instance, for the immediate push on the human publish path;
|
|
255
|
+
// the MCP child's instance has none (the periodic reconcile pushes its writes).
|
|
256
|
+
const railsThemesFile = path.join(dir, 'rails-themes.json');
|
|
257
|
+
// The cross-user RECIPE pool, cached locally (reframed Class-B cross-user, 2026-06-15).
|
|
258
|
+
// Same shape as the theme cache, but the payload is the producer's `knowHow` (the
|
|
259
|
+
// build instructions the agent absorbs), not a hex bundle. readRailsRecipes() below
|
|
260
|
+
// re-runs the Class-B scan on every pool recipe on read and DROPS any that trip it —
|
|
261
|
+
// a hard, defense-in-depth filter so a stranger's recipe with a baked-in
|
|
262
|
+
// secret/destructive/exfil command never reaches the shelf even if it slipped past
|
|
263
|
+
// publish or the cache was tampered. The remaining (un-scannable) judgment — "do these
|
|
264
|
+
// natural-language instructions look safe?" — is the AGENT's call at open_recipe time
|
|
265
|
+
// (source:'rails' is surfaced to it), NOT a platform consent wall. See
|
|
266
|
+
// docs/class-b-cross-user-readiness.md.
|
|
267
|
+
const railsRecipesFile = path.join(dir, 'rails-recipes.json');
|
|
268
|
+
// Self-seed drafts (cold-start, Producer #1): recipes the agent EXTRACTED from
|
|
269
|
+
// the user's existing work, staged here for REVIEW. Nothing reaches the shelf
|
|
270
|
+
// until the user approves a draft (publishDraft). The safety control is that a
|
|
271
|
+
// draft holds only generalized know-how — never client files/names/secrets.
|
|
272
|
+
const draftsFile = path.join(dir, 'drafts.json');
|
|
273
|
+
|
|
274
|
+
function ensureDir() {
|
|
275
|
+
try {
|
|
276
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
277
|
+
} catch {
|
|
278
|
+
/* read-only fs — degrades to no persistence */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- live outcome telemetry (B1) ----------------------------------------
|
|
283
|
+
// Real build-result events (recorded by record_build_result after a build lands
|
|
284
|
+
// or fails) are the signal behind outcomeScore. Cached on the events file's
|
|
285
|
+
// size+mtime so it stays correct even when the MCP child process appends.
|
|
286
|
+
let _outcomeCache = null;
|
|
287
|
+
let _outcomeCacheSig = null;
|
|
288
|
+
function liveOutcomes() {
|
|
289
|
+
let sig = '0';
|
|
290
|
+
try { const st = fs.statSync(eventsFile); sig = `${st.size}:${st.mtimeMs}`; } catch { /* no file yet */ }
|
|
291
|
+
if (_outcomeCache && _outcomeCacheSig === sig) return _outcomeCache;
|
|
292
|
+
const map = {};
|
|
293
|
+
for (const e of events()) {
|
|
294
|
+
if (e.type !== 'build-result' || !e.recipeId) continue;
|
|
295
|
+
const m = (map[e.recipeId] ??= { builds: 0, working: 0 });
|
|
296
|
+
m.builds += 1;
|
|
297
|
+
if (e.success) m.working += 1;
|
|
298
|
+
}
|
|
299
|
+
_outcomeCache = map;
|
|
300
|
+
_outcomeCacheSig = sig;
|
|
301
|
+
return map;
|
|
302
|
+
}
|
|
303
|
+
// The recipe's accumulated { builds, working }: its seed baseline (missing →
|
|
304
|
+
// {0,0}, the migration for old listings) plus live build-results.
|
|
305
|
+
function liveStats(r) {
|
|
306
|
+
const base = r.outcomeStats && typeof r.outcomeStats === 'object' ? r.outcomeStats : { builds: 0, working: 0 };
|
|
307
|
+
const live = liveOutcomes()[r.id] || { builds: 0, working: 0 };
|
|
308
|
+
return { builds: (base.builds || 0) + live.builds, working: (base.working || 0) + live.working };
|
|
309
|
+
}
|
|
310
|
+
// Bayesian-smoothed success rate. Prior mean = the declared outcomeScore (0.7 for
|
|
311
|
+
// an undeclared listing); with zero live results it equals the declared score, so
|
|
312
|
+
// seed rankings stay stable until real outcomes accumulate.
|
|
313
|
+
function liveScore(r) {
|
|
314
|
+
const prior = typeof r.outcomeScore === 'number' ? r.outcomeScore : 0.7;
|
|
315
|
+
const { builds, working } = liveStats(r);
|
|
316
|
+
const score = (working + OUTCOME_PRIOR_K * prior) / (builds + OUTCOME_PRIOR_K);
|
|
317
|
+
return Math.max(0, Math.min(1, Math.round(score * 1000) / 1000));
|
|
318
|
+
}
|
|
319
|
+
// The agent reports whether a build that used a recipe actually WORKED (B1) — the
|
|
320
|
+
// real signal behind the outcome score. Appended to events.jsonl; liveScore reads
|
|
321
|
+
// it on the next card render. Unknown recipe → a no-op error (keeps events clean).
|
|
322
|
+
function recordBuildResult({ recipeId, success, reason = '' } = {}) {
|
|
323
|
+
const r = getRecipe(recipeId);
|
|
324
|
+
if (!r) return { ok: false, error: `no recipe "${recipeId}" on the shelf` };
|
|
325
|
+
const evt = appendEvent({
|
|
326
|
+
type: 'build-result',
|
|
327
|
+
recipeId: r.id,
|
|
328
|
+
title: r.title,
|
|
329
|
+
success: !!success,
|
|
330
|
+
reason: String(reason || '').slice(0, 200),
|
|
331
|
+
});
|
|
332
|
+
return { ok: true, event: evt, outcome: { ...liveStats(r), score: liveScore(r) } };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- trust & provenance (B2) --------------------------------------------
|
|
336
|
+
// The §3 schema (docs/shelf-trust-provenance-design.md). riskClass is the spine:
|
|
337
|
+
// A data (themes) · B local build-recipe · C connected-service · D executable-skill.
|
|
338
|
+
// provenanceFor() also MIGRATES old records on read — a listing with the legacy
|
|
339
|
+
// safetyBadge but no riskClass gets defaults by kind, and a free-text sourceNote
|
|
340
|
+
// becomes a builtFrom[] entry — so an existing listings.json never breaks.
|
|
341
|
+
function riskClassOf(r) {
|
|
342
|
+
if (['A', 'B', 'C', 'D'].includes(r.riskClass)) return r.riskClass;
|
|
343
|
+
if (r.kind === 'theme') return 'A';
|
|
344
|
+
if (r.service || r.hasService) return 'C';
|
|
345
|
+
return 'B';
|
|
346
|
+
}
|
|
347
|
+
function normalizeBuiltFrom(r) {
|
|
348
|
+
if (Array.isArray(r.builtFrom)) {
|
|
349
|
+
return r.builtFrom.filter(Boolean).map((b) => ({ type: b.type || 'recipe', id: b.id ?? null, note: b.note || b.title || '' }));
|
|
350
|
+
}
|
|
351
|
+
if (r.builtFrom && (r.builtFrom.id || r.builtFrom.title)) {
|
|
352
|
+
// the C2 remix pointer { id, title } → the schema's array form
|
|
353
|
+
return [{ type: r.kind === 'theme' ? 'theme' : 'recipe', id: r.builtFrom.id ?? null, note: r.builtFrom.title || '' }];
|
|
354
|
+
}
|
|
355
|
+
if (r.sourceNote) return [{ type: 'scratch', id: null, note: String(r.sourceNote) }]; // migrate free-text
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
function provenanceFor(r) {
|
|
359
|
+
const riskClass = riskClassOf(r);
|
|
360
|
+
const trusted = r.source === 'seed' || r.source === 'theme'; // VW-shipped → pre-vetted
|
|
361
|
+
const dataTouched = r.dataTouched || (riskClass === 'C'
|
|
362
|
+
? { egress: [], scope: 'external', paid: { model: r.reward?.perUseValue ? 'per-use' : 'none', note: '' } }
|
|
363
|
+
: { egress: [], scope: 'in-workspace', paid: { model: 'none', note: '' } });
|
|
364
|
+
const attestation = r.attestation || {
|
|
365
|
+
privacy: riskClass === 'C' ? 'not-attested' : 'n/a',
|
|
366
|
+
attestedBy: null,
|
|
367
|
+
at: null,
|
|
368
|
+
};
|
|
369
|
+
const defaultVerdict =
|
|
370
|
+
riskClass === 'A' ? 'auto'
|
|
371
|
+
: riskClass === 'B' ? (trusted ? 'scanned' : 'unscanned')
|
|
372
|
+
: riskClass === 'C' ? (trusted ? 'reviewed' : 'unvetted')
|
|
373
|
+
: 'unvetted'; // D never auto-clears (enforcement deferred → own/teammate only)
|
|
374
|
+
const vetting = r.vetting || { verdict: defaultVerdict, stakes: 'ordinary', signature: null, capabilities: null, findings: [] };
|
|
375
|
+
return { riskClass, builtFrom: normalizeBuiltFrom(r), dataTouched, attestation, vetting };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The cross-user theme pool from the local rails cache, RE-VALIDATED on read —
|
|
379
|
+
// never trust the pool blindly (spike 2: 15/15 hostile bundles neutralized by
|
|
380
|
+
// normalizeTheme). A tampered cache file degrades to a clean bundle, not exec.
|
|
381
|
+
//
|
|
382
|
+
// Trust/ranking/payout fields are NEVER trusted from the pool (audit 2026-06-15):
|
|
383
|
+
// a producer could otherwise publish a theme that renders "Verified" with a
|
|
384
|
+
// perfect outcome score, 5 stars, and an inflated payout. We rebuild a clean card
|
|
385
|
+
// input keeping only display fields + the re-validated bundle, and RESET the
|
|
386
|
+
// trust signals so OUR provenanceFor derives the honest Class-A 'auto' verdict and
|
|
387
|
+
// a neutral outcome (local build-results then accrue). Display text is clamped.
|
|
388
|
+
function str(v, max) {
|
|
389
|
+
return typeof v === 'string' ? v.slice(0, max) : '';
|
|
390
|
+
}
|
|
391
|
+
function readRailsThemes() {
|
|
392
|
+
const raw = readJsonSafe(railsThemesFile, []);
|
|
393
|
+
if (!Array.isArray(raw)) return [];
|
|
394
|
+
return raw
|
|
395
|
+
.filter((r) => r && typeof r === 'object' && r.id && r.kind === 'theme')
|
|
396
|
+
.map((r) => {
|
|
397
|
+
const p = r.producer && typeof r.producer === 'object' ? r.producer : {};
|
|
398
|
+
return {
|
|
399
|
+
id: String(r.id).slice(0, 120),
|
|
400
|
+
kind: 'theme',
|
|
401
|
+
source: 'rails',
|
|
402
|
+
title: str(r.title, 80) || 'Untitled',
|
|
403
|
+
pitch: str(r.pitch, 200),
|
|
404
|
+
summary: str(r.summary, 400),
|
|
405
|
+
tags: Array.isArray(r.tags) ? r.tags.slice(0, 8).map((t) => String(t).slice(0, 24)) : [],
|
|
406
|
+
producer: {
|
|
407
|
+
name: str(p.name, 60) || 'A maker',
|
|
408
|
+
handle: str(p.handle, 40) || 'maker',
|
|
409
|
+
kind: 'maker',
|
|
410
|
+
},
|
|
411
|
+
theme: normalizeTheme(r.theme || {}),
|
|
412
|
+
// Remix lineage is safe to surface (it's just a pointer + label).
|
|
413
|
+
...(r.builtFrom && r.builtFrom.id
|
|
414
|
+
? { builtFrom: { id: String(r.builtFrom.id).slice(0, 120), title: str(r.builtFrom.title, 80) } }
|
|
415
|
+
: {}),
|
|
416
|
+
// RESET publisher-controlled trust/ranking/payout → our derivation governs.
|
|
417
|
+
outcomeScore: 0.7, // the new-listing prior (proven locally over time)
|
|
418
|
+
outcomeStats: { builds: 0, working: 0 },
|
|
419
|
+
rating: { stars: 0, count: 0 }, // no fake stars
|
|
420
|
+
reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
421
|
+
// No vetting/safetyBadge → provenanceFor derives Class-A 'auto'.
|
|
422
|
+
createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Overwrite the local cache of the global theme pool (called by the main
|
|
428
|
+
// server's periodic sync). Atomic; best-effort.
|
|
429
|
+
function writeRailsThemes(cards) {
|
|
430
|
+
ensureDir();
|
|
431
|
+
try {
|
|
432
|
+
writeJsonAtomic(railsThemesFile, Array.isArray(cards) ? cards : []);
|
|
433
|
+
} catch {
|
|
434
|
+
/* persistence best-effort */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The cross-user RECIPE pool from the local cache. Unlike a theme (inert hex data),
|
|
439
|
+
// a recipe's payload is `knowHow` — instructions the agent will absorb and run. So:
|
|
440
|
+
// 1. HARD read-time scan filter: any pool recipe whose know-how trips scanForUnsafe
|
|
441
|
+
// is DROPPED here (never served). The agent only ever sees scan-clean strangers.
|
|
442
|
+
// 2. Trust/ranking/payout are NEVER trusted from the pool (same rule as themes —
|
|
443
|
+
// a producer can't ship a fake "Verified"/perfect-score/inflated-payout). We
|
|
444
|
+
// rebuild a clean record, reset the trust signals, and stamp source:'rails' so
|
|
445
|
+
// the agent knows it's a maker outside the user's team (the real, judgment-call
|
|
446
|
+
// control — at open_recipe time, the AGENT decides, not a consent wall).
|
|
447
|
+
function readRailsRecipes() {
|
|
448
|
+
const raw = readJsonSafe(railsRecipesFile, []);
|
|
449
|
+
if (!Array.isArray(raw)) return [];
|
|
450
|
+
const out = [];
|
|
451
|
+
for (const r of raw) {
|
|
452
|
+
if (!r || typeof r !== 'object' || !r.id || r.kind !== 'recipe') continue;
|
|
453
|
+
const knowHow = typeof r.knowHow === 'string' ? r.knowHow.slice(0, MAX_SCAN_CHARS) : '';
|
|
454
|
+
if (!scanForUnsafe(knowHow).clean) continue; // hard filter — unsafe know-how never reaches the shelf
|
|
455
|
+
const p = r.producer && typeof r.producer === 'object' ? r.producer : {};
|
|
456
|
+
out.push({
|
|
457
|
+
id: String(r.id).slice(0, 120),
|
|
458
|
+
kind: 'recipe',
|
|
459
|
+
source: 'rails',
|
|
460
|
+
riskClass: 'B',
|
|
461
|
+
title: str(r.title, 80) || 'Untitled',
|
|
462
|
+
pitch: str(r.pitch, 200),
|
|
463
|
+
summary: str(r.summary, 400),
|
|
464
|
+
tags: Array.isArray(r.tags) ? r.tags.slice(0, 8).map((t) => String(t).slice(0, 24)) : [],
|
|
465
|
+
knowHow,
|
|
466
|
+
producer: {
|
|
467
|
+
name: str(p.name, 60) || 'A maker',
|
|
468
|
+
handle: str(p.handle, 40) || 'maker',
|
|
469
|
+
kind: 'maker',
|
|
470
|
+
},
|
|
471
|
+
...(r.builtFrom && r.builtFrom.id
|
|
472
|
+
? { builtFrom: { id: String(r.builtFrom.id).slice(0, 120), title: str(r.builtFrom.title, 80) } }
|
|
473
|
+
: {}),
|
|
474
|
+
// RESET publisher-controlled trust/ranking/payout → our derivation governs.
|
|
475
|
+
outcomeScore: 0.7, // the new-listing prior (proven locally over time)
|
|
476
|
+
outcomeStats: { builds: 0, working: 0 },
|
|
477
|
+
rating: { stars: 0, count: 0 }, // no fake stars
|
|
478
|
+
reward: { model: 'one-time', unit: 'per build', oneTimeValue: 5.0, perUseValue: 0 },
|
|
479
|
+
// We re-scanned it clean → an honest 'scanned' verdict; source:'rails' is what
|
|
480
|
+
// tells the agent (and the badge) it's a stranger's recipe, not a vetted one.
|
|
481
|
+
vetting: { verdict: 'scanned', stakes: 'ordinary', signature: null, capabilities: null, findings: [] },
|
|
482
|
+
dataTouched: { egress: [], scope: 'in-workspace', paid: { model: 'none', note: '' } },
|
|
483
|
+
attestation: { privacy: 'n/a', attestedBy: str(p.handle, 40) || 'maker', at: null },
|
|
484
|
+
createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Overwrite the local cache of the global recipe pool (periodic sync). Atomic.
|
|
491
|
+
function writeRailsRecipes(cards) {
|
|
492
|
+
ensureDir();
|
|
493
|
+
try {
|
|
494
|
+
writeJsonAtomic(railsRecipesFile, Array.isArray(cards) ? cards : []);
|
|
495
|
+
} catch {
|
|
496
|
+
/* persistence best-effort */
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The user's OWN recipe listings as public records to reconcile up to the pool
|
|
501
|
+
// (mirrors ownThemeListings, but recipes carry knowHow — the payload — and only
|
|
502
|
+
// SCAN-CLEAN recipes are ever pushed: the flagged ones stay strictly local).
|
|
503
|
+
function ownRecipeListings() {
|
|
504
|
+
const listings = readJsonSafe(listingsFile, []);
|
|
505
|
+
return (Array.isArray(listings) ? listings : [])
|
|
506
|
+
.filter((l) => l && l.kind !== 'theme') // recipes (a listing with no kind defaults to recipe)
|
|
507
|
+
.filter((l) => scanForUnsafe(l.knowHow || '').clean) // hard publish filter — never push unsafe know-how
|
|
508
|
+
.map((l) => ({ ...card(l), knowHow: l.knowHow || '' }));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function shelf() {
|
|
512
|
+
const seed = loadSeedRecipes(seedDir);
|
|
513
|
+
const listings = readJsonSafe(listingsFile, []);
|
|
514
|
+
const local = Array.isArray(listings) ? listings : [];
|
|
515
|
+
// Seeds + the user's OWN listings come first and SHADOW the pool: a theme the
|
|
516
|
+
// user published locally wins over its copy in the global pool (same id).
|
|
517
|
+
const base = [...seed, ...SEED_THEMES, ...local];
|
|
518
|
+
const seen = new Set(base.map((r) => r.id));
|
|
519
|
+
// Cross-user pools (themes + recipes), de-duped against base and each other; a
|
|
520
|
+
// listing the user published locally always shadows its copy in the pool.
|
|
521
|
+
const themePool = readRailsThemes().filter((r) => !seen.has(r.id));
|
|
522
|
+
for (const t of themePool) seen.add(t.id);
|
|
523
|
+
const recipePool = readRailsRecipes().filter((r) => !seen.has(r.id));
|
|
524
|
+
return [...base, ...themePool, ...recipePool];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// The user's own theme listings, as public cards — what the periodic sync
|
|
528
|
+
// reconciles up to the rails (catches themes the agent published via the MCP
|
|
529
|
+
// child, which has no rails of its own).
|
|
530
|
+
function ownThemeListings() {
|
|
531
|
+
const listings = readJsonSafe(listingsFile, []);
|
|
532
|
+
return (Array.isArray(listings) ? listings : [])
|
|
533
|
+
.filter((l) => l && l.kind === 'theme')
|
|
534
|
+
.map((l) => card(l));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function getRecipe(id) {
|
|
538
|
+
return shelf().find((r) => r.id === id) || null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Public, agent-facing card for a recipe (no know-how dump until opened).
|
|
542
|
+
function card(r, extra = {}) {
|
|
543
|
+
return {
|
|
544
|
+
id: r.id,
|
|
545
|
+
kind: r.kind || 'recipe',
|
|
546
|
+
// Provenance category: 'seed'|'theme' (VW-shipped) · 'listing' (own) · 'rails'
|
|
547
|
+
// (another user's, from the global pool). The shelf UI keys the Report action
|
|
548
|
+
// + the "from the community" labelling on this. Defaults to 'listing'.
|
|
549
|
+
source: r.source || 'listing',
|
|
550
|
+
title: r.title,
|
|
551
|
+
pitch: r.pitch,
|
|
552
|
+
producer: r.producer,
|
|
553
|
+
summary: r.summary,
|
|
554
|
+
vendorDescription: r.vendorDescription,
|
|
555
|
+
outcomeScore: liveScore(r),
|
|
556
|
+
outcomeStats: liveStats(r),
|
|
557
|
+
safetyBadge: r.safetyBadge, // legacy field kept for back-compat; web derives from `vetting`
|
|
558
|
+
...provenanceFor(r), // B2: riskClass · builtFrom · dataTouched · attestation · vetting
|
|
559
|
+
rating: r.rating,
|
|
560
|
+
reward: r.reward,
|
|
561
|
+
hasService: Boolean(r.service),
|
|
562
|
+
buildDir: r.buildDir,
|
|
563
|
+
// A theme card carries its own bundle — it IS its own preview (just hex), so
|
|
564
|
+
// the storefront can render a live swatch and apply it without a fetch.
|
|
565
|
+
...(r.kind === 'theme' && r.theme ? { theme: r.theme } : {}),
|
|
566
|
+
...extra,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function search(need, { limit = 4 } = {}) {
|
|
571
|
+
const ranked = shelf()
|
|
572
|
+
.map((r) => {
|
|
573
|
+
const { score, matched } = scoreRecipe(r, need);
|
|
574
|
+
return { r, score, matched };
|
|
575
|
+
})
|
|
576
|
+
.filter((x) => x.score > 0)
|
|
577
|
+
.sort((a, b) => b.score - a.score || liveScore(b.r) - liveScore(a.r))
|
|
578
|
+
.slice(0, limit);
|
|
579
|
+
return ranked.map((x) => card(x.r, { relevance: x.score, matched: x.matched }));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function appendEvent(evt) {
|
|
583
|
+
ensureDir();
|
|
584
|
+
const enriched = { id: rid(), ts: Date.now(), ...evt };
|
|
585
|
+
try {
|
|
586
|
+
fs.appendFileSync(eventsFile, `${JSON.stringify(enriched)}\n`);
|
|
587
|
+
} catch {
|
|
588
|
+
/* persistence best-effort */
|
|
589
|
+
}
|
|
590
|
+
return enriched;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function events() {
|
|
594
|
+
let raw = '';
|
|
595
|
+
try {
|
|
596
|
+
raw = fs.readFileSync(eventsFile, 'utf8');
|
|
597
|
+
} catch {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
return raw
|
|
601
|
+
.split('\n')
|
|
602
|
+
.map((l) => l.trim())
|
|
603
|
+
.filter(Boolean)
|
|
604
|
+
.map((l) => {
|
|
605
|
+
try {
|
|
606
|
+
return JSON.parse(l);
|
|
607
|
+
} catch {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
.filter(Boolean);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// A consumer's agent absorbed a recipe and built it: the three-way moment.
|
|
615
|
+
function recordUse({ recipeId, summary, consumer = 'you' }) {
|
|
616
|
+
const r = getRecipe(recipeId);
|
|
617
|
+
if (!r) return { ok: false, error: `no recipe "${recipeId}" on the shelf` };
|
|
618
|
+
const recurring = r.reward?.perUseValue || 0;
|
|
619
|
+
// A recurring SERVICE recipe (tool/API) earns per-use, NOT a build fee — so the
|
|
620
|
+
// build itself credits 0, and the meter ticks up per match. This keeps the live
|
|
621
|
+
// meter ("$X · N matches") reconciled with the "earns per match, ongoing" story.
|
|
622
|
+
// Pure know-how recipes earn their one-time build-time slice here.
|
|
623
|
+
const oneTime = r.service ? 0 : (r.reward?.oneTimeValue || 0);
|
|
624
|
+
const evt = appendEvent({
|
|
625
|
+
type: 'use',
|
|
626
|
+
recipeId: r.id,
|
|
627
|
+
title: r.title,
|
|
628
|
+
producer: r.producer,
|
|
629
|
+
consumer,
|
|
630
|
+
summary: summary || r.title,
|
|
631
|
+
value: oneTime,
|
|
632
|
+
unit: r.service ? 'recurring-start' : 'one-time',
|
|
633
|
+
});
|
|
634
|
+
const threeWay = {
|
|
635
|
+
you: `You got: ${summary || r.title}`,
|
|
636
|
+
producer: r.service
|
|
637
|
+
? `${r.producer?.name || 'The producer'} just gained a customer — your site uses their matching on every candidate, ongoing.`
|
|
638
|
+
: `${r.producer?.name || 'The producer'} earns a one-time share for the know-how you built on.`,
|
|
639
|
+
platform: 'VentureWild matched you to a proven recipe — and recorded it.',
|
|
640
|
+
};
|
|
641
|
+
return {
|
|
642
|
+
ok: true,
|
|
643
|
+
event: evt,
|
|
644
|
+
recipe: card(r),
|
|
645
|
+
threeWay,
|
|
646
|
+
credit: { producer: r.producer, oneTime, recurring, unit: r.reward?.unit },
|
|
647
|
+
service: r.service || null,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// TickUp's matching service ran once — recurring credit to the producer.
|
|
652
|
+
function recordServiceCall({ recipeId, serviceId, matches = 0 }) {
|
|
653
|
+
const r = getRecipe(recipeId) || shelf().find((x) => x.service?.id === serviceId);
|
|
654
|
+
const per = r?.reward?.perUseValue || 0;
|
|
655
|
+
return appendEvent({
|
|
656
|
+
type: 'service-call',
|
|
657
|
+
recipeId: r?.id || recipeId || null,
|
|
658
|
+
serviceId: serviceId || r?.service?.id || null,
|
|
659
|
+
producer: r?.producer || null,
|
|
660
|
+
matches,
|
|
661
|
+
value: per,
|
|
662
|
+
unit: 'per-use',
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// The flip side (§3.7): the user's agent packaged a build into a listing.
|
|
667
|
+
function publishListing({ id, title, pitch, summary, tags = [], knowHow = '', producer, buildDir, builtFrom = null }) {
|
|
668
|
+
ensureDir();
|
|
669
|
+
const listings = readJsonSafe(listingsFile, []);
|
|
670
|
+
const arr = Array.isArray(listings) ? listings : [];
|
|
671
|
+
const slug =
|
|
672
|
+
id ||
|
|
673
|
+
`${normalize(title).split(' ').slice(0, 4).join('-') || 'listing'}-${rid().slice(0, 4)}`;
|
|
674
|
+
// Class-B vetting (B2): a recipe is local build know-how → self-attest + an
|
|
675
|
+
// automated scan. A clean scan earns the 'scanned' verdict; a hit is recorded
|
|
676
|
+
// (verdict 'flagged' + findings) so the badge tells the truth — publishing isn't
|
|
677
|
+
// blocked here (single-user/local shelf), but the listing carries its verdict.
|
|
678
|
+
const scan = scanForUnsafe(knowHow);
|
|
679
|
+
const listing = {
|
|
680
|
+
id: slug,
|
|
681
|
+
kind: 'recipe',
|
|
682
|
+
title: title || 'Untitled',
|
|
683
|
+
pitch: pitch || '',
|
|
684
|
+
summary: summary || pitch || '',
|
|
685
|
+
vendorDescription: summary || pitch || '',
|
|
686
|
+
producer: producer || { name: 'You', handle: 'you', kind: 'maker' },
|
|
687
|
+
tags,
|
|
688
|
+
knowHow,
|
|
689
|
+
buildDir: buildDir || null,
|
|
690
|
+
outcomeScore: 0.7, // new listing: a starting, unproven score (earns its place by working)
|
|
691
|
+
outcomeStats: { builds: 0, working: 0 },
|
|
692
|
+
safetyBadge: 'new', // legacy field; the real signal is `vetting` below
|
|
693
|
+
riskClass: 'B',
|
|
694
|
+
...(builtFrom && builtFrom.id ? { builtFrom: { id: String(builtFrom.id), title: String(builtFrom.title || '') } } : {}),
|
|
695
|
+
dataTouched: { egress: [], scope: 'in-workspace', paid: { model: 'none', note: '' } },
|
|
696
|
+
attestation: { privacy: 'n/a', attestedBy: (producer || {}).handle || 'you', at: new Date().toISOString() },
|
|
697
|
+
vetting: {
|
|
698
|
+
verdict: scan.clean ? 'scanned' : 'flagged',
|
|
699
|
+
stakes: 'ordinary',
|
|
700
|
+
signature: null,
|
|
701
|
+
capabilities: null,
|
|
702
|
+
findings: scan.findings,
|
|
703
|
+
},
|
|
704
|
+
rating: { stars: 0, count: 0 },
|
|
705
|
+
reward: { model: 'one-time', unit: 'per build', perUseValue: 0, oneTimeValue: 5.0 },
|
|
706
|
+
source: 'listing',
|
|
707
|
+
createdAt: Date.now(),
|
|
708
|
+
};
|
|
709
|
+
// de-dupe by id (re-publish overwrites)
|
|
710
|
+
const next = arr.filter((l) => l.id !== listing.id);
|
|
711
|
+
next.push(listing);
|
|
712
|
+
writeJsonAtomic(listingsFile, next);
|
|
713
|
+
appendEvent({
|
|
714
|
+
type: 'publish',
|
|
715
|
+
recipeId: listing.id,
|
|
716
|
+
title: listing.title,
|
|
717
|
+
producer: listing.producer,
|
|
718
|
+
});
|
|
719
|
+
const publicCard = card(listing);
|
|
720
|
+
// Push to the GLOBAL pool ONLY if the scan is clean — the hard publish-time filter:
|
|
721
|
+
// a flagged recipe stays strictly local and never enters the cross-user pool.
|
|
722
|
+
// Recipes carry their know-how (the payload other agents absorb). Best-effort +
|
|
723
|
+
// fire-and-forget; a down rail (or the MCP child, which has no rails) never fails
|
|
724
|
+
// the local publish — the periodic sync reconciles via ownRecipeListings.
|
|
725
|
+
if (scan.clean && rails && typeof rails.publish === 'function') {
|
|
726
|
+
Promise.resolve(rails.publish({ ...publicCard, knowHow })).catch(() => {});
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
ok: true,
|
|
730
|
+
listing: publicCard,
|
|
731
|
+
earning: {
|
|
732
|
+
model: listing.reward.model,
|
|
733
|
+
oneTimeValue: listing.reward.oneTimeValue,
|
|
734
|
+
note: 'Represented earnings — you earn each time someone builds on this. (Money is for later.)',
|
|
735
|
+
},
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// --- themes on the shelf (the safest cross-user artifact) -----------------
|
|
740
|
+
|
|
741
|
+
// Publish a theme as a listing. The payload is a hex-token bundle (validated by
|
|
742
|
+
// normalizeTheme — same security boundary as set_theme: data, never CSS). It
|
|
743
|
+
// lands in listings.json with kind:'theme' and shows on the Themes shelf.
|
|
744
|
+
function publishTheme({ title, pitch, summary, tags = [], producer, theme, builtFrom = null } = {}) {
|
|
745
|
+
ensureDir();
|
|
746
|
+
const bundle = normalizeTheme(theme || {});
|
|
747
|
+
const listings = readJsonSafe(listingsFile, []);
|
|
748
|
+
const arr = Array.isArray(listings) ? listings : [];
|
|
749
|
+
const slug = `theme-${normalize(title).split(' ').slice(0, 3).join('-') || 'custom'}-${rid().slice(0, 4)}`;
|
|
750
|
+
// A remix records what it was tweaked from (the source theme on the shelf) so the
|
|
751
|
+
// network shows lineage — "built on each other's work", not a flat catalogue.
|
|
752
|
+
const provenance =
|
|
753
|
+
builtFrom && builtFrom.id
|
|
754
|
+
? { id: String(builtFrom.id), title: String(builtFrom.title || '') }
|
|
755
|
+
: null;
|
|
756
|
+
const listing = {
|
|
757
|
+
id: slug,
|
|
758
|
+
kind: 'theme',
|
|
759
|
+
source: 'theme',
|
|
760
|
+
title: title || bundle.name || 'Custom theme',
|
|
761
|
+
pitch: pitch || '',
|
|
762
|
+
summary: summary || pitch || `A ${bundle.mode} theme.`,
|
|
763
|
+
vendorDescription: summary || pitch || '',
|
|
764
|
+
producer: producer || { name: 'You', handle: 'you', kind: 'maker' },
|
|
765
|
+
tags,
|
|
766
|
+
theme: bundle,
|
|
767
|
+
...(provenance ? { builtFrom: provenance } : {}),
|
|
768
|
+
outcomeScore: 0.7,
|
|
769
|
+
outcomeStats: { builds: 0, working: 0 },
|
|
770
|
+
safetyBadge: 'new',
|
|
771
|
+
rating: { stars: 0, count: 0 },
|
|
772
|
+
reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
|
|
773
|
+
createdAt: Date.now(),
|
|
774
|
+
};
|
|
775
|
+
const next = arr.filter((l) => l.id !== listing.id);
|
|
776
|
+
next.push(listing);
|
|
777
|
+
writeJsonAtomic(listingsFile, next);
|
|
778
|
+
appendEvent({ type: 'publish', recipeId: listing.id, title: listing.title, producer: listing.producer, ...(provenance ? { builtFrom: provenance } : {}) });
|
|
779
|
+
const publicCard = card(listing);
|
|
780
|
+
// Push to the GLOBAL pool (next push §N) so every VW user can discover it.
|
|
781
|
+
// Best-effort + fire-and-forget: a down rail (or the MCP child, which has no
|
|
782
|
+
// rails) NEVER fails the local publish — the periodic sync reconciles it up.
|
|
783
|
+
if (rails && typeof rails.publish === 'function') {
|
|
784
|
+
Promise.resolve(rails.publish(publicCard)).catch(() => {});
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
ok: true,
|
|
788
|
+
listing: publicCard,
|
|
789
|
+
earning: { model: 'one-time', oneTimeValue: listing.reward.oneTimeValue, note: 'You earn each time someone applies it. (Money is for later.)' },
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// A consumer applied a theme from the shelf: record the three-way moment (they
|
|
794
|
+
// got the look · the producer earns · platform recorded it) and hand back the
|
|
795
|
+
// validated bundle for the browser to apply.
|
|
796
|
+
function recordThemeApply({ themeId, consumer = 'you' } = {}) {
|
|
797
|
+
const r = getRecipe(themeId);
|
|
798
|
+
if (!r || r.kind !== 'theme') return { ok: false, error: `no theme "${themeId}" on the shelf` };
|
|
799
|
+
const oneTime = r.reward?.oneTimeValue || 0;
|
|
800
|
+
const evt = appendEvent({
|
|
801
|
+
type: 'use',
|
|
802
|
+
recipeId: r.id,
|
|
803
|
+
title: r.title,
|
|
804
|
+
producer: r.producer,
|
|
805
|
+
consumer,
|
|
806
|
+
summary: `Applied the "${r.title}" theme`,
|
|
807
|
+
value: oneTime,
|
|
808
|
+
unit: 'one-time',
|
|
809
|
+
});
|
|
810
|
+
return {
|
|
811
|
+
ok: true,
|
|
812
|
+
event: evt,
|
|
813
|
+
theme: normalizeTheme(r.theme || {}),
|
|
814
|
+
title: r.title,
|
|
815
|
+
threeWay: {
|
|
816
|
+
you: `Your workspace now wears "${r.title}".`,
|
|
817
|
+
producer: `${r.producer?.name || 'The producer'} earns a one-time share for the look you adopted.`,
|
|
818
|
+
platform: 'VentureWild matched you to a proven theme — and recorded it.',
|
|
819
|
+
},
|
|
820
|
+
credit: { producer: r.producer, oneTime, unit: r.reward?.unit },
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// --- self-seed drafts (review-gated) --------------------------------------
|
|
825
|
+
|
|
826
|
+
function listDrafts() {
|
|
827
|
+
const d = readJsonSafe(draftsFile, []);
|
|
828
|
+
return Array.isArray(d) ? d : [];
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function getDraft(id) {
|
|
832
|
+
return listDrafts().find((d) => d.id === id) || null;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Stage a recipe the agent extracted from the user's existing work — does NOT
|
|
836
|
+
// publish. `sourceNote` records (for the user's review) what it was generalized
|
|
837
|
+
// from. Returns a review-shaped object the UI renders for approval.
|
|
838
|
+
function stageDraft({ title, pitch, summary, tags = [], knowHow = '', sourceNote = '' }) {
|
|
839
|
+
ensureDir();
|
|
840
|
+
const drafts = listDrafts();
|
|
841
|
+
const draft = {
|
|
842
|
+
id: `draft-${rid()}`,
|
|
843
|
+
title: title || 'Untitled',
|
|
844
|
+
pitch: pitch || '',
|
|
845
|
+
summary: summary || pitch || '',
|
|
846
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
847
|
+
knowHow: knowHow || '',
|
|
848
|
+
sourceNote: sourceNote || '',
|
|
849
|
+
createdAt: Date.now(),
|
|
850
|
+
};
|
|
851
|
+
drafts.push(draft);
|
|
852
|
+
writeJsonAtomic(draftsFile, drafts);
|
|
853
|
+
return draft;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function discardDraft(id) {
|
|
857
|
+
const drafts = listDrafts();
|
|
858
|
+
const next = drafts.filter((d) => d.id !== id);
|
|
859
|
+
writeJsonAtomic(draftsFile, next);
|
|
860
|
+
return { ok: next.length !== drafts.length };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Promote a reviewed draft onto the shelf — publishes EXACTLY what was reviewed
|
|
864
|
+
// (you publish what you saw), then removes it from the draft queue.
|
|
865
|
+
function publishDraft(id, { producer } = {}) {
|
|
866
|
+
const draft = getDraft(id);
|
|
867
|
+
if (!draft) return { ok: false, error: `no draft "${id}"` };
|
|
868
|
+
const res = publishListing({
|
|
869
|
+
title: draft.title,
|
|
870
|
+
pitch: draft.pitch,
|
|
871
|
+
summary: draft.summary,
|
|
872
|
+
tags: draft.tags,
|
|
873
|
+
knowHow: draft.knowHow,
|
|
874
|
+
producer: producer || { name: 'You', handle: 'you', kind: 'maker' },
|
|
875
|
+
});
|
|
876
|
+
discardDraft(id);
|
|
877
|
+
return res;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function setPreview({ dir: buildDir, recipeId, port = 4000 }) {
|
|
881
|
+
ensureDir();
|
|
882
|
+
const payload = { dir: buildDir, recipeId: recipeId || null, port, ts: Date.now() };
|
|
883
|
+
writeJsonAtomic(previewFile, payload);
|
|
884
|
+
return payload;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function getPreview() {
|
|
888
|
+
return readJsonSafe(previewFile, null);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Aggregate everything the UI needs: transactions, per-producer earnings,
|
|
892
|
+
// per-service usage meters, and the user's own listings.
|
|
893
|
+
function computeState() {
|
|
894
|
+
const evs = events();
|
|
895
|
+
const earnings = {}; // handle -> { name, total, uses, serviceCalls, matches }
|
|
896
|
+
const meters = {}; // serviceId -> { calls, matches, value, producer }
|
|
897
|
+
const transactions = [];
|
|
898
|
+
for (const e of evs) {
|
|
899
|
+
const h = e.producer?.handle || 'unknown';
|
|
900
|
+
const bucket = (earnings[h] ??= {
|
|
901
|
+
name: e.producer?.name || h,
|
|
902
|
+
handle: h,
|
|
903
|
+
total: 0,
|
|
904
|
+
uses: 0,
|
|
905
|
+
serviceCalls: 0,
|
|
906
|
+
matches: 0,
|
|
907
|
+
});
|
|
908
|
+
if (e.type === 'use') {
|
|
909
|
+
bucket.total += e.value || 0;
|
|
910
|
+
bucket.uses += 1;
|
|
911
|
+
transactions.push(e);
|
|
912
|
+
} else if (e.type === 'service-call') {
|
|
913
|
+
bucket.total += e.value || 0;
|
|
914
|
+
bucket.serviceCalls += 1;
|
|
915
|
+
bucket.matches += e.matches || 0;
|
|
916
|
+
const sid = e.serviceId || 'service';
|
|
917
|
+
const m = (meters[sid] ??= { calls: 0, matches: 0, value: 0, producer: e.producer || null });
|
|
918
|
+
m.calls += 1;
|
|
919
|
+
m.matches += e.matches || 0;
|
|
920
|
+
m.value += e.value || 0;
|
|
921
|
+
} else if (e.type === 'publish') {
|
|
922
|
+
transactions.push(e);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return {
|
|
926
|
+
transactions: transactions.slice(-50),
|
|
927
|
+
earnings: Object.values(earnings).sort((a, b) => b.total - a.total),
|
|
928
|
+
meters,
|
|
929
|
+
listings: (readJsonSafe(listingsFile, []) || []).map((l) => card(l)),
|
|
930
|
+
totals: {
|
|
931
|
+
events: evs.length,
|
|
932
|
+
creditPaid: Object.values(earnings).reduce((s, b) => s + b.total, 0),
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return {
|
|
938
|
+
dir,
|
|
939
|
+
eventsFile,
|
|
940
|
+
listingsFile,
|
|
941
|
+
previewFile,
|
|
942
|
+
draftsFile,
|
|
943
|
+
railsThemesFile,
|
|
944
|
+
railsRecipesFile,
|
|
945
|
+
writeRailsThemes,
|
|
946
|
+
writeRailsRecipes,
|
|
947
|
+
ownThemeListings,
|
|
948
|
+
ownRecipeListings,
|
|
949
|
+
shelf,
|
|
950
|
+
getRecipe,
|
|
951
|
+
card,
|
|
952
|
+
search,
|
|
953
|
+
recordUse,
|
|
954
|
+
recordBuildResult,
|
|
955
|
+
recordServiceCall,
|
|
956
|
+
publishListing,
|
|
957
|
+
publishTheme,
|
|
958
|
+
recordThemeApply,
|
|
959
|
+
listDrafts,
|
|
960
|
+
getDraft,
|
|
961
|
+
stageDraft,
|
|
962
|
+
discardDraft,
|
|
963
|
+
publishDraft,
|
|
964
|
+
setPreview,
|
|
965
|
+
getPreview,
|
|
966
|
+
computeState,
|
|
967
|
+
events,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function money(n) {
|
|
972
|
+
const v = Number(n) || 0;
|
|
973
|
+
return `$${v.toFixed(2)}`;
|
|
974
|
+
}
|