@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
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env sh
2
+ # Linear ticket-provider adapter (reference implementation).
3
+ #
4
+ # Credentials: LINEAR_API_KEY
5
+ # Board binding (role.yaml `ticket_provider:`):
6
+ # name: linear
7
+ # team: <TEAM_KEY> e.g. DEL
8
+ # project: "<Project name>" optional; scopes milestone/issue queries
9
+ # state_map: { in_review: "In Review", completed: "Done" } optional overrides
10
+ #
11
+ # Implements the contract in lib/ticket-provider.sh. All Linear access goes
12
+ # through GraphQL so the same envelope works in unattended runs.
13
+ set -eu
14
+
15
+ OP="${1:-}"; shift 2>/dev/null || true
16
+ ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
17
+ ROLE_YAML="$ROLE_DIR/role.yaml"
18
+
19
+ die() { echo "linear: $*" >&2; exit 1; }
20
+ need_key() { [ -n "${LINEAR_API_KEY:-}" ] || die "LINEAR_API_KEY is not set"; }
21
+
22
+ # tp_cfg KEY — read ticket_provider.<KEY> from role.yaml (best-effort, flat).
23
+ tp_cfg() {
24
+ [ -f "$ROLE_YAML" ] || return 0
25
+ python3 - "$ROLE_YAML" "$1" <<'PY'
26
+ import sys, re, pathlib
27
+ text = pathlib.Path(sys.argv[1]).read_text()
28
+ m = re.search(r'(?ms)^ticket_provider:\s*$(.*?)(?=^\S)', text + "\n\x00")
29
+ block = m.group(1) if m else ""
30
+ key = sys.argv[2]
31
+ mm = re.search(rf'(?m)^\s*{re.escape(key)}:\s*"?([^"\n]*)"?\s*$', block)
32
+ print(mm.group(1).strip() if mm else "")
33
+ PY
34
+ }
35
+
36
+ # pj_cfg KEY — read ticket_provider.<KEY> from the repo-root .project.json (the
37
+ # SOT), walking up from the role dir. Preferred over role.yaml.
38
+ pj_cfg() {
39
+ python3 - "$ROLE_DIR" "$1" <<'PY'
40
+ import sys, json, pathlib
41
+ start = pathlib.Path(sys.argv[1]).resolve(); key = sys.argv[2]
42
+ for parent in [start, *start.parents]:
43
+ f = parent / ".project.json"
44
+ if f.is_file():
45
+ try: tp = (json.loads(f.read_text()).get("ticket_provider") or {})
46
+ except Exception: tp = {}
47
+ print(tp.get(key, "") if isinstance(tp, dict) else ""); break
48
+ else:
49
+ print("")
50
+ PY
51
+ }
52
+
53
+ # gql QUERY [VARS_JSON] — POST a GraphQL request, print data JSON, fail on errors.
54
+ gql() {
55
+ need_key
56
+ _vars="${2:-}"; [ -n "$_vars" ] || _vars='{}'
57
+ python3 - "$1" "$_vars" <<'PY'
58
+ import json, os, sys, urllib.request, urllib.error
59
+ q, variables = sys.argv[1], json.loads(sys.argv[2])
60
+ req = urllib.request.Request(
61
+ "https://api.linear.app/graphql",
62
+ data=json.dumps({"query": q, "variables": variables}).encode(),
63
+ headers={"Authorization": os.environ["LINEAR_API_KEY"],
64
+ "Content-Type": "application/json"},
65
+ method="POST")
66
+ try:
67
+ body = json.loads(urllib.request.urlopen(req, timeout=30).read())
68
+ except urllib.error.HTTPError as e:
69
+ body = json.loads(e.read() or "{}")
70
+ except urllib.error.URLError as e:
71
+ print(f"linear request failed: {e}", file=sys.stderr); sys.exit(1)
72
+ if body.get("errors"):
73
+ print(json.dumps(body["errors"]), file=sys.stderr); sys.exit(1)
74
+ print(json.dumps(body.get("data") or {}))
75
+ PY
76
+ }
77
+
78
+ TEAM="$(pj_cfg team)"; [ -n "$TEAM" ] || TEAM="$(tp_cfg team)"
79
+ PROJECT="$(pj_cfg project)"; [ -n "$PROJECT" ] || PROJECT="$(tp_cfg project)"
80
+ SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
81
+ SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
82
+
83
+ # All Linear ops require the API key; fail fast and clean before any pipe.
84
+ need_key
85
+
86
+ case "$OP" in
87
+ resolve)
88
+ [ -n "$TEAM" ] || die "ticket_provider.team (Linear team key) not set in role.yaml"
89
+ gql 'query($k:String!){ teams(filter:{key:{eq:$k}}){nodes{id key name}} }' \
90
+ "$(printf '{"k":"%s"}' "$TEAM")" \
91
+ | python3 -c 'import sys,json; d=json.load(sys.stdin); t=(d.get("teams",{}).get("nodes") or [{}])[0]; print(json.dumps({"provider":"linear","board_id":t.get("id",""),"board_url":"https://linear.app/team/"+t.get("key","")}))'
92
+ ;;
93
+
94
+ active_milestone)
95
+ # Linear project milestones; pick the first non-completed milestone in the project.
96
+ gql 'query($p:String){ projects(filter:{name:{eq:$p}}){nodes{ id name projectMilestones{nodes{id name targetDate}} state }} }' \
97
+ "$(printf '{"p":"%s"}' "$PROJECT")" \
98
+ | python3 -c 'import sys,json
99
+ d=json.load(sys.stdin); ps=d.get("projects",{}).get("nodes") or []
100
+ p=ps[0] if ps else {}
101
+ ms=(p.get("projectMilestones",{}) or {}).get("nodes") or []
102
+ m=ms[0] if ms else {"id":p.get("id",""),"name":p.get("name","")}
103
+ print(json.dumps({"id":m.get("id",""),"name":m.get("name",""),"state":p.get("state","")}))'
104
+ ;;
105
+
106
+ list_issues)
107
+ [ -n "$TEAM" ] || die "ticket_provider.team not set"
108
+ gql 'query($k:String!){ issues(first:100, filter:{team:{key:{eq:$k}}}){nodes{ id identifier title updatedAt url state{name type} assignee{name} }} }' \
109
+ "$(printf '{"k":"%s"}' "$TEAM")" \
110
+ | python3 -c 'import sys,json
111
+ d=json.load(sys.stdin); out=[]
112
+ for n in d.get("issues",{}).get("nodes") or []:
113
+ st=n.get("state") or {}
114
+ out.append({"id":n["id"],"key":n.get("identifier",""),"title":n.get("title",""),
115
+ "state":st.get("name",""),"state_type":st.get("type",""),
116
+ "updated_at":n.get("updatedAt",""),
117
+ "assignee":(n.get("assignee") or {}).get("name",""),"url":n.get("url","")})
118
+ print(json.dumps(out))'
119
+ ;;
120
+
121
+ get_issue)
122
+ ID="${1:?usage: get_issue <id>}"
123
+ gql 'query($id:String!){ issue(id:$id){ id identifier title description state{name type} comments{nodes{id body user{name}}} } }' \
124
+ "$(printf '{"id":"%s"}' "$ID")" \
125
+ | python3 -c 'import sys,json
126
+ d=json.load(sys.stdin); i=d.get("issue") or {}
127
+ st=i.get("state") or {}
128
+ cs=[{"id":c["id"],"body":c.get("body",""),"author":(c.get("user") or {}).get("name","")} for c in (i.get("comments",{}) or {}).get("nodes") or []]
129
+ print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get("title",""),
130
+ "description":i.get("description",""),"acceptance":i.get("description",""),
131
+ "state":st.get("name",""),"state_type":st.get("type",""),"comments":cs}))'
132
+ ;;
133
+
134
+ comment)
135
+ ID="${1:?usage: comment <id> <body>}"; BODY="${2:?usage: comment <id> <body>}"
136
+ gql 'mutation($id:String!,$b:String!){ commentCreate(input:{issueId:$id,body:$b}){ comment{id} success } }' \
137
+ "$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"b":sys.argv[2]}))' "$ID" "$BODY")" \
138
+ | python3 -c 'import sys,json; d=json.load(sys.stdin); print((d.get("commentCreate",{}).get("comment") or {}).get("id",""))'
139
+ ;;
140
+
141
+ transition)
142
+ ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
143
+ # Map normalized -> a concrete Linear state name, then resolve its id on the team.
144
+ case "$TARGET" in
145
+ completed) WANT_TYPE=completed; WANT_NAME="$SM_DONE" ;;
146
+ in_review) WANT_TYPE=started; WANT_NAME="$SM_IN_REVIEW" ;;
147
+ started) WANT_TYPE=started; WANT_NAME="" ;;
148
+ unstarted) WANT_TYPE=unstarted; WANT_NAME="" ;;
149
+ backlog) WANT_TYPE=backlog; WANT_NAME="" ;;
150
+ *) die "invalid normalized state: $TARGET" ;;
151
+ esac
152
+ STATE_ID="$(gql 'query($id:String!){ issue(id:$id){ team{ states{nodes{id name type}} } } }' \
153
+ "$(printf '{"id":"%s"}' "$ID")" \
154
+ | WANT_TYPE="$WANT_TYPE" WANT_NAME="$WANT_NAME" python3 -c 'import sys,json,os
155
+ d=json.load(sys.stdin)
156
+ states=((d.get("issue") or {}).get("team") or {}).get("states",{}).get("nodes") or []
157
+ want_t=os.environ["WANT_TYPE"]; want_n=os.environ.get("WANT_NAME","")
158
+ named=[s for s in states if want_n and s["name"].lower()==want_n.lower()]
159
+ typed=[s for s in states if s.get("type")==want_t]
160
+ pick=(named or typed or [{}])[0]
161
+ print(pick.get("id",""))')"
162
+ [ -n "$STATE_ID" ] || die "no Linear state for normalized '$TARGET'"
163
+ gql 'mutation($id:String!,$s:String!){ issueUpdate(id:$id,input:{stateId:$s}){ success issue{identifier state{name}} } }' \
164
+ "$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"s":sys.argv[2]}))' "$ID" "$STATE_ID")" \
165
+ | python3 -c 'import sys,json; u=json.load(sys.stdin).get("issueUpdate",{});
166
+ print(("ok " + (u.get("issue") or {}).get("identifier","")) if u.get("success") else "FAILED"); sys.exit(0 if u.get("success") else 1)'
167
+ ;;
168
+
169
+ create_board)
170
+ # Linear teams/projects are created by humans; the adapter resolves, not creates.
171
+ echo "linear: create_board is a no-op (Linear team/project created via Linear UI); using resolve" >&2
172
+ exec sh "$0" resolve
173
+ ;;
174
+
175
+ *) die "unknown op: $OP" ;;
176
+ esac
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env sh
2
2
  # Plane ticket-provider adapter.
3
3
  #
4
- # Credentials: PLANE_API_KEY or PLANE_<WORKSPACE>_API_KEY (X-API-Key header)
4
+ # Credentials: PLANE_API_KEY (X-API-Key header)
5
5
  # Endpoint: PLANE_BASE (default https://plane.delo.sh)
6
- # Board binding (repo-root .project.json `ticket_provider:`):
6
+ # Board binding (role.yaml `ticket_provider:`):
7
+ # name: plane
7
8
  # workspace: <workspace-slug> (or env PLANE_WORKSPACE)
8
- # board_id: <project-uuid> (set by create_board / 42-ticket-provider)
9
+ # project: <project-uuid> (set by create_board / 42-ticket-provider)
9
10
  # state_map: { in_review: "In Review", completed: "Done" } optional
10
11
  #
11
12
  # Plane model: project = board, cycle = milestone, state.group in
@@ -20,21 +21,9 @@ ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
20
21
  ROLE_YAML="$ROLE_DIR/role.yaml"
21
22
  BASE="${PLANE_BASE:-https://plane.delo.sh}"
22
23
 
23
- FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
24
- if [ -f "$FLEET_ENV" ]; then
25
- # shellcheck disable=SC1090
26
- . "$FLEET_ENV"
27
- fi
28
-
29
24
  die() { echo "plane: $*" >&2; exit 1; }
30
25
  need_key() { [ -n "${PLANE_API_KEY:-}" ] || die "PLANE_API_KEY is not set"; }
31
26
 
32
- workspace_key() {
33
- key="$(printf '%s' "${1:-default}" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')"
34
- [ -n "$key" ] || key="DEFAULT"
35
- printf 'PLANE_%s_API_KEY' "$key"
36
- }
37
-
38
27
  tp_cfg() {
39
28
  [ -f "$ROLE_YAML" ] || return 0
40
29
  python3 - "$ROLE_YAML" "$1" <<'PY'
@@ -65,19 +54,13 @@ else:
65
54
  PY
66
55
  }
67
56
 
68
- # Board binding: .project.json (SOT) first, then legacy role.yaml, then env.
57
+ # Board binding: .project.json (SOT) first, then role.yaml, then env.
69
58
  WS="$(pj_cfg workspace)"; [ -n "$WS" ] || WS="$(tp_cfg workspace)"; WS="${WS:-${PLANE_WORKSPACE:-}}"
70
- PROJ="$(pj_cfg board_id)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg project)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg board_id)"
59
+ PROJ="$(pj_cfg board_id)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg project)"
71
60
  SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
72
61
  SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
73
62
  API="$BASE/api/v1/workspaces/$WS"
74
63
 
75
- if [ -z "${PLANE_API_KEY:-}" ]; then
76
- KEY="$(workspace_key "$WS")"
77
- eval "PLANE_API_KEY=\${$KEY:-}"
78
- export PLANE_API_KEY
79
- fi
80
-
81
64
  # api METHOD PATH [JSON_BODY] — call Plane REST, print response body.
82
65
  api() {
83
66
  need_key
@@ -85,12 +68,9 @@ api() {
85
68
  if [ -n "$body" ]; then
86
69
  curl -fsS -X "$method" "$API/$path" \
87
70
  -H "X-API-Key: $PLANE_API_KEY" -H "Content-Type: application/json" \
88
- -H "User-Agent: curl/8.0" \
89
71
  -d "$body"
90
72
  else
91
- curl -fsS -X "$method" "$API/$path" \
92
- -H "X-API-Key: $PLANE_API_KEY" \
93
- -H "User-Agent: curl/8.0"
73
+ curl -fsS -X "$method" "$API/$path" -H "X-API-Key: $PLANE_API_KEY"
94
74
  fi
95
75
  }
96
76
 
@@ -120,8 +100,8 @@ need_key
120
100
 
121
101
  case "$OP" in
122
102
  resolve)
123
- [ -n "$WS" ] || die "workspace not set (.project.json ticket_provider.workspace or PLANE_WORKSPACE)"
124
- [ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
103
+ [ -n "$WS" ] || die "workspace not set (role.yaml ticket_provider.workspace or PLANE_WORKSPACE)"
104
+ [ -n "$PROJ" ] || die "project not set (run 42-ticket-provider.sh)"
125
105
  printf '{"provider":"plane","board_id":"%s","board_url":"%s/%s/projects/%s/issues/"}\n' \
126
106
  "$PROJ" "$BASE" "$WS" "$PROJ"
127
107
  ;;
@@ -13,7 +13,7 @@ actively tries to break the work, surface unmet acceptance criteria, hidden
13
13
  regressions, and drift. On a clean adversarial verdict the loop treats the
14
14
  ticket as done and moves on — through the ticket-provider adapter
15
15
  (`tp transition <id> <state>`) — and emits a BloodBank decision event carrying
16
- the full report. This works identically on Plane or Trello.
16
+ the full report. This works identically on Linear, Plane, or Trello.
17
17
 
18
18
  The operator's verification is **deferred** to end-of-product QA over the review
19
19
  lane, backed by a queryable decision trail. A downstream regression rollback is
@@ -144,7 +144,7 @@ Write `<ISSUE>.review.md`; the script validates it:
144
144
  ```markdown
145
145
  # Autonomous Review Report: <ISSUE>
146
146
  ## Issue
147
- - Provider issue: <ISSUE>
147
+ - Linear/Plane/Trello issue: <ISSUE>
148
148
  - Review lane reason:
149
149
  ## Reviewer
150
150
  - Reviewer agent: <independent-agent-id>
@@ -19,7 +19,7 @@ approve merges.
19
19
  All board access goes through the adapter (`tp`, from
20
20
  `.scripts/lib/ticket-provider.sh`) and reasons in normalized states:
21
21
  `backlog | unstarted | started | in_review | completed`. Never call the provider
22
- directly — the engine is identical across Plane and Trello.
22
+ directly — the engine is identical across Linear, Plane, and Trello.
23
23
 
24
24
  ## Work-state feed
25
25
 
@@ -73,35 +73,3 @@ The loop never ends a pass with work parked waiting on the operator.
73
73
  prerequisite of the dependent and record the rollback.
74
74
 
75
75
  Board status is not proof. Repository evidence and the close gate are proof.
76
-
77
- ## Post-loop improvement (end-of-batch retro)
78
-
79
- When the board-clearing loop reaches a resting state — no ready or actionable
80
- ticket to move and no live worker to monitor (a stop condition above, not a
81
- mid-flight pause) — the sentinel runs a short end-of-batch retro before the pass
82
- ends. Because it fires only at rest, it never competes with live ticket flow
83
- (anti-stall).
84
-
85
- Answer three questions about the batch just cleared:
86
-
87
- 1. What hurt this batch?
88
- 2. What should change?
89
- 3. Is the fix repo-local or external/template/fleet?
90
-
91
- ALWAYS record the reflection as a run artifact under
92
- `_bmad-output/implementation-artifacts/`: the three answers, any repo-local fix
93
- applied, and a clear description of each proposed external/template/fleet
94
- improvement. Nothing is silently dropped — the artifact is the durable record.
95
-
96
- Then act on the answer — do not just note it:
97
-
98
- - Repo-local, small, safe, and within this pass's scope → implement it now as a
99
- minor follow-up. If it is larger or riskier, do not start it here; treat it as
100
- external and surface it below.
101
- - External / template / fleet (hermes-agent template, fleet config, another repo,
102
- or an undecided product/architecture decision) → the adapter cannot open issues,
103
- so surface it loudly instead: post a `tp comment` on the current tracking/active
104
- issue proposing the follow-up, and flag the operator (the human-facing control
105
- plane) to file it. Do not attempt to create a ticket through the adapter.
106
-
107
- The retro is evidence like any other pass output.
@@ -6,11 +6,6 @@ A cheap systemd heartbeat already decided this full pass is needed.
6
6
  Working repo: the git root containing this role at `agents/hermes/{{ role }}/`.
7
7
  Ticket provider: **{{ ticket_provider }}** (reached only through the adapter — see below).
8
8
 
9
- The configured ticket provider is the authoritative repo ticket board/SOT for
10
- this PM. Hermes local Kanban must NOT be an active durable task lifecycle
11
- surface. Local Kanban may only run as an explicitly configured ephemeral child
12
- execution queue and must never close/move authoritative repo tickets.
13
-
14
9
  You are the **{{ target_repo }} PM**, running your continuous board-reconciliation
15
10
  pass. Act autonomously, but stay inside the project contracts. Read
16
11
  `agents/hermes/{{ role }}/SOUL.md` and the engine docs under
@@ -32,7 +27,7 @@ tp transition <id> <normalized-state> # backlog|unstarted|started|in_review|com
32
27
  ```
33
28
 
34
29
  Reason in **normalized states**, not provider terms. This pass works identically
35
- on Plane or Trello.
30
+ on Linear, Plane, or Trello.
36
31
 
37
32
  ## Pass
38
33
 
@@ -96,26 +91,5 @@ on Plane or Trello.
96
91
  `last_activity_at`, `log_path`).
97
92
  10. Run or follow the session-end ritual; report board status, issues touched,
98
93
  evidence touched, and the active worker issue or blocker.
99
- 11. **Post-loop improvement (end-of-batch retro).** ONLY when the board-clearing
100
- loop reaches a resting state this pass — no ready/actionable ticket to move and
101
- no live worker to monitor (idle, or only backlog / external-blocked work
102
- remains). This is a retro AT REST: it never runs while a ready ticket or a
103
- healthy worker exists, and it must not block or delay live ticket flow
104
- (anti-stall, step 8). Answer three questions about the batch just cleared:
105
- (1) What hurt this batch? (2) What should change? (3) Is the fix repo-local or
106
- external/template/fleet? ALWAYS record the reflection as a run artifact
107
- (evidence) under `_bmad-output/implementation-artifacts/`: the three answers,
108
- any repo-local fix applied, and a clear description of each proposed
109
- external/template/fleet improvement. Nothing is silently dropped — the artifact
110
- is the durable record. Then act on the answer autonomously:
111
- - **Repo-local** and small, safe, and within this pass's scope: implement it now
112
- as a minor follow-up inside **{{ target_repo }}**. If it is larger or riskier
113
- than that, do NOT start it here — treat it as external and surface it.
114
- - **External / template / fleet** (hermes-agent template, fleet config, another
115
- repo, or an undecided product/architecture decision): the adapter cannot open
116
- issues, so surface it loudly instead — post a `tp comment` on the current
117
- tracking/active issue proposing the follow-up, and flag the operator (the
118
- human-facing control plane) to file it. Do not attempt to create a ticket
119
- through the adapter.
120
94
 
121
95
  Do not rely on a hard-coded seed ticket. Query the board every pass.
@@ -46,8 +46,8 @@ Envelope shape: CloudEvents 1.0, type `bloodbank.v1.<domain>.<entity>.<action>`,
46
46
  `source = hermes://agent/{{ agent_id }}`. The consumer in `./runtime/` already
47
47
  imports the envelope helper.
48
48
 
49
- You **MUST NOT** invent new event `type` values. The naming contract is owned
50
- by Holyfields and locked at `~/code/33GOD/bloodbank/docs/event-naming.md` —
49
+ You **MUST NOT** invent new event `type` values. Bloodbank owns the naming
50
+ contract at `~/code/33GOD/bloodbank/docs/event-naming.md` —
51
51
  read it before publishing a type you haven't published before.
52
52
 
53
53
  ## Role-specific behavior
@@ -55,24 +55,22 @@ read it before publishing a type you haven't published before.
55
55
  {% if role == "pm" -%}
56
56
  You are the **project manager**. You triage incoming requests from Telegram /
57
57
  Bloodbank command lanes, decompose them into discrete tasks on the
58
- configured ticket board, and route work to other agents in the fleet (e.g. the dev role
59
- on `bloodbank.cmd.v1.agent.{{ target_repo }}-dev.task.assign`). You do not
60
- write application code. You do not approve merges.
61
-
62
- The configured ticket provider (`{{ ticket_provider }}`) is the authoritative
63
- repo ticket board/SOT for this PM. Hermes local Kanban must NOT be an active
64
- durable task lifecycle surface. The kanban toolset is removed from the PM's cli
65
- surface. Local Kanban may only run as an explicitly configured ephemeral child
66
- execution queue and must never close/move authoritative repo tickets.
58
+ Plane board, and route work to other agents in the fleet (e.g. the dev role
59
+ on `bloodbank.cmd.v1.agent.task.assign` with
60
+ `data.target_agent_id = {{ target_repo }}-dev`). You do not write application
61
+ code. You do not approve merges.
67
62
 
68
63
  Default execution workflow for implementation delivery: use
69
- `subagent-driven-development` in provider-ticket-orchestrated implementation mode
64
+ `subagent-driven-development` in kanban-orchestrated codex mode
70
65
  (WIP=1, spec review gate, quality review gate).
71
66
 
72
67
  Decision events you commonly emit:
73
- - `bloodbank.v1.repo.{{ target_repo }}.decision.recorded`
74
- - `bloodbank.v1.repo.{{ target_repo }}.intake.triaged`
75
- - `bloodbank.v1.repo.{{ target_repo }}.task.created`
68
+ - `bloodbank.v1.repo.decision.recorded`
69
+ - `bloodbank.v1.repo.intake.triaged`
70
+ - `bloodbank.v1.repo.task.created`
71
+
72
+ Put `repo = {{ target_repo }}` in event data; never insert repo or agent
73
+ identifiers into Bloodbank type or subject tokens.
76
74
 
77
75
  Template-governor command contract:
78
76
  - If operator says `update template to capture <X>`, run `hermes-pm-template-maintenance` workflow:
@@ -87,17 +85,17 @@ write tests, run the project's `mise run test` or equivalent, open PRs, and
87
85
  respond to review comments. You do not merge — that's the reviewer's call.
88
86
 
89
87
  Events you commonly emit:
90
- - `bloodbank.v1.repo.{{ target_repo }}.code.committed`
91
- - `bloodbank.v1.repo.{{ target_repo }}.test.run.completed`
92
- - `bloodbank.v1.repo.{{ target_repo }}.pr.opened`
88
+ - `bloodbank.v1.repo.code.committed`
89
+ - `bloodbank.v1.repo.test.completed`
90
+ - `bloodbank.v1.repo.pr.opened`
93
91
  {%- elif role == "review" -%}
94
92
  You are the **reviewer**. You read PRs critically, check against the
95
93
  project's CONTRIBUTING and AGENTS.md, and either approve or request changes.
96
94
  You hold the merge gate for non-trivial changes.
97
95
 
98
96
  Events:
99
- - `bloodbank.v1.repo.{{ target_repo }}.review.completed`
100
- - `bloodbank.v1.repo.{{ target_repo }}.pr.merged`
97
+ - `bloodbank.v1.repo.review.completed`
98
+ - `bloodbank.v1.repo.pr.merged`
101
99
  {%- else -%}
102
100
  You operate as the **{{ role }}** agent for this repo. Define your contract
103
101
  in this file (this section), then publish a `bloodbank.v1.agent.contract.
@@ -112,7 +110,7 @@ declared` event so the fleet knows what to route to you.
112
110
  - **Hostnames**: Use `*.delo.sh` for external/cross-machine access (resolved
113
111
  via Cloudflare Tunnel), `localhost` for same-host, Docker network service
114
112
  names for container-to-container, Tailscale for private machine-to-machine.
115
- - **Ticketing**: Always include the configured provider's ticket reference in commit messages.
113
+ - **Plane**: Always include a Plane ticket reference in commit messages.
116
114
 
117
115
  ## Memory hygiene
118
116
 
@@ -21,16 +21,47 @@ profile: {{ agent_id }}
21
21
  telegram:
22
22
  bot_username: "{{ bot_handle }}"
23
23
 
24
- # Ticket-provider adapter preference (plane | trello).
25
- # Repo-owned board identity lives in repo-root .project.json, not in this role.
24
+ # Ticket-provider binding (linear | plane | trello).
25
+ # The heartbeat reconciliation pass talks ONLY to this via .scripts/lib/ticket-provider.sh;
26
+ # swapping providers is a one-line change here. Filled in by 42-ticket-provider.sh.
26
27
  ticket_provider:
27
28
  name: {{ ticket_provider }}
29
+ board_id: ""
30
+ board_url: ""
31
+ {%- if ticket_provider == 'linear' %}
32
+ team: "" # Linear team key, e.g. DEL (set before first sentinel run)
33
+ project: "" # optional Linear project name to scope milestones/issues
34
+ {%- elif ticket_provider == 'plane' %}
35
+ workspace: "{{ plane_workspace }}"
36
+ project: "" # Plane project uuid (set by 42-ticket-provider.sh)
37
+ {%- elif ticket_provider == 'trello' %}
38
+ board: "" # Trello board id (set by 42-ticket-provider.sh)
39
+ {%- endif %}
40
+ # Optional normalized-state name overrides for non-standard boards.
41
+ in_review: "" # e.g. "In Review"
42
+ completed: "" # e.g. "Done"
43
+
44
+ # Board-reconciliation knobs for the heartbeat sentinel pass.
45
+ reconcile:
46
+ enabled: false # heartbeat runs the autonomous board-reconciliation pass ONLY when true; default off = checkpoint-only
47
+ grace_hours: 0 # 0 = no human-approval wait (adversarially-reviewed review-lane = done); set >0 to require a wait
48
+ auto_review: true # autonomous adversarial review (act, do not wait) on/off
49
+
50
+ # Plane project (1:1 with this agent) — retained for fleet-registry back-compat.
51
+ plane:
52
+ # Empty -> resolved from ~/.config/hermes-agent-template/config.toml [plane].workspace
53
+ workspace: "{{ plane_workspace }}"
54
+ # identifier is set by .scripts/42-ticket-provider.sh after creation (plane only)
55
+ identifier: ""
28
56
 
29
57
  # Bloodbank wiring (consumed + produced subjects).
30
58
  bloodbank:
31
59
  subscribe:
32
- - "bloodbank.evt.v1.repo.{{ target_repo }}.>"
33
- - "bloodbank.cmd.v1.agent.{{ agent_id }}.>"
60
+ - "bloodbank.evt.v1.repo.>"
61
+ - "bloodbank.cmd.v1.agent.>"
62
+ routing:
63
+ repo: "{{ target_repo }}"
64
+ target_agent_id: "{{ agent_id }}"
34
65
  producer: "hermes-agent:{{ agent_id }}"
35
66
 
36
67
  # Runtime repo (git-tracked HERMES_HOME, auto-checkpointed).
@@ -0,0 +1,138 @@
1
+ import importlib.util
2
+ import os
3
+ import pathlib
4
+ import re
5
+ import sys
6
+ import tempfile
7
+ import types
8
+ import unittest
9
+
10
+
11
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
12
+ CONSUMER_PATH = ROOT / "runtime-scaffold" / "bloodbank-consumer.py"
13
+ GENERATED_CONSUMER_PATH = ROOT / "template" / ".runtime-scaffold" / "bloodbank-consumer.py"
14
+
15
+
16
+ def load_consumer():
17
+ sys.modules.setdefault("nats", types.ModuleType("nats"))
18
+ spec = importlib.util.spec_from_file_location("bloodbank_consumer_contract", CONSUMER_PATH)
19
+ module = importlib.util.module_from_spec(spec)
20
+ spec.loader.exec_module(module)
21
+ module.AGENT_ID = "demo-pm"
22
+ module.REPO = "demo"
23
+ module.PRODUCER = "hermes-agent:demo-pm"
24
+ module.SOURCE = "hermes://agent/demo-pm"
25
+ return module
26
+
27
+
28
+ class BloodbankConsumerContractTests(unittest.TestCase):
29
+ @classmethod
30
+ def setUpClass(cls):
31
+ cls.temp_home = tempfile.TemporaryDirectory()
32
+ cls.previous_home = os.environ.get("HERMES_HOME")
33
+ os.environ["HERMES_HOME"] = cls.temp_home.name
34
+ cls.consumer = load_consumer()
35
+
36
+ @classmethod
37
+ def tearDownClass(cls):
38
+ if cls.previous_home is None:
39
+ os.environ.pop("HERMES_HOME", None)
40
+ else:
41
+ os.environ["HERMES_HOME"] = cls.previous_home
42
+ cls.temp_home.cleanup()
43
+
44
+ def test_scaffold_copies_stay_identical(self):
45
+ self.assertEqual(CONSUMER_PATH.read_bytes(), GENERATED_CONSUMER_PATH.read_bytes())
46
+
47
+ def test_subscriptions_use_fixed_canonical_routes(self):
48
+ self.assertEqual(
49
+ self.consumer.SUBJECTS,
50
+ ["bloodbank.evt.v1.repo.>", "bloodbank.cmd.v1.agent.>"],
51
+ )
52
+ self.assertNotIn("demo", ".".join(self.consumer.SUBJECTS))
53
+ self.assertNotIn("demo-pm", ".".join(self.consumer.SUBJECTS))
54
+
55
+ def test_envelope_keeps_identity_out_of_type_and_subject(self):
56
+ envelope = self.consumer.build_envelope(
57
+ "bloodbank.v1.repo.issue.updated",
58
+ {"repo": "demo", "issue": "PJAN-1"},
59
+ )
60
+ self.assertEqual(envelope["type"], "bloodbank.v1.repo.issue.updated")
61
+ self.assertEqual(envelope["subject"], "bloodbank.evt.v1.repo.issue.updated")
62
+ self.assertEqual(envelope["data"]["repo"], "demo")
63
+ self.assertEqual(envelope["actor"]["agent_id"], "demo-pm")
64
+ self.assertEqual(envelope["source"], "hermes://agent/demo-pm")
65
+ self.assertNotIn("demo", envelope["type"])
66
+ self.assertNotIn("demo", envelope["subject"])
67
+ with self.assertRaisesRegex(ValueError, r"bloodbank\.v1"):
68
+ self.consumer.build_envelope(
69
+ "bloodbank.v2.repo.issue.updated",
70
+ {"repo": "demo"},
71
+ )
72
+
73
+ def test_repo_events_route_by_data_repo(self):
74
+ subject = "bloodbank.evt.v1.repo.issue.updated"
75
+ envelope = self.consumer.build_envelope(
76
+ "bloodbank.v1.repo.issue.updated",
77
+ {"repo": "demo"},
78
+ )
79
+ self.assertTrue(self.consumer._is_for_consumer(subject, envelope))
80
+ envelope["data"]["repo"] = "another-repo"
81
+ self.assertFalse(self.consumer._is_for_consumer(subject, envelope))
82
+
83
+ def test_agent_commands_route_by_target_agent_id(self):
84
+ subject = "bloodbank.cmd.v1.agent.task.assign"
85
+ envelope = self.consumer.build_envelope(
86
+ "bloodbank.v1.agent.task.assign",
87
+ {"target_agent_id": "demo-pm"},
88
+ kind="command",
89
+ )
90
+ self.assertTrue(self.consumer._is_for_consumer(subject, envelope))
91
+ envelope["data"]["target_agent_id"] = "other-agent"
92
+ self.assertFalse(self.consumer._is_for_consumer(subject, envelope))
93
+
94
+ def test_rejects_identifier_bearing_or_mismatched_routes(self):
95
+ envelope = self.consumer.build_envelope(
96
+ "bloodbank.v1.repo.issue.updated",
97
+ {"repo": "demo"},
98
+ )
99
+ self.assertFalse(
100
+ self.consumer._is_for_consumer(
101
+ "bloodbank.evt.v1.repo.demo.issue.updated",
102
+ envelope,
103
+ )
104
+ )
105
+ self.assertFalse(
106
+ self.consumer._is_for_consumer(
107
+ "bloodbank.evt.v1.repo.issue.created",
108
+ envelope,
109
+ )
110
+ )
111
+ envelope["kind"] = "command"
112
+ self.assertFalse(
113
+ self.consumer._is_for_consumer(
114
+ "bloodbank.evt.v1.repo.issue.updated",
115
+ envelope,
116
+ )
117
+ )
118
+
119
+ def test_generated_contract_docs_do_not_put_identifiers_in_routes(self):
120
+ coupled_paths = [
121
+ ROOT / "docs" / "architecture.md",
122
+ ROOT / "docs" / "operations.md",
123
+ ROOT / "template" / "SOUL.md.jinja",
124
+ ROOT / "template" / "role.yaml.jinja",
125
+ ROOT / "runtime-scaffold" / "memories" / "MEMORY.md",
126
+ ROOT / "template" / ".runtime-scaffold" / "memories" / "MEMORY.md",
127
+ ]
128
+ forbidden = re.compile(
129
+ r"bloodbank\.(?:v1\.repo|evt\.v1\.repo|cmd\.v1\.agent)\."
130
+ r"(?:\{\{|<repo>|<agent_id>)"
131
+ )
132
+ for path in coupled_paths:
133
+ with self.subTest(path=path):
134
+ self.assertIsNone(forbidden.search(path.read_text()))
135
+
136
+
137
+ if __name__ == "__main__":
138
+ unittest.main()