@delorenj/pjangler 1.2.9 → 1.2.13

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 (32) hide show
  1. package/dist/index.js +498 -204
  2. package/dist/mcp-server.js +488 -194
  3. package/package.json +1 -1
  4. package/templates/commonproject/copier.yml +4 -1
  5. package/templates/commonproject/template/.project.json.jinja +9 -12
  6. package/templates/commonproject/template/mise.toml.jinja +1 -1
  7. package/templates/hermes-agent/README.md +1 -1
  8. package/templates/hermes-agent/config.example.toml +66 -0
  9. package/templates/hermes-agent/docs/architecture.md +5 -0
  10. package/templates/hermes-agent/docs/bloodbank-gateway.md +57 -0
  11. package/templates/hermes-agent/docs/fleet-control-plane/README.md +1 -1
  12. package/templates/hermes-agent/docs/fleet-control-plane/n8n-service-hub.md +60 -0
  13. package/templates/hermes-agent/docs/operations.md +1 -1
  14. package/templates/hermes-agent/docs/sentinel/README.md +2 -2
  15. package/templates/hermes-agent/docs/sentinel/development.md +3 -3
  16. package/templates/hermes-agent/docs/sentinel/providers.md +6 -5
  17. package/templates/hermes-agent/install-local.sh +10 -4
  18. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +5 -3
  19. package/templates/hermes-agent/scripts/fleet-sync.sh +64 -1
  20. package/templates/hermes-agent/template/.gitignore.jinja +2 -0
  21. package/templates/hermes-agent/template/.scripts/01-config.sh +1 -0
  22. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +9 -0
  23. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +22 -3
  24. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +22 -0
  25. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -33
  26. package/templates/hermes-agent/template/.scripts/70-systemd.sh +8 -1
  27. package/templates/hermes-agent/template/.scripts/_lib.sh +61 -3
  28. package/templates/hermes-agent/template/.scripts/config.example.toml +5 -0
  29. package/templates/hermes-agent/template/.scripts/heartbeat.sh +66 -22
  30. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +3 -3
  31. package/templates/hermes-agent/template/.scripts/providers/plane.sh +29 -9
  32. package/templates/hermes-agent/template/role.yaml.jinja +2 -27
@@ -10,11 +10,12 @@
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` and mirror the
14
- # binding into role.yaml for back-compat (80-registry.sh / 99-summary.sh).
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.
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"
18
19
  # shellcheck source=lib/ticket-provider.sh
19
20
  source "$(dirname "$0")/lib/ticket-provider.sh"
20
21
 
@@ -46,13 +47,13 @@ PY
46
47
  }
47
48
 
48
49
  # pj_write — merge board binding (optional) + this agent into .project.json.
49
- # args: set_provider(0|1) provider board_id board_url workspace identifier
50
+ # args: set_provider(0|1) provider board_id workspace identifier
50
51
  pj_write() {
51
52
  REPO="$REPO" REPO_ROOT="$REPO_ROOT" AGENT_ID="$AGENT_ID" ROLE="$ROLE" \
52
53
  ROLE_DIR_REL="$ROLE_DIR_REL" PROJECT_DESC="${PROJECT_DESC:-}" \
53
54
  python3 - "$PROJECT_JSON" "$@" <<'PY'
54
55
  import sys, os, json, pathlib
55
- (path, set_provider, provider, board_id, board_url, workspace, identifier) = sys.argv[1:8]
56
+ (path, set_provider, provider, board_id, workspace, identifier) = sys.argv[1:7]
56
57
  p = pathlib.Path(path)
57
58
  try:
58
59
  d = json.loads(p.read_text())
@@ -71,43 +72,28 @@ if set_provider == "1":
71
72
  if workspace: tp["workspace"] = workspace
72
73
  if identifier: tp["identifier"] = identifier
73
74
  if board_id: tp["board_id"] = board_id
74
- if board_url: tp["board_url"] = board_url
75
+ tp.pop("board_url", None)
76
+ tp["state"] = "linked" if board_id else tp.get("state", "planned")
75
77
  ag = d.setdefault("agents", {})
76
78
  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})
77
81
  p.write_text(json.dumps(d, indent=2) + "\n")
78
82
  PY
79
83
  log " .project.json updated (agent=$AGENT_ID)"
80
84
  }
81
85
 
82
- # Mirror the binding into role.yaml so legacy consumers keep working.
83
- mirror_to_role_yaml() {
84
- # mirror_to_role_yaml <provider> <board_id> <board_url> <workspace> <identifier>
85
- local provider="$1" bid="$2" burl="$3" ws="$4" ident="$5"
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"
86
90
  yaml_set ticket_provider.name "$provider" 2>/dev/null || true
87
- [ -n "$bid" ] && yaml_set ticket_provider.board_id "$bid" 2>/dev/null || true
88
- [ -n "$burl" ] && yaml_set ticket_provider.board_url "$burl" 2>/dev/null || true
89
- case "$provider" in
90
- plane)
91
- [ -n "$bid" ] && echo "$bid" > "$ROLE_DIR/.scripts/.plane-project-id"
92
- [ -n "$ws" ] && yaml_set ticket_provider.workspace "$ws" 2>/dev/null || true
93
- [ -n "$bid" ] && yaml_set ticket_provider.project "$bid" 2>/dev/null || true
94
- [ -n "$ws" ] && yaml_set plane.workspace "$ws" 2>/dev/null || true
95
- [ -n "$ident" ] && yaml_set plane.identifier "$ident" 2>/dev/null || true
96
- ;;
97
- trello)
98
- [ -n "$bid" ] && yaml_set ticket_provider.board "$bid" 2>/dev/null || true
99
- ;;
100
- *)
101
- die "unsupported ticket provider: $provider (expected plane|trello)"
102
- ;;
103
- esac
104
91
  }
105
92
 
106
93
  # ── Provider resolution ──────────────────────────────────────────────────
107
94
  # An existing repo board (in .project.json) wins — every agent binds to it.
108
95
  SOT_TYPE="$(pj ticket_provider.type)"
109
96
  SOT_BOARD_ID="$(pj ticket_provider.board_id)"
110
- SOT_URL="$(pj ticket_provider.board_url)"
111
97
  SOT_WS="$(pj ticket_provider.workspace)"
112
98
  SOT_IDENT="$(pj ticket_provider.identifier)"
113
99
 
@@ -122,8 +108,8 @@ if [ -n "$SOT_BOARD_ID" ]; then
122
108
  fi
123
109
  case "$PROVIDER" in plane|trello) ;; *) die "unsupported ticket provider in .project.json: $PROVIDER (expected plane|trello)" ;; esac
124
110
  log "[42] binding $AGENT_ID to existing repo board (provider=$PROVIDER, id=$SOT_BOARD_ID)"
125
- mirror_to_role_yaml "$PROVIDER" "$SOT_BOARD_ID" "$SOT_URL" "$SOT_WS" "$SOT_IDENT"
126
- pj_write 0 "$PROVIDER" "" "" "" "" # register agent only; board already recorded
111
+ mirror_provider_name_to_role_yaml "$PROVIDER"
112
+ pj_write 1 "$PROVIDER" "$SOT_BOARD_ID" "$SOT_WS" "$SOT_IDENT"
127
113
  mark_done 42-ticket-provider
128
114
  exit 0
129
115
  fi
@@ -146,16 +132,15 @@ case "$PROVIDER" in
146
132
  KEYVAR=PLANE_API_KEY; [ "$PROVIDER" = trello ] && KEYVAR=TRELLO_KEY
147
133
  if [[ -z "${!KEYVAR:-}" ]]; then
148
134
  warn "[42] $KEYVAR not set; skipping board creation. Set creds and re-run ./.scripts/42-ticket-provider.sh"
149
- pj_write 1 "$PROVIDER" "" "" "${SOT_WS:-${PLANE_WORKSPACE:-}}" "$IDENT"
135
+ pj_write 1 "$PROVIDER" "" "${SOT_WS:-${PLANE_WORKSPACE:-}}" "$IDENT"
150
136
  mark_done 42-ticket-provider; exit 0
151
137
  fi
152
138
  OUT="$(tp create_board "$NAME" "$IDENT" "$DESC")" || die "create_board failed for $PROVIDER"
153
139
  BID="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_id",""))')"
154
- BURL="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_url",""))')"
155
140
  WS="${SOT_WS:-${PLANE_WORKSPACE:-}}"
156
141
  [ "$PROVIDER" = trello ] && WS=""
157
- mirror_to_role_yaml "$PROVIDER" "$BID" "$BURL" "$WS" "$IDENT"
158
- pj_write 1 "$PROVIDER" "$BID" "$BURL" "$WS" "$IDENT"
142
+ mirror_provider_name_to_role_yaml "$PROVIDER"
143
+ pj_write 1 "$PROVIDER" "$BID" "$WS" "$IDENT"
159
144
  ;;
160
145
 
161
146
  *) die "unknown ticket provider: $PROVIDER (expected plane|trello)" ;;
@@ -5,7 +5,10 @@
5
5
  source "$(dirname "$0")/_lib.sh"
6
6
  load_role_env
7
7
 
8
- already_done 70-systemd && { log "[70] systemd already installed — skipping"; exit 0; }
8
+ if already_done 70-systemd && [[ "${FORCE_SYSTEMD:-0}" != "1" ]]; then
9
+ log "[70] systemd already installed — skipping"
10
+ exit 0
11
+ fi
9
12
  [[ "${SKIP_SYSTEMD:-0}" == "1" ]] && { log "[70] systemd — SKIPPED"; mark_done 70-systemd; exit 0; }
10
13
 
11
14
  RUNTIME="$ROLE_DIR/runtime"
@@ -34,6 +37,7 @@ Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
34
37
  Environment=CODEX_HOME=$CODEX_HOME
35
38
  Environment=HERMES_KANBAN_DISPATCH_IN_GATEWAY=0
36
39
  Environment=HERMES_KANBAN_SLASH_ENABLED=0
40
+ EnvironmentFile=-$FLEET_ENV
37
41
  EnvironmentFile=-$RUNTIME/.env
38
42
  ExecStart=$HERMES_BIN gateway run --replace
39
43
  Restart=on-failure
@@ -59,6 +63,7 @@ WorkingDirectory=$RUNTIME
59
63
  Environment=HERMES_HOME=$RUNTIME
60
64
  Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
61
65
  Environment=CODEX_HOME=$CODEX_HOME
66
+ EnvironmentFile=-$FLEET_ENV
62
67
  EnvironmentFile=-$RUNTIME/.env
63
68
  ExecStart=$HERMES_AGENT_REPO/.venv/bin/python $RUNTIME/bloodbank-consumer.py
64
69
  Restart=on-failure
@@ -88,6 +93,7 @@ WorkingDirectory=$REPO_ROOT
88
93
  Environment=HERMES_HOME=$RUNTIME
89
94
  Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
90
95
  Environment=CODEX_HOME=$CODEX_HOME
96
+ EnvironmentFile=-$FLEET_ENV
91
97
  EnvironmentFile=-%h/.config/hermes-agent/env
92
98
  EnvironmentFile=-%h/.hermes/env
93
99
  EnvironmentFile=-%h/.hermes/hermes-agent.env
@@ -114,6 +120,7 @@ UNIT
114
120
 
115
121
  if systemd_user_available; then
116
122
  systemctl --user daemon-reload
123
+ systemctl --user disable --now "hermes-${AGENT_ID}-checkpoint.timer" "hermes-${AGENT_ID}-checkpoint.service" >/dev/null 2>&1 || true
117
124
  # `enable --now` both enables (persist across login) AND starts the unit now, so a
118
125
  # freshly provisioned agent comes up live instead of dormant. Units with missing
119
126
  # creds (e.g. a gateway with no Telegram token yet) fail softly via Restart=on-failure.
@@ -62,6 +62,41 @@ 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
+
65
100
  # ─── Distributable config (~/.config/hermes-agent-template/config.toml) ──────
66
101
  # Single source of truth for environment-specific defaults so this template can
67
102
  # be handed to someone else without editing any script. Ship config.example.toml
@@ -112,8 +147,10 @@ load_role_env() {
112
147
  BOT_HANDLE=$(yaml_get telegram.bot_username)
113
148
  PROFILE_NAME=$(yaml_get profile)
114
149
 
115
- # Plane workspace: empty in role.yaml -> resolve from config.toml.
116
- PLANE_WORKSPACE=$(yaml_get plane.workspace)
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)
117
154
  [[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(config_get plane.workspace "33god")
118
155
 
119
156
  # Runtime repo: role.yaml stores the bare repo name plus an optional owner.
@@ -129,6 +166,27 @@ load_role_env() {
129
166
  PLANE_WORKSPACE RUNTIME_REPO PROFILE_NAME
130
167
  }
131
168
 
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
+
132
190
  # Skip a step if previously completed (idempotent reruns).
133
191
  already_done() {
134
192
  local marker="$ROLE_DIR/.scripts/.done-$1"
@@ -165,7 +223,7 @@ BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_d
165
223
 
166
224
  # Plane
167
225
  PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
168
- PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
226
+ PLANE_API_KEY="${PLANE_API_KEY:-}"
169
227
 
170
228
  export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO HERMES_OAUTH_FILE CODEX_HOME \
171
229
  RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
@@ -27,6 +27,11 @@ 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
+ ]
30
35
  # Voxxy plugin + service defaults for Hermes PM agents.
31
36
  voxxy_plugin_dir = "~/code/voxxy/plugins/tts/voxxy"
32
37
  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 (Linear | Plane | Trello) — never a
9
+ # through the ticket-provider adapter (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,12 +19,18 @@ 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}"
22
23
  LOG_FILE="$RUNTIME/logs/heartbeat.log"
23
24
  CHECKPOINT_BIN="$ROLE_DIR/.scripts/checkpoint.sh"
24
25
  CHECKPOINT_STAMP="$RUNTIME/.last-checkpoint"
25
26
 
27
+ if [[ -f "$FLEET_ENV" ]]; then
28
+ # shellcheck disable=SC1090
29
+ source "$FLEET_ENV"
30
+ fi
31
+
26
32
  # Hermes binary: explicit env > ~/.config/hermes-agent/hermes-bin > PATH.
27
- HERMES_BIN="${HERMES_BIN:-}"
33
+ HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
28
34
  if [[ -z "$HERMES_BIN" ]]; then
29
35
  if [[ -r "$HOME/.config/hermes-agent/hermes-bin" ]]; then
30
36
  HERMES_BIN="$(cat "$HOME/.config/hermes-agent/hermes-bin")"
@@ -80,13 +86,61 @@ print(value.strip().strip('"').strip("'"))
80
86
  PYEOF
81
87
  }
82
88
 
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.
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.
85
124
  reconcile_enabled() {
86
- python3 - "$ROLE_YAML" <<'PYEOF'
87
- import re, sys
125
+ python3 - "$PROJECT_JSON" "$ROLE_YAML" <<'PYEOF'
126
+ import json
127
+ import re
128
+ import sys
88
129
  from pathlib import Path
89
- text = Path(sys.argv[1]).read_text()
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 = ""
90
144
  m = re.search(r'(?m)^reconcile:[ \t]*\n((?:[ \t]+\S.*\n?)*)', text)
91
145
  block = m.group(1) if m else ""
92
146
  me = re.search(r'(?m)^[ \t]+enabled:[ \t]*"?([A-Za-z]+)"?', block)
@@ -96,17 +150,8 @@ PYEOF
96
150
 
97
151
  AGENT_ID="$(yaml_value agent_id)"
98
152
  REPO_NAME="$(yaml_value repo)"
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)"
153
+ PROVIDER="$(project_json_value ticket_provider.type)"
154
+ [[ -n "$PROVIDER" ]] || PROVIDER="$(yaml_block_value ticket_provider name)"
110
155
  cd "$REPO_ROOT"
111
156
  mkdir -p "$RUNTIME/logs"
112
157
 
@@ -132,11 +177,10 @@ else
132
177
  fi
133
178
 
134
179
  # Reconcile gate: the autonomous board-reconciliation pass runs only when
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.
180
+ # repo-root .project.json automation.reconcile.enabled is true. Default off →
181
+ # the heartbeat just checkpoints (legacy checkpoint-timer behavior).
138
182
  if [[ "$(reconcile_enabled)" != "true" ]]; then
139
- printf '[heartbeat] reconcile disabled (reconcile.enabled != true) — checkpoint-only tick\n'
183
+ printf '[heartbeat] reconcile disabled (automation.reconcile.enabled != true) — checkpoint-only tick\n'
140
184
  maybe_checkpoint
141
185
  exit 0
142
186
  fi
@@ -3,8 +3,8 @@
3
3
  # sentinel engine and a concrete ticket system (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. Swapping providers is a one-line
7
- # config change in role.yaml (ticket_provider.name) no engine edits.
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.
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 role.yaml under `ticket_provider:`.
22
+ # headers) and the board binding from repo-root .project.json.
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 /
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env sh
2
2
  # Plane ticket-provider adapter.
3
3
  #
4
- # Credentials: PLANE_API_KEY (X-API-Key header)
4
+ # Credentials: PLANE_API_KEY or PLANE_<WORKSPACE>_API_KEY (X-API-Key header)
5
5
  # Endpoint: PLANE_BASE (default https://plane.delo.sh)
6
- # Board binding (role.yaml `ticket_provider:`):
7
- # name: plane
6
+ # Board binding (repo-root .project.json `ticket_provider:`):
8
7
  # workspace: <workspace-slug> (or env PLANE_WORKSPACE)
9
- # project: <project-uuid> (set by create_board / 42-ticket-provider)
8
+ # board_id: <project-uuid> (set by create_board / 42-ticket-provider)
10
9
  # state_map: { in_review: "In Review", completed: "Done" } optional
11
10
  #
12
11
  # Plane model: project = board, cycle = milestone, state.group in
@@ -21,9 +20,21 @@ ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
21
20
  ROLE_YAML="$ROLE_DIR/role.yaml"
22
21
  BASE="${PLANE_BASE:-https://plane.delo.sh}"
23
22
 
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
+
24
29
  die() { echo "plane: $*" >&2; exit 1; }
25
30
  need_key() { [ -n "${PLANE_API_KEY:-}" ] || die "PLANE_API_KEY is not set"; }
26
31
 
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
+
27
38
  tp_cfg() {
28
39
  [ -f "$ROLE_YAML" ] || return 0
29
40
  python3 - "$ROLE_YAML" "$1" <<'PY'
@@ -54,13 +65,19 @@ else:
54
65
  PY
55
66
  }
56
67
 
57
- # Board binding: .project.json (SOT) first, then role.yaml, then env.
68
+ # Board binding: .project.json (SOT) first, then legacy role.yaml, then env.
58
69
  WS="$(pj_cfg workspace)"; [ -n "$WS" ] || WS="$(tp_cfg workspace)"; WS="${WS:-${PLANE_WORKSPACE:-}}"
59
- PROJ="$(pj_cfg board_id)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg project)"
70
+ PROJ="$(pj_cfg board_id)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg project)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg board_id)"
60
71
  SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
61
72
  SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
62
73
  API="$BASE/api/v1/workspaces/$WS"
63
74
 
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
+
64
81
  # api METHOD PATH [JSON_BODY] — call Plane REST, print response body.
65
82
  api() {
66
83
  need_key
@@ -68,9 +85,12 @@ api() {
68
85
  if [ -n "$body" ]; then
69
86
  curl -fsS -X "$method" "$API/$path" \
70
87
  -H "X-API-Key: $PLANE_API_KEY" -H "Content-Type: application/json" \
88
+ -H "User-Agent: curl/8.0" \
71
89
  -d "$body"
72
90
  else
73
- curl -fsS -X "$method" "$API/$path" -H "X-API-Key: $PLANE_API_KEY"
91
+ curl -fsS -X "$method" "$API/$path" \
92
+ -H "X-API-Key: $PLANE_API_KEY" \
93
+ -H "User-Agent: curl/8.0"
74
94
  fi
75
95
  }
76
96
 
@@ -100,8 +120,8 @@ need_key
100
120
 
101
121
  case "$OP" in
102
122
  resolve)
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)"
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)"
105
125
  printf '{"provider":"plane","board_id":"%s","board_url":"%s/%s/projects/%s/issues/"}\n' \
106
126
  "$PROJ" "$BASE" "$WS" "$PROJ"
107
127
  ;;
@@ -21,35 +21,10 @@ profile: {{ agent_id }}
21
21
  telegram:
22
22
  bot_username: "{{ bot_handle }}"
23
23
 
24
- # Ticket-provider binding (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.
24
+ # Ticket-provider adapter preference (plane | trello).
25
+ # Repo-owned board identity lives in repo-root .project.json, not in this role.
27
26
  ticket_provider:
28
27
  name: {{ ticket_provider }}
29
- board_id: ""
30
- board_url: ""
31
- {%- if ticket_provider == 'plane' %}
32
- workspace: "{{ plane_workspace }}"
33
- project: "" # Plane project uuid (set by 42-ticket-provider.sh)
34
- {%- elif ticket_provider == 'trello' %}
35
- board: "" # Trello board id (set by 42-ticket-provider.sh)
36
- {%- endif %}
37
- # Optional normalized-state name overrides for non-standard boards.
38
- in_review: "" # e.g. "In Review"
39
- completed: "" # e.g. "Done"
40
-
41
- # Board-reconciliation knobs for the heartbeat sentinel pass.
42
- reconcile:
43
- enabled: false # heartbeat runs the autonomous board-reconciliation pass ONLY when true; default off = checkpoint-only
44
- grace_hours: 0 # 0 = no human-approval wait (adversarially-reviewed review-lane = done); set >0 to require a wait
45
- auto_review: true # autonomous adversarial review (act, do not wait) on/off
46
-
47
- # Plane metadata — retained for fleet-registry back-compat when provider=plane.
48
- plane:
49
- # Empty -> resolved from ~/.config/hermes-agent-template/config.toml [plane].workspace
50
- workspace: "{{ plane_workspace }}"
51
- # identifier is set by .scripts/42-ticket-provider.sh after creation (plane only)
52
- identifier: ""
53
28
 
54
29
  # Bloodbank wiring (consumed + produced subjects).
55
30
  bloodbank: