@delorenj/pjangler 1.4.5 → 1.5.2

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 (83) hide show
  1. package/README.md +41 -520
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +5747 -13483
  10. package/dist/mcp-server.js +6853 -14059
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -513
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. package/templates/hermes-agent/template/role.yaml.jinja +0 -112
@@ -1,457 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Opt-in, profile-local Slack Socket Mode provisioning.
3
- #
4
- # Slack credentials are invocation-only inputs. Capture them before _lib.sh
5
- # sources fleet.env so an accidentally shared fleet token can never provision a
6
- # profile. The non-secret allowed-user policy may still come from fleet.env.
7
- INVOCATION_SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN-}"
8
- INVOCATION_SLACK_APP_TOKEN="${SLACK_APP_TOKEN-}"
9
- INVOCATION_SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS-}"
10
- INVOCATION_ENABLE_SLACK="${ENABLE_SLACK-${WIRE_SLACK-}}"
11
- # Copy invocation-only inputs, then immediately clear their exported form.
12
- # Resolving/sourcing _lib.sh executes PATH children; none may inherit channel
13
- # credentials (or accidentally treat the invocation policy as fleet state).
14
- unset SLACK_BOT_TOKEN SLACK_APP_TOKEN SLACK_ALLOWED_USERS ENABLE_SLACK WIRE_SLACK
15
-
16
- # Every channel write lands in the host-global profile root that 10-hermes-profile.sh
17
- # creates. When that step is deferred the root does not exist yet, so there is no
18
- # honest Slack state to record -- not even the disabled state, which is itself a
19
- # write into the profile. Defer with it. This guard must precede _lib.sh because that
20
- # library creates the role log and reads fleet configuration.
21
- if [[ "${SKIP_HOST_STATE:-0}" == "1" ]]; then
22
- printf '%s\n' '[31] slack — DEFERRED (SKIP_HOST_STATE=1)' >&2
23
- exit 0
24
- fi
25
-
26
- # shellcheck source=_lib.sh
27
- source "$(dirname "$0")/_lib.sh"
28
- load_role_env
29
-
30
- SLACK_BOT_TOKEN="$INVOCATION_SLACK_BOT_TOKEN"
31
- SLACK_APP_TOKEN="$INVOCATION_SLACK_APP_TOKEN"
32
- if [[ -n "$INVOCATION_SLACK_ALLOWED_USERS" ]]; then
33
- SLACK_ALLOWED_USERS="$INVOCATION_SLACK_ALLOWED_USERS"
34
- else
35
- SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS-}"
36
- fi
37
- ENABLE_SLACK="$INVOCATION_ENABLE_SLACK"
38
- unset INVOCATION_SLACK_BOT_TOKEN INVOCATION_SLACK_APP_TOKEN
39
-
40
- PROFILE_HOME="$HOME/.hermes/profiles/$PROFILE_NAME"
41
- profile_root_require_real "$PROFILE_HOME"
42
- RUNTIME="$ROLE_DIR/runtime"
43
- ENVF="$RUNTIME/.env"
44
- mkdir -p "$RUNTIME"
45
- [[ ! -L "$ENVF" ]] || die "refusing to write Slack credentials through symlink: $ENVF"
46
-
47
- slack_reconcile_existing() {
48
- # Registry first, then the transaction helper's profile lock. The helper
49
- # snapshots and validates both refs plus role metadata inside this window.
50
- local rc=0
51
- fleet_lock_acquire
52
- trap 'fleet_lock_release' EXIT
53
- channel_transaction_slack_existing "$PROFILE_HOME" "$ENVF" || rc=$?
54
- fleet_lock_release
55
- trap - EXIT
56
- return "$rc"
57
- }
58
-
59
- slack_defer_if_unconfigured() {
60
- # Prepare and, when already verified, reconcile under one registry-lock
61
- # window. The helpers independently take the profile lock second.
62
- local prepare_rc=0 rc=0
63
- fleet_lock_acquire
64
- trap 'fleet_lock_release' EXIT
65
- channel_transaction_slack_prepare_unconfigured \
66
- "$PROFILE_HOME" "$ENVF" || prepare_rc=$?
67
- case "$prepare_rc" in
68
- 0)
69
- fleet_lock_release
70
- trap - EXIT
71
- return 2
72
- ;;
73
- 3)
74
- channel_transaction_slack_existing "$PROFILE_HOME" "$ENVF" || rc=$?
75
- ;;
76
- *)
77
- die "Slack deferred-state preparation transaction failed"
78
- ;;
79
- esac
80
- case "$rc" in
81
- 0)
82
- fleet_lock_release
83
- trap - EXIT
84
- return 0
85
- ;;
86
- 75)
87
- die "Slack 1Password reference validation is temporarily unavailable; preserved existing verified wiring for retry"
88
- ;;
89
- *)
90
- die "Slack verified wiring reconciliation transaction failed"
91
- ;;
92
- esac
93
- }
94
-
95
- slack_prepare_for_attempt() {
96
- # A new profile becomes explicitly disabled before remote verification. A
97
- # verified profile is a no-op so a failed rotation preserves active wiring.
98
- local rc=0
99
- fleet_lock_acquire
100
- trap 'fleet_lock_release' EXIT
101
- channel_transaction_slack_prepare_unconfigured \
102
- "$PROFILE_HOME" "$ENVF" || rc=$?
103
- fleet_lock_release
104
- trap - EXIT
105
- case "$rc" in
106
- 0|3) return 0 ;;
107
- *) die "Slack deferred-state preparation transaction failed" ;;
108
- esac
109
- }
110
-
111
- truthy() {
112
- case "${1,,}" in
113
- 1|true|yes|on) return 0 ;;
114
- *) return 1 ;;
115
- esac
116
- }
117
-
118
- # Preserve and reconcile durable wiring across transient 1Password outages.
119
- # An explicit token is a rotation request. With no tokens, the complete current
120
- # ref/identity snapshot is taken only after registry -> profile locks are held.
121
- if [[ -z "${SLACK_BOT_TOKEN:-}" && -z "${SLACK_APP_TOKEN:-}" ]]; then
122
- slack_reference_rc=0
123
- slack_reconcile_existing || slack_reference_rc=$?
124
- case "$slack_reference_rc" in
125
- 0)
126
- log "[31] slack — existing verified 1Password wiring reconciled"
127
- exit 0
128
- ;;
129
- 2) ;;
130
- 75)
131
- die "Slack 1Password reference validation is temporarily unavailable; preserved existing verified wiring for retry"
132
- ;;
133
- *)
134
- die "Slack verified wiring reconciliation transaction failed"
135
- ;;
136
- esac
137
- fi
138
-
139
- # Establish explicit disabled state for a first-time credential attempt. The
140
- # registry -> profile lock recheck preserves any already-verified wiring and
141
- # prevents this preparation from racing a concurrent rotation.
142
- if [[ -n "${SLACK_BOT_TOKEN:-}" || -n "${SLACK_APP_TOKEN:-}" ]]; then
143
- slack_prepare_for_attempt
144
- fi
145
-
146
- if [[ "${SKIP_SLACK:-0}" == "1" ]]; then
147
- slack_defer_rc=0
148
- slack_defer_if_unconfigured || slack_defer_rc=$?
149
- if [[ $slack_defer_rc -eq 2 ]]; then
150
- log "[31] slack — DEFERRED (SKIP_SLACK=1; no verified 1Password reference pair)"
151
- else
152
- log "[31] slack — existing verified 1Password wiring reconciled"
153
- fi
154
- exit 0
155
- fi
156
-
157
- if already_done 31-slack; then
158
- log "[31] existing completion marker preserved while Slack is reconciled"
159
- fi
160
-
161
- have_bot=0
162
- have_app=0
163
- [[ -n "$SLACK_BOT_TOKEN" ]] && have_bot=1
164
- [[ -n "$SLACK_APP_TOKEN" ]] && have_app=1
165
-
166
- if ! truthy "${ENABLE_SLACK:-0}" && (( ! have_bot && ! have_app )); then
167
- slack_defer_rc=0
168
- slack_defer_if_unconfigured || slack_defer_rc=$?
169
- if [[ $slack_defer_rc -eq 2 ]]; then
170
- log "[31] slack — deferred (opt in with ENABLE_SLACK=1 or supply both Slack tokens)"
171
- else
172
- log "[31] slack — existing verified 1Password wiring reconciled"
173
- fi
174
- exit 0
175
- fi
176
-
177
- if (( have_bot != have_app )) && ! truthy "${ENABLE_SLACK:-0}"; then
178
- die "Slack provisioning requires a dedicated SLACK_BOT_TOKEN and SLACK_APP_TOKEN pair"
179
- fi
180
-
181
- if truthy "${ENABLE_SLACK:-0}" && [[ -t 0 ]]; then
182
- if [[ -z "$SLACK_BOT_TOKEN" ]]; then
183
- read -r -s -p "Slack Bot User OAuth Token (xoxb-...): " SLACK_BOT_TOKEN
184
- echo >&2
185
- fi
186
- if [[ -z "$SLACK_APP_TOKEN" ]]; then
187
- read -r -s -p "Slack App-Level Socket Mode Token (xapp-...): " SLACK_APP_TOKEN
188
- echo >&2
189
- fi
190
- fi
191
-
192
- [[ -n "$SLACK_BOT_TOKEN" && -n "$SLACK_APP_TOKEN" ]] \
193
- || die "Slack provisioning requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN"
194
- [[ "$SLACK_BOT_TOKEN" =~ ^xoxb-[A-Za-z0-9-]+$ ]] || die "SLACK_BOT_TOKEN must be a Bot User OAuth token (xoxb-...)"
195
- [[ "$SLACK_APP_TOKEN" =~ ^xapp-[A-Za-z0-9-]+$ ]] || die "SLACK_APP_TOKEN must be an App-Level Socket Mode token (xapp-...)"
196
-
197
- SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS//[[:space:]]/}"
198
- if [[ -n "$SLACK_ALLOWED_USERS" && "$SLACK_ALLOWED_USERS" != "*" \
199
- && ! "$SLACK_ALLOWED_USERS" =~ ^[UW][A-Z0-9]+(,[UW][A-Z0-9]+)*$ ]]; then
200
- die "SLACK_ALLOWED_USERS must be '*' or comma-separated Slack member IDs"
201
- fi
202
-
203
- log "[31] verifying Slack bot identity via auth.test"
204
- auth_response="$({
205
- printf '%s\n' 'url = "https://slack.com/api/auth.test"'
206
- printf 'header = "Authorization: Bearer %s"\n' "$SLACK_BOT_TOKEN"
207
- printf '%s\n' 'header = "Content-Type: application/x-www-form-urlencoded"'
208
- printf '%s\n' 'request = "POST"' 'fail' 'silent' 'show-error' \
209
- 'connect-timeout = 10' 'max-time = 20' 'max-filesize = 1048576'
210
- } | curl --config -)" \
211
- || die "Slack auth.test request failed"
212
-
213
- identity=$(printf '%s' "$auth_response" | python3 -c '
214
- import json, sys
215
- try:
216
- data = json.load(sys.stdin)
217
- except Exception:
218
- raise SystemExit("Slack auth.test returned an invalid response")
219
- if not data.get("ok"):
220
- error = str(data.get("error") or "unknown_error")
221
- safe = "".join(c for c in error if c.isalnum() or c in "_-.")[:80]
222
- raise SystemExit("Slack auth.test rejected the bot token ({})".format(safe or "unknown_error"))
223
- values = [data.get(k, "") for k in ("team_id", "team", "user_id", "bot_id", "user")]
224
- if not values[0] or not values[2] or not values[3]:
225
- raise SystemExit("Slack auth.test response omitted required identity fields")
226
- print("\t".join(str(v).replace("\t", " ").replace("\n", " ") for v in values))
227
- ') || die "Slack bot identity verification failed"
228
- IFS=$'\t' read -r slack_team_id slack_team_name slack_bot_user_id slack_bot_id slack_bot_username <<< "$identity"
229
-
230
- # auth.test proves the bot installation/workspace, while bots.info provides the
231
- # authoritative Slack app identity for that bot. Correlation requires the
232
- # app's users:read scope; missing scope is a hard, truthful deferred outcome.
233
- log "[31] resolving Slack bot app identity via bots.info"
234
- bot_info_response="$({
235
- printf '%s\n' 'url = "https://slack.com/api/bots.info"'
236
- printf 'header = "Authorization: Bearer %s"\n' "$SLACK_BOT_TOKEN"
237
- printf '%s\n' 'header = "Content-Type: application/x-www-form-urlencoded"'
238
- printf 'data = "bot=%s"\n' "$slack_bot_id"
239
- printf '%s\n' 'request = "POST"' 'fail' 'silent' 'show-error' \
240
- 'connect-timeout = 10' 'max-time = 20' 'max-filesize = 1048576'
241
- } | curl --config -)" \
242
- || die "Slack bots.info request failed; Slack remains deferred"
243
- slack_bot_app_id="$(printf '%s' "$bot_info_response" | python3 -c '
244
- import json, re, sys
245
- try:
246
- data = json.load(sys.stdin)
247
- except Exception:
248
- raise SystemExit("Slack bots.info returned an invalid response")
249
- if not isinstance(data, dict) or not data.get("ok"):
250
- error = str(data.get("error") if isinstance(data, dict) else "unknown_error")
251
- if error == "missing_scope":
252
- raise SystemExit(
253
- "Slack bots.info requires users:read; add that bot scope, reinstall the app, and rerun"
254
- )
255
- safe = "".join(c for c in error if c.isalnum() or c in "_-." )[:80]
256
- raise SystemExit("Slack bots.info rejected identity lookup ({})".format(safe or "unknown_error"))
257
- bot = data.get("bot")
258
- app_id = bot.get("app_id") if isinstance(bot, dict) else None
259
- if not isinstance(app_id, str) or not re.fullmatch(r"[A-Z][A-Z0-9]+", app_id):
260
- raise SystemExit("Slack bots.info response omitted a valid bot.app_id")
261
- print(app_id)
262
- ')" || die "Slack bot app identity verification failed; Slack remains deferred"
263
-
264
- # Socket Mode requires a separately authenticated app-level token. A valid bot
265
- # token says nothing about the xapp credential, so prove that credential can
266
- # open a connection before claiming ownership or persisting either secret. The
267
- # token and Slack's returned WebSocket URL stay on anonymous pipes: neither is
268
- # exposed through curl argv, child environments, logs, or durable files.
269
- log "[31] verifying Slack Socket Mode app token via apps.connections.open"
270
- slack_socket_app_id="$({
271
- printf '%s\n' 'url = "https://slack.com/api/apps.connections.open"'
272
- printf 'header = "Authorization: Bearer %s"\n' "$SLACK_APP_TOKEN"
273
- printf '%s\n' 'header = "Content-Type: application/x-www-form-urlencoded"'
274
- printf '%s\n' 'request = "POST"' 'fail' 'silent' 'show-error' \
275
- 'connect-timeout = 10' 'max-time = 20' 'max-filesize = 1048576'
276
- } | curl --config - | python3 -c '
277
- import json, re, sys
278
- from urllib.parse import parse_qs, urlparse
279
- try:
280
- data = json.load(sys.stdin)
281
- except Exception:
282
- raise SystemExit("Slack apps.connections.open returned an invalid response")
283
- if not isinstance(data, dict) or not data.get("ok"):
284
- error = str(data.get("error") if isinstance(data, dict) else "unknown_error")
285
- safe = "".join(c for c in error if c.isalnum() or c in "_-." )[:80]
286
- raise SystemExit("Slack apps.connections.open rejected the app token ({})".format(safe or "unknown_error"))
287
- url = data.get("url")
288
- if not isinstance(url, str):
289
- raise SystemExit("Slack apps.connections.open response omitted a Socket Mode URL")
290
- parsed = urlparse(url)
291
- if parsed.scheme != "wss" or not parsed.netloc:
292
- raise SystemExit("Slack apps.connections.open response omitted a valid Socket Mode URL")
293
- app_ids = parse_qs(parsed.query, keep_blank_values=True).get("app_id", [])
294
- if len(app_ids) != 1 or not re.fullmatch(r"[A-Z][A-Z0-9]+", app_ids[0]):
295
- raise SystemExit("Slack Socket Mode URL omitted a valid app_id identity")
296
- print(app_ids[0])
297
- ')" || die "Slack Socket Mode app-token verification failed; Slack remains deferred"
298
-
299
- [[ "$slack_bot_app_id" == "$slack_socket_app_id" ]] \
300
- || die "Slack bot and Socket Mode tokens belong to different apps; Slack remains deferred"
301
-
302
- # Reject credential reuse, token rotation onto an identity owned by another
303
- # agent, and credentials parked in shared env files. The scan, durable identity
304
- # claim, and profile credential write share one fleet-wide flock.
305
- fleet_lock_acquire
306
- trap 'fleet_lock_release' EXIT
307
- python3 /dev/fd/3 "$REGISTRY_FILE" "$FLEET_ENV" "$ENVF" "$AGENT_ID" \
308
- "$slack_team_id" "$slack_bot_user_id" "$slack_bot_id" \
309
- "$ROLE_DIR" "$PROFILE_NAME" "$slack_team_name" "$slack_bot_username" \
310
- 3<<'PYEOF' <<<"${SLACK_BOT_TOKEN}"$'\n'"${SLACK_APP_TOKEN}"
311
- import os
312
- import pathlib
313
- import re
314
- import sys
315
- try:
316
- import yaml # type: ignore
317
- except ImportError:
318
- raise SystemExit("PyYAML is required for Slack fleet claims")
319
-
320
- (
321
- registry_path,
322
- fleet_path,
323
- target_path,
324
- agent_id,
325
- team_id,
326
- user_id,
327
- bot_id,
328
- role_dir,
329
- profile_name,
330
- team_name,
331
- bot_username,
332
- ) = sys.argv[1:]
333
- credential_lines = sys.stdin.read().splitlines()
334
- if len(credential_lines) != 2 or not all(credential_lines):
335
- raise SystemExit("Slack ownership scan received an invalid credential pair")
336
- bot_token, app_token = credential_lines
337
- for metadata_path in (pathlib.Path("/proc/self/cmdline"), pathlib.Path("/proc/self/environ")):
338
- if metadata_path.is_file():
339
- metadata = metadata_path.read_bytes()
340
- if any(secret.encode() in metadata for secret in (bot_token, app_token)):
341
- raise SystemExit("Slack ownership scan detected credential exposure in process metadata")
342
- target = pathlib.Path(target_path).resolve(strict=False)
343
-
344
- def env_values(path):
345
- try:
346
- text = path.read_text(encoding="utf-8")
347
- except (OSError, UnicodeError):
348
- return {}
349
- values = {}
350
- for key in ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"):
351
- match = re.search(rf"(?m)^\s*(?:export\s+)?{key}\s*=\s*(.*)$", text)
352
- if match:
353
- value = match.group(1).strip()
354
- if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
355
- value = value[1:-1]
356
- values[key] = value
357
- return values
358
-
359
- owners = [("shared fleet environment", pathlib.Path(fleet_path))]
360
- registry = pathlib.Path(registry_path)
361
- if registry.is_symlink():
362
- raise SystemExit(f"refusing to update registry symlink: {registry}")
363
- data = {"schema_version": 1, "agents": {}}
364
- if registry.is_file():
365
- try:
366
- data = yaml.safe_load(registry.read_text(encoding="utf-8")) or data
367
- except Exception as exc:
368
- raise SystemExit(f"cannot safely inspect Slack ownership registry: {type(exc).__name__}")
369
- if not isinstance(data, dict) or not isinstance(data.get("agents", {}), dict):
370
- raise SystemExit("cannot safely inspect Slack ownership registry: invalid agents mapping")
371
- agents = data.setdefault("agents", {})
372
- for other_id, entry in agents.items():
373
- if other_id == agent_id or not isinstance(entry, dict):
374
- continue
375
- slack = entry.get("slack") or {}
376
- if isinstance(slack, dict):
377
- same_user = user_id and slack.get("bot_user_id") == user_id
378
- same_bot = bot_id and slack.get("bot_id") == bot_id
379
- same_team_user = team_id and same_user and slack.get("team_id") == team_id
380
- if same_bot or same_team_user:
381
- raise SystemExit(f"Slack bot identity is already assigned to agent {other_id}")
382
- other_role_dir = entry.get("role_dir")
383
- if other_role_dir:
384
- owners.append((f"agent {other_id}", pathlib.Path(str(other_role_dir)) / "runtime" / ".env"))
385
-
386
- home_value = os.environ.get("HOME", "")
387
- if home_value:
388
- home = pathlib.Path(home_value)
389
- owners.append(("shared Hermes root", home / ".hermes" / ".env"))
390
- profiles = home / ".hermes" / "profiles"
391
- if profiles.is_dir():
392
- for profile in profiles.iterdir():
393
- owners.append((f"profile {profile.name}", profile / ".env"))
394
-
395
- seen = set()
396
- for owner, path in owners:
397
- resolved = path.resolve(strict=False)
398
- if resolved == target or resolved in seen:
399
- continue
400
- seen.add(resolved)
401
- values = env_values(path)
402
- if values.get("SLACK_BOT_TOKEN") == bot_token:
403
- raise SystemExit(f"Slack bot token is already assigned to {owner}")
404
- if values.get("SLACK_APP_TOKEN") == app_token:
405
- raise SystemExit(f"Slack app token is already assigned to {owner}")
406
-
407
- # Read-only by design. The channel transaction repeats the identity conflict
408
- # check under this same lock immediately before its recoverable registry write.
409
- PYEOF
410
-
411
- # Stage both credentials in one new 1Password item, verify both fields, then
412
- # switch both profile refs with one atomic delta update. A failed rotation
413
- # leaves the previously active pair untouched.
414
- ONEPASSWORD_ITEM_PREFIX="${HERMES_ONEPASSWORD_ITEM_PREFIX:-$(config_get fleet.onepassword_item_prefix 'hermes-agent')}"
415
- slack_stage="$(stage_onepassword_secret_pair \
416
- "${ONEPASSWORD_ITEM_PREFIX}-${AGENT_ID}-slack-credentials" \
417
- slack_bot_token "$SLACK_BOT_TOKEN" \
418
- slack_app_token "$SLACK_APP_TOKEN")" \
419
- || die "Slack credential pair could not be staged and verified in 1Password"
420
- if [[ "$slack_stage" != *$'\n'*$'\n'* ]]; then
421
- die "Slack credential-pair storage returned an invalid reference set"
422
- fi
423
- slack_staged_item_id="${slack_stage%%$'\n'*}"
424
- slack_pair_references="${slack_stage#*$'\n'}"
425
- slack_bot_reference="${slack_pair_references%%$'\n'*}"
426
- slack_app_reference="${slack_pair_references#*$'\n'}"
427
- [[ "$slack_app_reference" != *$'\n'* ]] \
428
- || die "Slack credential-pair storage returned too many references"
429
- unset SLACK_BOT_TOKEN SLACK_APP_TOKEN
430
-
431
- slack_transaction_exit() {
432
- local rc=$?
433
- trap - EXIT
434
- if [[ -n "${slack_staged_item_id:-}" ]]; then
435
- delete_staged_onepassword_item "$slack_staged_item_id" >/dev/null 2>&1 \
436
- || warn " staged Slack item cleanup failed; archive manually by immutable item id"
437
- fi
438
- fleet_lock_release
439
- exit "$rc"
440
- }
441
- trap slack_transaction_exit EXIT
442
-
443
- channel_transaction_slack \
444
- "$PROFILE_HOME" "$ENVF" "$slack_bot_reference" "$slack_app_reference" \
445
- "$slack_team_id" "$slack_team_name" "$slack_bot_user_id" \
446
- "$slack_bot_id" "$slack_bot_username" "$SLACK_ALLOWED_USERS" \
447
- || die "Slack local credential transaction failed"
448
-
449
- slack_staged_item_id=""
450
-
451
- fleet_lock_release
452
- trap - EXIT
453
-
454
- if [[ -z "$SLACK_ALLOWED_USERS" ]]; then
455
- warn " Slack is wired but denies all inbound users until SLACK_ALLOWED_USERS is set"
456
- fi
457
- log " verified Slack bot $slack_bot_username in $slack_team_name (profile-local credentials)"
@@ -1,60 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Bind this agent to its Plane board — WITHOUT inventing a board identifier.
3
- #
4
- # The identifier is Plane's to assign. This step never mints one. It resolves
5
- # the board through providers/plane.sh and persists ONLY the identifier Plane
6
- # reports back; if Plane reports none, it dies loudly rather than writing a
7
- # guess into role.yaml.
8
- #
9
- # Resolution order:
10
- # 1. The repo-root .project.json binding (the SOT) — read back live.
11
- # 2. Otherwise a repo-named board lookup, falling back to creation.
12
- #
13
- # Proposing an identifier for a BRAND-NEW board is legitimate, but it may only
14
- # come from explicit configuration (PLANE_IDENTIFIER, or an already-recorded
15
- # role.yaml plane.identifier). Full new-board provisioning with the
16
- # .project.json transaction lives in 42-ticket-provider.sh.
17
- # shellcheck source=_lib.sh
18
- source "$(dirname "$0")/_lib.sh"
19
- load_role_env
20
-
21
- already_done 40-plane && { log "[40] plane already set up — skipping"; exit 0; }
22
- [[ "${SKIP_PLANE:-0}" == "1" ]] && { log "[40] plane — SKIPPED"; mark_done 40-plane; exit 0; }
23
-
24
- [[ -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; }
25
-
26
- PLANE_ADAPTER="$(dirname "$0")/providers/plane.sh"
27
- [[ -x "$PLANE_ADAPTER" ]] || die "[40] missing Plane adapter: $PLANE_ADAPTER"
28
-
29
- # The board belongs to the REPO, not to this role. display_name carries the
30
- # role suffix ("Holocene PM") and must never become the board name — that is
31
- # how role-suffixed duplicate boards get created. Matches 42-ticket-provider.sh.
32
- NAME="$(printf '%s' "$REPO" | tr '_-' ' ' | python3 -c 'import sys; print(" ".join(w[:1].upper()+w[1:] for w in sys.stdin.read().split()))')"
33
-
34
- # Identifier PROPOSAL, used only in a create request and never persisted as
35
- # though it were confirmed. Explicit configuration only — never derived from
36
- # the repo or role strings.
37
- PROPOSED_IDENT="${PLANE_IDENTIFIER:-$(yaml_get plane.identifier)}"
38
-
39
- # 1. Prefer the recorded .project.json binding and read it back live.
40
- OUT="$("$PLANE_ADAPTER" resolve 2>/dev/null || true)"
41
- if [[ -n "$OUT" ]]; then
42
- log "[40] resolving plane board from the .project.json binding"
43
- else
44
- log "[40] no bound board — resolving/creating repo board '$NAME'${PROPOSED_IDENT:+ (proposed identifier $PROPOSED_IDENT)}"
45
- OUT="$("$PLANE_ADAPTER" create_board "$NAME" "$PROPOSED_IDENT" "Hermes agent board for $AGENT_ID")" \
46
- || die "[40] plane board resolution failed for '$NAME'"
47
- fi
48
-
49
- PROJECT_ID="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_id","") or "")')"
50
- LIVE_IDENTIFIER="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("identifier","") or "")')"
51
-
52
- [[ -n "$PROJECT_ID" ]] || die "[40] plane returned no board id: $OUT"
53
- [[ -n "$LIVE_IDENTIFIER" ]] \
54
- || die "[40] plane returned no authoritative identifier — refusing to persist a guess: $OUT"
55
-
56
- log " plane board id=$PROJECT_ID identifier=$LIVE_IDENTIFIER (authoritative, read back from Plane)"
57
-
58
- yaml_set plane.identifier "$LIVE_IDENTIFIER"
59
- echo "$PROJECT_ID" > "$ROLE_DIR/.scripts/.plane-project-id"
60
- mark_done 40-plane