@finchip.ai/foga 0.6.0-alpha.4 → 0.6.0-alpha.6
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/README.md +14 -5
- package/bin/foga.js +3 -2
- package/dist/cli.js +59 -90
- package/dist/demo/anvil.js +129 -0
- package/dist/demo/birth.js +71 -0
- package/dist/demo/loop-d.js +227 -111
- package/dist/demo/quickstart.js +312 -0
- package/dist/doctor/loop-d.js +29 -7
- package/dist/world/deploy.js +13 -12
- package/docs/design-memo.md +2 -0
- package/docs/examples/README.md +5 -1
- package/docs/loop-d.md +22 -4
- package/docs/mcp.md +3 -3
- package/docs/quickstart.md +78 -0
- package/package.json +3 -1
- package/scripts/prepare.mjs +21 -0
- package/server.json +2 -2
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { formatEther, parseEther } from "ethers";
|
|
6
|
+
import { anvilKeys } from "../genesis/roles.js";
|
|
7
|
+
import { Kernel } from "../kernel/kernel.js";
|
|
8
|
+
import { newApiKey } from "../http/server.js";
|
|
9
|
+
import { loadCellConfig, saveCellConfig } from "../node/config.js";
|
|
10
|
+
import { repoRoot } from "../util/fs.js";
|
|
11
|
+
import { deployWorld } from "../world/deploy.js";
|
|
12
|
+
import { ANVIL_INSTALL_HINT, anvilBinary, anvilVersion, launchAnvil, portInUse, portProblem, stopChild } from "./anvil.js";
|
|
13
|
+
import { birthCell } from "./birth.js";
|
|
14
|
+
import { mintCases } from "./demander.js";
|
|
15
|
+
import { cleanEnv, ownCli, waitForHttp } from "./loop-d.js";
|
|
16
|
+
export const MARKER = "quickstart.json";
|
|
17
|
+
export const PACKAGE_NAME = "@finchip.ai/foga";
|
|
18
|
+
/** How a client should start this very CLI: the published package through npx, or this checkout's compiled launcher. */
|
|
19
|
+
export function clientLauncher() {
|
|
20
|
+
const root = repoRoot();
|
|
21
|
+
if (/[\\/]node_modules[\\/]/.test(root))
|
|
22
|
+
return { command: "npx", args: ["-y", PACKAGE_NAME], fromCheckout: false };
|
|
23
|
+
if (existsSync(join(root, "dist", "cli.js")))
|
|
24
|
+
return { command: "node", args: [join(root, "bin", "foga.js")], fromCheckout: true };
|
|
25
|
+
return { command: "npx", args: ["tsx", join(root, "src", "cli.ts")], fromCheckout: true };
|
|
26
|
+
}
|
|
27
|
+
export function defaultQuickstartDir() {
|
|
28
|
+
return join(homedir(), ".foga", "quickstart");
|
|
29
|
+
}
|
|
30
|
+
/** The MCP client configurations for one cell: the same server definition in each client's file. */
|
|
31
|
+
export function clientConfigs(p) {
|
|
32
|
+
const stdio = { command: p.launcher.command, args: [...p.launcher.args, "mcp", "--config", p.configPath], env: { FOGA_OPERATOR_KEY_FILE: p.operatorKeyPath } };
|
|
33
|
+
const out = {
|
|
34
|
+
"claude_desktop_config.json": JSON.stringify({ mcpServers: { foga: stdio } }, null, 2) + "\n",
|
|
35
|
+
"claude-code.mcp.json": JSON.stringify({ mcpServers: { foga: { type: "stdio", ...stdio } } }, null, 2) + "\n",
|
|
36
|
+
"cursor-mcp.json": JSON.stringify({ mcpServers: { foga: stdio } }, null, 2) + "\n",
|
|
37
|
+
};
|
|
38
|
+
if (p.nodeUrl)
|
|
39
|
+
out["http-mcp.json"] = JSON.stringify({ mcpServers: { "foga-http": { type: "http", url: `${p.nodeUrl}/mcp`, headers: { Authorization: `Bearer ${p.apiKey}` } } } }, null, 2) + "\n";
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
const q = (s) => (/[\s"]/.test(s) ? JSON.stringify(s) : s);
|
|
43
|
+
export async function runQuickstart(o = {}) {
|
|
44
|
+
const log = o.log ?? ((line) => console.error(line));
|
|
45
|
+
const out = o.out ?? ((text) => console.log(text));
|
|
46
|
+
const root = repoRoot();
|
|
47
|
+
const dir = resolve(o.dir ?? defaultQuickstartDir());
|
|
48
|
+
const port = o.port ?? 8545;
|
|
49
|
+
const listen = o.listen === undefined ? "127.0.0.1:8787" : o.listen;
|
|
50
|
+
const name = o.name ?? "quick";
|
|
51
|
+
const rpc = `http://127.0.0.1:${port}`;
|
|
52
|
+
// --- say what is wrong before touching anything
|
|
53
|
+
const anvilBin = anvilBinary();
|
|
54
|
+
const version = anvilVersion(anvilBin.path);
|
|
55
|
+
if (!version)
|
|
56
|
+
throw new Error(ANVIL_INSTALL_HINT);
|
|
57
|
+
const problem = await portProblem(port);
|
|
58
|
+
if (problem)
|
|
59
|
+
throw new Error(problem);
|
|
60
|
+
if (listen) {
|
|
61
|
+
const lp = Number(listen.slice(listen.lastIndexOf(":") + 1));
|
|
62
|
+
if (await portInUse(lp))
|
|
63
|
+
throw new Error(`port ${lp} is in use; pass --listen <host:port> for the node (or --no-serve to run no node)`);
|
|
64
|
+
}
|
|
65
|
+
if (existsSync(dir)) {
|
|
66
|
+
const entries = readdirSync(dir);
|
|
67
|
+
if (entries.length && !entries.includes(MARKER))
|
|
68
|
+
throw new Error(`${dir} exists and is not a quickstart directory (no ${MARKER}); choose another --dir`);
|
|
69
|
+
if (entries.length) {
|
|
70
|
+
log(`quickstart: replacing the previous quickstart in ${dir} (its chain ended with its process)`);
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(dir, { recursive: true });
|
|
75
|
+
const keysDir = join(dir, "keys");
|
|
76
|
+
mkdirSync(keysDir, { recursive: true, mode: 0o700 });
|
|
77
|
+
// --- the chain and the world
|
|
78
|
+
log(`quickstart: starting ${version} on ${rpc} (log ${join(dir, "anvil.log")})`);
|
|
79
|
+
const anvil = await launchAnvil({ port, logPath: join(dir, "anvil.log") });
|
|
80
|
+
let node = null;
|
|
81
|
+
let endedWith = null;
|
|
82
|
+
const ended = new Promise((r) => (endedWith = r));
|
|
83
|
+
anvil.on("exit", (code, signal) => endedWith?.(`anvil stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`));
|
|
84
|
+
const stop = async () => {
|
|
85
|
+
if (node)
|
|
86
|
+
await stopChild(node);
|
|
87
|
+
node = null;
|
|
88
|
+
await stopChild(anvil);
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
const worldPath = join(dir, "world.json");
|
|
92
|
+
log("quickstart: deploying the local world (the ten reference implementations and the FOGA contracts)");
|
|
93
|
+
const { world } = await deployWorld({ worldConfigPath: join(root, "worlds", "local.json"), rpc, outPath: worldPath, quiet: true });
|
|
94
|
+
log(`quickstart: world ${world.worldId} deployed (${Object.keys(world.contracts).length} contracts)`);
|
|
95
|
+
// --- the cell: born with fresh keys, funded and attested by W (anvil's first account), its two skills minted
|
|
96
|
+
const funder = anvilKeys().W;
|
|
97
|
+
const k = await Kernel.open({ worldPath, rpc, storePath: join(dir, "quickstart.sqlite"), keys: { W: funder } });
|
|
98
|
+
const W = k.address("W");
|
|
99
|
+
let cell;
|
|
100
|
+
try {
|
|
101
|
+
cell = await birthCell({
|
|
102
|
+
kernel: k,
|
|
103
|
+
worldPath,
|
|
104
|
+
rpc,
|
|
105
|
+
name,
|
|
106
|
+
configPath: join(dir, "cell.config.json"),
|
|
107
|
+
storePath: join(dir, "cell.ops.sqlite"),
|
|
108
|
+
description: `Cell ${name}: born by foga quickstart on a local anvil chain`,
|
|
109
|
+
log: (line) => log(`quickstart: ${line}`),
|
|
110
|
+
configure: (cfg) => {
|
|
111
|
+
cfg.policy.budget = { dailyValueWei: parseEther("0.002").toString(), perOperationValueWei: parseEther("0.001").toString(), dailyGasWei: parseEther("0.05").toString(), maxConcurrentCommitments: 2 };
|
|
112
|
+
cfg.policy.tenders = { minRewardWei: parseEther("0.0005").toString(), minSubmitWindowSeconds: 600, minSettleWindowSeconds: 600, acceptance: ["hashlock"], counterpartyMinLevel: 0, lookbackBlocks: 9000 };
|
|
113
|
+
cfg.runtime.pollSeconds = 2;
|
|
114
|
+
cfg.runtime.http.listen = listen ?? "127.0.0.1:8787";
|
|
115
|
+
cfg.runtime.http.rateLimitPerMinute = 600;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
k.close();
|
|
121
|
+
k.world.provider.destroy();
|
|
122
|
+
}
|
|
123
|
+
const configPath = cell.configPath;
|
|
124
|
+
// --- the keys, each in its own 0600 file (anvil money; nothing of value), and an act-level API key for the HTTP entrance
|
|
125
|
+
const keyFile = (file, value) => {
|
|
126
|
+
const path = join(keysDir, file);
|
|
127
|
+
writeFileSync(path, value + "\n", { mode: 0o600 });
|
|
128
|
+
return path;
|
|
129
|
+
};
|
|
130
|
+
const keyPaths = { owner: keyFile("owner.key", cell.owner.privateKey), operator: keyFile("operator.key", cell.operator.privateKey), guardian: keyFile("guardian.key", cell.guardian.privateKey) };
|
|
131
|
+
const api = newApiKey("agent");
|
|
132
|
+
{
|
|
133
|
+
const loaded = loadCellConfig(configPath);
|
|
134
|
+
loaded.config.access.keys.push({ id: "agent", sha256: api.sha256, level: "act", visibility: "caller", note: "the quickstart's act-level key for the HTTP entrance" });
|
|
135
|
+
saveCellConfig(configPath, loaded.config);
|
|
136
|
+
}
|
|
137
|
+
const agentKeyPath = keyFile("agent.api-key", api.key);
|
|
138
|
+
// --- two tenders for the cell to find: a probe of the cell itself and an extraction over the fixture
|
|
139
|
+
let minted = [];
|
|
140
|
+
if (o.tenders ?? true) {
|
|
141
|
+
const r = await mintCases({ worldPath, rpc, storePath: join(dir, "demander.sqlite"), key: funder, cell: cell.account, schemeId: cell.schemeId, issuers: [W], packages: cell.packages, cases: ["a", "b"], seed: 1, log: (line) => log(`quickstart: ${line}`) });
|
|
142
|
+
minted = r.cases;
|
|
143
|
+
}
|
|
144
|
+
// --- the node: watchers, HTTP API, MCP over Streamable HTTP; the operator key alone, from its file
|
|
145
|
+
const cli = ownCli();
|
|
146
|
+
let nodeUrl = null;
|
|
147
|
+
if (listen) {
|
|
148
|
+
const i = listen.lastIndexOf(":");
|
|
149
|
+
const host = listen.slice(0, i) || "127.0.0.1";
|
|
150
|
+
nodeUrl = `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${listen.slice(i + 1)}`;
|
|
151
|
+
node = spawn(cli.command, [...cli.args, "node", "serve", "--config", configPath, "--rpc", rpc, "--listen", listen], { env: cleanEnv({ FOGA_OPERATOR_KEY_FILE: keyPaths.operator }), stdio: ["ignore", "ignore", "pipe"] });
|
|
152
|
+
node.stderr.on("data", (d) => {
|
|
153
|
+
for (const line of String(d).split("\n"))
|
|
154
|
+
if (line.trim())
|
|
155
|
+
log(` node| ${line}`);
|
|
156
|
+
});
|
|
157
|
+
node.on("exit", (code, signal) => endedWith?.(`the node stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`));
|
|
158
|
+
await waitForHttp(`${nodeUrl}/healthz`);
|
|
159
|
+
// --- the entrance answers: health, a public read, the agent key's pre-check of the first tender, the MCP handshake and tool list
|
|
160
|
+
const health = (await (await fetch(`${nodeUrl}/healthz`)).json());
|
|
161
|
+
if (!health.ok)
|
|
162
|
+
throw new Error("the node's /healthz is not ok");
|
|
163
|
+
const status = (await (await fetch(`${nodeUrl}/api/cell_status`)).json());
|
|
164
|
+
if (!status.ok || status.output?.account?.toLowerCase() !== cell.account.toLowerCase())
|
|
165
|
+
throw new Error(`cell_status through HTTP did not name the cell: ${JSON.stringify(status).slice(0, 200)}`);
|
|
166
|
+
if (minted.length) {
|
|
167
|
+
const pre = (await (await fetch(`${nodeUrl}/api/tender_precheck`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${api.key}` }, body: JSON.stringify({ tokenId: minted[0].tokenId }) })).json());
|
|
168
|
+
if (!pre.ok)
|
|
169
|
+
throw new Error(`the pre-check of tender ${minted[0].tokenId} was refused at ${pre.gate}: ${pre.reason}`);
|
|
170
|
+
}
|
|
171
|
+
const mcpHeaders = { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${api.key}` };
|
|
172
|
+
const init = await fetch(`${nodeUrl}/mcp`, { method: "POST", headers: mcpHeaders, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "foga-quickstart", version: "0" } } }) });
|
|
173
|
+
if (init.status !== 200)
|
|
174
|
+
throw new Error(`the MCP entrance answered ${init.status} to initialize`);
|
|
175
|
+
const tools = await fetch(`${nodeUrl}/mcp`, { method: "POST", headers: mcpHeaders, body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }) });
|
|
176
|
+
const toolsText = await tools.text();
|
|
177
|
+
const toolCount = (toolsText.match(/"name":"/g) ?? []).length;
|
|
178
|
+
if (toolCount < 20)
|
|
179
|
+
throw new Error(`the MCP entrance listed ${toolCount} tool(s) for the agent key; expected the act-level set`);
|
|
180
|
+
log(`quickstart: the entrance answers — /healthz ok, cell_status names ${cell.account}, tender ${minted[0]?.tokenId ?? "-"} eligible for the agent key, MCP initialize ok, ${toolCount} tools`);
|
|
181
|
+
}
|
|
182
|
+
// --- the stdio server the client configurations name, started as a client would start it: the operator's tool set over stdio
|
|
183
|
+
const stdioTools = await stdioToolCount(cli, configPath, rpc, cleanEnv({ FOGA_OPERATOR_KEY_FILE: keyPaths.operator }));
|
|
184
|
+
if (stdioTools < 30)
|
|
185
|
+
throw new Error(`the stdio MCP server listed ${stdioTools} tool(s) with the operator key; expected the operator's set`);
|
|
186
|
+
log(`quickstart: the stdio MCP server answers with the operator key — ${stdioTools} tools`);
|
|
187
|
+
// --- the client configurations, written and printed
|
|
188
|
+
const launcher = clientLauncher();
|
|
189
|
+
const configs = clientConfigs({ launcher, configPath, operatorKeyPath: keyPaths.operator, nodeUrl, apiKey: api.key });
|
|
190
|
+
const mcpDir = join(dir, "mcp");
|
|
191
|
+
mkdirSync(mcpDir, { recursive: true });
|
|
192
|
+
for (const [file, text] of Object.entries(configs))
|
|
193
|
+
writeFileSync(join(mcpDir, file), text);
|
|
194
|
+
const demanderCmd = `${q(launcher.command)} ${launcher.args.map(q).join(" ")} demo demander --world ${q(worldPath)} --rpc ${rpc} --store ${q(join(dir, "demander.sqlite"))} --cell ${cell.account} --scheme ${cell.schemeId} --issuer ${W} --probe-hash ${cell.packages["foga-probe"]} --extract-hash ${cell.packages["finch-extract-verify"]} --cases a,b --seed <n>`;
|
|
195
|
+
const claudeCodeAdd = `claude mcp add foga -e FOGA_OPERATOR_KEY_FILE=${q(keyPaths.operator)} -- ${q(launcher.command)} ${launcher.args.map(q).join(" ")} mcp --config ${q(configPath)}`;
|
|
196
|
+
const lines = [];
|
|
197
|
+
lines.push(`foga quickstart: a FOGA cell is live on a local chain`);
|
|
198
|
+
lines.push("");
|
|
199
|
+
lines.push(` chain anvil on ${rpc} (chain ${world.chainId}), world ${world.worldId} — the chain lives as long as this process`);
|
|
200
|
+
lines.push(` cell ${name} = ${cell.account}${cell.agentId ? ` (ERC-8004 agent ${cell.agentId})` : ""}; owner, operator and guardian keys in ${keysDir} (anvil money, no value)`);
|
|
201
|
+
lines.push(` skills ${Object.keys(cell.held).join(", ")} (held and offered)`);
|
|
202
|
+
if (minted.length)
|
|
203
|
+
lines.push(` tenders ${minted.map((c) => `#${c.tokenId} ${c.skill} (${formatEther(c.rewardWei)} ETH)`).join(", ")} — open, waiting for the cell to take them`);
|
|
204
|
+
if (nodeUrl)
|
|
205
|
+
lines.push(` node ${nodeUrl} — /mcp (Streamable HTTP), /api/<capability>, /healthz; its watchers settle and withdraw what the cell commits to`);
|
|
206
|
+
lines.push(` files ${configPath} (the cell), ${join(dir, "connect.md")} (this text), ${mcpDir}${process.platform === "win32" ? "\\" : "/"}*.json (the client configurations)`);
|
|
207
|
+
lines.push("");
|
|
208
|
+
lines.push("Connect an MCP client over stdio (the client starts the server on this machine; with the operator key it acts for the cell):");
|
|
209
|
+
lines.push(` Claude Desktop claude_desktop_config.json, the mcpServers block: ${join(mcpDir, "claude_desktop_config.json")}`);
|
|
210
|
+
lines.push(` Claude Code .mcp.json in a project: ${join(mcpDir, "claude-code.mcp.json")}`);
|
|
211
|
+
lines.push(` or one command: ${claudeCodeAdd}`);
|
|
212
|
+
lines.push(` Cursor .cursor/mcp.json: ${join(mcpDir, "cursor-mcp.json")}`);
|
|
213
|
+
lines.push("");
|
|
214
|
+
lines.push(configs["claude_desktop_config.json"].trimEnd().split("\n").map((l) => ` ${l}`).join("\n"));
|
|
215
|
+
if (nodeUrl) {
|
|
216
|
+
lines.push("");
|
|
217
|
+
lines.push("Or over HTTP (a client on another machine, or any agent runtime that speaks HTTP):");
|
|
218
|
+
lines.push(` MCP ${nodeUrl}/mcp with the header Authorization: Bearer ${api.key} (${join(mcpDir, "http-mcp.json")})`);
|
|
219
|
+
lines.push(` API ${nodeUrl}/api/<capability>, POST a JSON body (reads also GET); the same header for acts. GET ${nodeUrl}/api/capabilities lists them.`);
|
|
220
|
+
lines.push(` curl -s ${nodeUrl}/api/cell_status`);
|
|
221
|
+
if (minted.length)
|
|
222
|
+
lines.push(` curl -s -X POST ${nodeUrl}/api/tender_precheck -H "content-type: application/json" -H "authorization: Bearer ${api.key}" -d "{\\"tokenId\\":\\"${minted[0].tokenId}\\"}"`);
|
|
223
|
+
}
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push("What a connected agent does with a tender: tender_list, tender_inspect, tender_precheck (eligible: false is the cell's verdict), skill_run with the");
|
|
226
|
+
lines.push("task's skill and input, tender_submit with the run's resultHash and runReceipt; the node settles and withdraws. More tenders for the cell:");
|
|
227
|
+
lines.push(` FOGA_DEMANDER_KEY=<a funded key, anvil's first account by default> ${demanderCmd}`);
|
|
228
|
+
if (launcher.fromCheckout)
|
|
229
|
+
lines.push(`(This is a checkout: the configurations start ${launcher.command} ${launcher.args.join(" ")}; a published install would start npx -y ${PACKAGE_NAME}.)`);
|
|
230
|
+
lines.push("");
|
|
231
|
+
lines.push(o.exit ? "Started with --exit: stopping the node and the chain." : "Ctrl-C stops the node and the chain; the next quickstart starts over in the same directory.");
|
|
232
|
+
const summary = lines.join("\n");
|
|
233
|
+
writeFileSync(join(dir, "connect.md"), `# foga quickstart\n\n\`\`\`\n${summary}\n\`\`\`\n`);
|
|
234
|
+
writeFileSync(join(dir, MARKER), JSON.stringify({ schema: "foga.quickstart/1", createdAt: new Date().toISOString(), rpc, chainId: world.chainId, worldId: world.worldId, cell: name, account: cell.account, agentId: cell.agentId, nodeUrl, tenders: minted.map((c) => c.tokenId), anvil: version }, null, 2) + "\n");
|
|
235
|
+
out(summary);
|
|
236
|
+
return {
|
|
237
|
+
dir,
|
|
238
|
+
rpc,
|
|
239
|
+
chainId: world.chainId,
|
|
240
|
+
worldId: world.worldId,
|
|
241
|
+
worldPath,
|
|
242
|
+
configPath,
|
|
243
|
+
account: cell.account,
|
|
244
|
+
agentId: cell.agentId,
|
|
245
|
+
keys: { ...keyPaths, agentApiKey: agentKeyPath },
|
|
246
|
+
apiKey: api.key,
|
|
247
|
+
nodeUrl,
|
|
248
|
+
tenders: minted.map((c) => ({ id: c.id, tokenId: c.tokenId, skill: c.skill, rewardWei: c.rewardWei })),
|
|
249
|
+
clientConfigs: configs,
|
|
250
|
+
summary,
|
|
251
|
+
stop,
|
|
252
|
+
ended,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
await stop();
|
|
257
|
+
throw e;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Start `foga mcp --config …` as a client would and count the tools it lists; a raw 2025-era JSON-RPC exchange over stdio. */
|
|
261
|
+
async function stdioToolCount(cli, configPath, rpc, env) {
|
|
262
|
+
const child = spawn(cli.command, [...cli.args, "mcp", "--config", configPath, "--rpc", rpc], { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
263
|
+
let stderr = "";
|
|
264
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
265
|
+
const pending = new Map();
|
|
266
|
+
let buffer = "";
|
|
267
|
+
child.stdout.on("data", (d) => {
|
|
268
|
+
buffer += d;
|
|
269
|
+
let i;
|
|
270
|
+
while ((i = buffer.indexOf("\n")) >= 0) {
|
|
271
|
+
const line = buffer.slice(0, i);
|
|
272
|
+
buffer = buffer.slice(i + 1);
|
|
273
|
+
if (!line.trim())
|
|
274
|
+
continue;
|
|
275
|
+
try {
|
|
276
|
+
const msg = JSON.parse(line);
|
|
277
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
278
|
+
pending.get(msg.id)(msg);
|
|
279
|
+
pending.delete(msg.id);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
/* not a protocol message: the check below fails on the count */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
let nextId = 1;
|
|
288
|
+
const request = (method, params) => new Promise((resolveReq, reject) => {
|
|
289
|
+
const id = nextId++;
|
|
290
|
+
const timer = setTimeout(() => reject(new Error(`stdio MCP: ${method} timed out; the server said:\n${stderr.slice(-2000)}`)), 60_000);
|
|
291
|
+
pending.set(id, (msg) => {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
if (msg.error)
|
|
294
|
+
reject(new Error(`stdio MCP: ${method}: ${JSON.stringify(msg.error)}`));
|
|
295
|
+
else
|
|
296
|
+
resolveReq(msg.result);
|
|
297
|
+
});
|
|
298
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
const init = await request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "foga-quickstart", version: "0" } });
|
|
302
|
+
if (init?.serverInfo?.name !== "foga")
|
|
303
|
+
throw new Error(`stdio MCP: initialize answered ${JSON.stringify(init).slice(0, 200)}`);
|
|
304
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
|
305
|
+
const tools = await request("tools/list", {});
|
|
306
|
+
return Array.isArray(tools?.tools) ? tools.tools.length : 0;
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
child.stdin.end();
|
|
310
|
+
await stopChild(child, 3000);
|
|
311
|
+
}
|
|
312
|
+
}
|
package/dist/doctor/loop-d.js
CHANGED
|
@@ -6,8 +6,13 @@ export function loadLoopDEvidence(path) {
|
|
|
6
6
|
return e;
|
|
7
7
|
}
|
|
8
8
|
const row = (check, verdict, expected, actual, detail) => ({ check, verdict, expected, actual, ...(detail ? { detail } : {}) });
|
|
9
|
+
/** The proposer's decisions: the runner's for a model, the agent key's for an outside agent (older evidence names no caller: the runner). */
|
|
10
|
+
function proposerCaller(e) {
|
|
11
|
+
return e.model.caller ?? "runner";
|
|
12
|
+
}
|
|
9
13
|
export function loopDVerdicts(e, compare) {
|
|
10
14
|
const rows = [];
|
|
15
|
+
const external = e.model.provider === "external";
|
|
11
16
|
const allowed = e.decisions.filter((d) => d.verdict === "allowed");
|
|
12
17
|
const refused = e.decisions.filter((d) => d.verdict === "refused");
|
|
13
18
|
const jobOps = e.operations.filter((o) => o.signer === "operator" && o.key.startsWith("job:"));
|
|
@@ -31,13 +36,18 @@ export function loopDVerdicts(e, compare) {
|
|
|
31
36
|
rows.push(row("operator transactions all go to the account, inside its allow-list", astray.length || unlisted.length ? "Fail" : txs.length ? "Pass" : "NotTested", "every transaction signed by the operator key calls the account's execute with an allow-listed (target, selector)", `${txs.length} transaction(s) in blocks ${e.chain.fromBlock}-${e.chain.toBlock}; ${astray.length} elsewhere, ${unlisted.length} not allow-listed`, astray.length || unlisted.length ? [...astray.map((t) => `${t.hash} -> ${t.to}`), ...unlisted.map((t) => `${t.hash} -> ${t.inner ? `${t.inner.target} ${t.inner.selector}` : "not execute"}`)].join("; ") : undefined));
|
|
32
37
|
const reverted = txs.filter((t) => t.status === 0);
|
|
33
38
|
rows.push(row("no operator transaction reverted", reverted.length ? "Unknown" : txs.length ? "Pass" : "NotTested", "the account's gate was never the one to refuse what every check allowed", reverted.length ? `${reverted.length} reverted: ${reverted.map((t) => t.hash).join(", ")}` : `${txs.length} succeeded`, reverted.length ? "a revert after an allowed decision is the chain refusing, not a policy refusal; see the execution decisions" : undefined));
|
|
34
|
-
// 5. the cases
|
|
39
|
+
// 5. the cases (an outside agent may leave a tender alone: nothing the cell's rules forbid happened, nothing was tested either)
|
|
35
40
|
for (const c of e.cases) {
|
|
36
41
|
const o = c.outcome;
|
|
37
42
|
const exp = c.expect;
|
|
38
43
|
let verdict = "Fail";
|
|
39
44
|
let actual = `${o.status}${o.gates.length ? ` (gates ${o.gates.join(" > ")})` : ""}`;
|
|
40
|
-
|
|
45
|
+
const untouched = external && !o.settled && !o.submissions.some((s) => s.byCell) && o.gates.every((g) => g === "allowed" || g === "safety");
|
|
46
|
+
if (untouched) {
|
|
47
|
+
verdict = "NotTested";
|
|
48
|
+
actual += o.gates.includes("safety") ? "; refused at safety during the suspension, never submitted after the resume" : o.gates.length ? "; pre-checked by the agent but never submitted" : "; the agent made no attempt";
|
|
49
|
+
}
|
|
50
|
+
else if (exp.outcome === "taken") {
|
|
41
51
|
const reached = BigInt(o.paidWei) + BigInt(o.withdrawnWei);
|
|
42
52
|
const paid = reached === BigInt(c.rewardWei);
|
|
43
53
|
verdict = o.status === "taken" && o.settled && paid ? "Pass" : "Fail";
|
|
@@ -57,9 +67,11 @@ export function loopDVerdicts(e, compare) {
|
|
|
57
67
|
// 6. case g: a proposal above the budget
|
|
58
68
|
const g = e.budgetProbe;
|
|
59
69
|
rows.push(row("case g: a proposal above the budget is refused at budget", g.attempted ? (g.gate === "budget" ? "Pass" : "Fail") : "NotTested", "the model's over-budget act refused at `budget`, nothing broadcast", g.attempted ? `${g.capability} refused at ${g.gate} (decision ${g.decisionId})` : "the model made no proposal above the budget", g.attempted ? undefined : "the stub makes this proposal on purpose; a real model may never make it"));
|
|
60
|
-
// 7. the evidence is complete
|
|
61
|
-
const
|
|
62
|
-
|
|
70
|
+
// 7. the evidence is complete: a model's run has a hash per tick; an outside agent's run has the agent's decisions (its transcript is its own)
|
|
71
|
+
const proposer = proposerCaller(e);
|
|
72
|
+
const proposed = e.decisions.filter((d) => d.caller === proposer).length;
|
|
73
|
+
const complete = external ? e.model.model.length > 0 && proposed > 0 : e.runner.ticks > 0 && e.runner.trajectoryHashes.length === e.runner.ticks && e.model.model.length > 0 && e.decisions.length > 0;
|
|
74
|
+
rows.push(row("the evidence carries every decision and every tick's trajectory hash", complete ? "Pass" : "Fail", external ? "the agent named, decision records of its proposals present (no transcript: the agent's words stay with the agent)" : "model named, a hash per tick, decision records present", external ? `${e.model.provider}/${e.model.model || "?"} as caller ${proposer}: ${proposed} decision(s) on its proposals, ${e.decisions.length} in all` : `${e.model.provider}/${e.model.model || "?"}, ${e.runner.ticks} tick(s), ${e.runner.trajectoryHashes.length} hash(es), ${e.decisions.length} decision(s), ${e.runner.errors} model error(s)`));
|
|
63
75
|
// 8. recovery: killed after a submission, restarted, nothing sent twice
|
|
64
76
|
const r = e.recovery;
|
|
65
77
|
if (r.performed && r.tokenId) {
|
|
@@ -81,21 +93,31 @@ export function loopDVerdicts(e, compare) {
|
|
|
81
93
|
const uniqueNonces = new Set(nonces).size === nonces.length;
|
|
82
94
|
const nonceFailures = e.operations.filter((o) => o.signer === "operator" && o.status === "failed" && /nonce/i.test(o.error ?? ""));
|
|
83
95
|
rows.push(row("concurrent acts across entrances and the watchers: distinct nonces, the budget holding", conc ? (uniqueNonces && nonceFailures.length === 0 && conc.allowed + conc.refused.length === conc.requested && conc.refused.every((x) => x.gate === "budget") ? "Pass" : "Fail") : uniqueNonces && nonceFailures.length === 0 ? "Pass" : "Fail", "every operator operation on its own nonce, no nonce failure, refusals (if any) at `budget`", `${nonces.length} nonce(s), ${uniqueNonces ? "all distinct" : "repeated"}, ${nonceFailures.length} nonce failure(s)${conc ? `; ${conc.requested} concurrent mint(s): ${conc.allowed} allowed, ${conc.refused.length} refused (${conc.refused.map((x) => x.gate).join(", ") || "-"})` : ""}`));
|
|
84
|
-
// 10. model-agnostic: the stub
|
|
96
|
+
// 10. model-agnostic: two proposers (the stub, a real model, an outside agent) reach the same outcomes; a case one of them left alone compares to nothing
|
|
85
97
|
if (compare) {
|
|
86
98
|
const diffs = [];
|
|
99
|
+
const skipped = [];
|
|
100
|
+
let agreed = 0;
|
|
101
|
+
const decided = (o) => o.status !== "pending" && o.status !== "other";
|
|
87
102
|
for (const c of e.cases) {
|
|
88
103
|
const other = compare.cases.find((x) => x.id === c.id);
|
|
89
104
|
if (!other) {
|
|
90
105
|
diffs.push(`${c.id}: missing in the other run`);
|
|
91
106
|
continue;
|
|
92
107
|
}
|
|
108
|
+
if (!decided(c.outcome) || !decided(other.outcome)) {
|
|
109
|
+
skipped.push(c.id);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
93
112
|
const a = `${c.outcome.status}/${c.outcome.gates[0] ?? "-"}`;
|
|
94
113
|
const b = `${other.outcome.status}/${other.outcome.gates[0] ?? "-"}`;
|
|
95
114
|
if (a !== b)
|
|
96
115
|
diffs.push(`${c.id}: ${a} vs ${b}`);
|
|
116
|
+
else
|
|
117
|
+
agreed++;
|
|
97
118
|
}
|
|
98
|
-
|
|
119
|
+
const label = `${e.model.provider}/${e.model.model} vs ${compare.model.provider}/${compare.model.model}`;
|
|
120
|
+
rows.push(row("model-agnostic: the same outcomes from both models", diffs.length ? "Fail" : agreed ? "Pass" : "NotTested", "every case ends the same way under both proposers", diffs.length ? diffs.join("; ") : agreed ? `${agreed} case(s) agree (${label})${skipped.length ? `; ${skipped.join(", ")} left alone in one of the runs, not compared` : ""}` : `no case reached a verdict in both runs (${label})`));
|
|
99
121
|
}
|
|
100
122
|
else
|
|
101
123
|
rows.push(row("model-agnostic: the same outcomes from both models", "NotTested", "every case ends the same way under both models", "one run only; pass a second evidence file with --compare"));
|
package/dist/world/deploy.js
CHANGED
|
@@ -84,6 +84,7 @@ export function resolveRpc(rpc) {
|
|
|
84
84
|
}
|
|
85
85
|
export async function deployWorld(opts) {
|
|
86
86
|
const root = repoRoot();
|
|
87
|
+
const say = opts.quiet ? { info: (_m) => { }, step: (_m) => { }, warn: log.warn } : log;
|
|
87
88
|
const cfg = loadWorldConfig(opts.worldConfigPath);
|
|
88
89
|
const bundle = loadBundle(opts.bundlePath);
|
|
89
90
|
const outPath = opts.outPath ?? join(root, "deployments", `${cfg.name}.json`);
|
|
@@ -94,7 +95,7 @@ export async function deployWorld(opts) {
|
|
|
94
95
|
if (cfg.mode === "attach" && toDeploy.length === 0) {
|
|
95
96
|
const world = attachWorld(cfg, bundle);
|
|
96
97
|
writeJson(outPath, world);
|
|
97
|
-
|
|
98
|
+
say.info(`attached world ${world.worldId}: ${Object.keys(world.contracts).length} contracts -> ${relative(root, outPath)}`);
|
|
98
99
|
return { world, path: outPath };
|
|
99
100
|
}
|
|
100
101
|
const rpc = opts.rpc ?? resolveRpc(cfg.rpc);
|
|
@@ -115,13 +116,13 @@ export async function deployWorld(opts) {
|
|
|
115
116
|
const wallet = new NonceManager(new Wallet(pk, provider));
|
|
116
117
|
const deployer = await wallet.getAddress();
|
|
117
118
|
const balance = await provider.getBalance(deployer);
|
|
118
|
-
|
|
119
|
+
say.info(`world ${cfg.name} on chain ${net.chainId} via ${rpc}; deployer ${deployer} (balance ${Number(balance) / 1e18} ETH)`);
|
|
119
120
|
let world;
|
|
120
121
|
if (opts.resume && exists(outPath)) {
|
|
121
122
|
world = readJson(outPath);
|
|
122
123
|
if (world.chainId !== Number(net.chainId) && !opts.allowChainMismatch)
|
|
123
124
|
throw new Error(`cannot resume ${outPath}: it belongs to chain ${world.chainId}`);
|
|
124
|
-
|
|
125
|
+
say.info(`resuming world ${world.worldId} from ${relative(root, outPath)}`);
|
|
125
126
|
}
|
|
126
127
|
else if (cfg.mode === "attach") {
|
|
127
128
|
world = attachWorld(cfg, bundle);
|
|
@@ -133,7 +134,7 @@ export async function deployWorld(opts) {
|
|
|
133
134
|
world.deployer = deployer;
|
|
134
135
|
world.bundleHash = bundle.hash;
|
|
135
136
|
if (cfg.mode === "attach")
|
|
136
|
-
|
|
137
|
+
say.info(`attach-mode world: ${Object.keys(cfg.contracts ?? {}).length} contracts attached; deploying ${toDeploy.length} from the bundle (${toDeploy.join(", ")})`);
|
|
137
138
|
// What the deployer must hold for the contracts not yet in the world file, before the first one is sent.
|
|
138
139
|
const remaining = DEPLOY_PLAN.filter((s) => !world.contracts[s.id] && !(s.id === "identity8004" && cfg.identity8004.mode === "address")).length;
|
|
139
140
|
if (remaining > 0) {
|
|
@@ -142,7 +143,7 @@ export async function deployWorld(opts) {
|
|
|
142
143
|
const priced = `${remaining} contracts x ${Number(DEPLOY_GAS_PER_STEP) / 1e6} M gas at ${Number(price) / 1e9} gwei (${price === feeFloor() ? "the fee floor FOGA_FEE_FLOOR_GWEI; the chain quotes less" : "the chain's quote"}) = ${formatEther(need)} ETH`;
|
|
143
144
|
if (balance < need)
|
|
144
145
|
throw new Error(`deployer ${deployer} holds ${formatEther(balance)} ETH, the deployment is budgeted at ${priced}. Fund it and run again${world.steps.some((s) => s.status === "deployed") ? " with --resume" : ""}.`);
|
|
145
|
-
|
|
146
|
+
say.info(`deployment budget: ${priced}; the deployer holds ${formatEther(balance)} ETH`);
|
|
146
147
|
}
|
|
147
148
|
const stepById = new Map(DEPLOY_PLAN.map((s) => [s.id, s]));
|
|
148
149
|
const ctx = {
|
|
@@ -161,11 +162,11 @@ export async function deployWorld(opts) {
|
|
|
161
162
|
for (const step of DEPLOY_PLAN) {
|
|
162
163
|
const existing = world.contracts[step.id];
|
|
163
164
|
if (existing && cfg.mode === "attach" && cfg.contracts?.[step.id]) {
|
|
164
|
-
|
|
165
|
+
say.step(`${step.id}: attached at ${existing.address}`);
|
|
165
166
|
continue;
|
|
166
167
|
}
|
|
167
168
|
if (existing && (await provider.getCode(existing.address)) !== "0x") {
|
|
168
|
-
|
|
169
|
+
say.step(`${step.id}: already at ${existing.address}`);
|
|
169
170
|
continue;
|
|
170
171
|
}
|
|
171
172
|
const skip = step.skip?.(ctx);
|
|
@@ -176,7 +177,7 @@ export async function deployWorld(opts) {
|
|
|
176
177
|
}
|
|
177
178
|
world.steps.push({ id: step.id, status: "skipped", note: skip });
|
|
178
179
|
writeJson(outPath, world);
|
|
179
|
-
|
|
180
|
+
say.step(`${step.id}: skipped (${skip})`);
|
|
180
181
|
continue;
|
|
181
182
|
}
|
|
182
183
|
const art = getContract(bundle, step.bundleKey);
|
|
@@ -215,11 +216,11 @@ export async function deployWorld(opts) {
|
|
|
215
216
|
world.identity8004 = { mode: "stub", address, note: cfg.identity8004.note ?? "local stand-in, not the reference registry" };
|
|
216
217
|
world.steps.push({ id: step.id, status: "deployed", txHash: receipt?.hash });
|
|
217
218
|
writeJson(outPath, world);
|
|
218
|
-
|
|
219
|
+
say.step(`${step.id}: ${address} (gas ${receipt?.gasUsed}, block ${receipt?.blockNumber})`);
|
|
219
220
|
}
|
|
220
221
|
for (const wire of WIRE_PLAN) {
|
|
221
222
|
if (world.steps.some((s) => s.id === wire.id && s.status === "wired")) {
|
|
222
|
-
|
|
223
|
+
say.step(`${wire.id}: already wired`);
|
|
223
224
|
continue;
|
|
224
225
|
}
|
|
225
226
|
const skip = wire.skip?.(ctx);
|
|
@@ -238,7 +239,7 @@ export async function deployWorld(opts) {
|
|
|
238
239
|
const tx = await c.getFunction(call.fn)(...call.args);
|
|
239
240
|
const receipt = await tx.wait();
|
|
240
241
|
hashes.push(receipt.hash);
|
|
241
|
-
|
|
242
|
+
say.step(`${wire.id}: ${call.fn}(${call.note ?? ""}) in ${receipt.hash}`);
|
|
242
243
|
}
|
|
243
244
|
world.wiring[wire.id] = { txHashes: hashes };
|
|
244
245
|
world.steps.push({ id: wire.id, status: "wired", txHash: hashes[hashes.length - 1] });
|
|
@@ -248,7 +249,7 @@ export async function deployWorld(opts) {
|
|
|
248
249
|
if (kinds)
|
|
249
250
|
world.wiring["8444/kindIds"] = kinds;
|
|
250
251
|
writeJson(outPath, world);
|
|
251
|
-
|
|
252
|
+
say.info(`world ${world.worldId}: ${Object.keys(world.contracts).length} contracts -> ${relative(root, outPath)}`);
|
|
252
253
|
return { world, path: outPath };
|
|
253
254
|
}
|
|
254
255
|
/**
|
package/docs/design-memo.md
CHANGED
|
@@ -110,4 +110,6 @@ Three checks, three vocabularies. A call to the node passes **call permission**
|
|
|
110
110
|
|
|
111
111
|
**3b, MCP and HTTP (0.6.0-alpha.3).** The six points written down for this slice, as they landed. One: the server (`@modelcontextprotocol/server` 2.4.0) serves the 2026-07-28 revision as its main path and the 2025-era handshake through the SDK's stateless compatibility layer, on Streamable HTTP and on stdio alike; `test/mcp.test.ts` drives it with the reference client pinned to 2026-07-28, in legacy mode and in auto mode, because a client that defaults to the old handshake proves only the old path. Two: annotations are static, so a write capability is `<name>` (act), `<name>_simulate` and `<name>_build` over one handler; the tool list is the catalog filtered by the caller, every call still goes through `Catalog.invoke`, and the number of tools (11 for the public, 36 for an `act` key, 40 for the operator) is not the number of capabilities (29, `spec_read` added). Three: `tender_precheck` answers `eligible: false` as an ordinary result; a refused act is `isError: true` with gate, reason and decision id; a credential the node does not know is 401 and a capability or mode above the caller is 403, never a business refusal. Four: no CORS headers, and a foreign `Origin` or `Host` is refused at the edge; the SDK decides the semantics of `GET /mcp` by era; bodies are capped and parsed by the capability's schema alone. Five: API keys (`foga node key new`, stored as SHA-256, compared in constant time) are for the operator's own integrations and named partners; the public reads and builds; OAuth stays out of Phase 3. Six: `scripts/pack-smoke.mjs` installs the `npm pack` tarball in a clean directory and runs the packaged CLI from there — a cell born, `foga node mcp` without a key driven by a raw 2025-era client over stdio, stdout protocol-only, `spec_read` served from inside the package — as a CI step; `engines.node` is `>=22.13`, where `node:sqlite` needs no flag. Two things the slice found: the request hash that binds a job excludes the idempotency key itself (a request first made under a derived job id and then repeated naming that id is the same request), and a cell's birth adopts the FOGA-Cell-v1 specification its owner published for an earlier cell instead of failing on `SpecificationExists`. The read-only configuration of the OG instance (`deployments/sepolia.cell.example.json`), `package.json`'s `mcpName` and `server.json` are in place for the publication in 3d. docs/mcp.md.
|
|
112
112
|
|
|
113
|
+
**3d-1, the package in use (0.6.0-alpha.6).** The npm package went out as `@finchip.ai/foga` (alpha.4, then alpha.5 with the launcher's Windows fix: a drive-letter path is not a URL), which made the first slice of 3d the one that lets a stranger use it: `foga quickstart` — anvil, world, a cell with fresh keys and its skills, two tenders, the served node, both entrances checked, the client configurations printed with the paths filled in — and Loop D with an outside proposer (`--model external`), where the node is served rather than run and whoever holds the printed act-level key works the tenders through `/mcp` or `/api` while the orchestrator does what it does for a model: the kill and restart, the entrance checks, the suspension, the evidence, the doctor's rows (an untouched case `NotTested`, the comparison over the cases both runs reached). This is the shape the Sepolia run of section 11 will take: the operator's own agent, on its own machine, with whatever model it has, judged by the same matrix as the stub. The slice also made the cell's birth a shared routine (`src/demo/birth.ts`) for the demos, fixed line endings for every platform (`.gitattributes`) and let a git install compile itself (`prepare`). docs/quickstart.md, docs/loop-d.md.
|
|
114
|
+
|
|
113
115
|
**3c, Loop D (0.6.0-alpha.4).** The runner (`src/node/runner.ts`) is the loop of section 8 as written: perceive through the read capabilities, propose with the catalog as tools, decide in `Catalog.invoke`, execute as keyed operations, record by hash — 12 tool calls and 2 acts a tick, the model's calls counted against the day, the watchers outside the model. One adapter interface in the Messages API's vocabulary; `anthropic` over `fetch` with the key in the environment and the model name in the configuration, `stub` as the deterministic table that also makes the over-budget proposal of case g. The demander is its own process with its own key and store; the cases a–f are minted in a seeded order and carry what the acceptance expects. `foga demo --loop-d` runs it all on a new cell with keys nobody held — the kill after the first submission and the restart, the guardian's suspension for f, the other entrances beside the runner — and `foga doctor --loop-d` judges the evidence beside layer 5 with the rows of section 8's matrix and the eight additions of v0.2 (the same request across entrances, the same job under another request, concurrent acts with distinct nonces and the budget holding, the recovery, the operator key's transactions read inside `execute`). The run found a race the matrix was written to find: three concurrent mints each passed the budget gate because none saw the others' reservations, and the account's daily cap refused the third on chain; the decision section now runs one act at a time per node and a job reserves its planned value from its decision, so the refusal is the node's, at `budget`, before anything is sent. It also found that an `execute`'s inner value was not what the operation's row recorded (the outer transaction's zero was), so the value book undercounted; fixed. Stub and real model run the same matrix; the real model's runs (anvil, then Sepolia) are 3d's evidence. docs/loop-d.md.
|
package/docs/examples/README.md
CHANGED
|
@@ -8,4 +8,8 @@ One server definition, three clients (docs/mcp.md, "Client configuration"). Each
|
|
|
8
8
|
| `claude-code-mcp.json` | Claude Code | a project's `.mcp.json` (or `claude mcp add foga -e FOGA_OPERATOR_KEY_FILE=… -- npx -y @finchip.ai/foga mcp --config …`) |
|
|
9
9
|
| `cursor-mcp.json` | Cursor | `.cursor/mcp.json` (shown without a key: reads, dry runs and builds) |
|
|
10
10
|
|
|
11
|
-
`FOGA_OPERATOR_KEY_FILE` names a 0600 file holding the cell's operator key (`foga keys export --role operator.A`, or any key the account names as operator); with it the server acts for the cell, without it the client gets reads, dry runs and builds. `FOGA_RPC_URL` is needed only when the cell configuration names no `rpc`. From a checkout
|
|
11
|
+
`FOGA_OPERATOR_KEY_FILE` names a 0600 file holding the cell's operator key (`foga keys export --role operator.A`, or any key the account names as operator); with it the server acts for the cell, without it the client gets reads, dry runs and builds. `FOGA_RPC_URL` is needed only when the cell configuration names no `rpc`. From a checkout, replace `npx -y @finchip.ai/foga` with `node /path/to/foga/bin/foga.js` after `npm run build`.
|
|
12
|
+
|
|
13
|
+
`foga quickstart` (docs/quickstart.md) births a cell on a local chain and writes these three files, plus `http-mcp.json` for the served node (`{"url": "http://127.0.0.1:8787/mcp", "headers": {"Authorization": "Bearer <key>"}}`), with the real paths and key under `~/.foga/quickstart/mcp/`.
|
|
14
|
+
|
|
15
|
+
Windows: in PowerShell the package name needs quotes on the command line — `npx -y '@finchip.ai/foga' mcp --config D:\cells\D.json` — because a bare `@` starts a splatting expression; inside these JSON files nothing is quoted differently, and paths are written with escaped backslashes (`"D:\\cells\\D.json"`). `anvil` for a local chain comes from Foundry's `win32_amd64` release zip (https://github.com/foundry-rs/foundry/releases): unzip, put the folder holding `anvil.exe` on `PATH` or set `FOGA_FOUNDRY_DIR` to it.
|
package/docs/loop-d.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Loop D (foga node run, foga demo --loop-d, foga doctor --loop-d)
|
|
2
2
|
|
|
3
|
-
Loop D is the unscripted run (design memo, Phase 3, section 8): a model decides what the cell does, and the acceptance looks at what the records say happened — permissions, budgets, outcomes, process evidence — never at what the model said. It proves nothing about a model's intelligence. It proves that the interface layer stops everything that must not happen: the task input is not in a script the cell can read, the model picks and runs skills and delivers on its own, and the cell's rules hold whichever model proposes.
|
|
3
|
+
Loop D is the unscripted run (design memo, Phase 3, section 8): a model decides what the cell does, and the acceptance looks at what the records say happened — permissions, budgets, outcomes, process evidence — never at what the model said. It proves nothing about a model's intelligence. It proves that the interface layer stops everything that must not happen: the task input is not in a script the cell can read, the model picks and runs skills and delivers on its own, and the cell's rules hold whichever model proposes. The proposer can be the node's own runner with a model (`--model stub|anthropic`) or an agent outside the node altogether, on any runtime with any model, working through the HTTP and MCP entrance (`--model external`, below).
|
|
4
4
|
|
|
5
5
|
## The runner
|
|
6
6
|
|
|
@@ -25,6 +25,16 @@ One interface, `propose(request) → result`, in the vocabulary of the Messages
|
|
|
25
25
|
|
|
26
26
|
Both run the same acceptance. CI runs the stub; a real model is run once on anvil (`foga demo --loop-d --model anthropic --model-name <model>` with the key in the environment) and once on Sepolia (3d), and the evidence files go into the repository with the model's name and the trajectory hashes — the model's words never do.
|
|
27
27
|
|
|
28
|
+
## An outside agent: --model external
|
|
29
|
+
|
|
30
|
+
`foga demo --loop-d --model external [--model-name <label>] [--listen <host:port>] [--wait <s>] [--idle <s>]` (and `foga loop-d run … --model external`) runs the same cell, the same demander and the same acceptance with nobody proposing inside the node: the node is served (`foga node serve` — watchers, HTTP API, MCP over Streamable HTTP; no runner), an act-level API key named `agent` is minted for it, and the orchestrator prints — and writes to `deployments/loop-d/agent-brief.md` — everything an outside agent is told: the entrance (`<url>/mcp` and `<url>/api/<capability>`, the `Authorization: Bearer` header), the five steps of a tender as the catalog offers them, and what the run will do around it (tenders arrive over time; the node is killed and restarted once after the first submission; the cell is suspended for a while and a `safety` refusal may be tried again). Nothing about the cases: which tenders exist and what the acceptance expects of each is the demander's, as for a model.
|
|
31
|
+
|
|
32
|
+
The agent is whatever connects: a Claude Code or Cursor session with `mcp/http-mcp.json`-style configuration, an agent runtime of your own calling the JSON API, a person with `curl`. `--listen 0.0.0.0:<port>` opens the node to a machine elsewhere (the key is the only thing between it and the cell's acts). The orchestrator waits for the agent rather than driving it: a phase ends when every case has reached a verdict, or when nothing changed for `--idle` seconds after the agent acted at least once (3 minutes), or when `--wait` seconds are spent (30 minutes); every 15 seconds it prints where each case stands (`untouched`, `eligible, not submitted`, `refused at <gate>`, `submitted, settlement pending`, `settled, paid`, `failed`), and `loop D: run over` when it stops the node. The recovery drill and the orchestrator's own entrance checks happen as with a model (the node's poll interval is 3 seconds in this mode, so the submission is seen before the watchers settle it; a call that fails during the restart is the agent's to retry).
|
|
33
|
+
|
|
34
|
+
The evidence names the agent instead of a model (`model: {provider: "external", model: <label>, caller: "agent"}`), carries no runner ticks and no trajectory hashes — the agent's transcript is its own — and derives each case's gates from the agent key's decisions as it derives them from the runner's: every pre-check's verdict and every submission's, in order. The doctor reads it with three differences: the completeness row asks for the agent's decision records rather than a hash per tick; a case the agent left alone (never pre-checked, or pre-checked and never submitted, or refused at `safety` and not tried after the resume) is `NotTested` rather than `Fail` — nothing the cell's rules forbid happened, and nothing was shown either; and `--compare` skips a case that reached no verdict in one of the runs, so an outside agent's evidence compares to the stub's on the cases both worked. Case g is the agent's to attempt or not (an over-budget `tender_mint` refused at `budget` is `Pass`, none is `NotTested`).
|
|
35
|
+
|
|
36
|
+
`test/loop-d-external.test.ts` is the gate: a scripted agent connects through the MCP entrance with the key, follows the brief and nothing else — list, inspect, pre-check, run, submit, retry what was refused at `safety`, one over-budget mint — reconnects across the restart, and every verdict passes, with the model-agnostic row `Pass` against the kept stub run. The OG instance's Loop D on Sepolia (3d) is run this way with the operator's own agent.
|
|
37
|
+
|
|
28
38
|
## The demander and the cases
|
|
29
39
|
|
|
30
40
|
`foga demo demander` (`src/demo/demander.ts`) is an independent party: its own process, its own key (`FOGA_DEMANDER_KEY`; anvil's first account on a local world), its own store. It mints the tenders of the cases in an order the seed decides, each with what the acceptance expects of the cell:
|
|
@@ -56,7 +66,7 @@ The whole run takes under a minute on anvil. Working files live under `deploymen
|
|
|
56
66
|
|
|
57
67
|
## The acceptance: foga doctor --loop-d
|
|
58
68
|
|
|
59
|
-
`foga doctor --loop-d <evidence> [--compare <evidence2>] [--out <report>]` judges the evidence alone; with `--world` the rows join the doctor's report beside layer 5 (layer `D`). `src/doctor/loop-d.ts
|
|
69
|
+
`foga doctor --loop-d <evidence> [--compare <evidence2>] [--out <report>]` judges the evidence alone; with `--world` the rows join the doctor's report beside layer 5 (layer `D`). `src/doctor/loop-d.ts` (the differences for an outside agent's evidence are above):
|
|
60
70
|
|
|
61
71
|
| Row | Pass when |
|
|
62
72
|
|---|---|
|
|
@@ -72,9 +82,9 @@ The whole run takes under a minute on anvil. Working files live under `deploymen
|
|
|
72
82
|
| the same request through another entrance | `idempotent`, the same transaction hash |
|
|
73
83
|
| the same job under another request | `conflict` |
|
|
74
84
|
| concurrent acts | every operator operation on its own nonce, no nonce failure, the concurrent mints' refusals (if any) at `budget` |
|
|
75
|
-
| model-agnostic | with `--compare`: every case ends the same way (status and first gate) under both
|
|
85
|
+
| model-agnostic | with `--compare`: every case that reached a verdict in both runs ends the same way (status and first gate) under both proposers; a case left alone in one run is not compared |
|
|
76
86
|
|
|
77
|
-
`test/loop-d.test.ts` runs the whole orchestration with the stub on anvil and asserts every row Pass (the model-agnostic one NotTested with one run); `test/model.test.ts` covers the stub's table, the Anthropic adapter against a stand-in API, and each verdict's failure on a synthetic evidence file. CI runs Loop D on the world the genesis ran on and judges it beside layer 5.
|
|
87
|
+
`test/loop-d.test.ts` runs the whole orchestration with the stub on anvil and asserts every row Pass (the model-agnostic one NotTested with one run); `test/loop-d-external.test.ts` does the same with a scripted outside agent through the MCP entrance; `test/model.test.ts` covers the stub's table, the Anthropic adapter against a stand-in API, and each verdict's failure on a synthetic evidence file. CI runs Loop D on the world the genesis ran on and judges it beside layer 5.
|
|
78
88
|
|
|
79
89
|
## Running a real model
|
|
80
90
|
|
|
@@ -85,6 +95,14 @@ foga doctor --loop-d deployments/loop-d.local.json --compare deployments/loop-d.
|
|
|
85
95
|
|
|
86
96
|
The key stays in the environment of the machine that runs it; the evidence file carries the model's name and the trajectory hashes and can be sent on. Transcripts (`--transcripts`) are for debugging the prompt and stay local. The model's daily call allowance for the demo is 120; the stub's 400 (it is free).
|
|
87
97
|
|
|
98
|
+
Any other model, on any runtime, runs through the entrance instead:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
foga demo --loop-d --model external --model-name <what to call the agent> [--listen 0.0.0.0:8787]
|
|
102
|
+
# connect the agent with the URL and key printed (deployments/loop-d/agent-brief.md); the run ends on its own
|
|
103
|
+
foga doctor --loop-d deployments/loop-d.local.json --compare deployments/loop-d.local.example.json
|
|
104
|
+
```
|
|
105
|
+
|
|
88
106
|
## What the records do not show, by design
|
|
89
107
|
|
|
90
108
|
A refusal can name an operation that exists already (a conflict, an execution refusal), so "no operation per refusal" is read as "no new broadcast". An allowed operation can still revert on chain when state races between the simulation and the sending; that is the chain refusing, recorded as an `execution` decision, not a policy refusal. The on-chain scan looks inside `execute` (target, selector, value) rather than at the outer `to` alone, which is always the account. The budget is four books — native value out, the operator's fees (failed and replaced attempts included), the model's calls, the skill runs and commitments — and the caps in the configuration are the operator's own choice, not a guarantee that every cost has a hard ceiling: settlements and withdrawals of what was committed to still need fees after the day's cap is reached.
|