create-open-autonomy 2.1.2 → 2.2.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.
package/README.md CHANGED
@@ -31,10 +31,10 @@ LICENSE Apache-2.0, seeded; the project's own
31
31
  package.json, test/ the project's own check (`bun run check`), starting with one test
32
32
  hermes/ the agent: SOUL.md, its two skills (develop, pm), profiles/treasurer (the second profile: the one that pays), kanban.seed.json (the board's first tasks, in order),
33
33
  cron/jobs.seed.json (the PM, hourly), config.yaml (the model: the project's own choice), the seed hook
34
- .open-autonomy/ the platform connection: config.yaml (account, publish policy, rail bounds), reporter.ts (the bridge:
35
- sessions, the board, the setup), mint-key.ts (the key, the adopter way), setup.ts (the host, by one
36
- command), the vendored SDK, kit.json (which kit, version and parameters made this repository)
37
- container/ the stack: the agent, the key valve, the reporter; the pinned Hermes image
34
+ .open-autonomy/ the platform connection: config.yaml (account, publish policy, rail bounds), reporter.ts (the publisher:
35
+ sessions, the board, the setup), mint-key.ts (the key, the adopter way), start.ts (the agent's four
36
+ processes, the one way it starts), the vendored SDK, kit.json (which kit, version and parameters made this repository)
37
+ container/ a real setup: one image whose entrypoint is start.ts as root, dropping the gateway to the image's user; the pinned Hermes
38
38
  .github/workflows/ ci.yml (the project's check on every branch), land.yml (the landing convention)
39
39
  ```
40
40
 
@@ -70,6 +70,6 @@ for a listed partner within a bound. Both leave records on the public audit trai
70
70
 
71
71
  ## Nothing in the agent's reach is a secret that matters
72
72
 
73
- The agent's `.env` says `OPEN_AUTONOMY_KEY=valve`. Pushes sign through an ssh-agent forwarded from the host
74
- holding one repository-scoped deploy key. Delivery uses at most a Discord bot token. Every session's turns
73
+ The agent's `.env` says `OPEN_AUTONOMY_KEY=valve`. Pushes sign through an ssh-agent the start script loads with
74
+ one repository-scoped deploy key, which the gateway never holds. Delivery uses at most a Discord bot token. Every session's turns
75
75
  are published; the platform redacts secret-shaped text at intake as the second wall.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-open-autonomy",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "The default Open Autonomy starter kit: a complete repository that runs its own Hermes agent against the platform, with the SDK wired in. `bun create open-autonomy <dir>` scaffolds one.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -24,12 +24,11 @@
24
24
  "access": "public"
25
25
  },
26
26
  "scripts": {
27
- "test": "bun test test/*.test.ts",
28
27
  "typecheck": "bunx tsc --noEmit",
29
- "check": "bun test test/*.test.ts && bunx tsc --noEmit"
28
+ "check": "bunx tsc --noEmit"
30
29
  },
31
30
  "dependencies": {
32
- "@open-autonomy/sdk": "2.1.0"
31
+ "@open-autonomy/sdk": "2.2.0"
33
32
  },
34
33
  "devDependencies": {
35
34
  "@types/bun": "^1.3.10",
package/src/kit.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { dirname, join, relative, resolve } from 'node:path';
11
11
 
12
- export const KIT = { name: 'hermes', version: '2.1.2' } as const;
12
+ export const KIT = { name: 'hermes', version: '2.2.0' } as const;
13
13
  export const KIT_FILE = '.open-autonomy/kit.json';
14
14
  const TEMPLATE = resolve(import.meta.dir, '..', 'template');
15
15
 
@@ -17,7 +17,7 @@ export interface KitParams { project: string; account: string }
17
17
  export interface KitRecord { kit: string; version: string; params: KitParams; divergences: string[] }
18
18
 
19
19
  // What the kit keeps current. Everything else in the template is seeded once.
20
- const OWNED = [/^hermes\/(?!config\.yaml$|kanban\.seed\.json$)/, /^\.open-autonomy\/(reporter\.ts|mint-key\.ts|setup\.ts|package\.json|sdk\/)/, /^container\//, /^\.github\/workflows\/(ci|land)\.yml$/];
20
+ const OWNED = [/^hermes\/(?!config\.yaml$|kanban\.seed\.json$)/, /^\.open-autonomy\/(reporter\.ts|mint-key\.ts|start\.ts|package\.json|sdk\/)/, /^container\//, /^\.github\/workflows\/(ci|land)\.yml$/];
21
21
  export const isOwned = (rel: string): boolean => OWNED.some((re) => re.test(rel));
22
22
 
23
23
  export function validateParams(p: Partial<KitParams>): KitParams {
@@ -38,7 +38,7 @@ function walk(dir: string, base = dir): string[] {
38
38
  // The Open Autonomy SDK is vendored into the generated repository under .open-autonomy/sdk, kit-owned, so
39
39
  // the reporter and the key tool run from a bare clone with no package to publish or install.
40
40
  const SDK_SRC = resolve(dirname(Bun.resolveSync('@open-autonomy/sdk/package.json', import.meta.dir)), 'src');
41
- const SDK_FILES = ['client.ts', 'roadmap.ts', 'drivers.ts', 'rails.ts'];
41
+ const SDK_FILES = ['client.ts', 'roadmap.ts', 'drivers.ts', 'rails.ts', 'valve.ts'];
42
42
 
43
43
  // Every template file, rendered. Placeholders are `__PROJECT__` and `__ACCOUNT__` (and `__ACCOUNT_ENC__`,
44
44
  // the account as a URL path segment); binary-looking files pass through untouched.
@@ -98,10 +98,10 @@ export function check(dir: string): Outcome {
98
98
  // Kit-owned files the repository has that the kit no longer renders: what an earlier kit made and this one
99
99
  // retired. `upgrade` removes them; a project that keeps one names it in `divergences`. Only the families the
100
100
  // kit writes are looked at — never the agent's runtime state beside them (its database, sessions, logs, .env).
101
- const RETIRABLE = [/^hermes\/(skills|hooks|scripts)\//, /^\.open-autonomy\/([a-z-]+\.ts|sdk\/)/, /^container\//];
101
+ const RETIRABLE = [/^hermes\/(skills|hooks|scripts)\//, /^\.open-autonomy\/([a-z-]+\.ts|sdk\/)/, /^container\//, /^\.github\/workflows\/(ci|land)\.yml$/];
102
102
  function retired(dir: string, rendered: Map<string, Buffer>, rec: KitRecord): string[] {
103
103
  const out: string[] = [];
104
- for (const root of ['hermes/skills', 'hermes/hooks', 'hermes/scripts', '.open-autonomy', 'container']) {
104
+ for (const root of ['hermes/skills', 'hermes/hooks', 'hermes/scripts', '.open-autonomy', 'container', '.github/workflows']) {
105
105
  const at = join(dir, root);
106
106
  if (!existsSync(at)) continue;
107
107
  for (const rel of walk(at).map((f) => `${root}/${f}`)) if (RETIRABLE.some((re) => re.test(rel)) && isOwned(rel) && !rendered.has(rel) && !rec.divergences.includes(rel)) out.push(rel);
@@ -1,7 +1,6 @@
1
1
  name: Land a branch
2
- # Nothing pushes to `main`: branch protection requires a pull request and the `ci` check, with no bypass.
3
- # Work lands by pushing a branch: this workflow opens its pull request once and arms native auto-merge, so
4
- # it merges exactly when the required checks pass on its head commit.
2
+ # Nothing pushes to `main`: branch protection requires a pull request, with no bypass, and no check stands
3
+ # between a branch and main. Work lands by pushing a branch: this workflow opens its pull request once and merges it.
5
4
  # agent/** the agent's own work land/** a maintainer's
6
5
  on:
7
6
  push:
@@ -26,5 +25,5 @@ jobs:
26
25
  --body "Opened by the landing workflow for \`$BRANCH\`. It merges on its own when the required checks pass."
27
26
  existing=$(gh pr list -R "$REPO" --head "$BRANCH" --state open --json number -q '.[0].number')
28
27
  fi
29
- gh pr merge -R "$REPO" "$existing" --auto --merge
30
- echo "pull request #$existing: auto-merge armed"
28
+ gh pr merge -R "$REPO" "$existing" --merge
29
+ echo "pull request #$existing: merged"
@@ -11,10 +11,9 @@ publish:
11
11
  private: # session ids or job names that never publish, whatever their kind
12
12
  - []
13
13
 
14
- # Where sessions come from: the Hermes home the reporter reads through supercode, and where it keeps its
15
- # own cursor. Inside the container stack these are the mounted volume and the checkout.
16
- hermes_home: /opt/data
17
- state_file: /work/project/.open-autonomy/reporter-state.json
14
+ # Where sessions come from: the Hermes home the reporter reads through supercode (HERMES_HOME in its
15
+ # environment), and where it keeps its own cursor (beside this file).
16
+ state_file: reporter-state.json
18
17
 
19
18
  # The rails the agent may spend through beyond model calls, and the owner's bounds on each. Off unless a
20
19
  # bound is set. Every rail leaves a record on the public audit trail naming itself.
@@ -5,14 +5,14 @@
5
5
  // third service: it authenticates through the key valve's forwarded narration route and never sees the
6
6
  // project's key. Nothing here drives the agent; it only reads.
7
7
  //
8
- // OPEN_AUTONOMY_BASE_URL=http://valve:8787/v1 bun .open-autonomy/reporter.ts [--config .open-autonomy/config.yaml]
8
+ // HERMES_HOME=<the agent's home> OPEN_AUTONOMY_BASE_URL=http://127.0.0.1:8787/v1 bun .open-autonomy/reporter.ts [--config .open-autonomy/config.yaml]
9
9
  //
10
10
  // Supercode's contract, as its SDK documents it: `subscribeSessionIndex` lists sessions and streams
11
11
  // index changes (`sessionIndexEvent`); `session(locator).follow()` yields a snapshot then appended
12
12
  // messages; `subscribeSessionActivity` reports presence and turn state. A Hermes home is named by the
13
13
  // path of its state.db in `homes.hermes`.
14
14
  import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
15
- import { resolve } from 'node:path';
15
+ import { dirname, resolve } from 'node:path';
16
16
  import { SupercodeHarnessClient, type NormalizedMessage, type SessionActivity, type SessionDescriptor, type SessionLocator } from '@volter-ai-dev/supercode-harness-sdk';
17
17
  import { ROADMAP_SCHEMA, type RoadmapItem } from './sdk/roadmap.ts';
18
18
  import { OpenAutonomy, type Session, type Turn } from './sdk/client.ts';
@@ -54,8 +54,8 @@ function readConfig(path: string): Config {
54
54
  return {
55
55
  account: top.account ?? '', platform: (top.platform ?? 'https://open-autonomy.org').replace(/\/$/, ''),
56
56
  publish: { runs: (publish.runs ?? 'true') !== 'false', chats: (publish.chats ?? 'false') === 'true', private: priv },
57
- hermes_home: top.hermes_home ?? process.env.HERMES_HOME ?? '/opt/data',
58
- state_file: top.state_file ?? resolve(import.meta.dir, 'reporter-state.json'),
57
+ hermes_home: top.hermes_home ?? process.env.HERMES_HOME ?? '',
58
+ state_file: resolve(dirname(configPath), top.state_file ?? 'reporter-state.json'),
59
59
  };
60
60
  }
61
61
 
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bun
2
+ // Start the agent. The same processes wherever it runs; only how this script is started differs:
3
+ // on your machine `bun .open-autonomy/start.ts` — everything as you, no isolation, for development
4
+ // in a container the image's entrypoint, as root with the secrets mounted for root alone and `--as <user>`:
5
+ // the gateway and the reporter run as that user and can reach no key (container/README.md)
6
+ //
7
+ // bun .open-autonomy/start.ts [--home <dir>] [--secrets <dir>] [--project <dir>] [--origin <url>] [--as <user>]
8
+ //
9
+ // The processes, in order:
10
+ // ssh-agent holds <secrets>/deploy_key, its socket at <home>/ssh-agent.sock; the gateway pushes through it
11
+ // and never holds the key (absent: pushes are your own git's business)
12
+ // the clone <project> cloned from --origin when it is not a checkout yet (a container's first boot)
13
+ // the home hermes/ in the checkout copied into <home> before every start — the repository is the source of
14
+ // truth for what the agent IS; the home keeps what it has since done (its .env is kept)
15
+ // valve <secrets>/agent.env on :8787 (the developer's key), <secrets>/treasurer.env on :8788 (the
16
+ // treasurer's, the only one that pays) — the agent's .env names the valve and the word `valve`
17
+ // reporter keyless, publishing the home's sessions and board through the valve
18
+ // gateway `hermes gateway run` in the checkout, HERMES_HOME=<home>
19
+ // When any of them ends, all of them end and this exits 1: the supervisor outside (you, launchd, Docker) restarts.
20
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { homedir, userInfo } from 'node:os';
22
+ import { basename, resolve } from 'node:path';
23
+
24
+ const argv = process.argv.slice(2);
25
+ const arg = (name: string): string | undefined => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
26
+ const project = resolve(arg('--project') ?? resolve(import.meta.dir, '..'));
27
+ const home = resolve(arg('--home') ?? process.env.AGENT_HOME ?? resolve(homedir(), '.local', 'state', 'open-autonomy', basename(project), 'home'));
28
+ const secrets = resolve(arg('--secrets') ?? process.env.AGENT_SECRETS ?? resolve(homedir(), '.config', 'open-autonomy'));
29
+ const origin = arg('--origin') ?? process.env.ORIGIN;
30
+ const as = arg('--as');
31
+ const say = (m: string) => console.log(`start: ${m}`);
32
+ const sock = resolve(home, 'ssh-agent.sock');
33
+
34
+ // Who the agent's processes run as: you, or with --as the named user (root drops to it; the secrets stay root's).
35
+ const user = as ? (() => { const r = Bun.spawnSync({ cmd: ['id', '-u', as], stdout: 'pipe', stderr: 'pipe' }); const g = Bun.spawnSync({ cmd: ['id', '-g', as], stdout: 'pipe' }); if (r.exitCode !== 0) throw new Error(`start: no such user ${as}`); return { name: as, uid: Number(r.stdout.toString().trim()), gid: Number(g.stdout.toString().trim()) }; })() : null;
36
+ const drop = (cmd: string[]): string[] => (user ? ['setpriv', `--reuid=${user.uid}`, `--regid=${user.gid}`, '--clear-groups', ...cmd] : cmd);
37
+ const own = (path: string) => { if (user) Bun.spawnSync({ cmd: ['chown', '-R', `${user.uid}:${user.gid}`, path] }); };
38
+ const agentEnv = (): Record<string, string> => ({ ...process.env as Record<string, string>, HERMES_HOME: home, ...(user ? { HOME: home, USER: user.name, LOGNAME: user.name } : {}), ...(existsSync(sock) ? { SSH_AUTH_SOCK: sock } : {}), GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o StrictHostKeyChecking=accept-new' });
39
+
40
+ const children: Array<{ name: string; proc: ReturnType<typeof Bun.spawn> }> = [];
41
+ let ending = false;
42
+ function spawn(name: string, cmd: string[], opts: { cwd?: string; env?: Record<string, string>; asAgent?: boolean }) {
43
+ const proc = Bun.spawn({ cmd: opts.asAgent ? drop(cmd) : cmd, cwd: opts.cwd ?? project, env: opts.env ?? (process.env as Record<string, string>), stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' });
44
+ children.push({ name, proc });
45
+ proc.exited.then((code) => { if (ending) return; ending = true; say(`${name} ended (${code}); stopping the rest`); for (const c of children) if (c.proc !== proc) c.proc.kill(); setTimeout(() => process.exit(1), 500); });
46
+ return proc;
47
+ }
48
+ for (const sig of ['SIGTERM', 'SIGINT'] as const) process.on(sig, () => { ending = true; for (const c of children) c.proc.kill(); setTimeout(() => process.exit(0), 300); });
49
+
50
+ mkdirSync(home, { recursive: true });
51
+ own(home);
52
+
53
+ // 1. ssh-agent, as the agent (an agent only answers its own uid, or root): the key is added by us, from a file
54
+ // the agent cannot read, and lives in the agent's memory alone.
55
+ const deployKey = resolve(secrets, 'deploy_key');
56
+ if (existsSync(deployKey)) {
57
+ rmSync(sock, { force: true });
58
+ spawn('ssh-agent', ['ssh-agent', '-D', '-a', sock], { asAgent: true, cwd: home });
59
+ const t0 = Date.now(); while (!existsSync(sock) && Date.now() - t0 < 5000) Bun.sleepSync(50);
60
+ const add = Bun.spawnSync({ cmd: ['ssh-add', '-q', deployKey], env: { ...process.env, SSH_AUTH_SOCK: sock }, stdout: 'pipe', stderr: 'pipe' });
61
+ if (add.exitCode !== 0) { console.error(`start: ssh-add ${deployKey}: ${add.stderr.toString().trim()}`); process.exit(1); }
62
+ say(`ssh-agent holds the deploy key at ${sock}`);
63
+ } else say(`no ${deployKey}: pushes use your own git and keys`);
64
+
65
+ // 2. The checkout.
66
+ if (!existsSync(resolve(project, '.git'))) {
67
+ if (!origin) { console.error(`start: ${project} is not a checkout and no --origin to clone`); process.exit(1); }
68
+ mkdirSync(project, { recursive: true }); own(project);
69
+ const clone = Bun.spawnSync({ cmd: drop(['git', 'clone', '-q', origin, project]), env: agentEnv(), stdout: 'inherit', stderr: 'inherit' });
70
+ if (clone.exitCode !== 0) { console.error(`start: cannot clone ${origin}`); process.exit(1); }
71
+ say(`cloned ${origin} → ${project}`);
72
+ }
73
+
74
+ // 3. The home, from the checkout: everything under hermes/ except its .env, which is the home's own.
75
+ const committed = resolve(project, 'hermes');
76
+ if (existsSync(committed)) cpSync(committed, home, { recursive: true, filter: (src) => basename(src) !== '.env' });
77
+ const envFile = resolve(home, '.env');
78
+ if (!existsSync(envFile)) {
79
+ const lines = ['OPEN_AUTONOMY_BASE_URL=http://127.0.0.1:8787/v1', 'OPEN_AUTONOMY_KEY=valve'];
80
+ for (const k of ['DISCORD_BOT_TOKEN', 'DISCORD_HOME_CHANNEL']) if (process.env[k]) lines.push(`${k}=${process.env[k]}`);
81
+ writeFileSync(envFile, `${lines.join('\n')}\n`);
82
+ }
83
+ own(home);
84
+ say(`home ${home} synced from ${committed}`);
85
+
86
+ // 4. The valve: one key file per port; a missing developer's key is the one thing that stops the start.
87
+ const keys: string[] = [];
88
+ if (existsSync(resolve(secrets, 'agent.env'))) keys.push('--key', `${resolve(secrets, 'agent.env')}:8787`);
89
+ if (existsSync(resolve(secrets, 'treasurer.env'))) keys.push('--key', `${resolve(secrets, 'treasurer.env')}:8788`);
90
+ if (!keys.length) { console.error(`start: no ${resolve(secrets, 'agent.env')} — mint the developer's key: bun .open-autonomy/mint-key.ts`); process.exit(1); }
91
+ if (user) {
92
+ // The whole point of --as: the agent's user must not be able to read a key.
93
+ const peek = Bun.spawnSync({ cmd: drop(['cat', resolve(secrets, 'agent.env')]), stdout: 'pipe', stderr: 'pipe' });
94
+ if (peek.exitCode === 0) { console.error(`start: ${resolve(secrets, 'agent.env')} is readable by ${user.name}; the secrets must belong to root alone`); process.exit(1); }
95
+ }
96
+ // The valve and the reporter run from this script's own directory (its node_modules, its vendored SDK): in a
97
+ // container that is the image's copy, and the checkout only has to be the project.
98
+ spawn('valve', ['bun', resolve(import.meta.dir, 'sdk', 'valve.ts'), ...keys], {});
99
+
100
+ // 5. The reporter and the gateway, as the agent.
101
+ const env = agentEnv();
102
+ spawn('reporter', ['bun', resolve(import.meta.dir, 'reporter.ts'), '--config', resolve(project, '.open-autonomy', 'config.yaml')], { asAgent: true, env: { ...env, OPEN_AUTONOMY_BASE_URL: 'http://127.0.0.1:8787/v1' } });
103
+ spawn('gateway', ['hermes', 'gateway', 'run'], { asAgent: true, env });
104
+ say(`gateway up in ${project} as ${user?.name ?? userInfo().username}, home ${home}; the valve on :8787${keys.length > 2 ? ' and :8788' : ''}`);
105
+ if (!readFileSync(resolve(project, '.open-autonomy', 'config.yaml'), 'utf8').includes('account:')) say('warning: .open-autonomy/config.yaml names no account');
106
+ await new Promise(() => {});
@@ -1,7 +1,7 @@
1
1
  # __PROJECT__ — rules for the agent working this repository
2
2
 
3
3
  - **What this is.** __PROJECT__, a project that builds itself through Open Autonomy. `CONSTITUTION.md` is what it is and must remain. The board is what you build next, in order. `CONTRIBUTING.md` is how code is written here. `hermes/` is you.
4
- - **Checks.** `bun run check` from the repository root is the project's definition of green. It must pass before every push.
4
+ - **Checks.** `bun run check` from the repository root is the project's definition of green, in under thirty seconds. It must pass before every push. Behavior is verified by running the system, not by tests written for the occasion.
5
5
  - **Verify.** State here where the project is verified: its check, and any local or twinned surface. You cannot reach production and must not try. Where an acceptance line names a surface, exercise the surface.
6
6
  - **Git.** You cannot push to `main` and must not try. Work on `agent/<task id>` off a fresh `origin/main`, commit small with the task id first in the subject, and push the branch; the landing workflow opens the pull request and it merges itself when the checks pass. Never rewrite history, never force-push.
7
7
  - **Secrets.** There are none for you to use: your model calls and your pushes are authorized outside your reach. Never read or print `.env` files or key material; your sessions are published live.
@@ -17,6 +17,8 @@ else it got right. Changing this file is the owner's act, never a task's.
17
17
  the platform and published, with what it was for.
18
18
  - **Done means true in the running system.** A task is done when its acceptance lines hold where the project is
19
19
  verified, not when code exists.
20
+ - **No automated test runs for more than thirty seconds.** All of them together, ever. Behavior is verified by
21
+ driving the running system.
20
22
 
21
23
  ## Out of scope
22
24
 
@@ -5,8 +5,9 @@ the constitution's invariants. Short on purpose; the reviewer reads it whole.
5
5
 
6
6
  - **Language and tooling.** TypeScript on Bun. `bun run check` is the definition of green and runs in seconds.
7
7
  - **Shape.** Small modules with one job each, named for what they hold. No layer that exists only to forward.
8
- - **Tests.** A test proves an acceptance line or guards a bug that happened. No tests for their own sake, no
9
- mocks of our own code, no fixtures larger than the thing they test.
8
+ - **Thirty seconds, total, forever.** `bun run check` is every test and typecheck there is, and it must finish in
9
+ under thirty seconds. A test guards an invariant of the constitution or it is not written; behavior is verified by
10
+ driving the running system. Test cruft compounds, because every agent that follows writes more of it.
10
11
  - **Errors.** Fail loudly with the cause in the message. No silent fallbacks.
11
12
  - **Docs.** A file's header says what it is for. The README says how to run it. Nothing else is documented twice.
12
13
  - **Dependencies.** Add one only when writing it would be more code than reading it. Pin what you add.
@@ -13,12 +13,13 @@ every session it works, every cent it spends and everything it ships is public.
13
13
  - `AGENTS.md` is the agent's rules for this repository; `hermes/` is the agent.
14
14
  - `.open-autonomy/` is the project's connection to the platform: its config, the reporter that publishes
15
15
  the agent's sessions, and the record of the kit that made this repository.
16
- - `container/` runs it: the agent, the key valve that holds the project's key, and the reporter.
16
+ - `.open-autonomy/start.ts` starts it: the valve that holds the project's keys, the reporter, the Hermes gateway;
17
+ `container/` runs the same script as a container's entrypoint, for a real setup.
17
18
 
18
19
  ```bash
19
20
  bun run check # the project's own definition of green
20
21
  bun .open-autonomy/mint-key.ts # prove control of this repository, get the project's key
21
- AGENT_SECRETS=~/.config/open-autonomy docker compose -f container/compose.yml up -d --build
22
+ bun .open-autonomy/start.ts # the agent, here, as you (container/README.md for the container)
22
23
  ```
23
24
 
24
25
  Made with the Open Autonomy Hermes kit; `create-open-autonomy check .` says whether the kit's files are current.
@@ -60,3 +60,4 @@ hermes/.skills_prompt_snapshot.json
60
60
 
61
61
  # The reporter's own cursor (which sessions it has published how far).
62
62
  .open-autonomy/reporter-state.json
63
+ .open-autonomy/bun.lock
@@ -1,7 +1,7 @@
1
- # The agent's container: stock Hermes at the pinned tag (container/hermes.pin, built by
2
- # container/build-hermes.sh) plus what a worker runs: git over ssh to push, bun for the
3
- # project's checks. It holds no secret: the key lives in the valve, the push key in an ssh-agent forwarded
4
- # from the host, and the only thing in its environment is an optional Discord token.
1
+ # The agent's container: stock Hermes at the pinned tag (container/hermes.pin, built by container/build-hermes.sh) plus
2
+ # bun and git over ssh, started by the same script that starts it on a laptop (.open-autonomy/start.ts), here as root
3
+ # with `--as hermes`: the secrets mounted at /secrets are root's, and the gateway runs as the image's `hermes` user
4
+ # and can reach no key. The checkout is cloned into /work/project on first boot; the home lives at /opt/data.
5
5
  ARG HERMES_IMAGE=hermes-agent:v2026.8.31
6
6
  FROM ${HERMES_IMAGE}
7
7
  USER root
@@ -10,4 +10,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssh-client
10
10
  ARG BUN_VERSION=1.3.10
11
11
  RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash -s "bun-v${BUN_VERSION}" \
12
12
  && ln -sf /usr/local/bin/bun /usr/local/bin/bunx && bun --version
13
- RUN mkdir -p /work
13
+ # The start script and the reporter's dependencies, from the image — the checkout arrives after the first boot.
14
+ COPY .open-autonomy /opt/agent/.open-autonomy
15
+ RUN cd /opt/agent/.open-autonomy && bun install --no-save && ./node_modules/.bin/supercode --version
16
+ RUN mkdir -p /work/project /opt/data && chown hermes:hermes /work/project /opt/data
17
+ ENV PATH="/opt/agent/.open-autonomy/node_modules/.bin:${PATH}"
18
+ ENTRYPOINT ["bun", "/opt/agent/.open-autonomy/start.ts", "--project", "/work/project", "--home", "/opt/data", "--secrets", "/secrets", "--as", "hermes"]
@@ -1,49 +1,40 @@
1
1
  # Running the agent
2
2
 
3
- Three containers in one VM, none holding a secret that matters:
4
-
5
- - **agent** stock Hermes at the pinned tag (`hermes.pin`), with the project checkout at `/work/project`
6
- and the home volume at `/opt/data`, seeded from `hermes/` and re-synced from the repository on every start.
7
- - **valve** holds the developer's key (spend + narrate). The agent's model calls and the reporter's narration
8
- go through it; key management and admin routes never do. It re-reads the key file when it changes, so a
9
- rotated key needs no restart.
10
- - **valve-pay** — holds the treasurer's key, the only one with the `pay` scope. The treasurer profile alone is
11
- pointed at it, so a purchase can only be made by the treasurer, within the owner's bounds.
12
- - **reporter** — keyless. Reads the agent's sessions through supercode and publishes them to the project's
13
- page through the valve, as they happen.
14
-
15
- Every session's turns are published, so the agent's environment holds nothing whose leak matters:
16
- its `.env` says `OPEN_AUTONOMY_KEY=valve`; pushes sign through an ssh-agent forwarded from the host holding
17
- one repository-scoped deploy key; delivery uses at most a Discord bot token, which can only post as the bot.
18
-
19
- ## The host
20
-
21
- `bun .open-autonomy/setup.ts` does the steps below, idempotently, and says what it cannot do and what to run
22
- next; the world's stack step calls the same file. By hand:
23
-
24
- - `~/.config/open-autonomy/agent.env` the developer's key, from `bun .open-autonomy/mint-key.ts`;
25
- `~/.config/open-autonomy/treasurer.env` the treasurer's, from `bun .open-autonomy/mint-key.ts --scopes
26
- spend,narrate,pay --out ~/.config/open-autonomy/treasurer.env`. Rotate it with
27
- `bun .open-autonomy/mint-key.ts --rotate`: the valve takes the new key from the file without a restart, and its
28
- `/healthz` (and its log, and the reporter's) say when the key expires; both warn inside fourteen days.
29
- - An ssh-agent holding only the deploy key, forwarded into the Docker host (on macOS with colima:
30
- `SSH_AUTH_SOCK=~/.config/open-autonomy/agent.sock colima start <profile> --ssh-agent`).
31
- - The pinned Hermes image: `sh container/build-hermes.sh` builds it from `hermes.pin`.
32
-
33
- Then the two volumes, once: `oa-home` from your `hermes/` (with a `.env` naming
34
- `OPEN_AUTONOMY_BASE_URL=http://valve:8787/v1`, `OPEN_AUTONOMY_KEY=valve`, and the Discord token if any) and
35
- `oa-repo`, a clone made with the deploy key. Then:
3
+ The agent is four processes: an ssh-agent holding the deploy key, the valve holding the project's keys (the
4
+ developer's on :8787, the treasurer's on :8788, each re-read when its file changes), the keyless reporter, and
5
+ the Hermes gateway. One script starts them, `.open-autonomy/start.ts`, and it is the only way they are started.
6
+
7
+ **On your machine**, for development: everything as you, no isolation.
8
+
9
+ ```bash
10
+ bun .open-autonomy/mint-key.ts # the developer's key ~/.config/open-autonomy/agent.env
11
+ bun .open-autonomy/mint-key.ts --scopes spend,narrate,pay --out ~/.config/open-autonomy/treasurer.env
12
+ bun .open-autonomy/start.ts # ssh-agent, valve, reporter, gateway; Ctrl-C ends all
13
+ HERMES_HOME=~/.local/state/open-autonomy/<project>/home hermes kanban list # the board, from another shell
14
+ ```
15
+
16
+ **In a container**, for a real setup: the same script is the image's entrypoint, run as root with the secrets
17
+ mounted for root alone; the gateway and the reporter run as the image's `hermes` user and can reach no key
18
+ (the script refuses to start if they could). Every session's turns are published, so the agent's environment
19
+ holds nothing whose leak matters: its `.env` says `OPEN_AUTONOMY_KEY=valve`; pushes sign through the
20
+ ssh-agent's socket; delivery uses at most a Discord bot token, which can only post as the bot.
21
+
22
+ - `~/.config/open-autonomy/agent.env` and `treasurer.env`: the keys, as above (rotate with `--rotate`; the
23
+ valve takes the new key from the file without a restart).
24
+ - `~/.config/open-autonomy/deploy_key`: a deploy key for this one repository, write access:
25
+ `ssh-keygen -t ed25519 -N '' -f ~/.config/open-autonomy/deploy_key` and
26
+ `gh repo deploy-key add ~/.config/open-autonomy/deploy_key.pub --allow-write`. The container clones through it
27
+ on first boot.
28
+ - The pinned Hermes image: `sh container/build-hermes.sh` builds it from `hermes.pin` (~10 minutes, once).
36
29
 
37
30
  ```bash
38
31
  AGENT_SECRETS=~/.config/open-autonomy docker compose -f container/compose.yml up -d --build
39
- docker exec -u $UID oa-agent hermes cron list # the schedule: the PM, hourly, seeded from hermes/cron/jobs.seed.json
40
- docker exec -u $UID oa-agent hermes kanban create 'A task' --body '- its acceptance line' --assignee default --workspace dir:/work/project --skill develop # file work
41
- docker exec -u $UID oa-agent hermes kanban list # the board: the task, its lane, its attempts
42
- docker logs -f oa-reporter # what is being published
32
+ docker logs -f oa-agent # the start's four processes
33
+ docker exec -u hermes oa-agent hermes kanban list # the board
34
+ docker exec -u hermes oa-agent hermes kanban create 'A task' --body '- its acceptance line' --assignee default --workspace dir:/work/project --skill develop
43
35
  ```
44
36
 
45
- Several stacks on one Docker host, two projects or a project beside a world's copy of it: give each a name,
46
- `bun .open-autonomy/setup.ts --stack <name>` and `STACK=<name> docker compose -p <name> …`; the containers and
47
- volumes carry it (`<name>-agent`, `<name>-home`). The default is `oa`.
37
+ Several stacks on one Docker host: `STACK=<name> docker compose -p <name> …`; the container and volumes carry
38
+ the name (`<name>-agent`, `<name>-home`, `<name>-repo`). The default is `oa`.
48
39
 
49
- The kit owns this directory; `create-open-autonomy upgrade .` brings it forward.
40
+ The kit owns this directory and `.open-autonomy/start.ts`; `create-open-autonomy upgrade .` brings them forward.
@@ -1,53 +1,14 @@
1
- # The project's agent, as it runs: three containers in one VM, none holding a secret that matters.
2
- # agent stock Hermes (the gateway: the schedule, and Discord when configured) with the project checkout;
3
- # two profiles, the developer and the treasurer
4
- # valve holds the developer's key (spend + narrate): the model routes and the narration routes
5
- # valve-pay holds the treasurer's key (spend + narrate + pay): the same routes and the rails; only the
6
- # treasurer profile is pointed at it
7
- # reporter keyless; reads the agent's sessions through supercode and publishes them through the valve
8
- # The home and the checkout live in Docker volumes: SQLite over a host bind mount is a bus error waiting
9
- # to happen, and nothing on the host needs them. Inspect with `docker exec`.
1
+ # The agent as a real setup runs it: one container, started by the same script a laptop uses
2
+ # (.open-autonomy/start.ts), as root with the secrets mounted for root alone; the gateway inside runs as the
3
+ # image's `hermes` user and can reach no key. The home and the checkout live in Docker volumes (SQLite over a
4
+ # bind mount is a bus error waiting to happen); inspect them with `docker exec`.
10
5
  #
11
6
  # AGENT_SECRETS=~/.config/open-autonomy docker compose -f container/compose.yml up -d --build
7
+ #
12
8
  # Several stacks on one Docker host (two projects, or a project beside a world's copy of it): each has its own
13
- # STACK name, which the containers and volumes carry, and its own compose project: `STACK=<name> docker compose
9
+ # STACK name, which the container and volumes carry, and its own compose project: `STACK=<name> docker compose
14
10
  # -p <name> …`. The default is `oa`.
15
11
  services:
16
- valve:
17
- build:
18
- context: ..
19
- dockerfile: container/Dockerfile.valve
20
- image: __PROJECT__-valve:local
21
- container_name: ${STACK:-oa}-valve
22
- restart: unless-stopped
23
- environment:
24
- - AGENT_ENV_FILE=/secrets/agent.env
25
- volumes:
26
- - ${AGENT_SECRETS:-~/.config/open-autonomy}/agent.env:/secrets/agent.env:ro
27
- networks: [agent]
28
- valve-pay:
29
- build:
30
- context: ..
31
- dockerfile: container/Dockerfile.valve
32
- image: __PROJECT__-valve:local
33
- container_name: ${STACK:-oa}-valve-pay
34
- restart: unless-stopped
35
- environment:
36
- - AGENT_ENV_FILE=/secrets/treasurer.env
37
- volumes:
38
- - ${AGENT_SECRETS:-~/.config/open-autonomy}/treasurer.env:/secrets/treasurer.env:ro
39
- networks: [agent]
40
- # The committed home (SOUL, skills, config, the schedule seed) into the volume before the gateway starts:
41
- # hermes/ in the repository is the source of truth for what the agent IS; the volume only holds what the
42
- # agent has since done.
43
- home-sync:
44
- image: alpine:3
45
- container_name: ${STACK:-oa}-home-sync
46
- restart: "no"
47
- command: ["sh", "-c", "cd /repo/hermes && find . -type f ! -name '.env' -exec cp -a --parents {} /opt/data/ \\; && chown -R ${AGENT_UID:-501}:${AGENT_GID:-20} /opt/data && echo 'home synced from the repository'"]
48
- volumes:
49
- - oa-home:/opt/data
50
- - oa-repo:/repo:ro
51
12
  agent:
52
13
  build:
53
14
  context: ..
@@ -57,46 +18,16 @@ services:
57
18
  image: __PROJECT__-agent:local
58
19
  container_name: ${STACK:-oa}-agent
59
20
  restart: unless-stopped
60
- depends_on:
61
- valve: { condition: service_started }
62
- home-sync: { condition: service_completed_successfully }
63
- command: ["gateway", "run"]
64
- environment:
65
- # The host user's ids: the forwarded ssh-agent socket is theirs.
66
- - HERMES_UID=${AGENT_UID:-501}
67
- - HERMES_GID=${AGENT_GID:-20}
68
- - SSH_AUTH_SOCK=/ssh-agent
69
- - GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=accept-new
70
- volumes:
71
- - oa-home:/opt/data
72
- - oa-repo:/work/project
73
- - /run/host-services/ssh-auth.sock:/ssh-agent
74
- networks: [agent]
75
- reporter:
76
- build:
77
- context: ..
78
- dockerfile: container/Dockerfile.reporter
79
- image: __PROJECT__-reporter:local
80
- container_name: ${STACK:-oa}-reporter
81
- restart: unless-stopped
82
- # The agent's own ids: the home it reads and the checkout it keeps its cursor in are the agent's.
83
- user: "${AGENT_UID:-501}:${AGENT_GID:-20}"
84
- depends_on:
85
- valve: { condition: service_started }
86
- agent: { condition: service_started }
87
21
  environment:
88
- - OPEN_AUTONOMY_BASE_URL=http://valve:8787/v1
89
- - HERMES_HOME=/opt/data
22
+ - ORIGIN=${ORIGIN:-git@github.com:__ACCOUNT__.git} # cloned on first boot, through the deploy key
23
+ - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
24
+ - DISCORD_HOME_CHANNEL=${DISCORD_HOME_CHANNEL:-}
90
25
  volumes:
91
- - oa-home:/opt/data:ro
92
- - oa-repo:/work/project
93
- networks: [agent]
26
+ - ${AGENT_SECRETS:-~/.config/open-autonomy}:/secrets:ro
27
+ - home:/opt/data
28
+ - repo:/work/project
94
29
  volumes:
95
- oa-home:
30
+ home:
96
31
  name: ${STACK:-oa}-home
97
- external: true # seeded once from hermes/ (container/README.md)
98
- oa-repo:
32
+ repo:
99
33
  name: ${STACK:-oa}-repo
100
- external: true # the agent's clone
101
- networks:
102
- agent: {}
@@ -12,7 +12,8 @@ to this project's account and paid for by its patrons. The board is the roadmap:
12
12
  (`hermes kanban create`), the gateway's dispatcher pulls them down in order and runs each as a worker session
13
13
  that builds it and lands it on an `agent/<task id>` branch, the review lane (Hermes's own) verifies the handoff
14
14
  against `CONSTITUTION.md` and `CONTRIBUTING.md` in a session of its own, and once an hour the PM job reads the whole board and unsticks what is
15
- stuck. The reporter beside it (`.open-autonomy/reporter.ts`) publishes the board, every session, the agent's setup,
15
+ stuck. Beside it the valve holds the keys, an ssh-agent holds the deploy key, and the reporter
16
+ (`.open-autonomy/reporter.ts`) publishes the board, every session, the agent's setup,
16
17
  and the project's documents (`CONSTITUTION.md` as what it is, `CHANGELOG.md` as what shipped) to the project's
17
18
  page as they happen. The platform reads none of these files itself.
18
19
 
@@ -1,6 +1,6 @@
1
1
  You are this project's agent: the checked-in Hermes agent that builds the project you run in, month after month, on a token budget its sponsors fund through Open Autonomy. Your home is the repository you run in. Everything you are is readable there: this file, your two skills, your one scheduled job.
2
2
 
3
- The board is the roadmap. The owner files tasks on it; your dispatcher pulls them down in order and runs each as a worker session (the develop skill), the review lane verifies every handoff, and once an hour you look at the whole board and unstick what is stuck (the pm skill). When you review, the bar is two documents: `CONSTITUTION.md`, whose invariants no change may violate and whose out-of-scope no change may enter, and `CONTRIBUTING.md`, which the diff is held to. Every acceptance line made true and verified, and nothing in the diff that no line asked for, tests included. Approve in one paragraph naming what you checked; otherwise send it back naming each failing line. You finish things: a task is done when its acceptance lines are true in the running system, not when code exists. You never invent tasks; filing is the owner's job.
3
+ The board is the roadmap. The owner files tasks on it; your dispatcher pulls them down in order and runs each as a worker session (the develop skill), the review lane verifies every handoff, and once an hour you look at the whole board and unstick what is stuck (the pm skill). When you review, the bar is two documents: `CONSTITUTION.md`, whose invariants no change may violate and whose out-of-scope no change may enter, and `CONTRIBUTING.md`, which the diff is held to. Every acceptance line made true and verified by running the system, nothing in the diff that no line asked for, and no test that guards nothing in the constitution: test cruft is the one debt that compounds. Approve in one paragraph naming what you checked; otherwise send it back naming each failing line. You finish things: a task is done when its acceptance lines are true in the running system, not when code exists. You never invent tasks; filing is the owner's job.
4
4
 
5
5
  You spend sponsors' money. Every model call you make is metered to this project's account and shown in public. Be economical: read before you write, run the check once, and stop when the work is verified. Do not loop on a failure you cannot explain; say what you found and block the task with what is missing.
6
6
 
@@ -5,8 +5,7 @@
5
5
  "prompt": "You are this project's PM for the hour. Run the pm skill: read the board, unstick what is stuck, report.",
6
6
  "schedule": "every 60m",
7
7
  "skills": ["pm"],
8
- "deliver": "discord",
9
- "workdir": "/work/project"
8
+ "deliver": "discord"
10
9
  }
11
10
  ]
12
11
  }
@@ -108,15 +108,17 @@ def _seed_jobs() -> list:
108
108
  # ---- the board -------------------------------------------------------------------------------------------------
109
109
 
110
110
  def _seed_tasks() -> tuple:
111
+ import os
112
+ # Every seed task works in the project checkout: the directory the gateway runs in.
113
+ workspace = f"dir:{os.getcwd()}"
111
114
  seed_file = _hermes_home() / "kanban.seed.json"
112
115
  if not seed_file.exists():
113
- return "dir:/work/project", []
116
+ return workspace, []
114
117
  try:
115
118
  data = json.loads(seed_file.read_text(encoding="utf-8"))
116
119
  except (OSError, ValueError) as e:
117
120
  logger.error("seed: failed to read %s: %s", seed_file, e)
118
- return "dir:/work/project", []
119
- workspace = str(data.get("workspace") or "dir:/work/project")
121
+ return workspace, []
120
122
  tasks = [t for t in data.get("tasks", []) if isinstance(t, dict) and t.get("key") and t.get("title")]
121
123
  return workspace, tasks
122
124
 
@@ -160,6 +162,7 @@ def _seed_board() -> None:
160
162
 
161
163
 
162
164
  async def handle(event_type: str, context: dict) -> None:
165
+ import os
163
166
  _seed_board()
164
167
  try:
165
168
  from cron.jobs import create_job, load_jobs, update_job
@@ -205,7 +208,7 @@ async def handle(event_type: str, context: dict) -> None:
205
208
  deliver=_deliver_target(name, spec.get("deliver")),
206
209
  skills=spec.get("skills") or None,
207
210
  skill=spec.get("skill"),
208
- workdir=spec.get("workdir"),
211
+ workdir=os.getcwd(),
209
212
  script=spec.get("script"),
210
213
  no_agent=bool(spec.get("no_agent")),
211
214
  model=None if spec.get("no_agent") else model,
@@ -1,5 +1,4 @@
1
1
  {
2
- "workspace": "dir:/work/project",
3
2
  "tasks": [
4
3
  {
5
4
  "key": "hello",
@@ -4,10 +4,10 @@ For each request, in order:
4
4
 
5
5
  1. `kanban_show` it and read the owner's bounds in `.open-autonomy/config.yaml` (`rails:`). A request over the bound, or at a merchant outside the owner's categories, is refused: `kanban_block` it with the reason, and never widen a bound; that is the owner's commit.
6
6
  2. Mint the card through your valve, naming the developer's task so the purchase shows on its page:
7
- `curl -sf -X POST http://valve-pay:8787/v1/rails/card -H 'authorization: Bearer valve' -H 'content-type: application/json' -d '{"usd_cents": <ceiling>, "purpose": "<why>", "item": "<the developer's task id>"}'`.
7
+ `curl -sf -X POST http://127.0.0.1:8788/v1/rails/card -H 'authorization: Bearer valve' -H 'content-type: application/json' -d '{"usd_cents": <ceiling>, "purpose": "<why>", "item": "<the developer's task id>"}'`.
8
8
  The answer carries the card. It is single-use, bounded to that amount and the owner's categories, and retires on capture.
9
9
  3. Pay the merchant yourself, the way the request says. The card's number goes into the merchant's checkout and nowhere else: never into a comment, a file, a commit or a message.
10
- 4. Record the receipt on the developer's task and release it: `HERMES_HOME=/opt/data /opt/hermes/bin/hermes kanban comment <developer task> "RECEIPT: <what> at <merchant>, $<amount> on card ····<last4>"`, then `HERMES_HOME=/opt/data /opt/hermes/bin/hermes kanban unblock <developer task>` (the terminal's shell has neither the home nor the binary on its path; both are named in full).
10
+ 4. Record the receipt on the developer's task and release it: `hermes kanban comment <developer task> "RECEIPT: <what> at <merchant>, $<amount> on card ····<last4>"`, then `hermes kanban unblock <developer task>` (the terminal's shell has neither the home nor the binary on its path; both are named in full).
11
11
  5. `kanban_complete` your task with the receipt in one line.
12
12
 
13
13
  You never write code, never touch the developer's branch, and never buy what no request asked for. Every cent you spend is on the project's public books, under the task it served.
@@ -6,7 +6,7 @@
6
6
  model:
7
7
  default: zai/glm-5.3-flash
8
8
  provider: custom
9
- base_url: http://valve-pay:8787/v1 # the treasurer's valve: the only key that pays
9
+ base_url: http://127.0.0.1:8788/v1 # the valve's second port: the treasurer's key, the only one that pays
10
10
  api_key: valve
11
11
  api_mode: chat_completions
12
12
 
@@ -22,11 +22,13 @@ is not done.
22
22
  2. Read `CONSTITUTION.md` (what the project is and must remain: a task that would break an invariant or enter
23
23
  what is out of scope is blocked, not built), `CONTRIBUTING.md` (how code is written here) and `AGENTS.md`.
24
24
  Read the code an acceptance line touches before you write.
25
- 3. Build it. Match `CONTRIBUTING.md`. Write a test only where an acceptance line names one, or where the project's
26
- check would otherwise not cover the line; never tests for their own sake.
27
- 4. Verify every acceptance line where `AGENTS.md` says the project is verified: in the project's own twin world
28
- when it keeps one (`world/`), as its operator, driving the real surface; otherwise the project's check. You
29
- cannot reach production and must not try. Run the check once, green, before every push.
25
+ 3. Build it. Match `CONTRIBUTING.md`. Write no test unless the acceptance line guards an invariant of the
26
+ constitution and `bun run check` stays under thirty seconds with it; behavior is verified by running the system,
27
+ never by a test written for the occasion.
28
+ 4. Verify every acceptance line by driving the running system where `AGENTS.md` says it is verified: in the
29
+ project's own twin world when it keeps one (`world/`), as its operator, one action at a time, reading what
30
+ comes back; otherwise by running the program itself. You cannot reach production and must not try. Run
31
+ `bun run check` once, green, before every push.
30
32
  5. Commit small, signed as the agent, the task id first in the subject:
31
33
  `git commit -s --author="Open Autonomy agent <agent@open-autonomy.org>" -m "<task id>: <what changed>"`.
32
34
  6. Push the branch: `git push -u origin agent/<task id>`. The landing workflow opens the pull request and merges
@@ -44,7 +46,7 @@ You cannot pay: your key reaches the model, not the rails. The treasurer, a seco
44
46
  holds the only key that can. Ask it the way a company does, once, and wait:
45
47
 
46
48
  1. File the request, the one task you may create:
47
- `kanban_create` with `assignee: "treasurer"`, `workspace_kind: "dir"`, `workspace_path: "/work/project"` (so the
49
+ `kanban_create` with `assignee: "treasurer"`, `workspace_kind: "dir"`, `workspace_path: <the project checkout, your working directory>` (so the
48
50
  treasurer reads the owner's bounds), title `Purchase: <what> at <merchant>, at most $<ceiling>`, and a body of
49
51
  `- ` lines: what, merchant (and its category), at most N cents, purpose, `for task: <your task id>`,
50
52
  and how to pay (the merchant's checkout: a URL, a command). Stay within the owner's bounds in
@@ -61,7 +63,7 @@ The card never passes through you. Every purchase lands on the public audit trai
61
63
 
62
64
  The board's tools (`kanban_show`, `kanban_create`, `kanban_block`, `kanban_request_review`) are yours in the
63
65
  conversation. From the terminal the CLI needs its home and its path named in full:
64
- `HERMES_HOME=/opt/data /opt/hermes/bin/hermes kanban …`.
66
+ `hermes kanban …` (HERMES_HOME is set in your environment).
65
67
 
66
68
  ## Cost
67
69
 
@@ -23,7 +23,7 @@ as a worker session, and the review lane verifies every handoff. Your hour is ab
23
23
  restarts a worker that will only block again, and the board escalates repeated blocks into decomposition.
24
24
  - `running` with no heartbeat for over an hour, or `review` with no reviewer for over an hour: `hermes kanban
25
25
  unblock <id>` returns it to ready.
26
- The CLI from your shell needs its home and path in full: `HERMES_HOME=/opt/data /opt/hermes/bin/hermes kanban …`.
26
+ From your shell the CLI is `hermes kanban …` (HERMES_HOME is set in your environment).
27
27
  3. Report, in one paragraph, where the job says: what was done since the last hour, what is in progress, what is
28
28
  stuck and why, and what the owner must decide. When the board is empty, say so; nothing else to do.
29
29
 
@@ -3,6 +3,6 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "scripts": {
6
- "check": "bun test"
6
+ "check": "bunx tsc --noEmit"
7
7
  }
8
8
  }
@@ -0,0 +1,2 @@
1
+ // The project starts here. Its acceptance lines say what it becomes; `bun run check` typechecks what exists.
2
+ export {};
@@ -0,0 +1,4 @@
1
+ {
2
+ "compilerOptions": { "strict": true, "noEmit": true, "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "skipLibCheck": true },
3
+ "include": ["src/**/*.ts"]
4
+ }
@@ -1,18 +0,0 @@
1
- name: CI
2
- # Every change lands from a branch (agent/** the agent's, land/** a maintainer's): the push checks the head
3
- # commit, which is what branch protection and auto-merge require.
4
- on:
5
- push:
6
- branches: [main, 'agent/**', 'land/**']
7
- workflow_dispatch:
8
- permissions:
9
- contents: read
10
- jobs:
11
- ci:
12
- runs-on: ubuntu-latest
13
- steps:
14
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
15
- - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
16
- with:
17
- bun-version: 1.3.10
18
- - run: bun run check
@@ -1,94 +0,0 @@
1
- #!/usr/bin/env bun
2
- // The host, set up by one command, idempotently: what container/README.md asks of the owner before
3
- // `docker compose up`, done or found done, and what it cannot do said plainly. Safe to run again.
4
- //
5
- // bun .open-autonomy/setup.ts [--context <docker context>] [--secrets <dir>] [--origin <url>] [--stack <name>]
6
- // [--origin-in-container <url>] [--env KEY=VALUE ...] [--uid N --gid N] [--fresh]
7
- //
8
- // 1. the key files <secrets>/agent.env (the developer's: spend + narrate) and <secrets>/treasurer.env (the
9
- // treasurer's: spend + narrate + pay), default ~/.config/open-autonomy, from mint-key.ts — found or named
10
- // 2. the image hermes-agent:<tag> from container/hermes.pin — present, copied from another Docker host
11
- // that has it, or built (container/build-hermes.sh, ~10 minutes)
12
- // 3. the volumes <stack>-home from hermes/ (its .env: the valve's address, the dummy key, every --env), oa-repo
13
- // a clone of --origin (default: this repository's origin, cloned with your own git and
14
- // keys); --fresh recreates both
15
- // 4. what is yours the deploy key and the ssh-agent that forwards it, the Discord token; then compose up
16
- //
17
- // The world's stack step calls this same file, so what an adopter runs and what the world proves never drift.
18
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
19
- import { homedir } from 'node:os';
20
- import { dirname, join, resolve } from 'node:path';
21
-
22
- const here = resolve(import.meta.dir, '..');
23
- const argv = process.argv.slice(2);
24
- const arg = (name: string): string | undefined => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
25
- const args = (name: string): string[] => argv.flatMap((a, i) => (a === name && argv[i + 1] ? [argv[i + 1]] : []));
26
- const context = arg('--context') ?? process.env.DOCKER_CONTEXT;
27
- const secrets = resolve(arg('--secrets') ?? join(homedir(), '.config', 'open-autonomy'));
28
- const uid = arg('--uid') ?? String(process.getuid?.() ?? 501);
29
- const gid = arg('--gid') ?? String(process.getgid?.() ?? 20);
30
- const fresh = argv.includes('--fresh');
31
- // The stack's name on a Docker host shared with others (the containers and volumes carry it); `oa` alone otherwise.
32
- const stack = arg('--stack') ?? 'oa';
33
- const HOME_VOL = `${stack}-home`; const REPO_VOL = `${stack}-repo`;
34
- const docker = ['docker', ...(context ? ['--context', context] : [])];
35
- const say = (m: string) => console.log(`setup: ${m}`);
36
- const run = (cmd: string[], opts: { quiet?: boolean; check?: boolean; env?: Record<string, string>; cwd?: string } = {}) => {
37
- const r = Bun.spawnSync({ cmd, cwd: opts.cwd ?? here, stdout: 'pipe', stderr: opts.quiet ? 'pipe' : 'inherit', env: { ...process.env, ...opts.env } });
38
- if (opts.check !== false && r.exitCode !== 0) throw new Error(`${cmd.slice(0, 3).join(' ')} … failed (${r.exitCode})${opts.quiet ? `\n${r.stderr.toString().slice(-600)}` : ''}`);
39
- return { code: r.exitCode, out: r.stdout.toString() };
40
- };
41
- const todo: string[] = [];
42
-
43
- // 1. The key file.
44
- const keyFile = join(secrets, 'agent.env');
45
- const token = existsSync(keyFile) ? /^OPEN_AUTONOMY_KEY=(.+)$/m.exec(readFileSync(keyFile, 'utf8'))?.[1] : undefined;
46
- if (token) {
47
- try { const c = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')) as { kid?: string; exp?: string }; say(`key: ${c.kid} in ${keyFile}, expires ${c.exp}`); } catch { say(`key: present in ${keyFile}`); }
48
- } else {
49
- say(`no key in ${keyFile}`);
50
- todo.push(`mint the developer's key: bun .open-autonomy/mint-key.ts${secrets === join(homedir(), '.config', 'open-autonomy') ? '' : ` --out ${keyFile}`}`);
51
- }
52
- const payFile = join(secrets, 'treasurer.env');
53
- if (existsSync(payFile) && /^OPEN_AUTONOMY_KEY=/m.test(readFileSync(payFile, 'utf8'))) say(`treasurer's key: in ${payFile}`);
54
- else { say(`no treasurer's key in ${payFile}`); todo.push(`mint the treasurer's key (the only one that pays): bun .open-autonomy/mint-key.ts --scopes spend,narrate,pay --out ${payFile}`); }
55
-
56
- // 2. The image.
57
- const pin = Object.fromEntries(readFileSync(join(here, 'container', 'hermes.pin'), 'utf8').split('\n').map((l) => l.trim().split('=') as [string, string]).filter(([k]) => k && !k.startsWith('#')));
58
- const image = `hermes-agent:${pin.HERMES_TAG}`;
59
- if (run([...docker, 'image', 'inspect', image], { quiet: true, check: false }).code === 0) say(`image: ${image} present`);
60
- else {
61
- const other = run(['docker', 'context', 'ls', '-q'], { quiet: true }).out.split('\n').map((c) => c.trim()).filter((c) => c && c !== context)
62
- .find((c) => run(['docker', '--context', c, 'image', 'inspect', image], { quiet: true, check: false }).code === 0);
63
- if (other) { say(`image: copying ${image} from Docker host ${other}`); run(['sh', '-c', `docker --context '${other}' save '${image}' | ${docker.join(' ')} load`]); }
64
- else { say(`image: building ${image} (container/build-hermes.sh, ~10 minutes)`); run(['sh', join(here, 'container', 'build-hermes.sh')], { env: context ? { DOCKER_CONTEXT: context } : {} }); }
65
- }
66
-
67
- // 3. The volumes.
68
- const have = (v: string) => run([...docker, 'volume', 'inspect', v], { quiet: true, check: false }).code === 0;
69
- if (fresh) for (const v of [HOME_VOL, REPO_VOL]) run([...docker, 'volume', 'rm', '-f', v], { quiet: true, check: false });
70
- if (have(HOME_VOL) && have(REPO_VOL)) say(`volumes: ${HOME_VOL} and ${REPO_VOL} present (compose re-syncs the home from hermes/ on every start; --fresh recreates both)`);
71
- else {
72
- const origin = arg('--origin') ?? run(['git', 'remote', 'get-url', 'origin'], { quiet: true }).out.trim();
73
- const originInside = arg('--origin-in-container') ?? origin;
74
- for (const v of [HOME_VOL, REPO_VOL]) if (!have(v)) run([...docker, 'volume', 'create', v], { quiet: true });
75
- const env = [`OPEN_AUTONOMY_BASE_URL=http://valve:8787/v1`, `OPEN_AUTONOMY_KEY=valve`, ...args('--env')];
76
- run([...docker, 'run', '--rm', '-v', `${HOME_VOL}:/opt/data`, '-v', `${join(here, 'hermes')}:/src:ro`, 'alpine:3', 'sh', '-c',
77
- `cp -a /src/. /opt/data/ && printf '%s\\n' ${env.map((e) => `'${e.replace(/'/g, "'\\''")}'`).join(' ')} > /opt/data/.env && chown -R ${uid}:${gid} /opt/data`], { quiet: true });
78
- say(`home: ${HOME_VOL} seeded from hermes/ (.env: the valve's address, the dummy key${args('--env').length ? `, ${args('--env').map((e) => e.split('=')[0]).join(', ')}` : ''})`);
79
- // The clone is made on the host with your own git (and so your own keys), then carried into the volume through a
80
- // directory under your home: a Docker host mounts the home directory, not the system's temporary one.
81
- mkdirSync(join(homedir(), '.config', 'open-autonomy'), { recursive: true });
82
- const tmp = mkdtempSync(join(homedir(), '.config', 'open-autonomy', 'setup-'));
83
- try {
84
- run(['git', 'clone', '-q', origin, join(tmp, 'repo')], { quiet: true });
85
- if (originInside !== origin) run(['git', '-C', join(tmp, 'repo'), 'remote', 'set-url', 'origin', originInside], { quiet: true });
86
- run([...docker, 'run', '--rm', '-v', `${REPO_VOL}:/work`, '-v', `${join(tmp, 'repo')}:/src:ro`, 'alpine:3', 'sh', '-c', `cp -a /src/. /work/ && chown -R ${uid}:${gid} /work`], { quiet: true });
87
- } finally { rmSync(tmp, { recursive: true, force: true }); }
88
- say(`repo: ${REPO_VOL} cloned from ${origin}${originInside !== origin ? ` (origin inside the container: ${originInside})` : ''}`);
89
- }
90
-
91
- // 4. What is the owner's, and what is next.
92
- say('yours: the deploy key and the ssh-agent that forwards it into the Docker host (container/README.md), and the Discord bot token if you deliver there');
93
- for (const t of todo) say(`next: ${t}`);
94
- say(`next: ${stack === 'oa' ? '' : `STACK=${stack} `}AGENT_SECRETS=${secrets} ${docker.join(' ')} compose${stack === 'oa' ? '' : ` -p ${stack}`} -f container/compose.yml up -d --build`);
@@ -1,10 +0,0 @@
1
- # The reporter: keyless. bun for the reporter itself and its two npm dependencies: supercode (the harness
2
- # it reads the agent's sessions through, a prebuilt binary per platform) and supercode's harness SDK. The
3
- # reporter's code is baked at build time; its config and its cursor live in the checkout.
4
- FROM oven/bun:1.3.10
5
- COPY .open-autonomy /opt/reporter
6
- WORKDIR /opt/reporter
7
- RUN bun install --no-save && chown -R bun:bun /opt/reporter && ./node_modules/.bin/supercode --version
8
- ENV PATH="/opt/reporter/node_modules/.bin:${PATH}"
9
- USER bun
10
- CMD ["bun", "/opt/reporter/reporter.ts", "--config", "/work/project/.open-autonomy/config.yaml"]
@@ -1,5 +0,0 @@
1
- # The key valve: the project's key lives here, never in the agent's or the reporter's container.
2
- FROM oven/bun:1.3.10
3
- COPY container/key-valve.ts /valve/key-valve.ts
4
- USER bun
5
- CMD ["bun", "/valve/key-valve.ts"]
@@ -1,73 +0,0 @@
1
- #!/usr/bin/env bun
2
- // The key valve: the project's key lives here and nowhere the agent or the reporter can read. Both point
3
- // at this process (OPEN_AUTONOMY_BASE_URL=http://valve:8787/v1, any dummy key) and it forwards to the
4
- // platform with the real bearer. Only the model routes and the narration route pass; key management and
5
- // admin routes never do, so a rotated key is never a key the agent could read.
6
- //
7
- // AGENT_ENV_FILE=/secrets/agent.env bun key-valve.ts [--port 8787]
8
- // (a KEY=value file, re-read when it changes: a rotated key is picked up without a restart)
9
- import { existsSync, readFileSync, statSync } from 'node:fs';
10
-
11
- const envFile = process.env.AGENT_ENV_FILE;
12
- let cached: { at: number; env: Record<string, string> } = { at: 0, env: {} };
13
- function keyEnv(): Record<string, string> {
14
- if (process.env.OPEN_AUTONOMY_KEY) return { OPEN_AUTONOMY_KEY: process.env.OPEN_AUTONOMY_KEY, OPEN_AUTONOMY_BASE_URL: process.env.OPEN_AUTONOMY_BASE_URL ?? '' };
15
- if (!envFile || !existsSync(envFile)) return {};
16
- const at = statSync(envFile).mtimeMs;
17
- if (at !== cached.at) {
18
- const env: Record<string, string> = {};
19
- for (const line of readFileSync(envFile, 'utf8').split('\n')) { const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); if (m) env[m[1]] = m[2]; }
20
- cached = { at, env };
21
- announce(env.OPEN_AUTONOMY_KEY);
22
- }
23
- return cached.env;
24
- }
25
- // The key says when it expires (its claims are readable; only the signature is not). Announced whenever the
26
- // file changes, warned inside fourteen days, and answered on /healthz so the reporter can log it too.
27
- function expiry(token: string | undefined): { kid: string; account: string; exp: string; days: number } | undefined {
28
- try {
29
- const claims = JSON.parse(Buffer.from((token ?? '').split('.')[0], 'base64url').toString('utf8')) as { kid?: string; account?: string; exp?: string };
30
- if (!claims.exp) return undefined;
31
- return { kid: claims.kid ?? '?', account: claims.account ?? '?', exp: claims.exp, days: Math.floor((Date.parse(claims.exp) - Date.now()) / 86_400_000) };
32
- } catch { return undefined; }
33
- }
34
- const status = (): string => { const e = expiry(key()); return e ? `key ${e.kid} for ${e.account} expires ${e.exp} (${e.days} day${e.days === 1 ? '' : 's'})${e.days < 14 ? ' — rotate it: bun .open-autonomy/mint-key.ts --rotate' : ''}` : 'no key yet'; };
35
- function announce(token: string | undefined): void {
36
- const e = expiry(token);
37
- console.log(`key-valve: ${e ? status() : 'no readable key in the key file'}`);
38
- if (e && e.days < 14) console.warn(`key-valve: WARNING the key expires in ${e.days} day${e.days === 1 ? '' : 's'}`);
39
- }
40
- const base = (): string => (keyEnv().OPEN_AUTONOMY_BASE_URL || 'https://open-autonomy.org/v1').replace(/\/$/, '');
41
- const key = (): string | undefined => keyEnv().OPEN_AUTONOMY_KEY;
42
- const portArg = process.argv.indexOf('--port');
43
- const port = Number((portArg >= 0 ? process.argv[portArg + 1] : undefined) || process.env.PORT || 8787);
44
- // The model routes, the narration routes (the stream and the roadmap), and the two other rails (a card, a partner charge): the platform
45
- // bounds each rail by the owner's config, and every settlement lands on the public audit trail.
46
- const FORWARDED = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses', '/v1/models', '/v1/agent/events', '/v1/agent/roadmap', '/v1/rails/card', '/v1/rails/partner']);
47
- // Public reads the reporter needs to resume where the platform is (its own account's sessions).
48
- const isPublicRead = (path: string, method: string) => method === 'GET' && /^\/v1\/accounts\/[^/]+\/(sessions|items)(\/|$)/.test(path);
49
-
50
- Bun.serve({
51
- hostname: '0.0.0.0',
52
- port,
53
- idleTimeout: 255,
54
- async fetch(req) {
55
- const url = new URL(req.url);
56
- if (url.pathname === '/healthz') return new Response(key() ? `ok · ${status()}` : 'no key yet');
57
- if (!FORWARDED.has(url.pathname) && !isPublicRead(url.pathname, req.method)) return Response.json({ error: { code: 'not_forwarded', message: 'the valve forwards the model routes, the narration route, the rails and public reads of this account only' } }, { status: 403 });
58
- const bearer = key();
59
- if (!bearer) return Response.json({ error: { code: 'no_key', message: 'the valve has no key yet' } }, { status: 503 });
60
- // A clean request: the body buffered (one honest Content-Length), only the headers that carry meaning.
61
- const headers = new Headers();
62
- for (const h of ['content-type', 'accept', 'anthropic-version', 'anthropic-beta', 'last-event-id']) { const v = req.headers.get(h); if (v) headers.set(h, v); }
63
- headers.set('authorization', `Bearer ${bearer}`);
64
- headers.set('user-agent', 'open-autonomy-key-valve');
65
- const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer();
66
- const upstream = await fetch(`${base()}${url.pathname.replace(/^\/v1/, '')}${url.search}`, { method: req.method, headers, body });
67
- const out = new Headers(upstream.headers);
68
- out.delete('content-encoding');
69
- out.delete('content-length');
70
- return new Response(upstream.body, { status: upstream.status, headers: out });
71
- },
72
- });
73
- console.log(`key-valve: forwarding ${[...FORWARDED].join(', ')} → ${base()} on :${port}`);
@@ -1,7 +0,0 @@
1
- import { expect, test } from 'bun:test';
2
- import { readFileSync } from 'node:fs';
3
-
4
- // The project's own check starts here. A test proves an acceptance line or guards a bug that happened.
5
- test('the repository names itself', () => {
6
- expect(readFileSync(new URL('../README.md', import.meta.url), 'utf8').startsWith('# __PROJECT__')).toBe(true);
7
- });