@finchip.ai/foga 0.6.0-alpha.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +35 -14
  2. package/dist/a2a/card.js +54 -0
  3. package/dist/a2a/server.js +276 -0
  4. package/dist/a2a/types.js +20 -0
  5. package/dist/build/bundle.js +2 -1
  6. package/dist/cli-cell.js +176 -16
  7. package/dist/cli.js +150 -5
  8. package/dist/core/world.js +17 -0
  9. package/dist/demo/birth.js +11 -5
  10. package/dist/demo/loop-d.js +39 -1
  11. package/dist/demo/quickstart.js +97 -11
  12. package/dist/genesis/operator-scope.js +1 -0
  13. package/dist/genesis/probe.js +23 -7
  14. package/dist/http/access.js +105 -0
  15. package/dist/http/server.js +84 -43
  16. package/dist/http/signed.js +136 -0
  17. package/dist/mcp/server.js +48 -1
  18. package/dist/node/advise.js +134 -0
  19. package/dist/node/config.js +55 -1
  20. package/dist/node/documents.js +298 -0
  21. package/dist/node/preflight.js +208 -0
  22. package/dist/node/propagation.js +95 -0
  23. package/dist/node/wallet.js +132 -0
  24. package/dist/tools/documents.js +74 -0
  25. package/dist/tools/index.js +3 -1
  26. package/dist/tools/propagation.js +35 -0
  27. package/dist/tools/tenders.js +22 -6
  28. package/dist/util/version.js +2 -0
  29. package/dist/world/sourcify.js +165 -0
  30. package/docs/a2a.md +31 -0
  31. package/docs/design-memo.md +23 -6
  32. package/docs/documents.md +25 -0
  33. package/docs/examples/README.md +2 -2
  34. package/docs/extending.md +49 -0
  35. package/docs/loop-d.md +11 -0
  36. package/docs/mcp.md +18 -11
  37. package/docs/node.md +35 -11
  38. package/docs/og-deployment.md +131 -0
  39. package/docs/propagation.md +41 -0
  40. package/docs/quickstart.md +36 -21
  41. package/docs/wallet.md +62 -0
  42. package/docs/world.md +12 -0
  43. package/package.json +1 -1
  44. package/server.json +2 -2
package/dist/cli-cell.js CHANGED
@@ -6,6 +6,7 @@ import { cellInit } from "./node/init.js";
6
6
  import { KeyIsolationError, forbiddenVariables, loadOperatorKey, loadRoleKey } from "./node/keys.js";
7
7
  import { cellMigrate } from "./node/migrate.js";
8
8
  import { Node } from "./node/node.js";
9
+ import { loadWallet, walletKey } from "./node/wallet.js";
9
10
  import { Watchers } from "./node/watchers.js";
10
11
  import { defaultCatalog, OPERATOR_CALLER, PUBLIC_CALLER } from "./tools/index.js";
11
12
  import { exists, repoRoot } from "./util/fs.js";
@@ -20,23 +21,40 @@ import { fail } from "./util/log.js";
20
21
  export const CELL_USAGE = `
21
22
  foga cell list [--public] [--as-json] the capabilities (action, visibility, principal, modes) as the operator or as the public sees them
22
23
  foga cell schema <capability> the input and output JSON Schema of a capability
23
- foga cell new --name <cell> --world <world.json> --store <ops.sqlite> --owner <addr> --operator <addr> --guardian <addr> --out <cell.config.json> [--rpc <url>] [--skills <dir>] [--input-dir <dir>]
24
- foga cell init --config <cell.config.json> [--rpc <url>] [--capital-wei <n>]
25
- the cell's birth (the owner's key: FOGA_OWNER_KEY[_FILE]); writes the account, agent id, binding, record and unit into the config
24
+ foga cell new --name <cell> --world <world.json> --store <ops.sqlite> --out <cell.config.json> (--wallet <dir> | --owner <addr> --operator <addr> --guardian <addr>) [--rpc <url>] [--skills <dir>] [--input-dir <dir>]
25
+ foga cell init --config <cell.config.json> [--wallet <dir>] [--rpc <url>] [--capital-wei <n>]
26
+ the cell's birth (the owner's key: the wallet's, or FOGA_OWNER_KEY[_FILE]); writes the account, agent id, binding, record and unit into the config
26
27
  foga cell migrate --config <cell.config.json> --to <new.config.json> [--store <new ops.sqlite>] [--operator <addr>] [--capital-wei <n>] [--no-sweep]
27
28
  move the cell to a new revision-2 account (the owner's key); the new config and store are written beside
28
29
  foga cell status --config <cell.config.json> cell_status as the operator
29
- foga cell <capability> --config <cell.config.json> [--mode simulate|build|act] [--json '<input json>' | --json-file <f>] [--<field> <value> ...]
30
- any capability; an act loads FOGA_OPERATOR_KEY[_FILE], an admin capability FOGA_OWNER_KEY[_FILE] or FOGA_GUARDIAN_KEY[_FILE]
30
+ foga cell advise --config <cell.config.json> [--brief] [--as-json]
31
+ what a running cell should do next — separate the roles, tune the policy, standing, a public face, a supervisor,
32
+ backups, a dedicated RPC — only what applies to this cell; advice, never required; no chain read, no key
33
+ foga cell docs build --config <cell.config.json> [--rpc <url>] [--out <dir>]
34
+ the cell's documents from its configuration and the chain (registration, AID document, ARD descriptor, AUF unit,
35
+ agent card, server.json, manifest) under runtime.http.documentsDir — what the node serves at /documents and /.well-known
36
+ foga cell docs publish --config <cell.config.json> [--wallet <dir>] [--rpc <url>]
37
+ build, then commit on chain what differs (setAgentURI, setDocumentURI, updateDescriptor, publishVersion; the operator
38
+ key, one keyed job), then read every commitment back
39
+ foga cell docs status --config <cell.config.json> [--rpc <url>] what is built, what the chain holds, what a publish would commit
40
+ foga cell <capability> --config <cell.config.json> [--wallet <dir>] [--mode simulate|build|act] [--json '<input json>' | --json-file <f>] [--<field> <value> ...]
41
+ any capability; an act loads the operator key, an admin capability the owner's or the guardian's — from --wallet, or from
42
+ FOGA_OPERATOR_KEY[_FILE], FOGA_OWNER_KEY[_FILE], FOGA_GUARDIAN_KEY[_FILE]
31
43
  [--caller <id> --level read|build|act] call as another caller (what the public or an API-key holder would get)
32
44
  foga cell owner <capability> … | foga cell guardian <capability> … the same, said explicitly
45
+ foga node <command> … [--wallet <dir>] every node command takes the operator key from the wallet's operator.key (= FOGA_OPERATOR_KEY_FILE)
46
+ foga node preflight --config <cell.config.json> [--wallet <dir>] [--rpc <url>] [--no-executors] [--no-network] [--out <report.json>] [--strict]
47
+ the readiness reconciliation before the public is served: the account and its allow-list, the operator key and its
48
+ gas, the standing, the skills' packages and executors, the documents against the chain, the public URL, the model;
49
+ advisory (exit 5 on a Fail only with --strict)
33
50
  foga node tick --config <cell.config.json> run the watchers once (heartbeat, discovery, settlement, safety) in a node environment (operator key alone)
34
51
  foga node watch --config <cell.config.json> [--interval <s>] run the watchers on the poll interval until stopped
35
52
  foga node mcp --config <cell.config.json> MCP over stdio for a local client (Claude Desktop, Claude Code, Cursor): the operator's full level with
36
53
  FOGA_OPERATOR_KEY[_FILE] in the environment, read and build without it; logs go to stderr
37
54
  foga node serve --config <cell.config.json> [--listen <host:port>] [--no-watch]
38
- the HTTP server: POST /api/<capability>, /mcp (Streamable HTTP), /healthz, / — the public reads and builds,
39
- a Bearer key acts as its grant says; the watchers run beside it
55
+ host mode: POST /api/<capability>, /mcp (Streamable HTTP), /a2a (A2A 1.0), /.well-known/agent-card.json, /documents/*,
56
+ /healthz, / — the public reads and builds, a Bearer key or a wallet signature (FOGA-EIP712) acts as its grant says;
57
+ the watchers run beside it; no model
40
58
  foga node key new --config <cell.config.json> --id <id> [--level read|build|act] [--visibility public|caller|operator] [--note <text>]
41
59
  mint an API key for the HTTP entrance: printed once, kept only as its hash in the config
42
60
  foga node run --config <cell.config.json> [--model stub|anthropic] [--listen <host:port> | --no-http] [--interval <s>] [--once | --ticks <n>] [--transcripts <dir>]
@@ -44,7 +62,7 @@ export const CELL_USAGE = `
44
62
  judges) on the poll interval; the operator key alone; anthropic needs ANTHROPIC_API_KEY and runtime.model.model
45
63
  `;
46
64
  const str = (flags, key) => (typeof flags[key] === "string" ? flags[key] : undefined);
47
- const RESERVED = new Set(["config", "mode", "json", "json-file", "caller", "level", "rpc", "public", "help"]);
65
+ const RESERVED = new Set(["config", "mode", "json", "json-file", "caller", "level", "rpc", "public", "help", "wallet"]);
48
66
  function inputFromFlags(flags) {
49
67
  let input = {};
50
68
  const inline = str(flags, "json");
@@ -81,7 +99,26 @@ function callerFromFlags(flags) {
81
99
  fail(`--level must be read, build, act or admin`);
82
100
  return { id: id ?? "caller", entrance: "cli", level: lvl, visibility: lvl === "admin" ? "operator" : id ? "caller" : "public" };
83
101
  }
84
- function keysFor(role, strict = false) {
102
+ /** The wallet `--wallet <dir>` names, or null without the flag. */
103
+ function walletFromFlags(flags) {
104
+ const dir = str(flags, "wallet");
105
+ if (!dir)
106
+ return null;
107
+ const w = loadWallet(resolve(dir));
108
+ if (!w)
109
+ fail(`${resolve(dir)} holds no wallet (no wallet.json); foga wallet new --dir ${resolve(dir)} makes one`);
110
+ return w;
111
+ }
112
+ /** A role's key: from the wallet when --wallet is given, from the environment otherwise. */
113
+ function keysFor(role, strict = false, wallet = null) {
114
+ if (wallet) {
115
+ try {
116
+ return walletKey(wallet, role);
117
+ }
118
+ catch (e) {
119
+ fail(e.message);
120
+ }
121
+ }
85
122
  try {
86
123
  return (role === "operator" && strict ? loadOperatorKey() : loadRoleKey(role)).privateKey;
87
124
  }
@@ -120,7 +157,8 @@ export async function cellCommand(cmd, rest, flags) {
120
157
  return print({ name: cap.name, title: cap.title, description: cap.description, action: cap.action, visibility: cap.visibility, principal: cap.principal, ercs: cap.ercs, input: z.toJSONSchema(cap.input, { target: "draft-2020-12", io: "input" }), output: z.toJSONSchema(cap.output, { target: "draft-2020-12", io: "output" }) });
121
158
  }
122
159
  if (cmd === "new") {
123
- const need = (k) => str(flags, k) ?? fail(`--${k} is required`);
160
+ const wallet = walletFromFlags(flags);
161
+ const need = (k) => str(flags, k) ?? (wallet && wallet.addresses[k] ? wallet.addresses[k] : fail(`--${k} is required (or --wallet <dir> with a ${k} key)`));
124
162
  const out = resolve(need("out"));
125
163
  if (exists(out))
126
164
  fail(`${out} exists; choose another --out (a config is never overwritten)`);
@@ -137,13 +175,33 @@ export async function cellCommand(cmd, rest, flags) {
137
175
  const configPath = str(flags, "config") ?? fail("--config <cell.config.json> is required");
138
176
  const rpc = str(flags, "rpc");
139
177
  const log = (line) => console.error(line);
178
+ const wallet = walletFromFlags(flags);
179
+ if (cmd === "advise") {
180
+ const { adviseCell, formatAdvice } = await import("./node/advise.js");
181
+ const node = await Node.open({ configPath, rpc, keys: {}, allowUnborn: true, log: () => { } });
182
+ try {
183
+ const advice = adviseCell(node);
184
+ if (flags["as-json"])
185
+ return print(advice);
186
+ console.log(formatAdvice(advice, { brief: Boolean(flags.brief), config: configPath }));
187
+ }
188
+ finally {
189
+ node.close();
190
+ }
191
+ return;
192
+ }
140
193
  if (cmd === "init") {
141
- const owner = keysFor("owner");
194
+ const owner = keysFor("owner", false, wallet);
142
195
  const node = await Node.open({ configPath, rpc, keys: { owner }, allowUnborn: true, log });
143
196
  try {
144
197
  const capital = str(flags, "capital-wei");
145
198
  const result = await cellInit(node, { capitalWei: capital ? BigInt(capital) : undefined, log });
146
199
  print(result);
200
+ // Born: say what a cell should do next, once, briefly — the first run stays minimal, the hardening is advice.
201
+ const { adviseCell, formatAdvice } = await import("./node/advise.js");
202
+ const next = formatAdvice(adviseCell(node), { brief: true, config: configPath });
203
+ if (next)
204
+ console.error(`\n${next}`);
147
205
  }
148
206
  finally {
149
207
  node.close();
@@ -151,7 +209,7 @@ export async function cellCommand(cmd, rest, flags) {
151
209
  return;
152
210
  }
153
211
  if (cmd === "migrate") {
154
- const owner = keysFor("owner");
212
+ const owner = keysFor("owner", false, wallet);
155
213
  const to = resolve(str(flags, "to") ?? fail("--to <new.config.json> is required"));
156
214
  if (exists(to))
157
215
  fail(`${to} exists; choose another --to`);
@@ -166,6 +224,72 @@ export async function cellCommand(cmd, rest, flags) {
166
224
  }
167
225
  return;
168
226
  }
227
+ if (cmd === "docs") {
228
+ const sub = rest[0];
229
+ const { buildDocuments, readBack, documentsDir, onChainState, readBuilt, publishSteps } = await import("./node/documents.js");
230
+ const { version } = await import("./util/version.js");
231
+ if (sub === "build") {
232
+ const node = await Node.open({ configPath, rpc, keys: {}, log });
233
+ try {
234
+ const built = await buildDocuments(node, { version, dir: str(flags, "out") });
235
+ console.error(`documents v${built.manifest.version} written to ${built.dir}:`);
236
+ for (const [file, d] of Object.entries(built.manifest.documents))
237
+ console.error(` ${file.padEnd(20)} ${d.standard.padEnd(40)} ${d.contentHash}`);
238
+ const { steps, unchanged } = await publishSteps(node, built);
239
+ console.error(steps.length ? `a publish would commit: ${steps.map((x) => x.label).join("; ")}` : "every commitment on chain matches what was built");
240
+ if (unchanged.length)
241
+ console.error(`already as built: ${unchanged.join(", ")}`);
242
+ print(built.manifest);
243
+ }
244
+ finally {
245
+ node.close();
246
+ }
247
+ return;
248
+ }
249
+ if (sub === "status") {
250
+ const node = await Node.open({ configPath, rpc, keys: {}, log });
251
+ try {
252
+ const dir = documentsDir(node);
253
+ const built = readBuilt(dir);
254
+ const rows = built ? await readBack(node, built) : [];
255
+ print({ dir, manifest: built?.manifest ?? null, onChain: await onChainState(node), readBack: rows, pending: built ? (await publishSteps(node, built)).steps.map((x) => x.label) : [] });
256
+ if (rows.some((r) => r.ok === false))
257
+ process.exitCode = 2;
258
+ }
259
+ finally {
260
+ node.close();
261
+ }
262
+ return;
263
+ }
264
+ if (sub === "publish") {
265
+ const node = await Node.open({ configPath, rpc, keys: { operator: keysFor("operator", false, wallet) }, log });
266
+ try {
267
+ const result = await catalog.invoke(node, "docs_publish", {}, OPERATOR_CALLER, { mode: "act" });
268
+ if (!result.ok) {
269
+ print(result);
270
+ process.exitCode = 2;
271
+ return;
272
+ }
273
+ const out = result.output;
274
+ console.error(`documents v${out.version} in ${out.dir}: ${out.committed.length ? `committed ${out.committed.join(", ")}` : "nothing to commit"}${out.unchanged.length ? `; already as built: ${out.unchanged.join(", ")}` : ""} (job ${out.jobId}, ${out.operations.length} operation(s))`);
275
+ // the read-back from here fetches the URLs too, when the node serves them
276
+ const built = readBuilt(out.dir);
277
+ const rows = await readBack(node, built, { fetchUrls: true });
278
+ for (const r of rows)
279
+ console.error(` ${r.ok === true ? "ok " : r.ok === false ? "FAIL " : "unread "} ${r.commitment.padEnd(26)} ${r.detail}`);
280
+ print({ ...out, readBack: rows });
281
+ if (rows.some((r) => r.ok === false)) {
282
+ console.error("a commitment read back from the chain does not match what was built: the publish is not complete");
283
+ process.exitCode = 2;
284
+ }
285
+ }
286
+ finally {
287
+ node.close();
288
+ }
289
+ return;
290
+ }
291
+ fail("foga cell docs build | publish | status");
292
+ }
169
293
  // a capability, possibly said as "owner <capability>" / "guardian <capability>"
170
294
  let name = cmd;
171
295
  let explicitRole = null;
@@ -186,10 +310,10 @@ export async function cellCommand(cmd, rest, flags) {
186
310
  if (cap.action !== "read" && mode === "act") {
187
311
  if (cap.action === "admin") {
188
312
  const role = cap.signer ?? explicitRole ?? "owner";
189
- keys[role] = keysFor(role);
313
+ keys[role] = keysFor(role, false, wallet);
190
314
  }
191
315
  else
192
- keys.operator = keysFor("operator");
316
+ keys.operator = keysFor("operator", false, wallet);
193
317
  }
194
318
  const node = await Node.open({ configPath, rpc, keys, log });
195
319
  try {
@@ -230,6 +354,32 @@ export async function nodeCommand(cmd, rest, flags) {
230
354
  }
231
355
  const configPath = str(flags, "config") ?? fail("--config <cell.config.json> is required");
232
356
  const log = (line) => console.error(line);
357
+ // --wallet <dir>: the operator key file of the wallet, as FOGA_OPERATOR_KEY_FILE would name it; never beside another key source
358
+ const wallet = walletFromFlags(flags);
359
+ if (wallet) {
360
+ if (process.env.FOGA_OPERATOR_KEY || process.env.FOGA_OPERATOR_KEY_FILE)
361
+ fail("--wallet and FOGA_OPERATOR_KEY[_FILE] are both set; a node takes its operator key from one place");
362
+ if (!wallet.files.operator)
363
+ fail(`wallet ${wallet.dir} has no operator key`);
364
+ process.env.FOGA_OPERATOR_KEY_FILE = wallet.files.operator;
365
+ }
366
+ if (cmd === "preflight") {
367
+ const { preflight, printPreflight } = await import("./node/preflight.js");
368
+ const { writeJson } = await import("./util/fs.js");
369
+ const node = await Node.open({ configPath, rpc: str(flags, "rpc"), keys: {}, log });
370
+ try {
371
+ const report = await preflight(node, { executors: !flags["no-executors"], network: !flags["no-network"] });
372
+ printPreflight(report, (line) => console.log(line));
373
+ if (str(flags, "out"))
374
+ writeJson(resolve(str(flags, "out")), report);
375
+ if (flags.strict && report.summary.Fail > 0)
376
+ process.exitCode = 5;
377
+ }
378
+ finally {
379
+ node.close();
380
+ }
381
+ return;
382
+ }
233
383
  if (cmd === "key") {
234
384
  if (rest[0] !== "new")
235
385
  fail("foga node key new --config <cfg> --id <id> [--level act] [--visibility caller]");
@@ -281,7 +431,17 @@ export async function nodeCommand(cmd, rest, flags) {
281
431
  const { version } = await import("./util/version.js");
282
432
  const listen = str(flags, "listen") ?? node.config.runtime.http.listen;
283
433
  const running = await createHttpServer({ node, catalog, version, log }).listen(listen);
284
- log(`foga ${version}: serving cell ${node.config.name}${node.cell ? ` (${node.cell.address})` : ""} at ${running.url} — /mcp, /api/<capability>, /healthz; ${keys.operator ? "acting with the operator key" : "no operator key: reads and builds only"}; ${node.config.access.keys.length} API key(s) configured`);
434
+ const { documentsDir, readBuilt } = await import("./node/documents.js");
435
+ const built = readBuilt(documentsDir(node));
436
+ const signers = node.config.access.signers;
437
+ log(`foga ${version}: serving cell ${node.config.name}${node.cell ? ` (${node.cell.address})` : ""} at ${running.url} — /mcp, /api/<capability>, /a2a, /.well-known/agent-card.json, /documents/*, /healthz; ${keys.operator ? "acting with the operator key" : "no operator key: reads and builds only"}; ${node.config.access.keys.length} API key(s) configured; signatures: ${signers.default} by default, ${signers.allow.length} allowed${signers.standing ? `, standing level ${signers.standing.minLevel}+ granted ${signers.standing.level}` : ""}; documents ${built ? `v${built.manifest.version}` : "not built (foga cell docs build)"}`);
438
+ {
439
+ // One line of advice at start-up (the full list is `foga cell advise`); nothing here is a condition of serving.
440
+ const { adviseCell } = await import("./node/advise.js");
441
+ const recommended = adviseCell(node).filter((a) => a.level === "recommended");
442
+ if (recommended.length)
443
+ log(`advice: ${recommended.map((a) => a.id).join(", ")} — foga cell advise --config ${configPath} explains; nothing required`);
444
+ }
285
445
  const stopWatch = !flags["no-watch"] && keys.operator && node.cell ? startWatchLoop(new Watchers(node, catalog), node.config.runtime.pollSeconds * 1000, log) : () => { };
286
446
  const stop = () => {
287
447
  stopWatch();
@@ -363,7 +523,7 @@ export async function nodeCommand(cmd, rest, flags) {
363
523
  return;
364
524
  }
365
525
  if (cmd !== "tick" && cmd !== "watch")
366
- fail(`unknown node command ${cmd}; tick, watch, mcp, serve, run or key`);
526
+ fail(`unknown node command ${cmd}; tick, watch, mcp, serve, run, preflight or key`);
367
527
  // a node environment: the operator key alone, nothing that derives another role
368
528
  const operator = keysFor("operator", true);
369
529
  const node = await Node.open({ configPath, rpc: str(flags, "rpc"), keys: { operator }, log });
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { cpus, homedir, loadavg } from "node:os";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { buildBundle } from "./build/bundle.js";
@@ -21,6 +21,17 @@ usage:
21
21
  deploy-mode config: the whole stack; attach-mode config: attach what it
22
22
  lists and deploy the reference contracts it does not (deployer = W)
23
23
  foga world attach --world <config.json> [--out <file>] address book only, no transactions (every contract listed)
24
+ foga world verify --world <world.json> --sourcify [--dry-run] [--only <id,id>] [--server <url>] [--out <dir>] [--build-info <dir>]
25
+ submit every contract the world deployed from the bundle to Sourcify, with the standard JSON input of its
26
+ compiler run (.foga-build/out/<repo>/build-info, written by npm run bundle:verify); a local chain is a dry run
27
+ foga wallet new [--name <n> | --dir <dir>] [--cell] the first step: a key of your own — one signer in a 0600 file, your identity, which fills
28
+ every role a cell needs (default ~/.foga/wallet; --name under ~/.foga/wallets/<n>; --cell: the
29
+ separated owner/operator/guardian set for a guarded cell account). Never overwrites; prints the addresses
30
+ foga wallet show [--name <n> | --dir <dir>] [--rpc <url>] the wallet's addresses (and balances with an RPC)
31
+ foga wallet sign (--config <cell.config.json> | --url <node url> | --cell <account> --chain-id <n>) [--name <n> | --dir <dir>]
32
+ [--role signer|operator|owner|guardian] [--ttl <s>] [--agent-id <id>] [--method <METHOD> --path <path> [--body '<json>' | --body-file <f>]]
33
+ an Authorization header (FOGA-EIP712) proving the wallet to that cell — named by its configuration, by its node's
34
+ URL (/healthz says which account and chain) or by hand: a session envelope, or one bound to a request
24
35
  foga keys new [--out <file>] write a fresh 12-word phrase (default ~/.foga/genesis.mnemonic) and print the role addresses
25
36
  foga keys show print the role addresses the environment derives (FOGA_MNEMONIC[_FILE], FOGA_KEY_*)
26
37
  foga keys export --role <role> [--out <file>] write one role's key to a 0600 file for a node that holds that key alone
@@ -38,9 +49,10 @@ usage:
38
49
  [--zk auto|require|skip] [--crash-after <opKey>] [--crash-before <opKey>]
39
50
  run the genesis scenario (idempotent; re-run to resume)
40
51
  foga genesis status --world <world.json> [--store <file.sqlite>] list the operations the store holds
41
- foga quickstart [--dir <dir>] [--port <n>] [--listen <host:port> | --no-serve] [--name <cell>] [--no-tenders] [--exit]
42
- one command to a live cell: anvil, the local world, a cell born with fresh keys and its two skills, two tenders,
43
- the node serving HTTP and MCP, and the MCP client configurations printed with every path filled in (~/.foga/quickstart)
52
+ foga quickstart [--dir <dir>] [--wallet <dir>] [--port <n>] [--listen <host:port> | --no-serve] [--name <cell>] [--no-tenders] [--exit]
53
+ one command to a live cell: the wallet (made on the first run, reused after), anvil, the local world, a cell born with
54
+ the wallet's keys and its two skills, two tenders, the node serving HTTP and MCP, and the MCP client configurations
55
+ printed with every path filled in (~/.foga/quickstart)
44
56
  foga demo [--port <n>] [--keep] [--genesis <T0..T9>] start anvil, deploy the local world, run the doctor (and the genesis)
45
57
  foga demo --loop-d [--model stub|anthropic|external] [--model-name <name>] [--seed <n>] [--no-recovery] [--port <n>] [--keep]
46
58
  the above, then Loop D: a new cell born, a demander minting cases a-g, the node with a model (or served for an
@@ -149,6 +161,21 @@ async function main() {
149
161
  }
150
162
  return;
151
163
  }
164
+ if (group === "world" && cmd === "verify") {
165
+ if (!flags.sourcify)
166
+ fail("foga world verify --sourcify --world <world.json> (Sourcify is the one verifier wired so far)");
167
+ const worldPath = str(flags, "world") ?? fail("--world <world.json> is required (a deployed world)");
168
+ const { verifyWorld } = await import("./world/sourcify.js");
169
+ const { report, outDir } = await verifyWorld({ worldPath: resolve(worldPath), server: str(flags, "server"), dryRun: flags["dry-run"] ? true : undefined, only: str(flags, "only")?.split(","), outDir: str(flags, "out") ? resolve(str(flags, "out")) : undefined, buildInfoRoot: str(flags, "build-info") ? resolve(str(flags, "build-info")) : undefined, log: (line) => console.error(line) });
170
+ for (const r of report.results)
171
+ console.log(` ${r.outcome.padEnd(12)} ${r.id.padEnd(32)} ${r.address} ${r.contractIdentifier} (${r.compilerVersion})${r.outcome === "dry-run" ? "" : ` ${r.detail}`}`);
172
+ for (const m of report.missing)
173
+ console.log(` missing ${m.id.padEnd(32)} ${m.reason}`);
174
+ console.log(`sourcify: ${Object.entries(report.summary).map(([k, v]) => `${v} ${k}`).join(", ") || "nothing to do"}${report.missing.length ? `, ${report.missing.length} missing` : ""}; report ${relative(root, join(outDir, "report.json"))}`);
175
+ if (report.missing.length || report.results.some((r) => r.outcome === "failed" || r.outcome === "error"))
176
+ process.exitCode = 3;
177
+ return;
178
+ }
152
179
  if (group === "world" && (cmd === "deploy" || cmd === "attach")) {
153
180
  const worldConfigPath = str(flags, "world");
154
181
  if (!worldConfigPath)
@@ -217,6 +244,118 @@ async function main() {
217
244
  process.stdout.write(RELAY_SCRIPT);
218
245
  return;
219
246
  }
247
+ if (group === "wallet") {
248
+ const { CELL_ROLES, createWallet, loadWallet, walletDir, walletFileProblems } = await import("./node/wallet.js");
249
+ const dir = resolve(str(flags, "dir") ?? walletDir(str(flags, "name")));
250
+ const printWallet = async (w, made) => {
251
+ console.log(`${made ? "wallet made at" : "wallet"} ${w.dir}${w.name !== "default" ? ` (${w.name})` : ""}, ${w.createdAt}`);
252
+ const rpc = str(flags, "rpc") ?? process.env.FOGA_RPC_URL;
253
+ const provider = rpc ? new (await import("ethers")).JsonRpcProvider(rpc) : null;
254
+ for (const role of w.roles) {
255
+ let balance = "";
256
+ if (provider) {
257
+ try {
258
+ balance = ` ${(await import("ethers")).formatEther(await provider.getBalance(w.addresses[role]))} ETH`;
259
+ }
260
+ catch {
261
+ balance = " (balance: RPC did not answer)";
262
+ }
263
+ }
264
+ console.log(` ${role.padEnd(9)} ${w.addresses[role]}${balance} ${w.files[role]}`);
265
+ }
266
+ provider?.destroy();
267
+ for (const p of walletFileProblems(w))
268
+ log.warn(p);
269
+ const single = w.roles.length === 1 && w.roles[0] === "signer";
270
+ if (single)
271
+ console.log(`one key, your identity; it fills every role a cell needs. For a cell's birth, fund this address (capital and gas), then: foga cell new --wallet ${dir} … and foga cell init --config <cfg> --wallet ${dir}`);
272
+ else if (w.roles.includes("owner"))
273
+ console.log(`a separated cell wallet; fund the owner for a cell's birth (capital and gas) and the operator for its gas, then: foga cell new --wallet ${dir} … and foga cell init --config <cfg> --wallet ${dir}`);
274
+ };
275
+ if (cmd === "new") {
276
+ const existing = loadWallet(dir);
277
+ if (existing) {
278
+ await printWallet(existing, false);
279
+ console.log("(already there; a wallet is never overwritten — foga wallet new --name <n> makes another)");
280
+ return;
281
+ }
282
+ // the base is one key (--single, now the default); --cell makes the separated owner/operator/guardian set (the guarded FogaCellAccount profile)
283
+ const roles = flags.cell ? [...CELL_ROLES] : undefined;
284
+ try {
285
+ await printWallet(createWallet(dir, { name: str(flags, "name") ?? "default", roles }), true);
286
+ }
287
+ catch (e) {
288
+ fail(e.message);
289
+ }
290
+ return;
291
+ }
292
+ if (cmd === "show") {
293
+ const w = loadWallet(dir);
294
+ if (!w)
295
+ fail(`no wallet at ${dir}; foga wallet new makes one`);
296
+ await printWallet(w, false);
297
+ return;
298
+ }
299
+ if (cmd === "sign") {
300
+ const w = loadWallet(dir);
301
+ if (!w)
302
+ fail(`no wallet at ${dir}; foga wallet new makes one`);
303
+ const { walletKey } = await import("./node/wallet.js");
304
+ const { loadCellConfig, configPath } = await import("./node/config.js");
305
+ const { signAuthorization } = await import("./http/signed.js");
306
+ const { Wallet } = await import("ethers");
307
+ // the cell the signature is for: its own configuration (the operator's shell), its node's URL (anyone's: /healthz names the account and the chain), or both values by hand
308
+ let cellAddress;
309
+ let chainId;
310
+ let cellName;
311
+ if (str(flags, "config")) {
312
+ const loaded = loadCellConfig(str(flags, "config"));
313
+ cellAddress = loaded.config.identity.account ?? fail("the config names no account yet (the cell is not born)");
314
+ chainId = readJson(configPath(loaded, loaded.config.world)).chainId;
315
+ cellName = loaded.config.name;
316
+ }
317
+ else if (str(flags, "url")) {
318
+ const base = str(flags, "url").replace(/\/+$/, "");
319
+ let health;
320
+ try {
321
+ health = (await (await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(10_000) })).json());
322
+ }
323
+ catch (e) {
324
+ fail(`${base}/healthz did not answer: ${e.message}`);
325
+ }
326
+ if (!health.account || !health.chainId)
327
+ fail(`${base}/healthz names no account or chain (${JSON.stringify(health).slice(0, 200)}); the cell is not born or this is not a FOGA node`);
328
+ cellAddress = health.account;
329
+ chainId = health.chainId;
330
+ cellName = health.cell ?? base;
331
+ }
332
+ else if (str(flags, "cell") && str(flags, "chain-id")) {
333
+ cellAddress = str(flags, "cell");
334
+ chainId = Number(str(flags, "chain-id"));
335
+ cellName = cellAddress;
336
+ }
337
+ else
338
+ fail("the signature is made for one cell: --config <cell.config.json>, or --url <its node's URL>, or --cell <account> --chain-id <n>");
339
+ const role = (str(flags, "role") ?? (w.roles.includes("signer") ? "signer" : "operator"));
340
+ let key;
341
+ try {
342
+ key = walletKey(w, role);
343
+ }
344
+ catch (e) {
345
+ fail(e.message);
346
+ }
347
+ const method = str(flags, "method");
348
+ const path = str(flags, "path");
349
+ if ((method && !path) || (!method && path))
350
+ fail("--method and --path go together");
351
+ const bodyText = str(flags, "body") ?? (str(flags, "body-file") ? readFileSync(resolve(str(flags, "body-file")), "utf8") : undefined);
352
+ const header = await signAuthorization(new Wallet(key), { chainId, cell: cellAddress, ttlSeconds: str(flags, "ttl") ? Number(str(flags, "ttl")) : undefined, agentId: str(flags, "agent-id"), request: method ? { method, path: path, body: bodyText ?? null } : undefined });
353
+ console.error(`${role} ${new Wallet(key).address} signed ${method ? `a ${method} ${path} envelope` : "a session envelope"} for cell ${cellName} (${cellAddress}, chain ${chainId}); the header value follows (one line):`);
354
+ console.log(header);
355
+ return;
356
+ }
357
+ fail("foga wallet new | show | sign");
358
+ }
220
359
  if (group === "keys" && cmd === "export") {
221
360
  const { ROLE_ORDER, envKeys, roleAddresses } = await import("./genesis/roles.js");
222
361
  const role = str(flags, "role");
@@ -362,7 +501,7 @@ async function main() {
362
501
  const { runQuickstart } = await import("./demo/quickstart.js");
363
502
  let result;
364
503
  try {
365
- result = await runQuickstart({ dir: str(flags, "dir"), port: str(flags, "port") ? Number(str(flags, "port")) : undefined, listen: flags["no-serve"] ? null : str(flags, "listen"), name: str(flags, "name"), tenders: !flags["no-tenders"], exit: Boolean(flags.exit) });
504
+ result = await runQuickstart({ dir: str(flags, "dir"), wallet: str(flags, "wallet"), port: str(flags, "port") ? Number(str(flags, "port")) : undefined, listen: flags["no-serve"] ? null : str(flags, "listen"), name: str(flags, "name"), tenders: !flags["no-tenders"], exit: Boolean(flags.exit) });
366
505
  }
367
506
  catch (e) {
368
507
  fail(e.message);
@@ -371,13 +510,19 @@ async function main() {
371
510
  await result.stop();
372
511
  return;
373
512
  }
513
+ let asked = false;
374
514
  const stopping = () => {
515
+ if (asked)
516
+ return;
517
+ asked = true;
375
518
  console.error("quickstart: stopping the node and the chain");
376
519
  result.stop().finally(() => process.exit(0));
377
520
  };
378
521
  process.on("SIGINT", stopping);
379
522
  process.on("SIGTERM", stopping);
380
523
  const why = await result.ended;
524
+ if (why === "stopped")
525
+ return; // a stop that was asked for: both children are gone
381
526
  fail(`${why}; the quickstart is over`);
382
527
  }
383
528
  if (group === "demo") {
@@ -39,6 +39,23 @@ export class World {
39
39
  get chainId() {
40
40
  return this.file.chainId;
41
41
  }
42
+ get network() {
43
+ return this.file.network;
44
+ }
45
+ /**
46
+ * The covenants this world has contracts for, as ERC ids ("ERC-8414"), read off the contract keys ("8414/TaskToken";
47
+ * "identity8004/…" is the ERC-8004 registry). Data, not a constant: a world that attaches an eleventh covenant under
48
+ * a new key advertises it here with no code change (docs/extending.md), and a world that lacks one does not claim it.
49
+ */
50
+ covenants() {
51
+ const ids = new Set();
52
+ for (const key of [...Object.keys(this.file.contracts), ...this.dynamic.keys()]) {
53
+ const m = /^(?:identity)?(\d{4,5})$/.exec(key.split("/")[0]);
54
+ if (m)
55
+ ids.add(`ERC-${m[1]}`);
56
+ }
57
+ return [...ids].sort((a, b) => Number(a.slice(4)) - Number(b.slice(4)));
58
+ }
42
59
  has(id) {
43
60
  return Boolean(this.file.contracts[id] || this.dynamic.get(id));
44
61
  }
@@ -12,12 +12,18 @@ export async function birthCell(o) {
12
12
  const log = o.log ?? (() => { });
13
13
  const root = repoRoot();
14
14
  const W = k.address("W");
15
- const owner = Wallet.createRandom();
16
- const operator = Wallet.createRandom();
17
- const guardian = Wallet.createRandom();
18
- log(`cell ${o.name}: owner ${owner.address}, operator ${operator.address}, guardian ${guardian.address}`);
15
+ const owner = o.keys ? new Wallet(o.keys.owner) : Wallet.createRandom();
16
+ const operator = o.keys ? new Wallet(o.keys.operator) : Wallet.createRandom();
17
+ const guardian = o.keys ? new Wallet(o.keys.guardian) : Wallet.createRandom();
18
+ log(`cell ${o.name}: owner ${owner.address}, operator ${operator.address}, guardian ${guardian.address}${o.keys ? " (the wallet's keys)" : " (fresh keys)"}`);
19
19
  // --- funding by W, the standing scheme registered by W
20
- const price = (await k.world.provider.getFeeData()).gasPrice ?? 1000000000n;
20
+ // The reserve is priced at the chain's gas price, floored at 1 gwei. A public testnet can quote a price near zero
21
+ // (Sepolia has sat at a few wei), which would fund the roles with the flat amounts alone: the operator below the
22
+ // cell policy's gas floor (a quarter of the daily gas budget — the budget gate then refuses every tender) and the
23
+ // guardian with next to nothing for its suspend and resume. The floor keeps the funding meaningful on any chain;
24
+ // on a local chain, whose price is above it, nothing changes.
25
+ const quoted = (await k.world.provider.getFeeData()).gasPrice ?? 1000000000n;
26
+ const price = quoted > 1000000000n ? quoted : 1000000000n;
21
27
  const gas = (units) => units * price * 2n;
22
28
  await k.transfer("fund.owner", "W", owner.address, parseEther("0.05") + gas(12000000n));
23
29
  await k.transfer("fund.operator", "W", operator.address, gas(20000000n) + parseEther("0.003"));
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { createServer } from "node:net";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -106,6 +106,14 @@ export async function runLoopD(opts) {
106
106
  const root = repoRoot();
107
107
  const dir = resolve(opts.dir);
108
108
  mkdirSync(dir, { recursive: true });
109
+ // One working directory holds one run: the cell's configuration and store, the orchestrator's and the demander's
110
+ // stores, the cases, the brief, the transcripts. A second run in the same directory would find the first run's
111
+ // keyed operations and replay them (a resumable kernel resumes) against a cell with fresh keys — the birth then
112
+ // fails on the first replayed answer. So a previous run's files are cleared first; the evidence (`--out`) lives
113
+ // elsewhere and is kept.
114
+ const cleared = cleanLoopDDir(dir);
115
+ if (cleared.length)
116
+ log(`loop D: cleared the previous run's files in ${dir} (${cleared.join(", ")})`);
109
117
  const worldPath = resolve(opts.worldPath);
110
118
  const seed = opts.seed ?? 7;
111
119
  const cases = opts.cases ?? ALL_CASES;
@@ -563,3 +571,33 @@ export function defaultLoopDDir() {
563
571
  mkdirSync(dir, { recursive: true });
564
572
  return dir;
565
573
  }
574
+ /** The files a Loop D run leaves in its working directory — its own and nothing else. */
575
+ const LOOP_D_FILES = ["D.json", "D.ops.sqlite", "D.ops.sqlite-wal", "D.ops.sqlite-shm", "orchestrator.sqlite", "orchestrator.sqlite-wal", "orchestrator.sqlite-shm", "demander.sqlite", "demander.sqlite-wal", "demander.sqlite-shm", "agent-brief.md"];
576
+ const LOOP_D_DIRS = ["transcripts", "documents"];
577
+ /** Remove what a previous Loop D run left in `dir` (only the run's own files); returns the names removed. */
578
+ export function cleanLoopDDir(dir) {
579
+ if (!existsSync(dir))
580
+ return [];
581
+ const removed = [];
582
+ for (const name of LOOP_D_FILES) {
583
+ const p = join(dir, name);
584
+ if (existsSync(p)) {
585
+ rmSync(p, { force: true });
586
+ removed.push(name);
587
+ }
588
+ }
589
+ for (const name of readdirSync(dir)) {
590
+ if (/^cases-.*\.json$/.test(name)) {
591
+ rmSync(join(dir, name), { force: true });
592
+ removed.push(name);
593
+ }
594
+ }
595
+ for (const name of LOOP_D_DIRS) {
596
+ const p = join(dir, name);
597
+ if (existsSync(p)) {
598
+ rmSync(p, { recursive: true, force: true });
599
+ removed.push(`${name}/`);
600
+ }
601
+ }
602
+ return removed;
603
+ }