@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,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.
@@ -62,41 +62,6 @@ p.write_text(new)
62
62
  PYEOF
63
63
  }
64
64
 
65
- # Locate the repo-root .project.json for this role, if present.
66
- project_json_path() {
67
- local root
68
- root="$(project_repo_path 2>/dev/null || true)"
69
- [[ -n "$root" && -f "$root/.project.json" ]] && { printf '%s' "$root/.project.json"; return 0; }
70
- return 1
71
- }
72
-
73
- # project_json_get <dotted.key> — read a string/scalar from repo-root
74
- # .project.json. This is the deterministic SOT for project wiring.
75
- project_json_get() {
76
- local key="$1" path
77
- path="$(project_json_path 2>/dev/null || true)"
78
- [[ -f "$path" ]] || { printf ''; return 0; }
79
- python3 - "$path" "$key" <<'PYEOF'
80
- import json
81
- import pathlib
82
- import sys
83
-
84
- path, key = sys.argv[1:3]
85
- try:
86
- cur = json.loads(pathlib.Path(path).read_text())
87
- except Exception:
88
- print("", end="")
89
- raise SystemExit(0)
90
- for part in key.split("."):
91
- if isinstance(cur, dict) and part in cur:
92
- cur = cur[part]
93
- else:
94
- print("", end="")
95
- raise SystemExit(0)
96
- print(cur if isinstance(cur, (str, int, float, bool)) else "", end="")
97
- PYEOF
98
- }
99
-
100
65
  # ─── Distributable config (~/.config/hermes-agent-template/config.toml) ──────
101
66
  # Single source of truth for environment-specific defaults so this template can
102
67
  # be handed to someone else without editing any script. Ship config.example.toml
@@ -147,10 +112,8 @@ load_role_env() {
147
112
  BOT_HANDLE=$(yaml_get telegram.bot_username)
148
113
  PROFILE_NAME=$(yaml_get profile)
149
114
 
150
- # Plane workspace: repo-root .project.json wins; role.yaml is legacy fallback.
151
- PLANE_WORKSPACE=$(project_json_get ticket_provider.workspace)
152
- [[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(yaml_get ticket_provider.workspace)
153
- [[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(yaml_get plane.workspace)
115
+ # Plane workspace: empty in role.yaml -> resolve from config.toml.
116
+ PLANE_WORKSPACE=$(yaml_get plane.workspace)
154
117
  [[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(config_get plane.workspace "33god")
155
118
 
156
119
  # Runtime repo: role.yaml stores the bare repo name plus an optional owner.
@@ -166,27 +129,6 @@ load_role_env() {
166
129
  PLANE_WORKSPACE RUNTIME_REPO PROFILE_NAME
167
130
  }
168
131
 
169
- plane_workspace_key() {
170
- local workspace="${1:-${PLANE_WORKSPACE:-}}"
171
- workspace="$(printf '%s' "$workspace" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')"
172
- [[ -n "$workspace" ]] || workspace="DEFAULT"
173
- printf 'PLANE_%s_API_KEY' "$workspace"
174
- }
175
-
176
- resolve_plane_api_key() {
177
- if [[ -n "${PLANE_API_KEY:-}" ]]; then
178
- export PLANE_API_KEY
179
- return 0
180
- fi
181
- local key value
182
- key="$(plane_workspace_key "${1:-${PLANE_WORKSPACE:-}}")"
183
- value="${!key:-}"
184
- if [[ -n "$value" ]]; then
185
- PLANE_API_KEY="$value"
186
- export PLANE_API_KEY
187
- fi
188
- }
189
-
190
132
  # Skip a step if previously completed (idempotent reruns).
191
133
  already_done() {
192
134
  local marker="$ROLE_DIR/.scripts/.done-$1"
@@ -223,7 +165,7 @@ BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_d
223
165
 
224
166
  # Plane
225
167
  PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
226
- PLANE_API_KEY="${PLANE_API_KEY:-}"
168
+ PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
227
169
 
228
170
  export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO HERMES_OAUTH_FILE CODEX_HOME \
229
171
  RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
@@ -27,11 +27,6 @@ oauth_file = "~/.hermes/auth.json"
27
27
  codex_home = "~/.codex"
28
28
  # Canonical external skills dir mirrored into each agent profile.
29
29
  canonical_skills_dir = "/home/delorenj/.agents/skills"
30
- # PM-only external skill libraries surfaced alongside Hermes built-ins.
31
- pm_external_skill_dirs = [
32
- "~/code/skillex/skill-sets/global/.system",
33
- "~/code/skillex/packs/bmad/6.10.2",
34
- ]
35
30
  # Voxxy plugin + service defaults for Hermes PM agents.
36
31
  voxxy_plugin_dir = "~/code/voxxy/plugins/tts/voxxy"
37
32
  vox_url = "https://vox.delo.sh"
@@ -6,7 +6,7 @@
6
6
  # reconciliation pass when local state says no worker is active, the worker
7
7
  # heartbeat is stale, or the last full run is outside the cooldown window. The
8
8
  # full pass executes the role's sentinel.prompt.md, which reasons about tickets
9
- # through the ticket-provider adapter (Plane | Trello) — never a
9
+ # through the ticket-provider adapter (Linear | Plane | Trello) — never a
10
10
  # hardcoded backend. After the sentinel decision (skip OR full), it
11
11
  # opportunistically checkpoints the runtime submodule (commit+push) at most once
12
12
  # per HEARTBEAT_CHECKPOINT_MIN_INTERVAL_SECONDS, so memory/session state stays
@@ -19,18 +19,12 @@ PROMPT_FILE="$ROLE_DIR/.scripts/sentinel.prompt.md"
19
19
  STATE_FILE="$RUNTIME/continuous-ticket-sentinel-state.json"
20
20
  LOCK_FILE="$RUNTIME/continuous-ticket-sentinel.lock"
21
21
  ROLE_YAML="$ROLE_DIR/role.yaml"
22
- FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
23
22
  LOG_FILE="$RUNTIME/logs/heartbeat.log"
24
23
  CHECKPOINT_BIN="$ROLE_DIR/.scripts/checkpoint.sh"
25
24
  CHECKPOINT_STAMP="$RUNTIME/.last-checkpoint"
26
25
 
27
- if [[ -f "$FLEET_ENV" ]]; then
28
- # shellcheck disable=SC1090
29
- source "$FLEET_ENV"
30
- fi
31
-
32
26
  # Hermes binary: explicit env > ~/.config/hermes-agent/hermes-bin > PATH.
33
- HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
27
+ HERMES_BIN="${HERMES_BIN:-}"
34
28
  if [[ -z "$HERMES_BIN" ]]; then
35
29
  if [[ -r "$HOME/.config/hermes-agent/hermes-bin" ]]; then
36
30
  HERMES_BIN="$(cat "$HOME/.config/hermes-agent/hermes-bin")"
@@ -86,61 +80,13 @@ print(value.strip().strip('"').strip("'"))
86
80
  PYEOF
87
81
  }
88
82
 
89
- repo_root() {
90
- local dir="$ROLE_DIR"
91
- for _ in 1 2 3 4 5; do
92
- dir="$(dirname "$dir")"
93
- if [[ -d "$dir/.git" || -f "$dir/.git" ]]; then printf '%s\n' "$dir"; return 0; fi
94
- done
95
- return 1
96
- }
97
- REPO_ROOT="$(repo_root)"
98
- PROJECT_JSON="$REPO_ROOT/.project.json"
99
-
100
- project_json_value() {
101
- python3 - "$PROJECT_JSON" "$1" <<'PYEOF'
102
- import json
103
- import pathlib
104
- import sys
105
-
106
- path, key = sys.argv[1:3]
107
- try:
108
- cur = json.loads(pathlib.Path(path).read_text())
109
- except Exception:
110
- print("")
111
- raise SystemExit(0)
112
- for part in key.split("."):
113
- if isinstance(cur, dict) and part in cur:
114
- cur = cur[part]
115
- else:
116
- print("")
117
- raise SystemExit(0)
118
- print(cur if isinstance(cur, (str, int, float, bool)) else "")
119
- PYEOF
120
- }
121
-
122
- # Project-owned automation gate. Legacy role.yaml reconcile.enabled is honored
123
- # only for older agents that have not migrated their .project.json yet.
83
+ # True only when role.yaml has a reconcile: block with enabled: true. Block-aware
84
+ # so an unrelated `enabled:` leaf elsewhere in the file can't flip it on.
124
85
  reconcile_enabled() {
125
- python3 - "$PROJECT_JSON" "$ROLE_YAML" <<'PYEOF'
126
- import json
127
- import re
128
- import sys
86
+ python3 - "$ROLE_YAML" <<'PYEOF'
87
+ import re, sys
129
88
  from pathlib import Path
130
- project_path, role_path = map(Path, sys.argv[1:3])
131
- try:
132
- project = json.loads(project_path.read_text())
133
- except Exception:
134
- project = {}
135
- reconcile = ((project.get("automation") or {}).get("reconcile") or {}) if isinstance(project, dict) else {}
136
- if "enabled" in reconcile:
137
- value = reconcile.get("enabled")
138
- print("true" if str(value).lower() == "true" else "false")
139
- raise SystemExit(0)
140
- try:
141
- text = role_path.read_text()
142
- except Exception:
143
- text = ""
89
+ text = Path(sys.argv[1]).read_text()
144
90
  m = re.search(r'(?m)^reconcile:[ \t]*\n((?:[ \t]+\S.*\n?)*)', text)
145
91
  block = m.group(1) if m else ""
146
92
  me = re.search(r'(?m)^[ \t]+enabled:[ \t]*"?([A-Za-z]+)"?', block)
@@ -150,8 +96,17 @@ PYEOF
150
96
 
151
97
  AGENT_ID="$(yaml_value agent_id)"
152
98
  REPO_NAME="$(yaml_value repo)"
153
- PROVIDER="$(project_json_value ticket_provider.type)"
154
- [[ -n "$PROVIDER" ]] || PROVIDER="$(yaml_block_value ticket_provider name)"
99
+ PROVIDER="$(yaml_block_value ticket_provider name)"
100
+
101
+ repo_root() {
102
+ local dir="$ROLE_DIR"
103
+ for _ in 1 2 3 4 5; do
104
+ dir="$(dirname "$dir")"
105
+ if [[ -d "$dir/.git" || -f "$dir/.git" ]]; then printf '%s\n' "$dir"; return 0; fi
106
+ done
107
+ return 1
108
+ }
109
+ REPO_ROOT="$(repo_root)"
155
110
  cd "$REPO_ROOT"
156
111
  mkdir -p "$RUNTIME/logs"
157
112
 
@@ -177,10 +132,11 @@ else
177
132
  fi
178
133
 
179
134
  # Reconcile gate: the autonomous board-reconciliation pass runs only when
180
- # repo-root .project.json automation.reconcile.enabled is true. Default off →
181
- # the heartbeat just checkpoints (legacy checkpoint-timer behavior).
135
+ # role.yaml's reconcile.enabled is true. Default off → the heartbeat just
136
+ # checkpoints (behaves like the legacy hourly checkpoint timer). Flip
137
+ # reconcile.enabled to opt a repo into autonomous board reconciliation.
182
138
  if [[ "$(reconcile_enabled)" != "true" ]]; then
183
- printf '[heartbeat] reconcile disabled (automation.reconcile.enabled != true) — checkpoint-only tick\n'
139
+ printf '[heartbeat] reconcile disabled (reconcile.enabled != true) — checkpoint-only tick\n'
184
140
  maybe_checkpoint
185
141
  exit 0
186
142
  fi
@@ -1,10 +1,10 @@
1
1
  # shellcheck shell=bash
2
2
  # Ticket-provider adapter dispatcher — the single seam between the heartbeat
3
- # sentinel engine and a concrete ticket system (Plane | Trello).
3
+ # sentinel engine and a concrete ticket system (Linear | Plane | Trello).
4
4
  #
5
5
  # The engine NEVER calls a provider directly. It calls `tp <op> [args...]`,
6
- # which dispatches to providers/<provider>.sh. Repo-root .project.json owns the
7
- # provider/board binding; role.yaml is only a legacy provider-name fallback.
6
+ # which dispatches to providers/<provider>.sh. Swapping providers is a one-line
7
+ # config change in role.yaml (ticket_provider.name) no engine edits.
8
8
  #
9
9
  # Contract (operations every provider must implement):
10
10
  # resolve -> JSON {provider, board_id, board_url}
@@ -19,7 +19,7 @@
19
19
  # create_board <name> <id> <d> -> JSON {board_id, board_url}
20
20
  #
21
21
  # Each provider reads its credentials from the environment (see providers/*.sh
22
- # headers) and the board binding from repo-root .project.json.
22
+ # headers) and the board binding from role.yaml under `ticket_provider:`.
23
23
 
24
24
  # Resolve the provider name: explicit env wins, then repo-root .project.json
25
25
  # (the SOT), then role.yaml (self-parsed so this works even when _lib.sh /
@@ -64,7 +64,7 @@ PY
64
64
  )"
65
65
  [ -n "$name" ] && { printf '%s\n' "$name"; return 0; }
66
66
  fi
67
- printf 'plane\n'
67
+ printf 'linear\n'
68
68
  }
69
69
 
70
70
  # Directory holding provider implementations (sibling of this lib).
@@ -81,10 +81,6 @@ tp() {
81
81
 
82
82
  local name impl
83
83
  name="$(tp_provider_name)"
84
- case "$name" in
85
- plane|trello) ;;
86
- *) echo "tp: unsupported ticket provider '$name' (expected plane|trello)" >&2; return 2 ;;
87
- esac
88
84
  impl="$(tp_providers_dir)/${name}.sh"
89
85
 
90
86
  if [ ! -f "$impl" ]; then