@delorenj/pjangler 1.4.2 → 1.4.4

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 (39) hide show
  1. package/README.md +528 -0
  2. package/contracts/fleet-contract.yaml +513 -0
  3. package/dist/index.js +9333 -1509
  4. package/dist/mcp-server.js +6634 -1096
  5. package/dist/prompt.js +2 -1
  6. package/package.json +10 -4
  7. package/templates/hermes-agent/copier.yml +16 -3
  8. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  9. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  10. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  11. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  12. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  13. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  14. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  15. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  16. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  17. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  18. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  19. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  20. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  21. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  22. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  23. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  25. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
  26. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  27. package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
  28. package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
  29. package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
  30. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
  32. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +15 -4
@@ -24,10 +24,6 @@ fi
24
24
  # shellcheck source=_lib.sh
25
25
  source "$(dirname "$0")/_lib.sh"
26
26
  load_role_env
27
- # shellcheck source=lib/ticket-provider.sh
28
- source "$(dirname "$0")/lib/ticket-provider.sh"
29
-
30
- already_done 42-ticket-provider && { log "[42] ticket provider already set up — skipping"; exit 0; }
31
27
 
32
28
  # Locate the repo-root .project.json (the SOT).
33
29
  REPO_ROOT="$(project_repo_path 2>/dev/null || true)"
@@ -35,15 +31,59 @@ REPO_ROOT="$(project_repo_path 2>/dev/null || true)"
35
31
  PROJECT_JSON="$REPO_ROOT/.project.json"
36
32
  ROLE_DIR_REL="${ROLE_DIR#"$REPO_ROOT"/}"
37
33
 
34
+ # Serialize the complete read / provider check-or-create / atomic write
35
+ # transaction per project. The lock lives outside the checkout so a normal
36
+ # provision cannot leave repository dirt behind.
37
+ command -v flock >/dev/null 2>&1 \
38
+ || die "flock is required for safe ticket-provider binding"
39
+ PROJECT_LOCK_KEY="$(printf '%s' "$PROJECT_JSON" | sha256sum)"
40
+ PROJECT_LOCK_KEY="${PROJECT_LOCK_KEY%% *}"
41
+ PROJECT_LOCK_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/pjangler-ticket-provider"
42
+ mkdir -p "$PROJECT_LOCK_DIR"
43
+ PROJECT_LOCK_FILE="$PROJECT_LOCK_DIR/$PROJECT_LOCK_KEY.lock"
44
+ [[ ! -L "$PROJECT_LOCK_FILE" ]] \
45
+ || die "refusing ticket-provider lock symlink: $PROJECT_LOCK_FILE"
46
+ exec {PROJECT_LOCK_FD}>"$PROJECT_LOCK_FILE"
47
+ chmod 600 "$PROJECT_LOCK_FILE"
48
+ flock -w "${PROJECT_LOCK_TIMEOUT_SECONDS:-30}" "$PROJECT_LOCK_FD" \
49
+ || die "timed out waiting for ticket-provider lock: $PROJECT_JSON"
50
+ project_lock_release() {
51
+ flock -u "$PROJECT_LOCK_FD" 2>/dev/null || true
52
+ exec {PROJECT_LOCK_FD}>&-
53
+ }
54
+ trap project_lock_release EXIT
55
+
56
+ [[ ! -L "$PROJECT_JSON" ]] || die "refusing .project.json symlink: $PROJECT_JSON"
57
+ if [[ -e "$PROJECT_JSON" ]]; then
58
+ python3 - "$PROJECT_JSON" <<'PY' \
59
+ || die "malformed .project.json; refusing ticket-provider mutation or board creation"
60
+ import json
61
+ import pathlib
62
+ import sys
63
+
64
+ path = pathlib.Path(sys.argv[1])
65
+ try:
66
+ value = json.loads(path.read_text(encoding="utf-8"))
67
+ except (OSError, UnicodeError, json.JSONDecodeError):
68
+ raise SystemExit(1)
69
+ raise SystemExit(0 if isinstance(value, dict) else 1)
70
+ PY
71
+ fi
72
+
73
+ # shellcheck source=lib/ticket-provider.sh
74
+ source "$(dirname "$0")/lib/ticket-provider.sh"
75
+
76
+ already_done 42-ticket-provider \
77
+ && log "[42] ticket-provider marker found — revalidating canonical board binding"
78
+
38
79
  # pj <dotted.key> — read a string value from .project.json (empty if absent).
39
80
  pj() {
40
81
  [ -f "$PROJECT_JSON" ] || { printf ''; return 0; }
41
82
  python3 - "$PROJECT_JSON" "$1" <<'PY'
42
83
  import sys, json, pathlib
43
- try:
44
- d = json.loads(pathlib.Path(sys.argv[1]).read_text())
45
- except Exception:
46
- print(""); raise SystemExit(0)
84
+ d = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
85
+ if not isinstance(d, dict):
86
+ raise SystemExit(".project.json root must be an object")
47
87
  cur = d
48
88
  for k in sys.argv[2].split("."):
49
89
  if isinstance(cur, dict) and k in cur:
@@ -55,19 +95,63 @@ PY
55
95
  }
56
96
 
57
97
  # pj_write — merge board binding (optional) + this agent into .project.json.
58
- # args: set_provider(0|1) provider board_id board_url workspace identifier team
98
+ # args: set_provider(0|1) provider board_id workspace identifier team
99
+ # identifier_source
100
+ #
101
+ # identifier_source is REQUIRED whenever identifier is non-empty and must be
102
+ # `provider` (read back out of the provider's own response) or `proposed` (a
103
+ # local prefix the provider never confirmed, e.g. the string Trello echoes back
104
+ # because Trello mints no key). There is deliberately no third option and no
105
+ # default: an unstamped identifier is refused outright, so no locally-computed
106
+ # string can ever land in .project.json wearing the authority of a live board.
107
+ #
108
+ # `provider` is further refused outright for a provider that assigns no
109
+ # identifiers at all. Trello cannot have named a key it has no concept of, so
110
+ # the claim is not merely unproven, it is impossible — and a downstream reader
111
+ # must always be able to tell "Plane assigned PJAN" from "we picked INT and
112
+ # Trello confirmed a board exists".
113
+ #
114
+ # Those are two different facts, so they get two different fields. Whether the
115
+ # BOARD is real is stamped in board_confirmed_at, and that is what a link rests
116
+ # on; whether the KEY came from the provider is identifier_source. Every
117
+ # provider can answer the first. Only some can answer the second.
59
118
  pj_write() {
60
119
  REPO="$REPO" REPO_ROOT="$REPO_ROOT" AGENT_ID="$AGENT_ID" ROLE="$ROLE" \
61
120
  ROLE_DIR_REL="$ROLE_DIR_REL" PROJECT_DESC="${PROJECT_DESC:-}" \
62
121
  python3 - "$PROJECT_JSON" "$@" <<'PY'
63
- import sys, os, json, pathlib
64
- (path, set_provider, provider, board_id, board_url, workspace, identifier, team) = sys.argv[1:9]
122
+ import datetime
123
+ import errno
124
+ import sys, os, json, pathlib, stat, tempfile
125
+ (path, set_provider, provider, board_id, workspace, identifier, team,
126
+ identifier_source) = sys.argv[1:9]
127
+ # Providers that mint their own keys and hand them back on read.
128
+ IDENTIFIER_ASSIGNING = ("plane", "linear")
129
+ if identifier and identifier_source not in ("provider", "proposed"):
130
+ raise SystemExit(
131
+ "refusing to persist ticket_provider.identifier %r with source %r: "
132
+ "every identifier must be stamped provider|proposed" % (identifier, identifier_source)
133
+ )
134
+ if identifier_source == "provider" and provider not in IDENTIFIER_ASSIGNING:
135
+ raise SystemExit(
136
+ "refusing to persist ticket_provider.identifier %r as provider-sourced: "
137
+ "%s assigns no identifiers, so it cannot have named one" % (identifier, provider)
138
+ )
139
+ now = (
140
+ datetime.datetime.now(datetime.timezone.utc)
141
+ .isoformat(timespec="milliseconds")
142
+ .replace("+00:00", "Z")
143
+ )
65
144
  p = pathlib.Path(path)
66
- try:
67
- d = json.loads(p.read_text())
145
+ if p.is_symlink():
146
+ raise SystemExit(f"refusing .project.json symlink: {p}")
147
+ if p.exists():
148
+ try:
149
+ d = json.loads(p.read_text(encoding="utf-8"))
150
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
151
+ raise SystemExit(f"malformed .project.json: {type(exc).__name__}")
68
152
  if not isinstance(d, dict):
69
- d = {}
70
- except Exception:
153
+ raise SystemExit(".project.json root must be an object")
154
+ else:
71
155
  d = {}
72
156
  repo = os.environ.get("REPO", "")
73
157
  d.setdefault("project_name", repo)
@@ -78,10 +162,22 @@ if set_provider == "1":
78
162
  tp = d.setdefault("ticket_provider", {})
79
163
  tp["type"] = provider
80
164
  if workspace: tp["workspace"] = workspace
81
- if identifier: tp["identifier"] = identifier
82
- if board_id: tp["board_id"] = board_id
83
- if board_url: tp["board_url"] = board_url
165
+ if identifier:
166
+ tp["identifier"] = identifier
167
+ tp["identifier_source"] = identifier_source
168
+ if identifier_source == "provider":
169
+ tp["identifier_fetched_at"] = now
170
+ else:
171
+ tp.pop("identifier_fetched_at", None)
172
+ if board_id:
173
+ tp["board_id"] = board_id
174
+ # Every caller that reaches here with a board id got it out of the
175
+ # provider itself (create_board or resolve), so this write IS the
176
+ # confirmation and may stamp its own instant.
177
+ tp["board_confirmed_at"] = now
84
178
  if team: tp["team"] = team
179
+ tp.pop("board_url", None)
180
+ tp["state"] = "linked" if board_id else "deferred"
85
181
  ag = d.setdefault("agents", {})
86
182
  entry = ag.get(os.environ["AGENT_ID"], {})
87
183
  if not isinstance(entry, dict):
@@ -89,21 +185,46 @@ if not isinstance(entry, dict):
89
185
  entry.update({
90
186
  "role": os.environ["ROLE"],
91
187
  "role_dir": os.environ["ROLE_DIR_REL"],
92
- "provisioning_state": "provisioned",
188
+ "provisioning_state": "linked" if board_id else "deferred",
93
189
  })
94
190
  ag[os.environ["AGENT_ID"]] = entry
95
- p.write_text(json.dumps(d, indent=2) + "\n")
191
+ rendered = json.dumps(d, indent=2) + "\n"
192
+ p.parent.mkdir(parents=True, exist_ok=True)
193
+ mode = stat.S_IMODE(p.stat().st_mode) if p.exists() else 0o644
194
+ fd, temporary = tempfile.mkstemp(prefix=f".{p.name}.ticket-provider-", dir=p.parent)
195
+ try:
196
+ os.fchmod(fd, mode)
197
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
198
+ handle.write(rendered)
199
+ handle.flush()
200
+ os.fsync(handle.fileno())
201
+ os.replace(temporary, p)
202
+ unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
203
+ directory_fd = os.open(p.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
204
+ try:
205
+ try:
206
+ os.fsync(directory_fd)
207
+ except OSError as exc:
208
+ if exc.errno not in unsupported:
209
+ raise
210
+ finally:
211
+ os.close(directory_fd)
212
+ except BaseException:
213
+ try:
214
+ os.unlink(temporary)
215
+ except FileNotFoundError:
216
+ pass
217
+ raise
96
218
  PY
97
219
  log " .project.json updated (agent=$AGENT_ID)"
98
220
  }
99
221
 
100
222
  # Mirror the binding into role.yaml so legacy consumers keep working.
101
223
  mirror_to_role_yaml() {
102
- # mirror_to_role_yaml <provider> <board_id> <board_url> <workspace> <identifier> <team>
103
- local provider="$1" bid="$2" burl="$3" ws="$4" ident="$5" team="$6"
224
+ # mirror_to_role_yaml <provider> <board_id> <workspace> <identifier> <team>
225
+ local provider="$1" bid="$2" ws="$3" ident="$4" team="$5"
104
226
  yaml_set ticket_provider.name "$provider" 2>/dev/null || true
105
227
  [ -n "$bid" ] && yaml_set ticket_provider.board_id "$bid" 2>/dev/null || true
106
- [ -n "$burl" ] && yaml_set ticket_provider.board_url "$burl" 2>/dev/null || true
107
228
  case "$provider" in
108
229
  plane)
109
230
  [ -n "$bid" ] && echo "$bid" > "$ROLE_DIR/.scripts/.plane-project-id"
@@ -125,9 +246,9 @@ mirror_to_role_yaml() {
125
246
  # An existing repo board (in .project.json) wins — every agent binds to it.
126
247
  SOT_TYPE="$(pj ticket_provider.type)"
127
248
  SOT_BOARD_ID="$(pj ticket_provider.board_id)"
128
- SOT_URL="$(pj ticket_provider.board_url)"
129
249
  SOT_WS="$(pj ticket_provider.workspace)"
130
250
  SOT_IDENT="$(pj ticket_provider.identifier)"
251
+ SOT_IDENT_SOURCE="$(pj ticket_provider.identifier_source)"
131
252
  SOT_TEAM="$(pj ticket_provider.team)"
132
253
 
133
254
  # role.yaml provider comes from copier --data (the operator's pjangler choice).
@@ -140,8 +261,30 @@ if [ -n "$SOT_BOARD_ID" ]; then
140
261
  warn "[42] requested provider '$ROLE_PROVIDER' but repo board is '$PROVIDER' (.project.json wins); binding to existing board"
141
262
  fi
142
263
  log "[42] binding $AGENT_ID to existing repo board (provider=$PROVIDER, id=$SOT_BOARD_ID)"
143
- mirror_to_role_yaml "$PROVIDER" "$SOT_BOARD_ID" "$SOT_URL" "$SOT_WS" "$SOT_IDENT" "$SOT_TEAM"
144
- pj_write 0 "$PROVIDER" "" "" "" "" "" # register agent only; board already recorded
264
+ LIVE_IDENT="$SOT_IDENT"
265
+ # An identifier already sitting in .project.json is only as trustworthy as
266
+ # the source recorded beside it. A record written before sources existed
267
+ # carries none, and an unsourced identifier is a proposal — not a fact.
268
+ IDENT_SOURCE=""
269
+ [ -z "$LIVE_IDENT" ] || IDENT_SOURCE="${SOT_IDENT_SOURCE:-proposed}"
270
+ # …and a `provider` stamp beside a provider that assigns no identifiers is
271
+ # not evidence either. It is a stale claim from a writer that conflated
272
+ # "the board is confirmed" with "the provider named this key". Correct it
273
+ # on the way through rather than carrying the lie into another manifest.
274
+ case "$PROVIDER" in
275
+ plane|linear) ;;
276
+ *) if [ "$IDENT_SOURCE" = provider ]; then IDENT_SOURCE=proposed; fi ;;
277
+ esac
278
+ if [ "$PROVIDER" = plane ]; then
279
+ OUT="$(tp resolve)" || die "existing Plane board could not be validated"
280
+ LIVE_IDENT="$(printf '%s' "$OUT" | python3 -c 'import sys,json
281
+ try: print(str(json.load(sys.stdin).get("identifier") or ""))
282
+ except Exception: print("")')"
283
+ [ -n "$LIVE_IDENT" ] || die "existing Plane board has no authoritative live identifier"
284
+ IDENT_SOURCE=provider
285
+ fi
286
+ mirror_to_role_yaml "$PROVIDER" "$SOT_BOARD_ID" "$SOT_WS" "$LIVE_IDENT" "$SOT_TEAM"
287
+ pj_write 1 "$PROVIDER" "$SOT_BOARD_ID" "$SOT_WS" "$LIVE_IDENT" "$SOT_TEAM" "$IDENT_SOURCE"
145
288
  mark_done 42-ticket-provider
146
289
  exit 0
147
290
  fi
@@ -150,10 +293,14 @@ fi
150
293
  PROVIDER="${ROLE_PROVIDER:-${SOT_TYPE:-plane}}"
151
294
  log "[42] no board in .project.json — bootstrapping a repo board (provider: $PROVIDER)"
152
295
 
153
- # Repo-based identity, matching CommonProject's scheme (slug[:4] uppercased).
154
- RAW=$(printf '%s' "$REPO" | tr -cd '[:alnum:]' | tr '[:lower:]' '[:upper:]')
155
- while [ ${#RAW} -lt 2 ]; do RAW="${RAW}X"; done
156
- IDENT="${SOT_IDENT:-${RAW:0:4}}"
296
+ # Identifier PROPOSAL for a brand-new board only. It is sent to the provider in
297
+ # the create request and is NEVER persisted as though it were confirmed: every
298
+ # write below uses LIVE_IDENT, taken from the provider's own response, carrying
299
+ # the source that response earns it (`provider` where the provider mints the
300
+ # identifier, `proposed` where — as with Trello — it only echoes ours back).
301
+ PROPOSED_RAW=$(printf '%s' "$REPO" | tr -cd '[:alnum:]' | tr '[:lower:]' '[:upper:]')
302
+ while [ ${#PROPOSED_RAW} -lt 2 ]; do PROPOSED_RAW="${PROPOSED_RAW}X"; done
303
+ PROPOSED_IDENT="${SOT_IDENT:-${PROPOSED_RAW:0:4}}"
157
304
  # Board NAME = repo name, separators->space, title-cased. NOT display_name —
158
305
  # display_name carries the role suffix and must never become the board name.
159
306
  NAME="$(printf '%s' "$REPO" | tr '_-' ' ' | python3 -c 'import sys; print(" ".join(w[:1].upper()+w[1:] for w in sys.stdin.read().split()))')"
@@ -161,24 +308,28 @@ DESC="Ticket board for $REPO"
161
308
 
162
309
  case "$PROVIDER" in
163
310
  linear)
311
+ # Linear DOES mint an authoritative identifier — the team key — so nothing
312
+ # here ever persists the local proposal. Without a live team read there is
313
+ # no identifier to record, and the record simply carries none.
164
314
  if [[ -z "${LINEAR_API_KEY:-}" ]]; then
165
315
  warn "[42] LINEAR_API_KEY not set; set role.yaml/.project.json ticket_provider.team and re-run ./.scripts/42-ticket-provider.sh"
166
- pj_write 1 linear "" "" "" "$IDENT" "$SOT_TEAM"
316
+ pj_write 1 linear "" "" "" "$SOT_TEAM" ""
167
317
  mark_done 42-ticket-provider; exit 0
168
318
  fi
169
319
  OUT="$(tp resolve 2>/dev/null || true)"
170
320
  BID="$(printf '%s' "$OUT" | python3 -c 'import sys,json
171
321
  try: print(json.load(sys.stdin).get("board_id",""))
172
322
  except Exception: print("")')"
173
- BURL="$(printf '%s' "$OUT" | python3 -c 'import sys,json
174
- try: print(json.load(sys.stdin).get("board_url",""))
323
+ LIVE_IDENT="$(printf '%s' "$OUT" | python3 -c 'import sys,json
324
+ try: print(str(json.load(sys.stdin).get("identifier") or ""))
175
325
  except Exception: print("")')"
176
326
  if [ -n "$BID" ]; then
177
- mirror_to_role_yaml linear "$BID" "$BURL" "" "$IDENT" "$SOT_TEAM"
178
- pj_write 1 linear "$BID" "$BURL" "" "$IDENT" "$SOT_TEAM"
327
+ [ -n "$LIVE_IDENT" ] || die "linear resolved a team with no authoritative key"
328
+ mirror_to_role_yaml linear "$BID" "" "$LIVE_IDENT" "$SOT_TEAM"
329
+ pj_write 1 linear "$BID" "" "$LIVE_IDENT" "$SOT_TEAM" provider
179
330
  else
180
331
  warn "[42] linear resolve returned no board; set ticket_provider.team and re-run"
181
- pj_write 1 linear "" "" "" "$IDENT" "$SOT_TEAM"
332
+ pj_write 1 linear "" "" "" "$SOT_TEAM" ""
182
333
  fi
183
334
  ;;
184
335
 
@@ -186,16 +337,34 @@ except Exception: print("")')"
186
337
  KEYVAR=PLANE_API_KEY; [ "$PROVIDER" = trello ] && KEYVAR=TRELLO_KEY
187
338
  if [[ -z "${!KEYVAR:-}" ]]; then
188
339
  warn "[42] $KEYVAR not set; skipping board creation. Set creds and re-run ./.scripts/42-ticket-provider.sh"
189
- pj_write 1 "$PROVIDER" "" "" "${SOT_WS:-$PLANE_WORKSPACE}" "$IDENT" ""
340
+ # Deferred: no board was created, so there is no confirmed identifier.
341
+ # Persist nothing rather than freezing the proposal into .project.json.
342
+ pj_write 1 "$PROVIDER" "" "${SOT_WS:-$PLANE_WORKSPACE}" "" "" ""
190
343
  mark_done 42-ticket-provider; exit 0
191
344
  fi
192
- OUT="$(tp create_board "$NAME" "$IDENT" "$DESC")" || die "create_board failed for $PROVIDER"
345
+ OUT="$(tp create_board "$NAME" "$PROPOSED_IDENT" "$DESC")" || die "create_board failed for $PROVIDER"
193
346
  BID="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_id",""))')"
194
- BURL="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_url",""))')"
347
+ LIVE_IDENT="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("identifier","") or "")')"
195
348
  WS="${SOT_WS:-$PLANE_WORKSPACE}"
196
349
  [ "$PROVIDER" = trello ] && WS=""
197
- mirror_to_role_yaml "$PROVIDER" "$BID" "$BURL" "$WS" "$IDENT" ""
198
- pj_write 1 "$PROVIDER" "$BID" "$BURL" "$WS" "$IDENT" ""
350
+ case "$PROVIDER" in
351
+ plane)
352
+ # Plane mints the identifier and hands it back on create; anything else
353
+ # is a guess and must not be written.
354
+ [ -n "$LIVE_IDENT" ] \
355
+ || die "created/bound Plane board has no authoritative live identifier"
356
+ IDENT_SOURCE=provider
357
+ ;;
358
+ trello)
359
+ # Trello mints no key. create_board echoes back the prefix it was
360
+ # handed, which confirms what the board is BOUND under — not a value
361
+ # Trello chose. Record it stamped as the proposal it is, so nothing
362
+ # downstream can read it as provider truth.
363
+ IDENT_SOURCE=proposed
364
+ ;;
365
+ esac
366
+ mirror_to_role_yaml "$PROVIDER" "$BID" "$WS" "$LIVE_IDENT" ""
367
+ pj_write 1 "$PROVIDER" "$BID" "$WS" "$LIVE_IDENT" "" "$IDENT_SOURCE"
199
368
  ;;
200
369
 
201
370
  *) die "unknown ticket provider: $PROVIDER (expected linear|plane|trello)" ;;
@@ -8,6 +8,8 @@ load_role_env
8
8
  if [[ "$ROLE" == "reporter" ]]; then
9
9
  log "[70] reporter systemd — gateway/consumer intentionally not installed"
10
10
  log " use the reporter runtime's explicit install command after credential and policy preflight"
11
+ yaml_upsert_block_value service_state gateway not-applicable
12
+ yaml_upsert_block_value service_state heartbeat not-applicable
11
13
  mark_done 70-systemd
12
14
  exit 0
13
15
  fi
@@ -18,6 +20,7 @@ RUNTIME="$ROLE_DIR/runtime"
18
20
  FLEET_HOME="${HERMES_FLEET_HOME:-$HOME/.hermes}"
19
21
  PROFILE_HOME="$FLEET_HOME/profiles/${PROFILE_NAME:-$AGENT_ID}"
20
22
  REPO_ROOT="$(project_repo_path)" || REPO_ROOT="$ROLE_DIR"
23
+
21
24
  SYS_DIR="$HOME/.config/systemd/user"
22
25
  GW_UNIT="hermes-${AGENT_ID}-gateway.service"
23
26
  HB_SVC="hermes-${AGENT_ID}-heartbeat.service"
@@ -37,6 +40,19 @@ if [[ "${SKIP_SYSTEMD:-0}" == "1" ]]; then
37
40
  exit 0
38
41
  fi
39
42
 
43
+ # Legacy manifests defaulted reconciliation off and had no way to distinguish
44
+ # that default from an operator decision. The new explicit_opt_out sentinel is
45
+ # authoritative: migrate unmarked roles to the operational PM default, while a
46
+ # rendered/operator-recorded opt-out remains checkpoint-only on every rerun.
47
+ if [[ "$(yaml_get reconcile.explicit_opt_out)" == "true" ]]; then
48
+ yaml_upsert_block_value reconcile enabled false bool
49
+ log " PM reconciliation explicit opt-out preserved"
50
+ else
51
+ yaml_upsert_block_value reconcile enabled true bool
52
+ yaml_upsert_block_value reconcile explicit_opt_out false bool
53
+ log " PM reconciliation enabled (operational default)"
54
+ fi
55
+
40
56
  # Render every caller-controlled systemd scalar through the same data-only
41
57
  # serializer used by fleet backfill. This validation happens before mkdir,
42
58
  # systemctl, or unit writes, so CR/LF/NUL cannot create a second directive and
@@ -72,19 +88,13 @@ HB_LOG_OUTPUT="$(systemd_scalar "append:$RUNTIME/logs/heartbeat.log")"
72
88
  GW_EXEC_START="$(systemd_exec_value "$ROLE_DIR/.scripts/credential-launch.sh")"
73
89
  HB_EXEC_START="$GW_EXEC_START"
74
90
 
75
- # Encrypted credentials are optional and take precedence over the existing
76
- # ignored runtime/.env fallback. Validate every path-derived unit scalar before
77
- # any directory, unit, marker, or systemd mutation.
91
+ # A model credential may be supplied through systemd's encrypted credential
92
+ # store. Chat-channel values are never materialized here: Hermes resolves their
93
+ # profile-scoped op:// references natively at process startup.
78
94
  CREDENTIAL_DIR="${HERMES_SYSTEMD_CREDENTIAL_DIR:-$HOME/.config/hermes-agent/credentials}"
79
- TELEGRAM_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-telegram-bot-token.cred"
80
95
  MODEL_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-model-api-key.cred"
81
96
  GW_CREDENTIAL_LINES=""
82
97
  HB_CREDENTIAL_LINES=""
83
- if [[ -f "$TELEGRAM_CREDENTIAL" ]]; then
84
- command -v systemd-creds >/dev/null 2>&1 \
85
- || die "encrypted Telegram credential exists but systemd-creds is unavailable"
86
- GW_CREDENTIAL_LINES="LoadCredentialEncrypted=$(systemd_value "telegram_bot_token:$TELEGRAM_CREDENTIAL")"
87
- fi
88
98
  if [[ -f "$MODEL_CREDENTIAL" ]]; then
89
99
  [[ -n "$(yaml_get model.key_env)" ]] \
90
100
  || die "encrypted model credential exists but model.key_env is blank in role.yaml"
@@ -94,6 +104,42 @@ if [[ -f "$MODEL_CREDENTIAL" ]]; then
94
104
  HB_CREDENTIAL_LINES="LoadCredentialEncrypted=$(systemd_value "model_api_key:$MODEL_CREDENTIAL")"
95
105
  fi
96
106
 
107
+ # A gateway is eligible only when at least one channel identity is verified AND
108
+ # both the named-profile mapping and the referenced 1Password value resolve.
109
+ # Role metadata alone is not sufficient: a stale done marker must never revive
110
+ # a credential-less crash loop.
111
+ gateway_ready=0
112
+ gateway_validation_unavailable=0
113
+ if [[ "$(yaml_get telegram.provisioning_status)" == "verified" ]] \
114
+ && profile_onepassword_ref_exists "$PROFILE_HOME" TELEGRAM_BOT_TOKEN; then
115
+ telegram_reference_rc=0
116
+ profile_onepassword_ref_validate "$PROFILE_HOME" TELEGRAM_BOT_TOKEN \
117
+ || telegram_reference_rc=$?
118
+ if [[ $telegram_reference_rc -eq 0 ]]; then
119
+ gateway_ready=1
120
+ elif [[ $telegram_reference_rc -eq 75 ]]; then
121
+ gateway_validation_unavailable=1
122
+ fi
123
+ fi
124
+ if [[ "$(yaml_get slack.provisioning_status)" == "verified" ]] \
125
+ && profile_onepassword_ref_exists "$PROFILE_HOME" SLACK_BOT_TOKEN \
126
+ && profile_onepassword_ref_exists "$PROFILE_HOME" SLACK_APP_TOKEN; then
127
+ slack_bot_reference_rc=0
128
+ slack_app_reference_rc=0
129
+ profile_onepassword_ref_validate "$PROFILE_HOME" SLACK_BOT_TOKEN \
130
+ || slack_bot_reference_rc=$?
131
+ profile_onepassword_ref_validate "$PROFILE_HOME" SLACK_APP_TOKEN \
132
+ || slack_app_reference_rc=$?
133
+ if [[ $slack_bot_reference_rc -eq 0 && $slack_app_reference_rc -eq 0 ]]; then
134
+ gateway_ready=1
135
+ elif [[ $slack_bot_reference_rc -eq 75 || $slack_app_reference_rc -eq 75 ]]; then
136
+ gateway_validation_unavailable=1
137
+ fi
138
+ fi
139
+ if [[ $gateway_ready -eq 0 && $gateway_validation_unavailable -eq 1 ]]; then
140
+ die "channel 1Password validation is temporarily unavailable; preserving existing gateway state for retry"
141
+ fi
142
+
97
143
  # Singleton-runtime contract: units set HERMES_HOME to the agent's NAMED PROFILE
98
144
  # dir, never the raw runtime path — Hermes derives profile identity and shared
99
145
  # fleet auth from the unresolved HERMES_HOME. $RUNTIME stays correct for
@@ -159,11 +205,21 @@ chmod +x "$HEARTBEAT_BIN" "$CREDENTIAL_LAUNCHER" "$ROLE_DIR/.scripts/checkpoint.
159
205
  || die "named profile is not a real directory; run: pj migrate hermes.runtime-singleton '$REPO_ROOT'"
160
206
 
161
207
  # Gateway unit
208
+ #
209
+ # StartLimit* belongs in [Unit], not [Service] — systemd only still parses it
210
+ # under [Service] for backwards compatibility. Without it, Restart=on-failure
211
+ # retries forever and a gateway that can never start (bad token, bad config)
212
+ # sits in `activating` indefinitely instead of settling into `failed`, so
213
+ # `systemctl --user --failed` never lists it and neither the sentinel nor a
214
+ # human ever sees the crashloop. That is exactly how the fleet accumulated
215
+ # 10,427 invisible restarts. 5 tries in 5 minutes, then stop and report failed.
162
216
  cat > "$SYS_DIR/$GW_UNIT" <<UNIT
163
217
  [Unit]
164
218
  Description=$GW_DESCRIPTION
165
219
  After=network-online.target
166
220
  Wants=network-online.target
221
+ StartLimitIntervalSec=300
222
+ StartLimitBurst=5
167
223
 
168
224
  [Service]
169
225
  Type=simple
@@ -227,14 +283,72 @@ UNIT
227
283
 
228
284
  if systemd_user_available; then
229
285
  systemctl --user daemon-reload
230
- # `enable --now` both enables (persist across login) AND starts the unit now, so a
231
- # freshly provisioned agent comes up live instead of dormant. Units with missing
232
- # creds (e.g. a gateway with no Telegram token yet) fail softly via Restart=on-failure.
233
- for u in "$GW_UNIT" "$HB_TIMER"; do
234
- systemctl --user enable --now "$u" >/dev/null 2>&1 && log " enabled + started: $u" || warn " failed to enable/start: $u"
235
- done
286
+ if systemctl --user enable --now "$HB_TIMER" >/dev/null 2>&1; then
287
+ # The timer's first scheduled tick can be a minute away. Run the oneshot
288
+ # once now so deployment proves the heartbeat command itself completed;
289
+ # timer activity alone is not an operational postcondition.
290
+ if ! systemctl --user start "$HB_SVC" >/dev/null 2>&1; then
291
+ systemctl --user disable --now "$HB_TIMER" >/dev/null 2>&1 || true
292
+ yaml_upsert_block_value service_state heartbeat error
293
+ clear_done 70-systemd
294
+ die "required heartbeat oneshot failed its deployment probe: $HB_SVC"
295
+ fi
296
+ if hb_health="$(systemd_wait_for_stable_health \
297
+ systemd_timer_health_snapshot "$HB_TIMER" "$HB_SVC")"; then
298
+ yaml_upsert_block_value service_state heartbeat active
299
+ log " heartbeat enabled + active with healthy latest result: $HB_TIMER"
300
+ else
301
+ systemctl --user disable --now "$HB_TIMER" >/dev/null 2>&1 || true
302
+ yaml_upsert_block_value service_state heartbeat error
303
+ clear_done 70-systemd
304
+ die "heartbeat did not stabilize healthy: $hb_health"
305
+ fi
306
+ else
307
+ yaml_upsert_block_value service_state heartbeat error
308
+ clear_done 70-systemd
309
+ die "failed to enable/start required heartbeat timer: $HB_TIMER"
310
+ fi
311
+
312
+ if [[ $gateway_ready -eq 1 ]]; then
313
+ if systemctl --user enable --now "$GW_UNIT" >/dev/null 2>&1; then
314
+ if gw_health="$(systemd_wait_for_stable_health \
315
+ systemd_service_health_snapshot "$GW_UNIT" running)"; then
316
+ yaml_upsert_block_value service_state gateway active
317
+ log " credentialed gateway enabled + active and stabilized: $GW_UNIT"
318
+ else
319
+ systemctl --user disable --now "$GW_UNIT" >/dev/null 2>&1 || true
320
+ systemctl --user reset-failed "$GW_UNIT" >/dev/null 2>&1 || true
321
+ yaml_upsert_block_value service_state gateway error
322
+ clear_done 70-systemd
323
+ die "credentialed gateway did not stabilize healthy: $gw_health"
324
+ fi
325
+ else
326
+ yaml_upsert_block_value service_state gateway error
327
+ clear_done 70-systemd
328
+ die "failed to enable/start credentialed gateway: $GW_UNIT"
329
+ fi
330
+ else
331
+ systemctl --user disable --now "$GW_UNIT" >/dev/null 2>&1 \
332
+ || die "could not enforce deferred gateway disablement: $GW_UNIT"
333
+ systemctl --user reset-failed "$GW_UNIT" >/dev/null 2>&1 || true
334
+ gw_deferred_health="$(systemd_gateway_deferred_snapshot "$GW_UNIT")"
335
+ if [[ "$gw_deferred_health" == "ok|deferred" ]]; then
336
+ yaml_upsert_block_value service_state gateway deferred
337
+ log " gateway deferred: disabled + inactive until a channel credential is verified"
338
+ else
339
+ yaml_upsert_block_value service_state gateway error
340
+ clear_done 70-systemd
341
+ die "gateway deferral was not proven disabled + inactive ($gw_deferred_health)"
342
+ fi
343
+ fi
236
344
  else
237
345
  warn " systemd --user not available; units installed at $SYS_DIR but not enabled"
346
+ yaml_upsert_block_value service_state heartbeat installed
347
+ if [[ $gateway_ready -eq 1 ]]; then
348
+ yaml_upsert_block_value service_state gateway installed
349
+ else
350
+ yaml_upsert_block_value service_state gateway deferred
351
+ fi
238
352
  fi
239
353
 
240
354
  mark_done 70-systemd
@@ -40,8 +40,10 @@ python3 - "$REGISTRY_FILE" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" \
40
40
  "$PLANE_WORKSPACE" "$PLANE_PROJECT_ID" "$(yaml_get plane.identifier)" \
41
41
  "$RUNTIME_REPO" "$HERMES_BIN" "$HERMES_AGENT_REPO" "$HERMES_RUNTIME_GIT_URL" \
42
42
  "$HERMES_RUNTIME_GIT_REF" "$HERMES_RUNTIME_GIT_SHA" "$FLEET_ENV" \
43
- "hermes-${AGENT_ID}-gateway.service" "hermes-${AGENT_ID}-heartbeat.timer" <<'PYEOF'
43
+ "hermes-${AGENT_ID}-gateway.service" "hermes-${AGENT_ID}-heartbeat.timer" \
44
+ "$(yaml_get service_state.gateway)" "$(yaml_get service_state.heartbeat)" <<'PYEOF'
44
45
  import datetime
46
+ import copy
45
47
  import errno
46
48
  import os
47
49
  import pathlib
@@ -56,11 +58,17 @@ except ImportError:
56
58
  slack_status, slack_team_id, slack_team_name, slack_user_id, slack_bot_id,
57
59
  slack_username, bloodbank_enabled, bloodbank_scope, bloodbank_target, plane_ws, plane_id,
58
60
  plane_ident, runtime_repo, hermes_bin, hermes_repo, hermes_git_url,
59
- hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat) = sys.argv[1:33]
61
+ hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat,
62
+ gateway_state, heartbeat_state) = sys.argv[1:35]
60
63
  p = pathlib.Path(path)
61
64
  if p.is_symlink():
62
65
  raise SystemExit(f"refusing to update registry symlink: {p}")
63
- data = yaml.safe_load(p.read_text()) or {"schema_version": 1, "agents": {}}
66
+ data = yaml.safe_load(p.read_text(encoding="utf-8")) or {"schema_version": 1, "agents": {}}
67
+ if not isinstance(data, dict):
68
+ raise SystemExit("fleet registry root must be a mapping")
69
+ agents = data.setdefault("agents", {})
70
+ if not isinstance(agents, dict):
71
+ raise SystemExit("fleet registry agents must be a mapping")
64
72
  if bloodbank_enabled == "":
65
73
  bloodbank_enabled_value = False
66
74
  elif bloodbank_enabled == "true":
@@ -69,7 +77,13 @@ elif bloodbank_enabled == "false":
69
77
  bloodbank_enabled_value = False
70
78
  else:
71
79
  raise SystemExit("bloodbank.enabled must be the strict YAML boolean true or false")
72
- data.setdefault("agents", {})[agent_id] = {
80
+ existing = agents.get(agent_id, {})
81
+ if not isinstance(existing, dict):
82
+ raise SystemExit(f"fleet registry entry for {agent_id} must be a mapping")
83
+ provisioned_at = existing.get("provisioned_at")
84
+ if not isinstance(provisioned_at, str) or not provisioned_at:
85
+ provisioned_at = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
86
+ managed = {
73
87
  "repo": repo, "role": role, "display_name": display,
74
88
  "project_path": project, "role_dir": role_dir,
75
89
  "profile_name": profile,
@@ -101,9 +115,31 @@ data.setdefault("agents", {})[agent_id] = {
101
115
  "git_sha": hermes_git_sha,
102
116
  "fleet_env": fleet_env,
103
117
  },
104
- "systemd": {"gateway_unit": gw, "heartbeat_timer": heartbeat},
105
- "provisioned_at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
118
+ "systemd": {
119
+ "gateway_unit": gw,
120
+ "heartbeat_timer": heartbeat,
121
+ "gateway_state": gateway_state,
122
+ "heartbeat_state": heartbeat_state,
123
+ },
124
+ "provisioned_at": provisioned_at,
106
125
  }
126
+
127
+ def merge_managed(current, update):
128
+ result = copy.deepcopy(current)
129
+ for key, value in update.items():
130
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
131
+ result[key] = merge_managed(result[key], value)
132
+ else:
133
+ result[key] = copy.deepcopy(value)
134
+ return result
135
+
136
+ entry = merge_managed(existing, managed)
137
+ # This is retired managed schema, not extension metadata. Keeping it would
138
+ # falsely advertise a second per-agent Bloodbank execution path.
139
+ systemd = entry.get("systemd")
140
+ if isinstance(systemd, dict):
141
+ systemd.pop("consumer_unit", None)
142
+ agents[agent_id] = entry
107
143
  rendered = yaml.safe_dump(data, sort_keys=False)
108
144
  p.parent.mkdir(parents=True, exist_ok=True)
109
145