@delorenj/pjangler 1.2.18 → 1.2.19

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 (48) hide show
  1. package/dist/index.js +244 -6
  2. package/dist/mcp-server.js +245 -7
  3. package/package.json +8 -2
  4. package/templates/commonproject/AGENTS.md +3 -3
  5. package/templates/commonproject/README.md +11 -12
  6. package/templates/commonproject/copier.yml +8 -27
  7. package/templates/commonproject/template/.project.json.jinja +9 -13
  8. package/templates/hermes-agent/README.md +9 -9
  9. package/templates/hermes-agent/config.example.toml +1 -66
  10. package/templates/hermes-agent/copier.yml +4 -3
  11. package/templates/hermes-agent/docs/architecture.md +9 -12
  12. package/templates/hermes-agent/docs/fleet-control-plane/README.md +1 -1
  13. package/templates/hermes-agent/docs/operations.md +6 -5
  14. package/templates/hermes-agent/docs/sentinel/README.md +9 -7
  15. package/templates/hermes-agent/docs/sentinel/architecture.md +1 -2
  16. package/templates/hermes-agent/docs/sentinel/development.md +12 -13
  17. package/templates/hermes-agent/docs/sentinel/providers.md +34 -15
  18. package/templates/hermes-agent/install-local.sh +15 -14
  19. package/templates/hermes-agent/runtime-scaffold/README.md +1 -1
  20. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +46 -14
  21. package/templates/hermes-agent/runtime-scaffold/memories/MEMORY.md +2 -2
  22. package/templates/hermes-agent/scripts/fleet-sync.sh +1 -64
  23. package/templates/hermes-agent/template/.gitignore.jinja +0 -2
  24. package/templates/hermes-agent/template/.runtime-scaffold/README.md +1 -1
  25. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +43 -9
  26. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +2 -2
  27. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -1
  28. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -9
  29. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +3 -22
  30. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -22
  31. package/templates/hermes-agent/template/.scripts/40-plane.sh +51 -0
  32. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +59 -21
  33. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +2 -1
  34. package/templates/hermes-agent/template/.scripts/70-systemd.sh +1 -10
  35. package/templates/hermes-agent/template/.scripts/_lib.sh +3 -61
  36. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -5
  37. package/templates/hermes-agent/template/.scripts/heartbeat.sh +22 -66
  38. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +5 -9
  39. package/templates/hermes-agent/template/.scripts/providers/linear.sh +176 -0
  40. package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -29
  41. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +2 -2
  42. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +1 -33
  43. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +1 -27
  44. package/templates/hermes-agent/template/SOUL.md.jinja +19 -21
  45. package/templates/hermes-agent/template/role.yaml.jinja +35 -4
  46. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +138 -0
  47. package/templates/hermes-agent/docs/bloodbank-gateway.md +0 -57
  48. package/templates/hermes-agent/docs/fleet-control-plane/n8n-service-hub.md +0 -60
@@ -1,7 +1,7 @@
1
1
  # Provider adapters
2
2
 
3
3
  This guide is the reference for the ticket-provider abstraction: the contract
4
- every adapter implements, the two adapters that ship today, and a
4
+ every adapter implements, the three adapters that ship today, and a
5
5
  step-by-step process for adding a new provider or verifying an existing one
6
6
  against a live board. Read [Architecture](architecture.md) first for how the
7
7
  adapter fits into the engine.
@@ -43,8 +43,12 @@ maps each to its back end's concrete state.
43
43
 
44
44
  ## The adapters that ship today
45
45
 
46
- The repository includes two supported adapters with different verification status.
46
+ The repository includes three adapters with different verification status.
47
47
 
48
+ - **Linear** (`providers/linear.sh`) is the reference implementation. It uses
49
+ the Linear GraphQL API. It's verified live against a real board. Linear's
50
+ `issue(id:)` field accepts both the UUID and the human identifier (for
51
+ example, `DEL-42`), so the engine can pass identifiers through.
48
52
  - **Plane** (`providers/plane.sh`) uses the Plane REST API with `X-API-Key`
49
53
  authentication. A Plane project maps to the board, a cycle maps to the
50
54
  milestone, and `state.group` maps to the state type. It's verified live. Note
@@ -60,12 +64,26 @@ The repository includes two supported adapters with different verification statu
60
64
 
61
65
  Set credentials in the environment and the board binding in `role.yaml`.
62
66
 
67
+ <details>
68
+ <summary>Linear</summary>
69
+
70
+ - Credentials: `LINEAR_API_KEY`.
71
+ - Binding:
72
+
73
+ ```yaml
74
+ ticket_provider:
75
+ name: linear
76
+ team: DEL # Linear team key
77
+ project: "" # optional, scopes milestone and issue queries
78
+ ```
79
+
80
+ </details>
81
+
63
82
  <details>
64
83
  <summary>Plane</summary>
65
84
 
66
- - Credentials: `PLANE_API_KEY` or workspace-specific
67
- `PLANE_<WORKSPACE>_API_KEY` from the shared fleet env. Endpoint:
68
- `PLANE_BASE` (default `https://plane.delo.sh`).
85
+ - Credentials: `PLANE_API_KEY`. Endpoint: `PLANE_BASE` (default
86
+ `https://plane.delo.sh`).
69
87
  - Binding:
70
88
 
71
89
  ```yaml
@@ -96,9 +114,8 @@ Set credentials in the environment and the board binding in `role.yaml`.
96
114
 
97
115
  To add a fourth provider, for example GitHub Issues or Jira, follow these steps.
98
116
 
99
- 1. Create `template/.scripts/providers/<name>.sh`. Start from `plane.sh` if it
100
- uses REST with a single API key, or `trello.sh` if it has a board/list/card
101
- model.
117
+ 1. Create `template/.scripts/providers/<name>.sh`. Start from `linear.sh` if the
118
+ back end uses GraphQL, or `plane.sh` if it uses REST with a single API key.
102
119
  2. Implement every operation in [the contract](#the-contract). Return the exact
103
120
  JSON shapes shown in the table. Map your back end's states to the five
104
121
  normalized states.
@@ -110,7 +127,8 @@ To add a fourth provider, for example GitHub Issues or Jira, follow these steps.
110
127
  5. Extend `template/role.yaml.jinja` with a provider-specific binding block
111
128
  under the `{% if ticket_provider == '<name>' %}` branch.
112
129
  6. Teach `42-ticket-provider.sh` how to resolve or create the board for your
113
- provider, if it differs from the existing `plane` and `trello` cases.
130
+ provider, if it differs from the existing `linear`, `plane`, and `trello`
131
+ cases.
114
132
  7. Validate and verify with the steps below.
115
133
 
116
134
  <!-- prettier-ignore -->
@@ -140,7 +158,7 @@ Before you touch a live board, confirm the adapter parses and fails cleanly.
140
158
  exit, with no Python traceback:
141
159
 
142
160
  ```bash
143
- ( unset PLANE_API_KEY PLANE_33GOD_API_KEY; sh template/.scripts/providers/plane.sh resolve )
161
+ ( unset LINEAR_API_KEY; sh template/.scripts/providers/linear.sh resolve )
144
162
  ```
145
163
 
146
164
  ## Verifying an adapter against a live board
@@ -151,15 +169,15 @@ never closes or modifies a ticket, so it's safe to run.
151
169
 
152
170
  ```bash
153
171
  # 1. Load the credential into the environment only (never print it).
154
- export PLANE_API_KEY="$(grep -E '^(export )?PLANE(_[A-Z0-9_]+)?_API_KEY=' \
155
- ~/.hermes/<agent>.env | head -1 | sed -E 's/^(export )?PLANE(_[A-Z0-9_]+)?_API_KEY=//; s/^"//; s/"$//')"
172
+ export LINEAR_API_KEY="$(grep -E '^(export )?LINEAR_API_KEY=' \
173
+ ~/.hermes/<agent>.env | head -1 | sed -E 's/^(export )?LINEAR_API_KEY=//; s/^"//; s/"$//')"
156
174
 
157
175
  # 2. Stage a role directory bound to the real board.
158
176
  T=$(mktemp -d); RD="$T/agents/hermes/pm"
159
177
  mkdir -p "$RD/.scripts/lib" "$RD/.scripts/providers"
160
178
  cp template/.scripts/lib/ticket-provider.sh "$RD/.scripts/lib/"
161
- cp template/.scripts/providers/plane.sh "$RD/.scripts/providers/"
162
- printf 'repo: demo\nrole: pm\nticket_provider:\n name: plane\n workspace: 33god\n project: <project-uuid>\n' \
179
+ cp template/.scripts/providers/linear.sh "$RD/.scripts/providers/"
180
+ printf 'repo: demo\nrole: pm\nticket_provider:\n name: linear\n team: DEL\n' \
163
181
  > "$RD/role.yaml"
164
182
 
165
183
  # 3. Run the read operations.
@@ -181,4 +199,5 @@ verification.
181
199
  ## Read next
182
200
 
183
201
  - [Development guide](development.md): the full edit, validate, and propagate
184
- workflow, plus the open roadmap, which includes live-verifying Trello.
202
+ workflow, plus the open roadmap, which includes live-verifying Plane and
203
+ Trello.
@@ -15,11 +15,10 @@
15
15
  #
16
16
  # Environment overrides (skip the prompts):
17
17
  # HAT_REPO=<name> project/repo name (default: basename of CWD)
18
- # HAT_PROVIDER=plane|trello (default: plane)
18
+ # HAT_PROVIDER=linear|plane|trello (default: plane)
19
19
  # HAT_ROLES="pm" roles to install (default: pm)
20
20
  # HAT_DRY_RUN=1 print actions, change nothing
21
- # Provider creds: PLANE_API_KEY or PLANE_<WORKSPACE>_API_KEY + PLANE_BASE
22
- # | TRELLO_KEY+TRELLO_TOKEN
21
+ # Provider creds: LINEAR_API_KEY | PLANE_API_KEY+PLANE_BASE | TRELLO_KEY+TRELLO_TOKEN
23
22
  set -eu
24
23
 
25
24
  say() { printf '\033[36m%s\033[0m\n' "$*"; }
@@ -101,7 +100,7 @@ TOML
101
100
  fi
102
101
 
103
102
  # --- 4. Provider credentials + board binding ---------------------------------
104
- ask HAT_PROVIDER "Ticket provider (plane|trello)" "plane"
103
+ ask HAT_PROVIDER "Ticket provider (linear|plane|trello)" "plane"
105
104
  PROVIDER="$HAT_PROVIDER"
106
105
  ask HAT_REPO "Project/repo name" "$(basename "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]')"
107
106
  REPO="$HAT_REPO"
@@ -110,17 +109,13 @@ PM_ENV="$HOME/.hermes/${REPO}-pm.env"
110
109
  # These get written into the pm role.yaml binding after render.
111
110
  # Pre-seed from optional env knobs so the install can run non-interactively.
112
111
  TP_WORKSPACE="${HAT_PLANE_WORKSPACE:-}"; TP_PROJECT="${HAT_PLANE_PROJECT:-}"
113
- TP_BOARD="${HAT_TRELLO_BOARD:-}"
112
+ TP_TEAM="${HAT_LINEAR_TEAM:-}"; TP_BOARD="${HAT_TRELLO_BOARD:-}"
114
113
  case "$PROVIDER" in
115
114
  plane)
116
115
  ask PLANE_BASE "Plane base URL" "https://app.plane.so"
116
+ : "${PLANE_API_KEY:?Set PLANE_API_KEY in your environment, then re-run}"
117
117
  ask TP_WORKSPACE "Plane workspace slug" ""
118
118
  [ -n "$TP_WORKSPACE" ] || die "workspace is required"
119
- TP_WORKSPACE_KEY="$(printf '%s' "$TP_WORKSPACE" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')"
120
- TP_WORKSPACE_KEY="PLANE_${TP_WORKSPACE_KEY}_API_KEY"
121
- eval "TP_WORKSPACE_API_KEY=\${$TP_WORKSPACE_KEY:-}"
122
- PLANE_API_KEY="${PLANE_API_KEY:-$TP_WORKSPACE_API_KEY}"
123
- : "${PLANE_API_KEY:?Set PLANE_API_KEY or $TP_WORKSPACE_KEY in your environment, then re-run}"
124
119
  say " Plane projects in '$TP_WORKSPACE':"
125
120
  _pj="$(mktemp)"
126
121
  curl -fsS "$PLANE_BASE/api/v1/workspaces/$TP_WORKSPACE/projects/?per_page=100" \
@@ -138,9 +133,15 @@ PY
138
133
  rm -f "$_pj"
139
134
  ask TP_PROJECT "Plane project UUID to manage" ""
140
135
  [ -n "$TP_PROJECT" ] || die "project UUID is required"
141
- CRED_LINES="$TP_WORKSPACE_KEY=$PLANE_API_KEY
136
+ CRED_LINES="PLANE_API_KEY=$PLANE_API_KEY
142
137
  PLANE_BASE=$PLANE_BASE"
143
138
  ;;
139
+ linear)
140
+ : "${LINEAR_API_KEY:?Set LINEAR_API_KEY in your environment, then re-run}"
141
+ ask TP_TEAM "Linear team key (for example DEL)" ""
142
+ [ -n "$TP_TEAM" ] || die "team key is required"
143
+ CRED_LINES="LINEAR_API_KEY=$LINEAR_API_KEY"
144
+ ;;
144
145
  trello)
145
146
  : "${TRELLO_KEY:?Set TRELLO_KEY in your environment, then re-run}"
146
147
  : "${TRELLO_TOKEN:?Set TRELLO_TOKEN in your environment, then re-run}"
@@ -164,13 +165,12 @@ fi
164
165
  export SKIP_TELEGRAM=1 SKIP_EMAIL=1 SKIP_RUNTIME_REPO=1 SKIP_PLANE=1 \
165
166
  SKIP_BLOODBANK=1 SKIP_SYSTEMD=1
166
167
  ROLES="${HAT_ROLES:-pm}"
167
- SCRUB_PLANE_KEY="${TP_WORKSPACE_KEY:-PLANE_DEFAULT_API_KEY}"
168
168
  for ROLE in $ROLES; do
169
169
  say "5. provisioning role: $ROLE"
170
170
  DEST="$PROJECT_DIR/agents/hermes/$ROLE"
171
171
  # Scrub provider creds from copier's environment so 42-ticket-provider skips
172
172
  # board CREATION; we bind to the existing board in step 6 instead.
173
- run "env -u PLANE_API_KEY -u '$SCRUB_PLANE_KEY' \
173
+ run "env -u PLANE_API_KEY -u PLANE_33GOD_API_KEY -u LINEAR_API_KEY \
174
174
  -u TRELLO_KEY -u TRELLO_TOKEN \
175
175
  copier copy '$TEMPLATE_SRC' '$DEST' --trust --defaults --overwrite \
176
176
  --data target_repo='$REPO' --data role='$ROLE' --data ticket_provider='$PROVIDER'"
@@ -180,7 +180,7 @@ done
180
180
  PM_ROLE="$PROJECT_DIR/agents/hermes/pm/role.yaml"
181
181
  if [ -f "$PM_ROLE" ] && [ "${HAT_DRY_RUN:-0}" != "1" ]; then
182
182
  say "6. binding pm to your $PROVIDER board"
183
- TP_WORKSPACE="$TP_WORKSPACE" TP_PROJECT="$TP_PROJECT" \
183
+ TP_WORKSPACE="$TP_WORKSPACE" TP_PROJECT="$TP_PROJECT" TP_TEAM="$TP_TEAM" \
184
184
  TP_BOARD="$TP_BOARD" PROVIDER="$PROVIDER" python3 - "$PM_ROLE" <<'PY'
185
185
  import os, re, sys, pathlib
186
186
  p = pathlib.Path(sys.argv[1]); t = p.read_text()
@@ -190,6 +190,7 @@ def setleaf(text, key, val):
190
190
  return new if n else text
191
191
  t = setleaf(t, "workspace", os.environ.get("TP_WORKSPACE",""))
192
192
  t = setleaf(t, "project", os.environ.get("TP_PROJECT",""))
193
+ t = setleaf(t, "team", os.environ.get("TP_TEAM",""))
193
194
  t = setleaf(t, "board", os.environ.get("TP_BOARD",""))
194
195
  p.write_text(t)
195
196
  print(" bound:", {k:v for k,v in os.environ.items() if k.startswith("TP_") and v})
@@ -13,7 +13,7 @@ and recoverable on failure.
13
13
  | `memories/MEMORY.md` | yes | The condensed mental-model summary loaded each session |
14
14
  | `memories/USER.md` | yes | The operator's persona (Jarad DeLorenzo, ...) |
15
15
  | `sessions/sessions.db` | yes (LFS) | SQLite store of every conversation |
16
- | `bloodbank-consumer.py` | yes | NATS subscriber for repo-scoped events |
16
+ | `bloodbank-consumer.py` | yes | NATS subscriber using canonical subjects plus envelope routing data |
17
17
  | `decisions/` | yes | Agent-emitted decisions, one file per important call |
18
18
  | `.env` | **no** | API keys + Telegram bot token (per-machine secret) |
19
19
  | `auth.json` | **no** | Deprecated local OAuth store; fleet auth defaults to `HERMES_OAUTH_FILE=~/.hermes/auth.json` |
@@ -3,14 +3,16 @@
3
3
  Bloodbank consumer for hermes agent {{agent_id}}.
4
4
 
5
5
  Subscribes to:
6
- bloodbank.evt.v1.repo.{{repo}}.>
7
- bloodbank.cmd.v1.agent.{{agent_id}}.>
6
+ bloodbank.evt.v1.repo.>
7
+ bloodbank.cmd.v1.agent.>
8
8
 
9
- This scaffold is a transitional receiver: it proves NATS subscription and keeps
10
- a durable audit copy at $HERMES_HOME/bloodbank-inbox/<timestamp>.json. It is
11
- not the final command path. Bloodbank command subjects should become a Hermes
12
- gateway/transport so each consumed event enters the same turn queue as Telegram
13
- or web input, with backpressure handled by the queue instead of by a file drain.
9
+ The Bloodbank subject identifies the fixed domain/entity/action route. Repo and
10
+ agent identifiers stay in envelope data, so this consumer filters repo events
11
+ by ``data.repo`` and agent commands by ``data.target_agent_id``.
12
+
13
+ For each event/command received, writes it to the agent's notification queue at
14
+ $HERMES_HOME/bloodbank-inbox/<timestamp>.json so the gateway can ingest it on
15
+ the next tick. Reconnects on broker churn; idempotent across restarts.
14
16
 
15
17
  Generated by hermes-agent-template/runtime-scaffold/bloodbank-consumer.py and
16
18
  rendered with role-specific values at provision time. Re-render by re-running
@@ -49,8 +51,8 @@ PRODUCER = f"hermes-agent:{AGENT_ID}"
49
51
  SOURCE = f"hermes://agent/{AGENT_ID}"
50
52
 
51
53
  SUBJECTS = [
52
- f"bloodbank.evt.v1.repo.{REPO}.>",
53
- f"bloodbank.cmd.v1.agent.{AGENT_ID}.>",
54
+ "bloodbank.evt.v1.repo.>",
55
+ "bloodbank.cmd.v1.agent.>",
54
56
  ]
55
57
 
56
58
  KIND_MARKERS = {"event": "evt", "command": "cmd", "reply": "rpy"}
@@ -62,8 +64,8 @@ def _now():
62
64
 
63
65
  def _subject_and_domain(ce_type, kind):
64
66
  parts = ce_type.split(".")
65
- if len(parts) != 5 or parts[0] != "bloodbank" or not parts[1].startswith("v"):
66
- raise ValueError(f"type {ce_type!r} must match bloodbank.vN.<domain>.<entity>.<action>")
67
+ if len(parts) != 5 or parts[0] != "bloodbank" or parts[1] != "v1":
68
+ raise ValueError(f"type {ce_type!r} must match bloodbank.v1.<domain>.<entity>.<action>")
67
69
  if kind not in KIND_MARKERS:
68
70
  raise ValueError(f"unknown envelope kind {kind!r}")
69
71
  _vendor, version, domain, entity, action = parts
@@ -95,6 +97,33 @@ def build_envelope(ce_type, data, *, kind="event", correlationid=None, causation
95
97
  return env
96
98
 
97
99
 
100
+ def _is_for_consumer(subject, payload):
101
+ """Route canonical subjects using identifiers carried in envelope data."""
102
+ if not isinstance(payload, dict):
103
+ return False
104
+ parts = subject.split(".")
105
+ if len(parts) != 6 or parts[0] != "bloodbank" or parts[2] != "v1":
106
+ return False
107
+
108
+ _vendor, kind, version, domain, entity, action = parts
109
+ if kind not in KIND_MARKERS.values():
110
+ return False
111
+ if KIND_MARKERS.get(payload.get("kind")) != kind:
112
+ return False
113
+ if payload.get("subject") != subject:
114
+ return False
115
+ if payload.get("type") != f"bloodbank.{version}.{domain}.{entity}.{action}":
116
+ return False
117
+ data = payload.get("data")
118
+ if not isinstance(data, dict):
119
+ return False
120
+ if kind == "evt" and domain == "repo":
121
+ return data.get("repo") == REPO
122
+ if kind == "cmd" and domain == "agent":
123
+ return data.get("target_agent_id") == AGENT_ID
124
+ return False
125
+
126
+
98
127
  async def main():
99
128
  nc = await nats.connect(NATS_URL, name=f"hermes-{AGENT_ID}")
100
129
  stop = asyncio.Event()
@@ -103,7 +132,10 @@ async def main():
103
132
  try:
104
133
  payload = json.loads(msg.data.decode())
105
134
  except Exception:
106
- payload = {"_raw": msg.data.decode(errors="replace")}
135
+ sys.stderr.write(f"[bloodbank-consumer:{AGENT_ID}] skipped unroutable payload on {msg.subject}\n")
136
+ return
137
+ if not _is_for_consumer(msg.subject, payload):
138
+ return
107
139
  entry = {
108
140
  "received_at": _now(),
109
141
  "subject": msg.subject,
@@ -125,7 +157,7 @@ async def main():
125
157
  "bloodbank.v1.agent.online.changed",
126
158
  {"agent_id": AGENT_ID, "repo": REPO, "role": ROLE, "state": "online"},
127
159
  )
128
- await nc.publish("bloodbank.evt.v1.agent.online.changed", json.dumps(online).encode())
160
+ await nc.publish(online["subject"], json.dumps(online).encode())
129
161
 
130
162
  def _shutdown(*_):
131
163
  stop.set()
@@ -138,7 +170,7 @@ async def main():
138
170
  "bloodbank.v1.agent.online.changed",
139
171
  {"agent_id": AGENT_ID, "repo": REPO, "role": ROLE, "state": "offline"},
140
172
  )
141
- await nc.publish("bloodbank.evt.v1.agent.online.changed", json.dumps(offline).encode())
173
+ await nc.publish(offline["subject"], json.dumps(offline).encode())
142
174
  await nc.drain()
143
175
 
144
176
 
@@ -7,8 +7,8 @@ short and high-signal — anything that should ALWAYS be in context.
7
7
 
8
8
  You are **{{display_name}}**, the `{{role}}` agent for the `{{repo}}` repo.
9
9
  Your scope is the repo at the project root. You communicate via Telegram
10
- (`@{{repo}}_{{role}}_bot`) and Bloodbank events on the
11
- `bloodbank.evt.v1.repo.{{repo}}.*` lane.
10
+ (`@{{repo}}_{{role}}_bot`) and canonical Bloodbank repo events on
11
+ `bloodbank.evt.v1.repo.>`, filtered by `data.repo = {{repo}}`.
12
12
 
13
13
  ## Operator
14
14
 
@@ -42,11 +42,7 @@ for p in sys.argv[2].split("."):
42
42
  cur = cur[p]
43
43
  else:
44
44
  print(sys.argv[3], end=""); sys.exit(0)
45
- if isinstance(cur, list):
46
- cur = " ".join(str(x) for x in cur)
47
- else:
48
- cur = str(cur)
49
- print(os.path.expanduser(cur), end="")
45
+ print(os.path.expanduser(str(cur)), end="")
50
46
  PYEOF
51
47
  return 0
52
48
  }
@@ -60,7 +56,6 @@ REGISTRY_FILE="${HERMES_FLEET_REGISTRY_FILE:-$(cfg fleet.registry_file "$HOME/.h
60
56
  PROFILES_DIR="${HERMES_FLEET_HOME:-$HOME/.hermes}/profiles"
61
57
  VOXXY_PLUGIN_DIR="${VOXXY_PLUGIN_DIR:-$(cfg fleet.voxxy_plugin_dir "$HOME/code/voxxy/plugins/tts/voxxy")}"
62
58
  VOX_URL_VALUE="${VOX_URL:-$(cfg fleet.vox_url 'https://vox.delo.sh')}"
63
- PM_EXTERNAL_SKILL_DIRS="${PM_EXTERNAL_SKILL_DIRS:-$(cfg fleet.pm_external_skill_dirs "$HOME/code/skillex/skill-sets/global/.system $HOME/code/skillex/packs/bmad/6.10.2")}"
64
59
 
65
60
  APPLY=0
66
61
  RESTART=1
@@ -141,42 +136,6 @@ path.write_text("\n".join(lines) + "\n")
141
136
  PYEOF
142
137
  }
143
138
 
144
- pm_skill_dirs_status() { # pm_skill_dirs_status <config-path>
145
- python3 - "$1" "$PM_EXTERNAL_SKILL_DIRS" <<'PYEOF'
146
- import pathlib
147
- import sys
148
-
149
- import yaml
150
-
151
- path = pathlib.Path(sys.argv[1])
152
- desired = [entry for entry in sys.argv[2].split() if entry]
153
- if not path.exists():
154
- print('missing')
155
- raise SystemExit(0)
156
- data = yaml.safe_load(path.read_text()) or {}
157
- skills = data.get('skills') or {}
158
- current = list(skills.get('external_dirs') or [])
159
- print('ok' if current == desired else 'mismatch')
160
- PYEOF
161
- }
162
-
163
- set_pm_skill_dirs() { # set_pm_skill_dirs <config-path>
164
- python3 - "$1" "$PM_EXTERNAL_SKILL_DIRS" <<'PYEOF'
165
- import pathlib
166
- import sys
167
-
168
- import yaml
169
-
170
- path = pathlib.Path(sys.argv[1])
171
- desired = [entry for entry in sys.argv[2].split() if entry]
172
- data = yaml.safe_load(path.read_text()) or {}
173
- skills = data.get('skills') or {}
174
- skills['external_dirs'] = desired
175
- data['skills'] = skills
176
- path.write_text(yaml.safe_dump(data, sort_keys=False))
177
- PYEOF
178
- }
179
-
180
139
  wanted_agent() {
181
140
  [[ ${#ONLY_AGENTS[@]} -eq 0 ]] && return 0
182
141
  local a
@@ -279,28 +238,6 @@ while IFS=$'\t' read -r agent_id role_dir profile_name gateway_unit consumer_uni
279
238
  # 5. PM fleet voice contract: runtime plugin symlink + Voxxy config + VOX_URL.
280
239
  role_name="$(basename "$role_dir")"
281
240
  if [[ "$role_name" == "pm" && -d "$runtime" ]]; then
282
- runtime_config="$runtime/config.yaml"
283
- if [[ -n "$PM_EXTERNAL_SKILL_DIRS" ]]; then
284
- skill_dir_status="$(pm_skill_dirs_status "$runtime_config")"
285
- case "$skill_dir_status" in
286
- ok) ;;
287
- missing)
288
- note "$agent_id" DRIFT "runtime config missing: $runtime_config"
289
- DRIFT=$((DRIFT + 1))
290
- ;;
291
- *)
292
- if [[ $APPLY -eq 1 ]]; then
293
- set_pm_skill_dirs "$runtime_config"
294
- note "$agent_id" FIXED "PM runtime skills.external_dirs synchronized"
295
- changed=1; FIXED=$((FIXED + 1))
296
- else
297
- note "$agent_id" DRIFT "PM runtime skills.external_dirs differ from fleet contract"
298
- DRIFT=$((DRIFT + 1))
299
- fi
300
- ;;
301
- esac
302
- fi
303
-
304
241
  plugin_link="$runtime/plugins/tts/voxxy"
305
242
  if [[ -d "$VOXXY_PLUGIN_DIR" ]]; then
306
243
  if [[ -L "$plugin_link" ]]; then
@@ -4,5 +4,3 @@
4
4
  # We just ignore the provisioning log so it doesn't end up in the project repo.
5
5
  .scripts/.provision.log
6
6
  .scripts/.last-run
7
- runtime/
8
-
@@ -13,7 +13,7 @@ and recoverable on failure.
13
13
  | `memories/MEMORY.md` | yes | The condensed mental-model summary loaded each session |
14
14
  | `memories/USER.md` | yes | The operator's persona (Jarad DeLorenzo, ...) |
15
15
  | `sessions/sessions.db` | yes (LFS) | SQLite store of every conversation |
16
- | `bloodbank-consumer.py` | yes | NATS subscriber for repo-scoped events |
16
+ | `bloodbank-consumer.py` | yes | NATS subscriber using canonical subjects plus envelope routing data |
17
17
  | `decisions/` | yes | Agent-emitted decisions, one file per important call |
18
18
  | `.env` | **no** | API keys + Telegram bot token (per-machine secret) |
19
19
  | `auth.json` | **no** | Deprecated local OAuth store; fleet auth defaults to `HERMES_OAUTH_FILE=~/.hermes/auth.json` |
@@ -3,8 +3,12 @@
3
3
  Bloodbank consumer for hermes agent {{agent_id}}.
4
4
 
5
5
  Subscribes to:
6
- bloodbank.evt.v1.repo.{{repo}}.>
7
- bloodbank.cmd.v1.agent.{{agent_id}}.>
6
+ bloodbank.evt.v1.repo.>
7
+ bloodbank.cmd.v1.agent.>
8
+
9
+ The Bloodbank subject identifies the fixed domain/entity/action route. Repo and
10
+ agent identifiers stay in envelope data, so this consumer filters repo events
11
+ by ``data.repo`` and agent commands by ``data.target_agent_id``.
8
12
 
9
13
  For each event/command received, writes it to the agent's notification queue at
10
14
  $HERMES_HOME/bloodbank-inbox/<timestamp>.json so the gateway can ingest it on
@@ -47,8 +51,8 @@ PRODUCER = f"hermes-agent:{AGENT_ID}"
47
51
  SOURCE = f"hermes://agent/{AGENT_ID}"
48
52
 
49
53
  SUBJECTS = [
50
- f"bloodbank.evt.v1.repo.{REPO}.>",
51
- f"bloodbank.cmd.v1.agent.{AGENT_ID}.>",
54
+ "bloodbank.evt.v1.repo.>",
55
+ "bloodbank.cmd.v1.agent.>",
52
56
  ]
53
57
 
54
58
  KIND_MARKERS = {"event": "evt", "command": "cmd", "reply": "rpy"}
@@ -60,8 +64,8 @@ def _now():
60
64
 
61
65
  def _subject_and_domain(ce_type, kind):
62
66
  parts = ce_type.split(".")
63
- if len(parts) != 5 or parts[0] != "bloodbank" or not parts[1].startswith("v"):
64
- raise ValueError(f"type {ce_type!r} must match bloodbank.vN.<domain>.<entity>.<action>")
67
+ if len(parts) != 5 or parts[0] != "bloodbank" or parts[1] != "v1":
68
+ raise ValueError(f"type {ce_type!r} must match bloodbank.v1.<domain>.<entity>.<action>")
65
69
  if kind not in KIND_MARKERS:
66
70
  raise ValueError(f"unknown envelope kind {kind!r}")
67
71
  _vendor, version, domain, entity, action = parts
@@ -93,6 +97,33 @@ def build_envelope(ce_type, data, *, kind="event", correlationid=None, causation
93
97
  return env
94
98
 
95
99
 
100
+ def _is_for_consumer(subject, payload):
101
+ """Route canonical subjects using identifiers carried in envelope data."""
102
+ if not isinstance(payload, dict):
103
+ return False
104
+ parts = subject.split(".")
105
+ if len(parts) != 6 or parts[0] != "bloodbank" or parts[2] != "v1":
106
+ return False
107
+
108
+ _vendor, kind, version, domain, entity, action = parts
109
+ if kind not in KIND_MARKERS.values():
110
+ return False
111
+ if KIND_MARKERS.get(payload.get("kind")) != kind:
112
+ return False
113
+ if payload.get("subject") != subject:
114
+ return False
115
+ if payload.get("type") != f"bloodbank.{version}.{domain}.{entity}.{action}":
116
+ return False
117
+ data = payload.get("data")
118
+ if not isinstance(data, dict):
119
+ return False
120
+ if kind == "evt" and domain == "repo":
121
+ return data.get("repo") == REPO
122
+ if kind == "cmd" and domain == "agent":
123
+ return data.get("target_agent_id") == AGENT_ID
124
+ return False
125
+
126
+
96
127
  async def main():
97
128
  nc = await nats.connect(NATS_URL, name=f"hermes-{AGENT_ID}")
98
129
  stop = asyncio.Event()
@@ -101,7 +132,10 @@ async def main():
101
132
  try:
102
133
  payload = json.loads(msg.data.decode())
103
134
  except Exception:
104
- payload = {"_raw": msg.data.decode(errors="replace")}
135
+ sys.stderr.write(f"[bloodbank-consumer:{AGENT_ID}] skipped unroutable payload on {msg.subject}\n")
136
+ return
137
+ if not _is_for_consumer(msg.subject, payload):
138
+ return
105
139
  entry = {
106
140
  "received_at": _now(),
107
141
  "subject": msg.subject,
@@ -123,7 +157,7 @@ async def main():
123
157
  "bloodbank.v1.agent.online.changed",
124
158
  {"agent_id": AGENT_ID, "repo": REPO, "role": ROLE, "state": "online"},
125
159
  )
126
- await nc.publish("bloodbank.evt.v1.agent.online.changed", json.dumps(online).encode())
160
+ await nc.publish(online["subject"], json.dumps(online).encode())
127
161
 
128
162
  def _shutdown(*_):
129
163
  stop.set()
@@ -136,7 +170,7 @@ async def main():
136
170
  "bloodbank.v1.agent.online.changed",
137
171
  {"agent_id": AGENT_ID, "repo": REPO, "role": ROLE, "state": "offline"},
138
172
  )
139
- await nc.publish("bloodbank.evt.v1.agent.online.changed", json.dumps(offline).encode())
173
+ await nc.publish(offline["subject"], json.dumps(offline).encode())
140
174
  await nc.drain()
141
175
 
142
176
 
@@ -7,8 +7,8 @@ short and high-signal — anything that should ALWAYS be in context.
7
7
 
8
8
  You are **{{display_name}}**, the `{{role}}` agent for the `{{repo}}` repo.
9
9
  Your scope is the repo at the project root. You communicate via Telegram
10
- (`@{{repo}}_{{role}}_bot`) and Bloodbank events on the
11
- `bloodbank.evt.v1.repo.{{repo}}.*` lane.
10
+ (`@{{repo}}_{{role}}_bot`) and canonical Bloodbank repo events on
11
+ `bloodbank.evt.v1.repo.>`, filtered by `data.repo = {{repo}}`.
12
12
 
13
13
  ## Operator
14
14
 
@@ -26,7 +26,6 @@ hermes_repo = "~/.hermes/hermes-agent"
26
26
  fleet_env = "~/.hermes/fleet.env"
27
27
  registry_file = "~/.hermes/agents-registry.yaml"
28
28
  canonical_skills_dir = "/home/delorenj/.agents/skills"
29
- pm_external_skill_dirs = ["~/code/skillex/skill-sets/global/.system", "~/code/skillex/packs/bmad/6.10.2"]
30
29
 
31
30
  [github]
32
31
  runtime_repo_owner = "delorenj"
@@ -47,15 +47,6 @@ PYEOF
47
47
 
48
48
  upsert_fleet_env HERMES_FLEET_OAUTH_FILE "$HERMES_OAUTH_FILE"
49
49
  upsert_fleet_env HERMES_FLEET_CODEX_HOME "$CODEX_HOME"
50
-
51
- PLANE_WORKSPACE_KEY="$(plane_workspace_key "$PLANE_WORKSPACE")"
52
- PLANE_WORKSPACE_VALUE="${!PLANE_WORKSPACE_KEY:-}"
53
- if [[ -z "$PLANE_WORKSPACE_VALUE" && -n "${PLANE_API_KEY:-}" ]]; then
54
- PLANE_WORKSPACE_VALUE="$PLANE_API_KEY"
55
- fi
56
- if [[ -n "$PLANE_WORKSPACE_VALUE" ]]; then
57
- upsert_fleet_env "$PLANE_WORKSPACE_KEY" "$PLANE_WORKSPACE_VALUE"
58
- fi
59
50
  chmod 600 "$FLEET_ENV"
60
51
 
61
52
  mark_done 05-fleet-env
@@ -110,29 +110,10 @@ CANONICAL_SKILLS_DIR="${CANONICAL_SKILLS_DIR:-$(config_get fleet.canonical_skill
110
110
  CANONICAL_PM_SKILL_SRC="$CANONICAL_SKILLS_DIR/subagent-driven-development"
111
111
  LOCAL_PM_SKILL_DST="$PROFILE_HOME/skills/software-development/subagent-driven-development"
112
112
 
113
- if [[ "$ROLE" == "pm" ]]; then
114
- PM_EXTERNAL_SKILL_DIRS="${PM_EXTERNAL_SKILL_DIRS:-$(config_get fleet.pm_external_skill_dirs "$HOME/code/skillex/skill-sets/global/.system $HOME/code/skillex/packs/bmad/6.10.2")}"
115
- read -r -a RESOLVED_PM_EXTERNAL_SKILL_DIRS <<< "$PM_EXTERNAL_SKILL_DIRS"
116
- if [[ ${#RESOLVED_PM_EXTERNAL_SKILL_DIRS[@]} -gt 0 ]]; then
117
- log " setting PM skills.external_dirs = ${RESOLVED_PM_EXTERNAL_SKILL_DIRS[*]}"
118
- python3 - "$PROFILE_HOME/config.yaml" "${RESOLVED_PM_EXTERNAL_SKILL_DIRS[@]}" <<'PYEOF'
119
- import pathlib
120
- import sys
121
-
122
- import yaml
123
-
124
- path = pathlib.Path(sys.argv[1])
125
- external_dirs = sys.argv[2:]
126
- data = yaml.safe_load(path.read_text()) or {}
127
- skills = data.get("skills") or {}
128
- skills["external_dirs"] = external_dirs
129
- data["skills"] = skills
130
- path.write_text(yaml.safe_dump(data, sort_keys=False))
131
- PYEOF
132
- fi
133
- fi
134
-
135
113
  if [[ -d "$CANONICAL_SKILLS_DIR" ]]; then
114
+ log " setting skills.external_dirs[0] = $CANONICAL_SKILLS_DIR"
115
+ env HERMES_HOME="$PROFILE_HOME" "$HERMES_BIN" config set skills.external_dirs.0 "$CANONICAL_SKILLS_DIR"
116
+
136
117
  # Ensure key PM/local-ops skills are symlinked into runtime/profile skills root.
137
118
  # This preserves canonical ownership and keeps updates instant across agents.
138
119
  read -r -a SYMLINKED_RUNTIME_SKILLS <<< "${SYMLINKED_RUNTIME_SKILLS:-$(config_get fleet.symlinked_runtime_skills 'delonet-conventions delonet-dotenv hermes-pm-template-maintenance hindsight subagent-driven-development')}"
@@ -144,28 +144,6 @@ log " profile symlink $PROFILE_HOME -> $RUNTIME_LOCAL"
144
144
  # Apply the one genuine per-repo config delta directly to the runtime config.
145
145
  env HERMES_HOME="$RUNTIME_LOCAL" "$HERMES_BIN" config set terminal.cwd "$PROJECT_PATH" >/dev/null 2>&1 || true
146
146
 
147
- if [[ "$ROLE" == "pm" ]]; then
148
- PM_EXTERNAL_SKILL_DIRS="${PM_EXTERNAL_SKILL_DIRS:-$(config_get fleet.pm_external_skill_dirs "$HOME/code/skillex/skill-sets/global/.system $HOME/code/skillex/packs/bmad/6.10.2")}"
149
- read -r -a RESOLVED_PM_EXTERNAL_SKILL_DIRS <<< "$PM_EXTERNAL_SKILL_DIRS"
150
- if [[ ${#RESOLVED_PM_EXTERNAL_SKILL_DIRS[@]} -gt 0 ]]; then
151
- python3 - "$RUNTIME_LOCAL/config.yaml" "${RESOLVED_PM_EXTERNAL_SKILL_DIRS[@]}" <<'PYEOF'
152
- import pathlib
153
- import sys
154
-
155
- import yaml
156
-
157
- path = pathlib.Path(sys.argv[1])
158
- external_dirs = sys.argv[2:]
159
- data = yaml.safe_load(path.read_text()) or {}
160
- skills = data.get("skills") or {}
161
- skills["external_dirs"] = external_dirs
162
- data["skills"] = skills
163
- path.write_text(yaml.safe_dump(data, sort_keys=False))
164
- PYEOF
165
- log " set PM runtime skills.external_dirs -> ${RESOLVED_PM_EXTERNAL_SKILL_DIRS[*]}"
166
- fi
167
- fi
168
-
169
147
  if [[ "$ROLE" == "pm" ]]; then
170
148
  VOXXY_PLUGIN_DIR="${VOXXY_PLUGIN_DIR:-$(config_get fleet.voxxy_plugin_dir "$HOME/code/voxxy/plugins/tts/voxxy")}"
171
149
  if [[ -d "$VOXXY_PLUGIN_DIR" ]]; then