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.
Files changed (40) hide show
  1. package/README.md +75 -0
  2. package/package.json +38 -0
  3. package/src/cli.ts +35 -0
  4. package/src/kit.ts +139 -0
  5. package/template/.github/workflows/ci.yml +18 -0
  6. package/template/.github/workflows/land.yml +30 -0
  7. package/template/.open-autonomy/config.yaml +27 -0
  8. package/template/.open-autonomy/mint-key.ts +49 -0
  9. package/template/.open-autonomy/package.json +10 -0
  10. package/template/.open-autonomy/reporter.ts +320 -0
  11. package/template/.open-autonomy/setup.ts +91 -0
  12. package/template/AGENTS.md +9 -0
  13. package/template/CHANGELOG.md +4 -0
  14. package/template/CONSTITUTION.md +23 -0
  15. package/template/CONTRIBUTING.md +13 -0
  16. package/template/LICENSE +55 -0
  17. package/template/README.md +24 -0
  18. package/template/container/Dockerfile +13 -0
  19. package/template/container/Dockerfile.reporter +10 -0
  20. package/template/container/Dockerfile.valve +5 -0
  21. package/template/container/README.md +45 -0
  22. package/template/container/build-hermes.sh +21 -0
  23. package/template/container/compose.yml +99 -0
  24. package/template/container/hermes.pin +6 -0
  25. package/template/container/key-valve.ts +73 -0
  26. package/template/hermes/.no-bundled-skills +0 -0
  27. package/template/hermes/README.md +19 -0
  28. package/template/hermes/SOUL.md +7 -0
  29. package/template/hermes/config.yaml +32 -0
  30. package/template/hermes/cron/jobs.seed.json +12 -0
  31. package/template/hermes/hooks/seed/HOOK.yaml +4 -0
  32. package/template/hermes/hooks/seed/handler.py +220 -0
  33. package/template/hermes/kanban.seed.json +13 -0
  34. package/template/hermes/profiles/treasurer/.no-bundled-skills +0 -0
  35. package/template/hermes/profiles/treasurer/SOUL.md +13 -0
  36. package/template/hermes/profiles/treasurer/config.yaml +33 -0
  37. package/template/hermes/skills/open-autonomy/develop/SKILL.md +69 -0
  38. package/template/hermes/skills/open-autonomy/pm/SKILL.md +30 -0
  39. package/template/package.json +8 -0
  40. 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,4 @@
1
+ name: seed
2
+ description: Seed the schedule from cron/jobs.seed.json and the board from kanban.seed.json on gateway startup
3
+ events:
4
+ - gateway:startup
@@ -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
+ }
@@ -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