create-open-autonomy 2.1.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 +75 -0
- package/package.json +38 -0
- package/src/cli.ts +35 -0
- package/src/kit.ts +139 -0
- package/template/.github/workflows/ci.yml +18 -0
- package/template/.github/workflows/land.yml +30 -0
- package/template/.open-autonomy/config.yaml +27 -0
- package/template/.open-autonomy/mint-key.ts +49 -0
- package/template/.open-autonomy/package.json +10 -0
- package/template/.open-autonomy/reporter.ts +320 -0
- package/template/.open-autonomy/setup.ts +91 -0
- package/template/AGENTS.md +9 -0
- package/template/CHANGELOG.md +4 -0
- package/template/CONSTITUTION.md +23 -0
- package/template/CONTRIBUTING.md +13 -0
- package/template/LICENSE +55 -0
- package/template/README.md +24 -0
- package/template/container/Dockerfile +13 -0
- package/template/container/Dockerfile.reporter +10 -0
- package/template/container/Dockerfile.valve +5 -0
- package/template/container/README.md +45 -0
- package/template/container/build-hermes.sh +21 -0
- package/template/container/compose.yml +99 -0
- package/template/container/hermes.pin +6 -0
- package/template/container/key-valve.ts +73 -0
- package/template/hermes/.no-bundled-skills +0 -0
- package/template/hermes/README.md +19 -0
- package/template/hermes/SOUL.md +7 -0
- package/template/hermes/config.yaml +32 -0
- package/template/hermes/cron/jobs.seed.json +12 -0
- package/template/hermes/hooks/seed/HOOK.yaml +4 -0
- package/template/hermes/hooks/seed/handler.py +220 -0
- package/template/hermes/kanban.seed.json +13 -0
- package/template/hermes/profiles/treasurer/.no-bundled-skills +0 -0
- package/template/hermes/profiles/treasurer/SOUL.md +13 -0
- package/template/hermes/profiles/treasurer/config.yaml +33 -0
- package/template/hermes/skills/open-autonomy/develop/SKILL.md +69 -0
- package/template/hermes/skills/open-autonomy/pm/SKILL.md +30 -0
- package/template/package.json +8 -0
- package/template/test/project.test.ts +7 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Running the agent
|
|
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:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
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
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The kit owns this directory; `create-open-autonomy upgrade .` brings it forward.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Build the pinned Hermes image the agent runs on (container/hermes.pin). Clones the tag into a
|
|
3
|
+
# temporary directory — this repository never vendors Hermes — verifies the commit, and builds
|
|
4
|
+
# `hermes-agent:<tag>` in the current Docker context. Takes ~10 minutes the first time.
|
|
5
|
+
set -eu
|
|
6
|
+
here=$(cd "$(dirname "$0")" && pwd)
|
|
7
|
+
. "$here/hermes.pin"
|
|
8
|
+
image="hermes-agent:$HERMES_TAG"
|
|
9
|
+
if [ "${FORCE:-}" != "1" ] && docker image inspect "$image" >/dev/null 2>&1; then
|
|
10
|
+
echo "$image already built (FORCE=1 to rebuild)"
|
|
11
|
+
exit 0
|
|
12
|
+
fi
|
|
13
|
+
work=$(mktemp -d)
|
|
14
|
+
trap 'rm -rf "$work"' EXIT
|
|
15
|
+
git clone -q --depth 1 --branch "$HERMES_TAG" "$HERMES_REPO" "$work/hermes"
|
|
16
|
+
have=$(git -C "$work/hermes" rev-parse HEAD)
|
|
17
|
+
[ "$have" = "$HERMES_COMMIT" ] || { echo "hermes.pin: $HERMES_TAG is $have, not the pinned $HERMES_COMMIT" >&2; exit 1; }
|
|
18
|
+
# The upstream Dockerfile uses a symbolic --chmod, which needs a Dockerfile frontend newer than the
|
|
19
|
+
# daemon's built-in one.
|
|
20
|
+
docker build --build-arg BUILDKIT_SYNTAX=docker/dockerfile:1.17 -t "$image" "$work/hermes"
|
|
21
|
+
echo "built $image ($HERMES_COMMIT)"
|
|
@@ -0,0 +1,99 @@
|
|
|
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`.
|
|
10
|
+
#
|
|
11
|
+
# AGENT_SECRETS=~/.config/open-autonomy docker compose -f container/compose.yml up -d --build
|
|
12
|
+
services:
|
|
13
|
+
valve:
|
|
14
|
+
build:
|
|
15
|
+
context: ..
|
|
16
|
+
dockerfile: container/Dockerfile.valve
|
|
17
|
+
image: __PROJECT__-valve:local
|
|
18
|
+
container_name: oa-valve
|
|
19
|
+
restart: unless-stopped
|
|
20
|
+
environment:
|
|
21
|
+
- AGENT_ENV_FILE=/secrets/agent.env
|
|
22
|
+
volumes:
|
|
23
|
+
- ${AGENT_SECRETS:-~/.config/open-autonomy}/agent.env:/secrets/agent.env:ro
|
|
24
|
+
networks: [agent]
|
|
25
|
+
valve-pay:
|
|
26
|
+
build:
|
|
27
|
+
context: ..
|
|
28
|
+
dockerfile: container/Dockerfile.valve
|
|
29
|
+
image: __PROJECT__-valve:local
|
|
30
|
+
container_name: oa-valve-pay
|
|
31
|
+
restart: unless-stopped
|
|
32
|
+
environment:
|
|
33
|
+
- AGENT_ENV_FILE=/secrets/treasurer.env
|
|
34
|
+
volumes:
|
|
35
|
+
- ${AGENT_SECRETS:-~/.config/open-autonomy}/treasurer.env:/secrets/treasurer.env:ro
|
|
36
|
+
networks: [agent]
|
|
37
|
+
# The committed home (SOUL, skills, config, the schedule seed) into the volume before the gateway starts:
|
|
38
|
+
# hermes/ in the repository is the source of truth for what the agent IS; the volume only holds what the
|
|
39
|
+
# agent has since done.
|
|
40
|
+
home-sync:
|
|
41
|
+
image: alpine:3
|
|
42
|
+
container_name: oa-home-sync
|
|
43
|
+
restart: "no"
|
|
44
|
+
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'"]
|
|
45
|
+
volumes:
|
|
46
|
+
- oa-home:/opt/data
|
|
47
|
+
- oa-repo:/repo:ro
|
|
48
|
+
agent:
|
|
49
|
+
build:
|
|
50
|
+
context: ..
|
|
51
|
+
dockerfile: container/Dockerfile
|
|
52
|
+
args:
|
|
53
|
+
HERMES_IMAGE: ${HERMES_IMAGE:-hermes-agent:v2026.8.31} # container/build-hermes.sh builds it
|
|
54
|
+
image: __PROJECT__-agent:local
|
|
55
|
+
container_name: oa-agent
|
|
56
|
+
restart: unless-stopped
|
|
57
|
+
depends_on:
|
|
58
|
+
valve: { condition: service_started }
|
|
59
|
+
home-sync: { condition: service_completed_successfully }
|
|
60
|
+
command: ["gateway", "run"]
|
|
61
|
+
environment:
|
|
62
|
+
# The host user's ids: the forwarded ssh-agent socket is theirs.
|
|
63
|
+
- HERMES_UID=${AGENT_UID:-501}
|
|
64
|
+
- HERMES_GID=${AGENT_GID:-20}
|
|
65
|
+
- SSH_AUTH_SOCK=/ssh-agent
|
|
66
|
+
- GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=accept-new
|
|
67
|
+
volumes:
|
|
68
|
+
- oa-home:/opt/data
|
|
69
|
+
- oa-repo:/work/project
|
|
70
|
+
- /run/host-services/ssh-auth.sock:/ssh-agent
|
|
71
|
+
networks: [agent]
|
|
72
|
+
reporter:
|
|
73
|
+
build:
|
|
74
|
+
context: ..
|
|
75
|
+
dockerfile: container/Dockerfile.reporter
|
|
76
|
+
image: __PROJECT__-reporter:local
|
|
77
|
+
container_name: oa-reporter
|
|
78
|
+
restart: unless-stopped
|
|
79
|
+
# The agent's own ids: the home it reads and the checkout it keeps its cursor in are the agent's.
|
|
80
|
+
user: "${AGENT_UID:-501}:${AGENT_GID:-20}"
|
|
81
|
+
depends_on:
|
|
82
|
+
valve: { condition: service_started }
|
|
83
|
+
agent: { condition: service_started }
|
|
84
|
+
environment:
|
|
85
|
+
- OPEN_AUTONOMY_BASE_URL=http://valve:8787/v1
|
|
86
|
+
- HERMES_HOME=/opt/data
|
|
87
|
+
volumes:
|
|
88
|
+
- oa-home:/opt/data:ro
|
|
89
|
+
- oa-repo:/work/project
|
|
90
|
+
networks: [agent]
|
|
91
|
+
volumes:
|
|
92
|
+
oa-home:
|
|
93
|
+
name: oa-home
|
|
94
|
+
external: true # seeded once from hermes/ (container/README.md)
|
|
95
|
+
oa-repo:
|
|
96
|
+
name: oa-repo
|
|
97
|
+
external: true # the agent's clone
|
|
98
|
+
networks:
|
|
99
|
+
agent: {}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# The Hermes the agent runs, pinned so the image is reproducible from a clean machine.
|
|
2
|
+
# container/build-hermes.sh clones this exact tag and builds `hermes-agent:<tag>`, which
|
|
3
|
+
# container/Dockerfile builds on. Bump both lines together, rebuild, and prove a run in the world.
|
|
4
|
+
HERMES_REPO=https://github.com/NousResearch/hermes-agent.git
|
|
5
|
+
HERMES_TAG=v2026.8.31
|
|
6
|
+
HERMES_COMMIT=29112bef099274229cadff79cdff7bf7b99c4b77
|
|
@@ -0,0 +1,73 @@
|
|
|
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}`);
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# This project's agent
|
|
2
|
+
|
|
3
|
+
This directory is a complete Hermes home (`HERMES_HOME`), from the Open Autonomy Hermes kit. Everything the
|
|
4
|
+
agent is lives here and is committed: `SOUL.md` (identity), `skills/` (the two things it does beyond what Hermes
|
|
5
|
+
brings: `develop`, `pm`), `kanban.seed.json` (the board's first tasks, in order; one with a `held` reason is filed parked, the owner's to release with `hermes kanban unblock`), `cron/jobs.seed.json` (its one job: the
|
|
6
|
+
PM, hourly), `config.yaml` (which model, through the platform; a worker takes it at dispatch, so a model change
|
|
7
|
+
never strands anything), `hooks/` (the seed: the schedule and the board, on every boot, idempotent). Its runtime
|
|
8
|
+
state (sessions, logs, caches, `.env`, the board's database) is git-ignored.
|
|
9
|
+
|
|
10
|
+
The agent's model calls go through the Open Autonomy platform on the project's key, so every call is metered
|
|
11
|
+
to this project's account and paid for by its patrons. The board is the roadmap: the owner files tasks
|
|
12
|
+
(`hermes kanban create`), the gateway's dispatcher pulls them down in order and runs each as a worker session
|
|
13
|
+
that builds it and lands it on an `agent/<task id>` branch, the review lane (Hermes's own) verifies the handoff
|
|
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,
|
|
16
|
+
and the project's documents (`CONSTITUTION.md` as what it is, `CHANGELOG.md` as what shipped) to the project's
|
|
17
|
+
page as they happen. The platform reads none of these files itself.
|
|
18
|
+
|
|
19
|
+
How it runs, and how to run it yourself: `container/README.md`.
|
|
@@ -0,0 +1,7 @@
|
|
|
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
|
+
|
|
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.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
Be direct. Report what changed, what is verified, and what is left. No filler, no narration of tool calls, no restating the ask.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# The project agent's Hermes configuration. The model is this project's choice (runtime config, seeded by
|
|
2
|
+
# the kit, never checked by it). Secrets live in hermes/.env (git-ignored):
|
|
3
|
+
# OPEN_AUTONOMY_BASE_URL / OPEN_AUTONOMY_KEY — inside the stack, the key valve's address and a dummy
|
|
4
|
+
# DISCORD_BOT_TOKEN / DISCORD_HOME_CHANNEL — optional Discord delivery
|
|
5
|
+
model:
|
|
6
|
+
default: zai/glm-5.3-flash
|
|
7
|
+
provider: custom
|
|
8
|
+
base_url: ${OPEN_AUTONOMY_BASE_URL}
|
|
9
|
+
api_key: ${OPEN_AUTONOMY_KEY}
|
|
10
|
+
api_mode: chat_completions
|
|
11
|
+
|
|
12
|
+
terminal:
|
|
13
|
+
backend: local
|
|
14
|
+
timeout: 300
|
|
15
|
+
|
|
16
|
+
# Everything the agent is must be readable in the repository, so it carries no private memory between runs.
|
|
17
|
+
memory:
|
|
18
|
+
memory_enabled: false
|
|
19
|
+
user_profile_enabled: false
|
|
20
|
+
|
|
21
|
+
compression:
|
|
22
|
+
enabled: true
|
|
23
|
+
threshold: 0.50
|
|
24
|
+
|
|
25
|
+
agent:
|
|
26
|
+
max_turns: 80
|
|
27
|
+
|
|
28
|
+
# The board (Hermes Kanban) the schedule files roadmap items on. Its dispatcher runs inside the gateway and
|
|
29
|
+
# the review lane verifies every handoff; the interval is how soon a filed task is picked up.
|
|
30
|
+
kanban:
|
|
31
|
+
dispatch_interval_seconds: 15
|
|
32
|
+
review_dispatch: true
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jobs": [
|
|
3
|
+
{
|
|
4
|
+
"name": "pm",
|
|
5
|
+
"prompt": "You are this project's PM for the hour. Run the pm skill: read the board, unstick what is stuck, report.",
|
|
6
|
+
"schedule": "every 60m",
|
|
7
|
+
"skills": ["pm"],
|
|
8
|
+
"deliver": "discord",
|
|
9
|
+
"workdir": "/work/project"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Seed the schedule and the board from the committed seeds on gateway startup.
|
|
2
|
+
|
|
3
|
+
Two seeds, both in the agent's home: cron/jobs.seed.json (the schedule) and kanban.seed.json (the board's
|
|
4
|
+
starting tasks, in order, each waiting on the one before it). Both are idempotent: a job is matched by name, a
|
|
5
|
+
task by its idempotency key `seed:<key>`, so a boot never files twice and the owner's own tasks are untouched.
|
|
6
|
+
|
|
7
|
+
The Open Autonomy repository commits the schedule definition in
|
|
8
|
+
hermes/cron/jobs.seed.json (byte-stable) and git-ignores the runtime store
|
|
9
|
+
hermes/cron/jobs.json, which Hermes rewrites on every tick with next_run_at /
|
|
10
|
+
last_run_at / fire_claim etc. This hook reconciles the runtime store to the
|
|
11
|
+
seed on every gateway boot so the committed definition stays the source of
|
|
12
|
+
truth and no scheduler run-state ever churns the commit.
|
|
13
|
+
|
|
14
|
+
Every seeded job is pinned to the provider and model hermes/config.yaml names,
|
|
15
|
+
at creation and again on every boot the config moved: an unpinned job snapshots
|
|
16
|
+
the global model when created and Hermes's drift guard skips its fires once the
|
|
17
|
+
owner changes the model, stranding the schedule. Pinned, the job runs on the
|
|
18
|
+
config's model, and the next boot after a change re-pins it.
|
|
19
|
+
|
|
20
|
+
Idempotent: it only creates jobs that are in the seed and missing from the
|
|
21
|
+
live store (matched by name). It never deletes, pauses, or edits existing
|
|
22
|
+
jobs, so runtime state (completed runs, next_run_at) is preserved across
|
|
23
|
+
restarts and an operator can still add jobs by hand without the seed
|
|
24
|
+
clobbering them.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("hooks.seed")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _hermes_home() -> Path:
|
|
37
|
+
import os
|
|
38
|
+
return Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# A seed may name a delivery platform the project has not configured (the template says `discord`; Discord is
|
|
42
|
+
# optional). Hermes blocks such a job before any model call, so the job is created delivering `local` instead —
|
|
43
|
+
# the run still happens and the platform's receipts remain the record — and the fallback is logged. Platforms
|
|
44
|
+
# are matched by the credential their gateway needs; anything else passes through untouched.
|
|
45
|
+
_PLATFORM_CREDENTIALS = {
|
|
46
|
+
"discord": ("DISCORD_BOT_TOKEN",),
|
|
47
|
+
"telegram": ("TELEGRAM_BOT_TOKEN",),
|
|
48
|
+
"slack": ("SLACK_BOT_TOKEN",),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _configured(var: str) -> bool:
|
|
53
|
+
import os
|
|
54
|
+
if os.environ.get(var):
|
|
55
|
+
return True
|
|
56
|
+
env_file = _hermes_home() / ".env"
|
|
57
|
+
try:
|
|
58
|
+
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
59
|
+
key, _, value = line.strip().partition("=")
|
|
60
|
+
if key == var and value.strip().strip("'\""):
|
|
61
|
+
return True
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _deliver_target(name: str, deliver) -> object:
|
|
68
|
+
platform = str(deliver).strip().lower() if isinstance(deliver, str) else ""
|
|
69
|
+
needs = _PLATFORM_CREDENTIALS.get(platform)
|
|
70
|
+
if not needs or any(_configured(v) for v in needs):
|
|
71
|
+
return deliver
|
|
72
|
+
logger.warning(
|
|
73
|
+
"seed: job '%s' delivers to %s but no %s is configured; delivering locally instead",
|
|
74
|
+
name, platform, " / ".join(needs),
|
|
75
|
+
)
|
|
76
|
+
return "local"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _config_inference() -> tuple:
|
|
80
|
+
"""The model and provider hermes/config.yaml names: what every seeded job is pinned to."""
|
|
81
|
+
try:
|
|
82
|
+
from hermes_cli.config import load_config_readonly
|
|
83
|
+
cfg = load_config_readonly() or {}
|
|
84
|
+
except Exception as e: # pragma: no cover - import path depends on runtime
|
|
85
|
+
logger.warning("seed: cannot read config.yaml for the model pin: %s", e)
|
|
86
|
+
return None, None
|
|
87
|
+
model = cfg.get("model")
|
|
88
|
+
if isinstance(model, dict):
|
|
89
|
+
return (str(model.get("default") or "").strip() or None, str(model.get("provider") or "").strip() or None)
|
|
90
|
+
if isinstance(model, str):
|
|
91
|
+
return (model.strip() or None, None)
|
|
92
|
+
return None, None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _seed_jobs() -> list:
|
|
96
|
+
seed_file = _hermes_home() / "cron" / "jobs.seed.json"
|
|
97
|
+
if not seed_file.exists():
|
|
98
|
+
return []
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(seed_file.read_text(encoding="utf-8"))
|
|
101
|
+
except (OSError, ValueError) as e:
|
|
102
|
+
logger.error("seed: failed to read %s: %s", seed_file, e)
|
|
103
|
+
return []
|
|
104
|
+
jobs = data.get("jobs", []) if isinstance(data, dict) else data
|
|
105
|
+
return [j for j in jobs if isinstance(j, dict)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- the board -------------------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def _seed_tasks() -> tuple:
|
|
111
|
+
seed_file = _hermes_home() / "kanban.seed.json"
|
|
112
|
+
if not seed_file.exists():
|
|
113
|
+
return "dir:/work/project", []
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(seed_file.read_text(encoding="utf-8"))
|
|
116
|
+
except (OSError, ValueError) as e:
|
|
117
|
+
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")
|
|
120
|
+
tasks = [t for t in data.get("tasks", []) if isinstance(t, dict) and t.get("key") and t.get("title")]
|
|
121
|
+
return workspace, tasks
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _seed_board() -> None:
|
|
125
|
+
"""File every seed task that is not on the board yet, each a child of the one before it, so the dispatcher
|
|
126
|
+
pulls them down one at a time in the seed's order. `hermes kanban create --idempotency-key` returns the
|
|
127
|
+
existing task for a key it has seen, so a second boot changes nothing."""
|
|
128
|
+
import subprocess
|
|
129
|
+
workspace, tasks = _seed_tasks()
|
|
130
|
+
if not tasks:
|
|
131
|
+
return
|
|
132
|
+
subprocess.run(["hermes", "kanban", "init"], capture_output=True, text=True)
|
|
133
|
+
previous = None
|
|
134
|
+
for spec in tasks:
|
|
135
|
+
body = "\n".join(f"- {line}" for line in spec.get("acceptance", []) if isinstance(line, str))
|
|
136
|
+
cmd = ["hermes", "kanban", "create", str(spec["title"]), "--body", body, "--assignee", "default",
|
|
137
|
+
"--workspace", workspace, "--idempotency-key", f"seed:{spec['key']}", "--created-by", "seed",
|
|
138
|
+
"--skill", "develop", "--json"]
|
|
139
|
+
if previous:
|
|
140
|
+
cmd += ["--parent", previous]
|
|
141
|
+
|
|
142
|
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
143
|
+
if r.returncode != 0:
|
|
144
|
+
logger.error("seed: cannot file task '%s': %s", spec["key"], (r.stderr or r.stdout).strip()[-400:])
|
|
145
|
+
return
|
|
146
|
+
m = re.search(r'"id":\s*"([^"]+)"', r.stdout)
|
|
147
|
+
if not m:
|
|
148
|
+
logger.error("seed: no task id in the board's answer for '%s'", spec["key"])
|
|
149
|
+
return
|
|
150
|
+
previous = m.group(1)
|
|
151
|
+
# A task the seed holds (`"held": "<why>"`) is filed and parked in the board's Scheduled lane at once, for the
|
|
152
|
+
# owner to release with `hermes kanban unblock`; only at filing, so a release is never undone by the next
|
|
153
|
+
# boot. Parked, not blocked: the board escalates repeated blocks into triage and decomposition, and the
|
|
154
|
+
# PM never touches Scheduled.
|
|
155
|
+
held = spec.get("held")
|
|
156
|
+
created_at = re.search(r'"created_at":\s*(\d+)', r.stdout)
|
|
157
|
+
if held and created_at and time.time() - int(created_at.group(1)) < 120:
|
|
158
|
+
subprocess.run(["hermes", "kanban", "schedule", previous, str(held)], capture_output=True, text=True)
|
|
159
|
+
logger.info("seed: the board holds the %d seed task(s)", len(tasks))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def handle(event_type: str, context: dict) -> None:
|
|
163
|
+
_seed_board()
|
|
164
|
+
try:
|
|
165
|
+
from cron.jobs import create_job, load_jobs, update_job
|
|
166
|
+
except Exception as e: # pragma: no cover - import path depends on runtime
|
|
167
|
+
logger.error("seed: cannot import cron.jobs: %s", e)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
seed = _seed_jobs()
|
|
171
|
+
if not seed:
|
|
172
|
+
logger.info("seed: no jobs.seed.json present; nothing to seed")
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
live = load_jobs()
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error("seed: cannot load live jobs: %s", e)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
model, provider = _config_inference()
|
|
182
|
+
live_by_name = {j.get("name"): j for j in live if j.get("name")}
|
|
183
|
+
created = 0
|
|
184
|
+
repinned = 0
|
|
185
|
+
for spec in seed:
|
|
186
|
+
name = spec.get("name")
|
|
187
|
+
if not name:
|
|
188
|
+
continue
|
|
189
|
+
job = live_by_name.get(name)
|
|
190
|
+
if job is not None:
|
|
191
|
+
if bool(spec.get("no_agent")) or ((job.get("model") or None) == model and (job.get("provider") or None) == provider):
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
update_job(job["id"], {"model": model, "provider": provider})
|
|
195
|
+
repinned += 1
|
|
196
|
+
logger.info("seed: re-pinned job '%s' to %s / %s (config.yaml moved)", name, provider, model)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error("seed: failed to re-pin job '%s': %s", name, e)
|
|
199
|
+
continue
|
|
200
|
+
try:
|
|
201
|
+
create_job(
|
|
202
|
+
prompt=spec.get("prompt"),
|
|
203
|
+
schedule=spec.get("schedule"),
|
|
204
|
+
name=name,
|
|
205
|
+
deliver=_deliver_target(name, spec.get("deliver")),
|
|
206
|
+
skills=spec.get("skills") or None,
|
|
207
|
+
skill=spec.get("skill"),
|
|
208
|
+
workdir=spec.get("workdir"),
|
|
209
|
+
script=spec.get("script"),
|
|
210
|
+
no_agent=bool(spec.get("no_agent")),
|
|
211
|
+
model=None if spec.get("no_agent") else model,
|
|
212
|
+
provider=None if spec.get("no_agent") else provider,
|
|
213
|
+
)
|
|
214
|
+
created += 1
|
|
215
|
+
logger.info("seed: created job '%s' from seed, pinned to %s / %s", name, provider, model)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error("seed: failed to create job '%s': %s", name, e)
|
|
218
|
+
|
|
219
|
+
if created or repinned:
|
|
220
|
+
logger.info("seed: seeded %d job(s) from jobs.seed.json, re-pinned %d", created, repinned)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"workspace": "dir:/work/project",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"key": "hello",
|
|
6
|
+
"title": "The project says hello",
|
|
7
|
+
"acceptance": [
|
|
8
|
+
"`bun run check` passes on a fresh clone.",
|
|
9
|
+
"README.md describes what this project is for in one paragraph."
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
You are this project's treasurer: the one profile whose key can pay. The developer builds; you buy. A task assigned to you is a purchase request the developer filed: what to buy, at which merchant, for at most how much, why, and which of the developer's tasks is waiting on it (`for task:`).
|
|
2
|
+
|
|
3
|
+
For each request, in order:
|
|
4
|
+
|
|
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
|
+
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>"}'`.
|
|
8
|
+
The answer carries the card. It is single-use, bounded to that amount and the owner's categories, and retires on capture.
|
|
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).
|
|
11
|
+
5. `kanban_complete` your task with the receipt in one line.
|
|
12
|
+
|
|
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.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# The treasurer profile's config: the same agent, pointed at its own valve. Everything else is the default's.
|
|
2
|
+
# The project agent's Hermes configuration. The model is this project's choice (runtime config, seeded by
|
|
3
|
+
# the kit, never checked by it). Secrets live in hermes/.env (git-ignored):
|
|
4
|
+
# OPEN_AUTONOMY_BASE_URL / OPEN_AUTONOMY_KEY — inside the stack, the key valve's address and a dummy
|
|
5
|
+
# DISCORD_BOT_TOKEN / DISCORD_HOME_CHANNEL — optional Discord delivery
|
|
6
|
+
model:
|
|
7
|
+
default: zai/glm-5.3-flash
|
|
8
|
+
provider: custom
|
|
9
|
+
base_url: http://valve-pay:8787/v1 # the treasurer's valve: the only key that pays
|
|
10
|
+
api_key: valve
|
|
11
|
+
api_mode: chat_completions
|
|
12
|
+
|
|
13
|
+
terminal:
|
|
14
|
+
backend: local
|
|
15
|
+
timeout: 300
|
|
16
|
+
|
|
17
|
+
# Everything the agent is must be readable in the repository, so it carries no private memory between runs.
|
|
18
|
+
memory:
|
|
19
|
+
memory_enabled: false
|
|
20
|
+
user_profile_enabled: false
|
|
21
|
+
|
|
22
|
+
compression:
|
|
23
|
+
enabled: true
|
|
24
|
+
threshold: 0.50
|
|
25
|
+
|
|
26
|
+
agent:
|
|
27
|
+
max_turns: 80
|
|
28
|
+
|
|
29
|
+
# The board (Hermes Kanban) the schedule files roadmap items on. Its dispatcher runs inside the gateway and
|
|
30
|
+
# the review lane verifies every handoff; the interval is how soon a filed task is picked up.
|
|
31
|
+
kanban:
|
|
32
|
+
dispatch_interval_seconds: 15
|
|
33
|
+
review_dispatch: true
|