@delorenj/pjangler 1.2.18 → 1.2.21

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 (57) hide show
  1. package/dist/index.js +884 -295
  2. package/dist/mcp-server.js +892 -303
  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 +11 -31
  7. package/templates/commonproject/template/.agents/hooks/README.md +13 -26
  8. package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
  9. package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
  10. package/templates/commonproject/template/.agents/local.example.json +2 -8
  11. package/templates/commonproject/template/.agents/skills.json +6 -0
  12. package/templates/commonproject/template/.project.json.jinja +9 -13
  13. package/templates/commonproject/template/mise.toml.jinja +9 -16
  14. package/templates/hermes-agent/README.md +9 -9
  15. package/templates/hermes-agent/config.example.toml +1 -66
  16. package/templates/hermes-agent/copier.yml +4 -3
  17. package/templates/hermes-agent/docs/architecture.md +9 -12
  18. package/templates/hermes-agent/docs/fleet-control-plane/README.md +1 -1
  19. package/templates/hermes-agent/docs/operations.md +6 -5
  20. package/templates/hermes-agent/docs/sentinel/README.md +9 -7
  21. package/templates/hermes-agent/docs/sentinel/architecture.md +1 -2
  22. package/templates/hermes-agent/docs/sentinel/development.md +12 -13
  23. package/templates/hermes-agent/docs/sentinel/providers.md +34 -15
  24. package/templates/hermes-agent/install-local.sh +15 -14
  25. package/templates/hermes-agent/runtime-scaffold/README.md +1 -1
  26. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +46 -14
  27. package/templates/hermes-agent/runtime-scaffold/memories/MEMORY.md +2 -2
  28. package/templates/hermes-agent/scripts/fleet-sync.sh +1 -64
  29. package/templates/hermes-agent/template/.gitignore.jinja +0 -2
  30. package/templates/hermes-agent/template/.runtime-scaffold/README.md +1 -1
  31. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +43 -9
  32. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +2 -2
  33. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -1
  34. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -9
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +3 -22
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -22
  37. package/templates/hermes-agent/template/.scripts/40-plane.sh +51 -0
  38. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +59 -21
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +2 -1
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +1 -10
  41. package/templates/hermes-agent/template/.scripts/_lib.sh +3 -61
  42. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -5
  43. package/templates/hermes-agent/template/.scripts/heartbeat.sh +33 -66
  44. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +5 -9
  45. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
  46. package/templates/hermes-agent/template/.scripts/providers/linear.sh +176 -0
  47. package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -29
  48. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +2 -2
  49. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +1 -33
  50. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +22 -27
  51. package/templates/hermes-agent/template/SOUL.md.jinja +49 -30
  52. package/templates/hermes-agent/template/role.yaml.jinja +35 -4
  53. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +138 -0
  54. package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
  55. package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
  56. package/templates/hermes-agent/docs/bloodbank-gateway.md +0 -57
  57. package/templates/hermes-agent/docs/fleet-control-plane/n8n-service-hub.md +0 -60
@@ -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
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # Create a Plane project in the configured workspace (one project per agent).
3
+ # Workspace + base URL come from role.yaml / config.toml (see _lib.sh).
4
+ # shellcheck source=_lib.sh
5
+ source "$(dirname "$0")/_lib.sh"
6
+ load_role_env
7
+
8
+ already_done 40-plane && { log "[40] plane already set up — skipping"; exit 0; }
9
+ [[ "${SKIP_PLANE:-0}" == "1" ]] && { log "[40] plane — SKIPPED"; mark_done 40-plane; exit 0; }
10
+
11
+ [[ -n "$PLANE_API_KEY" ]] || { warn "[40] PLANE_API_KEY not set; skipping. set PLANE_33GOD_API_KEY and re-run ./.scripts/40-plane.sh"; exit 0; }
12
+
13
+ # Build the 5-char identifier: <repo[0..2]><role[0..1]> uppercased
14
+ RAW=$(printf '%s%s' "${REPO:0:3}" "${ROLE:0:2}" | tr -cd '[:alnum:]' | tr '[:lower:]' '[:upper:]')
15
+ while (( ${#RAW} < 3 )); do RAW="${RAW}X"; done
16
+ IDENT="${RAW:0:5}"
17
+
18
+ # Project name = display_name (already smart-cased: "Bloodbank PM", "Hermes-Agent Dev").
19
+ # Plane forbids hyphens in names — substitute spaces.
20
+ NAME="${DISPLAY_NAME//-/ }"
21
+
22
+ log "[40] creating plane project '$NAME' [$IDENT]"
23
+
24
+ # Check for existing project with this identifier
25
+ EXISTING=$(curl -sS "$PLANE_BASE/api/v1/workspaces/$PLANE_WORKSPACE/projects/?per_page=200" \
26
+ -H "X-API-Key: $PLANE_API_KEY" \
27
+ | python3 -c "
28
+ import sys, json
29
+ d = json.load(sys.stdin)
30
+ ident = '$IDENT'
31
+ for p in d.get('results', []):
32
+ if (p.get('identifier') or '').upper() == ident:
33
+ print(p['id']); break")
34
+
35
+ if [[ -n "$EXISTING" ]]; then
36
+ log " plane project $IDENT already exists (id=$EXISTING) — reusing"
37
+ PROJECT_ID="$EXISTING"
38
+ else
39
+ RESP=$(curl -sS -X POST "$PLANE_BASE/api/v1/workspaces/$PLANE_WORKSPACE/projects/" \
40
+ -H "X-API-Key: $PLANE_API_KEY" \
41
+ -H "Content-Type: application/json" \
42
+ -d "$(python3 -c "import json,sys; print(json.dumps({'name': sys.argv[1], 'identifier': sys.argv[2], 'description': sys.argv[3]}))" \
43
+ "$NAME" "$IDENT" "Hermes agent board for $AGENT_ID")")
44
+ PROJECT_ID=$(echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id') or '')")
45
+ [[ -n "$PROJECT_ID" ]] || die "plane create failed: $RESP"
46
+ log " plane project created id=$PROJECT_ID"
47
+ fi
48
+
49
+ yaml_set plane.identifier "$IDENT"
50
+ echo "$PROJECT_ID" > "$ROLE_DIR/.scripts/.plane-project-id"
51
+ mark_done 40-plane
@@ -10,12 +10,11 @@
10
10
  # 2. Otherwise (hermes run on a repo with no CommonProject board yet)
11
11
  # -> create ONE repo-named board and write it back into .project.json so
12
12
  # it becomes the SOT for every agent in this repo.
13
- # Either way we register this agent under .project.json `agents`. role.yaml is
14
- # identity/personality only; it must not carry board UUIDs or derived URLs.
13
+ # Either way we register this agent under .project.json `agents` and mirror the
14
+ # binding into role.yaml for back-compat (80-registry.sh / 99-summary.sh).
15
15
  # shellcheck source=_lib.sh
16
16
  source "$(dirname "$0")/_lib.sh"
17
17
  load_role_env
18
- resolve_plane_api_key "$PLANE_WORKSPACE"
19
18
  # shellcheck source=lib/ticket-provider.sh
20
19
  source "$(dirname "$0")/lib/ticket-provider.sh"
21
20
 
@@ -47,13 +46,13 @@ PY
47
46
  }
48
47
 
49
48
  # pj_write — merge board binding (optional) + this agent into .project.json.
50
- # args: set_provider(0|1) provider board_id workspace identifier
49
+ # args: set_provider(0|1) provider board_id board_url workspace identifier team
51
50
  pj_write() {
52
51
  REPO="$REPO" REPO_ROOT="$REPO_ROOT" AGENT_ID="$AGENT_ID" ROLE="$ROLE" \
53
52
  ROLE_DIR_REL="$ROLE_DIR_REL" PROJECT_DESC="${PROJECT_DESC:-}" \
54
53
  python3 - "$PROJECT_JSON" "$@" <<'PY'
55
54
  import sys, os, json, pathlib
56
- (path, set_provider, provider, board_id, workspace, identifier) = sys.argv[1:7]
55
+ (path, set_provider, provider, board_id, board_url, workspace, identifier, team) = sys.argv[1:9]
57
56
  p = pathlib.Path(path)
58
57
  try:
59
58
  d = json.loads(p.read_text())
@@ -72,30 +71,47 @@ if set_provider == "1":
72
71
  if workspace: tp["workspace"] = workspace
73
72
  if identifier: tp["identifier"] = identifier
74
73
  if board_id: tp["board_id"] = board_id
75
- tp.pop("board_url", None)
76
- tp["state"] = "linked" if board_id else tp.get("state", "planned")
74
+ if board_url: tp["board_url"] = board_url
75
+ if team: tp["team"] = team
77
76
  ag = d.setdefault("agents", {})
78
77
  ag[os.environ["AGENT_ID"]] = {"role": os.environ["ROLE"], "role_dir": os.environ["ROLE_DIR_REL"]}
79
- automation = d.setdefault("automation", {})
80
- automation.setdefault("reconcile", {"enabled": False, "grace_hours": 0, "auto_review": True})
81
78
  p.write_text(json.dumps(d, indent=2) + "\n")
82
79
  PY
83
80
  log " .project.json updated (agent=$AGENT_ID)"
84
81
  }
85
82
 
86
- # Keep only the legacy adapter name in role.yaml. Stable board identity belongs
87
- # in .project.json.
88
- mirror_provider_name_to_role_yaml() {
89
- local provider="$1"
83
+ # Mirror the binding into role.yaml so legacy consumers keep working.
84
+ mirror_to_role_yaml() {
85
+ # mirror_to_role_yaml <provider> <board_id> <board_url> <workspace> <identifier> <team>
86
+ local provider="$1" bid="$2" burl="$3" ws="$4" ident="$5" team="$6"
90
87
  yaml_set ticket_provider.name "$provider" 2>/dev/null || true
88
+ [ -n "$bid" ] && yaml_set ticket_provider.board_id "$bid" 2>/dev/null || true
89
+ [ -n "$burl" ] && yaml_set ticket_provider.board_url "$burl" 2>/dev/null || true
90
+ case "$provider" in
91
+ plane)
92
+ [ -n "$bid" ] && echo "$bid" > "$ROLE_DIR/.scripts/.plane-project-id"
93
+ [ -n "$ws" ] && yaml_set ticket_provider.workspace "$ws" 2>/dev/null || true
94
+ [ -n "$bid" ] && yaml_set ticket_provider.project "$bid" 2>/dev/null || true
95
+ [ -n "$ws" ] && yaml_set plane.workspace "$ws" 2>/dev/null || true
96
+ [ -n "$ident" ] && yaml_set plane.identifier "$ident" 2>/dev/null || true
97
+ ;;
98
+ trello)
99
+ [ -n "$bid" ] && yaml_set ticket_provider.board "$bid" 2>/dev/null || true
100
+ ;;
101
+ linear)
102
+ [ -n "$team" ] && yaml_set ticket_provider.team "$team" 2>/dev/null || true
103
+ ;;
104
+ esac
91
105
  }
92
106
 
93
107
  # ── Provider resolution ──────────────────────────────────────────────────
94
108
  # An existing repo board (in .project.json) wins — every agent binds to it.
95
109
  SOT_TYPE="$(pj ticket_provider.type)"
96
110
  SOT_BOARD_ID="$(pj ticket_provider.board_id)"
111
+ SOT_URL="$(pj ticket_provider.board_url)"
97
112
  SOT_WS="$(pj ticket_provider.workspace)"
98
113
  SOT_IDENT="$(pj ticket_provider.identifier)"
114
+ SOT_TEAM="$(pj ticket_provider.team)"
99
115
 
100
116
  # role.yaml provider comes from copier --data (the operator's pjangler choice).
101
117
  ROLE_PROVIDER="$(yaml_get ticket_provider.name)"
@@ -106,10 +122,9 @@ if [ -n "$SOT_BOARD_ID" ]; then
106
122
  if [ -n "$ROLE_PROVIDER" ] && [ "$ROLE_PROVIDER" != "$PROVIDER" ]; then
107
123
  warn "[42] requested provider '$ROLE_PROVIDER' but repo board is '$PROVIDER' (.project.json wins); binding to existing board"
108
124
  fi
109
- case "$PROVIDER" in plane|trello) ;; *) die "unsupported ticket provider in .project.json: $PROVIDER (expected plane|trello)" ;; esac
110
125
  log "[42] binding $AGENT_ID to existing repo board (provider=$PROVIDER, id=$SOT_BOARD_ID)"
111
- mirror_provider_name_to_role_yaml "$PROVIDER"
112
- pj_write 1 "$PROVIDER" "$SOT_BOARD_ID" "$SOT_WS" "$SOT_IDENT"
126
+ mirror_to_role_yaml "$PROVIDER" "$SOT_BOARD_ID" "$SOT_URL" "$SOT_WS" "$SOT_IDENT" "$SOT_TEAM"
127
+ pj_write 0 "$PROVIDER" "" "" "" "" "" # register agent only; board already recorded
113
128
  mark_done 42-ticket-provider
114
129
  exit 0
115
130
  fi
@@ -128,22 +143,45 @@ NAME="$(printf '%s' "$REPO" | tr '_-' ' ' | python3 -c 'import sys; print(" ".j
128
143
  DESC="Ticket board for $REPO"
129
144
 
130
145
  case "$PROVIDER" in
146
+ linear)
147
+ if [[ -z "${LINEAR_API_KEY:-}" ]]; then
148
+ warn "[42] LINEAR_API_KEY not set; set role.yaml/.project.json ticket_provider.team and re-run ./.scripts/42-ticket-provider.sh"
149
+ pj_write 1 linear "" "" "" "$IDENT" "$SOT_TEAM"
150
+ mark_done 42-ticket-provider; exit 0
151
+ fi
152
+ OUT="$(tp resolve 2>/dev/null || true)"
153
+ BID="$(printf '%s' "$OUT" | python3 -c 'import sys,json
154
+ try: print(json.load(sys.stdin).get("board_id",""))
155
+ except Exception: print("")')"
156
+ BURL="$(printf '%s' "$OUT" | python3 -c 'import sys,json
157
+ try: print(json.load(sys.stdin).get("board_url",""))
158
+ except Exception: print("")')"
159
+ if [ -n "$BID" ]; then
160
+ mirror_to_role_yaml linear "$BID" "$BURL" "" "$IDENT" "$SOT_TEAM"
161
+ pj_write 1 linear "$BID" "$BURL" "" "$IDENT" "$SOT_TEAM"
162
+ else
163
+ warn "[42] linear resolve returned no board; set ticket_provider.team and re-run"
164
+ pj_write 1 linear "" "" "" "$IDENT" "$SOT_TEAM"
165
+ fi
166
+ ;;
167
+
131
168
  plane|trello)
132
169
  KEYVAR=PLANE_API_KEY; [ "$PROVIDER" = trello ] && KEYVAR=TRELLO_KEY
133
170
  if [[ -z "${!KEYVAR:-}" ]]; then
134
171
  warn "[42] $KEYVAR not set; skipping board creation. Set creds and re-run ./.scripts/42-ticket-provider.sh"
135
- pj_write 1 "$PROVIDER" "" "${SOT_WS:-${PLANE_WORKSPACE:-}}" "$IDENT"
172
+ pj_write 1 "$PROVIDER" "" "" "${SOT_WS:-$PLANE_WORKSPACE}" "$IDENT" ""
136
173
  mark_done 42-ticket-provider; exit 0
137
174
  fi
138
175
  OUT="$(tp create_board "$NAME" "$IDENT" "$DESC")" || die "create_board failed for $PROVIDER"
139
176
  BID="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_id",""))')"
140
- WS="${SOT_WS:-${PLANE_WORKSPACE:-}}"
177
+ BURL="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_url",""))')"
178
+ WS="${SOT_WS:-$PLANE_WORKSPACE}"
141
179
  [ "$PROVIDER" = trello ] && WS=""
142
- mirror_provider_name_to_role_yaml "$PROVIDER"
143
- pj_write 1 "$PROVIDER" "$BID" "$WS" "$IDENT"
180
+ mirror_to_role_yaml "$PROVIDER" "$BID" "$BURL" "$WS" "$IDENT" ""
181
+ pj_write 1 "$PROVIDER" "$BID" "$BURL" "$WS" "$IDENT" ""
144
182
  ;;
145
183
 
146
- *) die "unknown ticket provider: $PROVIDER (expected plane|trello)" ;;
184
+ *) die "unknown ticket provider: $PROVIDER (expected linear|plane|trello)" ;;
147
185
  esac
148
186
 
149
187
  mark_done 42-ticket-provider
@@ -20,7 +20,8 @@ log "[60] installing bloodbank consumer for $AGENT_ID"
20
20
  # placeholders. If $RUNTIME/bloodbank-consumer.py was ever lost (runtime
21
21
  # submodule wiped, manual rm, etc.) and then someone restored it by
22
22
  # copying from $RUNTIME_SCAFFOLD_DIR, they'd end up with an un-rendered
23
- # consumer subscribing to literal `bloodbank.evt.v1.repo.{{repo}}.>`.
23
+ # consumer whose envelope routing filter compares against literal `{{repo}}`
24
+ # and `{{agent_id}}` values.
24
25
  # This block handles both failure modes idempotently.
25
26
  CONSUMER="$RUNTIME/bloodbank-consumer.py"
26
27
  SCAFFOLD_CONSUMER="$RUNTIME_SCAFFOLD_DIR/bloodbank-consumer.py"
@@ -5,10 +5,7 @@
5
5
  source "$(dirname "$0")/_lib.sh"
6
6
  load_role_env
7
7
 
8
- if already_done 70-systemd && [[ "${FORCE_SYSTEMD:-0}" != "1" ]]; then
9
- log "[70] systemd already installed — skipping"
10
- exit 0
11
- fi
8
+ already_done 70-systemd && { log "[70] systemd already installed — skipping"; exit 0; }
12
9
  [[ "${SKIP_SYSTEMD:-0}" == "1" ]] && { log "[70] systemd — SKIPPED"; mark_done 70-systemd; exit 0; }
13
10
 
14
11
  RUNTIME="$ROLE_DIR/runtime"
@@ -35,9 +32,6 @@ Type=simple
35
32
  Environment=HERMES_HOME=$RUNTIME
36
33
  Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
37
34
  Environment=CODEX_HOME=$CODEX_HOME
38
- Environment=HERMES_KANBAN_DISPATCH_IN_GATEWAY=0
39
- Environment=HERMES_KANBAN_SLASH_ENABLED=0
40
- EnvironmentFile=-$FLEET_ENV
41
35
  EnvironmentFile=-$RUNTIME/.env
42
36
  ExecStart=$HERMES_BIN gateway run --replace
43
37
  Restart=on-failure
@@ -63,7 +57,6 @@ WorkingDirectory=$RUNTIME
63
57
  Environment=HERMES_HOME=$RUNTIME
64
58
  Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
65
59
  Environment=CODEX_HOME=$CODEX_HOME
66
- EnvironmentFile=-$FLEET_ENV
67
60
  EnvironmentFile=-$RUNTIME/.env
68
61
  ExecStart=$HERMES_AGENT_REPO/.venv/bin/python $RUNTIME/bloodbank-consumer.py
69
62
  Restart=on-failure
@@ -93,7 +86,6 @@ WorkingDirectory=$REPO_ROOT
93
86
  Environment=HERMES_HOME=$RUNTIME
94
87
  Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
95
88
  Environment=CODEX_HOME=$CODEX_HOME
96
- EnvironmentFile=-$FLEET_ENV
97
89
  EnvironmentFile=-%h/.config/hermes-agent/env
98
90
  EnvironmentFile=-%h/.hermes/env
99
91
  EnvironmentFile=-%h/.hermes/hermes-agent.env
@@ -120,7 +112,6 @@ UNIT
120
112
 
121
113
  if systemd_user_available; then
122
114
  systemctl --user daemon-reload
123
- systemctl --user disable --now "hermes-${AGENT_ID}-checkpoint.timer" "hermes-${AGENT_ID}-checkpoint.service" >/dev/null 2>&1 || true
124
115
  # `enable --now` both enables (persist across login) AND starts the unit now, so a
125
116
  # freshly provisioned agent comes up live instead of dormant. Units with missing
126
117
  # creds (e.g. a gateway with no Telegram token yet) fail softly via Restart=on-failure.