Skip to content

Commit 8191e93

Browse files
ralyodioclaude
andauthored
PRD 0011: teach the herd the agent protocol (#415)
The herd read paint instead of speaking protocol. Three symptoms, one cause: state came from regex against a screen capture, a prompt left no evidence it was ever submitted, and the roster stopped at the edge of the box. Phase 1 — believe the engine. `herd hooks install claude` writes Claude Code's own lifecycle hooks so state comes from the engine (`authority: hook`), merging into the user's settings file rather than clobbering it. Sessions are launched with MOSHCODE_HERD_NAME/DIR so a hook can name what it is reporting for, and a hook fired outside a herd session does nothing and exits 0. `herd doctor` checks substrate, manifest drift, stale reports, and — for the first time — says what is wrong with rules.json instead of ignoring it. blocked gains sub-kinds (permission/question/menu) that ride in --json and notifications. Phase 2 — the ledger. Every prompt mints a task: id, transitions with timestamps, and the output captured as a screen delta, in 0600 JSONL capped at 500 tasks per session. `herd tasks`, `herd task`, `herd log`, `herd stats` read it back; blocked time is reported as what it is, human latency. The write goes where the watcher's notification decision already happens, not in a second poller. `wait --any/--all` replaces the polling loop every fan-out script had. Phase 3 — the protocol. `herd serve` exposes the herd over A2A v0.3.0 (discovery, message/send, tasks/get, tasks/cancel) behind the moshcode login, with no unauthenticated mode, loopback included, and --agent sessions withheld unless --expose-autonomous. `herd remote add` puts a deployed agent on the roster — a2a or a bare POST endpoint — and prompt/read/wait/kill work on it unchanged, with auth from MOSHCODE_REMOTE_<NAME>_TOKEN and never in the manifest. Phase 4 — `herd eval` runs a dataset across engines with either the dataset's own patterns or an engine as judge, exiting 0/4/5 so CI can tell a worse agent from a broken box. gradient joins the tool table with its own runtime check. 119 new tests. Decisions taken while building are recorded at the end of the PRD rather than edited into its requirements. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8c8cbfb commit 8191e93

24 files changed

Lines changed: 5247 additions & 62 deletions

.github/workflows/herd-eval.yml

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# NOT managed by the sh1pt Actions Fleet — hand-written for PRD 0011 R13.
2+
# The two fleet-managed workflows (ci.yml, test.yml) carry a pack hash and are
3+
# reverted by the next fleet sync, so this lives in its own file rather than as
4+
# a job added to one of them.
5+
#
6+
# What this gates: "the agent still passes the dataset", as a red/green check
7+
# next to `npm test`. It needs a real engine with real credentials, which a
8+
# runner does not have by default — so the whole job is a no-op until a
9+
# credential secret exists, and says so rather than going green by accident. A
10+
# gate that fails on every fork because nobody added a secret is a gate people
11+
# turn off.
12+
name: herd eval
13+
14+
on:
15+
workflow_dispatch:
16+
inputs:
17+
threshold:
18+
description: "Score every engine has to reach (0-1)"
19+
default: "0.8"
20+
engines:
21+
description: "Comma-separated engines to compare"
22+
default: "claude"
23+
pull_request:
24+
paths:
25+
- "evals/**"
26+
- "src/herd-eval.mjs"
27+
- "src/herd-tasks.mjs"
28+
- ".github/workflows/herd-eval.yml"
29+
30+
permissions:
31+
contents: read
32+
33+
concurrency:
34+
group: herd-eval-${{ github.ref }}
35+
cancel-in-progress: true
36+
37+
jobs:
38+
eval:
39+
runs-on: ubuntu-latest
40+
timeout-minutes: 30
41+
steps:
42+
- uses: actions/checkout@v7
43+
44+
# The `secrets` context is not available in a job-level `if`, so the
45+
# check is a step that publishes an output the rest of the job reads.
46+
- id: creds
47+
env:
48+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
49+
run: |
50+
if [ -n "$ANTHROPIC_API_KEY" ]; then
51+
echo "have=true" >> "$GITHUB_OUTPUT"
52+
else
53+
echo "have=false" >> "$GITHUB_OUTPUT"
54+
echo "::notice title=herd eval skipped::no engine credentials on this runner — add ANTHROPIC_API_KEY to turn this check on"
55+
fi
56+
57+
- uses: pnpm/action-setup@v6
58+
if: steps.creds.outputs.have == 'true'
59+
60+
- uses: actions/setup-node@v7
61+
if: steps.creds.outputs.have == 'true'
62+
with:
63+
node-version: '22'
64+
cache: pnpm
65+
66+
- run: pnpm install --frozen-lockfile
67+
if: steps.creds.outputs.have == 'true'
68+
69+
# The herd needs somewhere to run its sessions. Without tmux it would
70+
# fall back to script(1), which works, but tmux is one apt away and is
71+
# the substrate people actually use.
72+
- run: sudo apt-get update && sudo apt-get install -y tmux
73+
if: steps.creds.outputs.have == 'true'
74+
75+
- run: npm install -g @anthropic-ai/claude-code
76+
if: steps.creds.outputs.have == 'true'
77+
78+
# `rules` as the judge, not an engine: the dataset carries its own
79+
# expectations, and a judge that is itself an LLM would make a flaky
80+
# check out of a deterministic one. Exit 4 is "below the threshold" and
81+
# exit 5 is "the harness could not run" — the job distinguishes them so a
82+
# broken runner does not get filed as a worse agent.
83+
- name: run the dataset
84+
if: steps.creds.outputs.have == 'true'
85+
env:
86+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
87+
run: |
88+
set +e
89+
node bin/moshcode.mjs herd eval \
90+
--dataset evals/moshcode.jsonl \
91+
--engines "${{ inputs.engines || 'claude' }}" \
92+
--threshold "${{ inputs.threshold || '0.8' }}" \
93+
--json > eval.json
94+
code=$?
95+
set -e
96+
cat eval.json
97+
case "$code" in
98+
0) echo "::notice title=herd eval::every engine is at or above the threshold" ;;
99+
4) echo "::error title=herd eval::an engine scored below the threshold"; exit 1 ;;
100+
5) echo "::error title=herd eval::the harness could not run (infrastructure, not the agent)"; exit 1 ;;
101+
*) echo "::error title=herd eval::unexpected exit $code"; exit 1 ;;
102+
esac
103+
104+
- uses: actions/upload-artifact@v4
105+
if: steps.creds.outputs.have == 'true' && always()
106+
with:
107+
name: herd-eval-report
108+
path: eval.json
109+
if-no-files-found: ignore

README.md

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,9 @@ moshcode agents claude -d --name api # and an agent
263263

264264
```sh
265265
$ moshcode ps
266-
api claude blocked ~/src/coinpay 3m
267-
logs shell idle ~/src/coinpay 3m
268-
work shell idle ~/src/coinpay 3m
266+
api claude blocked ~/src/coinpay 3m screen
267+
logs shell idle ~/src/coinpay 3m screen
268+
work shell idle ~/src/coinpay 3m screen
269269

270270
⚠ 1 waiting on you — moshcode attach api
271271
```
@@ -317,9 +317,9 @@ Every session carries a state: `working`, `blocked`, `done`, `idle`, or
317317
`unknown`. `blocked` means a human decision is the only thing missing.
318318

319319
```
320-
api claude blocked ~/src/coinpay 12m
321-
web codex working ~/src/ugig.net 4m
322-
audit opencode done ~/src/moshpit-dns 1h
320+
api claude blocked ~/src/coinpay 12m hook
321+
web codex working ~/src/ugig.net 4m screen
322+
audit opencode done ~/src/moshpit-dns 1h runtime
323323
```
324324

325325
State comes from one authority per session, never two. An engine that reports
@@ -416,6 +416,120 @@ await herdWait("api"); await herdWait("web");
416416
say(herdRead("api", { lines: 20 }));
417417
```
418418

419+
Fanning work out is easy; joining on it used to be a hand-rolled polling loop.
420+
`--any` returns on the first session to get there, `--all` when the last one
421+
has, and both take the same `--state` and `--timeout` as a single wait:
422+
423+
```sh
424+
moshcode wait --any api web docs # --json names the winner
425+
moshcode wait --all api web --state done
426+
```
427+
428+
```js
429+
const first = await herdWait(["api", "web", "docs"], { any: true });
430+
await herdWait(["api", "web"], { states: ["done"] });
431+
```
432+
433+
### Let the engine say what it is doing
434+
435+
Reading a screen works and it rots — engines change their wording between
436+
releases and nothing tells you. When an engine has lifecycle hooks, install
437+
them once and its state comes from the engine itself:
438+
439+
```sh
440+
moshcode herd hooks install claude
441+
✓ claude — 3 hooks installed (stop, notification, prompt-submit)
442+
```
443+
444+
`moshcode ps` then reads `hook` in its last column instead of `screen`. The
445+
file is **merged, never clobbered** — your own hooks stay, and `hooks remove`
446+
takes out only what moshcode put in. A hook that fires outside a herd session
447+
does nothing and exits 0, so installing one cannot break an engine you run by
448+
hand, and the screen rules stay as the fallback for everything else.
449+
`moshcode herd doctor` says what is installed, what has drifted, and — for the
450+
first time — what is wrong with your `rules.json` instead of ignoring it.
451+
452+
### What happened while you slept
453+
454+
Every prompt through the herd mints a **task**: an id, its state transitions
455+
with timestamps, and the output it produced. `ps` still answers "now"; this
456+
answers "what happened".
457+
458+
```sh
459+
$ moshcode herd tasks api
460+
t-01 22:14 done 4m "port the auth routes"
461+
t-02 22:19 blocked 6h11 "run the migration"
462+
463+
$ moshcode herd task t-02 # transitions, and what came back
464+
$ moshcode herd log api # the raw state history
465+
$ moshcode herd stats api
466+
api working 3h02 · blocked 6h11 · idle 1h40
467+
blocked 6h11 over 2 spell(s) — that one is you
468+
```
469+
470+
Blocked time is the herd's name for *human latency*: the agent was ready and
471+
you were asleep. Ledgers live in `~/.moshcode/herd/tasks/<session>.jsonl` at
472+
`0600`, capped at the last 500 tasks per session. From a script,
473+
`herdTasks(name)` and `herdTask(id)` return them as values.
474+
475+
### Agents that are not on this box
476+
477+
A deployed agent can be a herd member. Two kinds: `a2a` speaks
478+
[A2A v0.3.0](https://a2a-protocol.org/v0.3.0/specification/) (card discovery,
479+
`message/send`, `tasks/get`, `tasks/cancel`), and `run` is a bare endpoint that
480+
takes `POST {"prompt": …}` — the shape a `gradient agent deploy` prints.
481+
482+
```sh
483+
moshcode herd remote add research https://agents.do-ai.run/…/production --kind run
484+
export MOSHCODE_REMOTE_RESEARCH_TOKEN=… # never written to the manifest, never synced
485+
```
486+
487+
```
488+
$ moshcode ps
489+
api claude blocked ~/src/coinpay 12m hook
490+
research remote idle agents.do-ai.run — remote
491+
```
492+
493+
`prompt`, `read`, `wait` and `kill` work on it unchanged, which is the point: a
494+
fan-out script across a local pty and a deployed agent contains no `if
495+
(remote)`. A remote's state is the *remote's claim* — `ps` says `remote` in the
496+
last column so it is never mistaken for something this box verified — and
497+
`kill` on one deregisters it here rather than reaching across the network to
498+
end somebody else's agent.
499+
500+
### The herd, over A2A
501+
502+
`moshcode herd serve` exposes this machine's herd to any A2A client: the herd's
503+
card at `/.well-known/agent-card.json`, each member at `/<name>/`,
504+
`message/send` → prompt, `tasks/get` → the ledger, `tasks/cancel` → interrupt.
505+
`blocked` is A2A's `input-required`; the states that do not map cleanly round
506+
down and carry the honest one in task metadata.
507+
508+
```sh
509+
moshcode login # it verifies tokens against app.moshcode.sh
510+
moshcode herd serve # 127.0.0.1:7683 by default
511+
```
512+
513+
It is a shell on a socket and is treated like one: **no unauthenticated mode,
514+
loopback included**, a loud warning past `127.0.0.1`, and sessions started with
515+
`--agent` withheld unless you pass `--expose-autonomous` — an engine with its
516+
approvals bypassed plus a network prompt is the worst pairing on the menu.
517+
518+
### Which engine is best at *this* repo
519+
520+
Not a leaderboard run against engines nobody deploys on repos nobody has — your
521+
dataset, your engines, your machine:
522+
523+
```sh
524+
moshcode herd eval --dataset evals/moshcode.jsonl --engines claude,codex --threshold 0.8
525+
```
526+
527+
A row is `{"prompt": "…", "expect": "pattern"}` or
528+
`{"prompt": "…", "rubric": "…"}` (jsonl, json or csv). Scoring is either the
529+
dataset's own patterns or an engine acting as judge (`--judge claude`). Exit
530+
codes are distinct on purpose — `0` pass, `4` below the threshold, `5` the
531+
harness could not run — because CI has to tell a worse agent from a broken box.
532+
419533
### After a reboot
420534

421535
```sh

evals/moshcode.jsonl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{"id": "state-authority", "prompt": "In src/herd-state.mjs, what value does the `authority` field take when a session's state came from its engine's own lifecycle hook rather than from its screen? Answer with the single word and nothing else.", "expect": "\\bhook\\b"}
2+
{"id": "vocabulary-file", "prompt": "Which file in this repo holds the moshscript command vocabulary — the verbs a .mosh script can call? Answer with the repo-relative path and nothing else.", "expect": "src/commands\\.mjs"}
3+
{"id": "safe-answer", "prompt": "The herd's state classifier has one state it treats as always safe, never blocking a launch, and returns when no rule matched. Name it in one word.", "expect": "\\bunknown\\b"}
4+
{"id": "wait-exit-codes", "prompt": "`moshcode wait` exits with distinct codes so scripts can branch on the outcome. Which exit code means the wait timed out? Answer with the number only.", "expect": "(^|\\D)2(\\D|$)"}
5+
{"id": "substrate-fallback", "prompt": "The herd runs sessions on tmux when it is available. Name the program it uses to allocate a pty when tmux is not installed. One word.", "expect": "script"}
6+
{"id": "manifest-mode", "prompt": "What file mode does the herd write its session manifest with, and why? Give the octal mode first.", "expect": "0?600"}
7+
{"id": "blocked-meaning", "prompt": "In the herd's roster, what does the state `blocked` mean? Answer in one short sentence.", "rubric": "The answer must say that the session is waiting on a human decision or input — an approval, a question, or a menu — and not that it is stuck, crashed, or busy working.", "expect": "human|you|decision|input|approval|ask"}
8+
{"id": "no-second-api", "prompt": "Does moshcode expose a separate socket API for agents to drive the herd, apart from the CLI? Answer yes or no, then one sentence.", "rubric": "The answer must be 'no': the CLI with --json on every verb is the one surface, and `herd serve` is that same surface answering a socket rather than a second API.", "expect": "^\\W*no\\b"}

0 commit comments

Comments
 (0)