@delorenj/pjangler 1.4.6 → 1.5.3

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 -596
  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 +9385 -18379
  10. package/dist/mcp-server.js +9432 -17852
  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 -584
  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,241 +0,0 @@
1
- #!/usr/bin/env sh
2
- # Trello ticket-provider adapter.
3
- #
4
- # Credentials: TRELLO_KEY TRELLO_TOKEN (query-param auth)
5
- # Board binding (role.yaml `ticket_provider:`):
6
- # name: trello
7
- # board: <board-id> (set by create_board / 42-ticket-provider)
8
- # state_map: { backlog:"Backlog", unstarted:"To Do", started:"In Progress",
9
- # in_review:"Review", completed:"Done", cancelled:"Cancelled" }
10
- # optional
11
- #
12
- # Trello model: board = project & milestone, list = state, card = issue.
13
- # Trello has no milestone primitive, so active_milestone returns the board.
14
- #
15
- # NOTE: list names are matched case-insensitively against state_map; override
16
- # state_map in role.yaml if the board uses different column names.
17
- set -eu
18
-
19
- OP="${1:-}"; shift 2>/dev/null || true
20
- ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
21
- ROLE_YAML="$ROLE_DIR/role.yaml"
22
- API="https://api.trello.com/1"
23
-
24
- die() { echo "trello: $*" >&2; exit 1; }
25
- need_key() { [ -n "${TRELLO_KEY:-}" ] && [ -n "${TRELLO_TOKEN:-}" ] || die "TRELLO_KEY and TRELLO_TOKEN must be set"; }
26
-
27
- tp_cfg() {
28
- [ -f "$ROLE_YAML" ] || return 0
29
- python3 - "$ROLE_YAML" "$1" <<'PY'
30
- import sys, re, pathlib
31
- text = pathlib.Path(sys.argv[1]).read_text()
32
- m = re.search(r'(?ms)^ticket_provider:\s*$(.*?)(?=^\S)', text + "\n\x00")
33
- block = m.group(1) if m else ""
34
- mm = re.search(rf'(?m)^\s*{re.escape(sys.argv[2])}:\s*"?([^"\n]*)"?\s*$', block)
35
- print(mm.group(1).strip() if mm else "")
36
- PY
37
- }
38
-
39
- # pj_cfg KEY — read ticket_provider.<KEY> from the repo-root .project.json (the
40
- # SOT), walking up from the role dir. Preferred over role.yaml.
41
- pj_cfg() {
42
- python3 - "$ROLE_DIR" "$1" <<'PY'
43
- import sys, json, pathlib
44
- start = pathlib.Path(sys.argv[1]).resolve(); key = sys.argv[2]
45
- for parent in [start, *start.parents]:
46
- f = parent / ".project.json"
47
- if f.is_file():
48
- try: tp = (json.loads(f.read_text()).get("ticket_provider") or {})
49
- except Exception: tp = {}
50
- print(tp.get(key, "") if isinstance(tp, dict) else ""); break
51
- else:
52
- print("")
53
- PY
54
- }
55
-
56
- BOARD="$(pj_cfg board_id)"; [ -n "$BOARD" ] || BOARD="$(tp_cfg board)"
57
- # Normalized -> Trello list name (overridable via role.yaml state_map keys).
58
- list_name_for() {
59
- case "$1" in
60
- backlog) v="$(tp_cfg backlog)"; printf '%s' "${v:-Backlog}" ;;
61
- unstarted) v="$(tp_cfg unstarted)"; printf '%s' "${v:-To Do}" ;;
62
- started) v="$(tp_cfg started)"; printf '%s' "${v:-In Progress}" ;;
63
- in_review) v="$(tp_cfg in_review)"; printf '%s' "${v:-Review}" ;;
64
- completed) v="$(tp_cfg completed)"; printf '%s' "${v:-Done}" ;;
65
- cancelled) v="$(tp_cfg cancelled)"; printf '%s' "${v:-Cancelled}" ;;
66
- *) die "invalid normalized state: $1" ;;
67
- esac
68
- }
69
-
70
- # api METHOD PATH [extra-query] — call Trello, auth appended, print body.
71
- api() {
72
- need_key
73
- method="$1"; path="$2"; extra="${3:-}"
74
- sep="?"; case "$path" in *\?*) sep="&" ;; esac
75
- url="$API/$path${sep}key=$TRELLO_KEY&token=$TRELLO_TOKEN${extra:+&$extra}"
76
- curl -fsS -X "$method" "$url"
77
- }
78
-
79
- # Resolve a list id on the board by (normalized) state.
80
- list_id_for() {
81
- [ -n "$BOARD" ] || die "ticket_provider.board not set"
82
- want="$(list_name_for "$1")"
83
- api GET "boards/$BOARD/lists" | NM="$want" python3 -c 'import sys,json,os
84
- rows=json.load(sys.stdin); nm=os.environ["NM"].strip().casefold()
85
- matches=[row for row in rows if str(row.get("name") or "").strip().casefold()==nm]
86
- if len(matches) != 1:
87
- raise SystemExit("trello: exact list name %r resolved %d lists; exactly one is required" % (os.environ["NM"], len(matches)))
88
- list_id=str(matches[0].get("id") or "")
89
- if not list_id:
90
- raise SystemExit("trello: resolved list omitted its id")
91
- print(list_id)'
92
- }
93
-
94
- # All Trello ops require credentials; fail fast and clean before any pipe.
95
- need_key
96
-
97
- case "$OP" in
98
- resolve)
99
- [ -n "$BOARD" ] || die "board not set (run 42-ticket-provider.sh)"
100
- api GET "boards/$BOARD" "fields=name,url" | python3 -c 'import sys,json
101
- b=json.load(sys.stdin); print(json.dumps({"provider":"trello","board_id":b.get("id",""),"board_url":b.get("url","")}))'
102
- ;;
103
-
104
- active_milestone)
105
- [ -n "$BOARD" ] || die "board not set"
106
- api GET "boards/$BOARD" "fields=name" | python3 -c 'import sys,json
107
- b=json.load(sys.stdin); print(json.dumps({"id":b.get("id",""),"name":b.get("name",""),"state":"active"}))'
108
- ;;
109
-
110
- list_issues)
111
- [ -n "$BOARD" ] || die "board not set"
112
- # cards + lists, then label each card with its list name as the state.
113
- LISTS="$(api GET "boards/$BOARD/lists" "fields=name")"
114
- CARDS="$(api GET "boards/$BOARD/cards" "fields=name,idList,dateLastActivity,url,shortLink")"
115
- printf '%s\n%s\n' "$LISTS" "$CARDS" | python3 -c 'import sys,json
116
- parts=sys.stdin.read().split("\n",1)
117
- lists={l["id"]:l.get("name","") for l in json.loads(parts[0] or "[]")}
118
- out=[]
119
- for c in json.loads(parts[1] or "[]"):
120
- nm=lists.get(c.get("idList",""),"")
121
- out.append({"id":c.get("id",""),"key":c.get("shortLink",""),"title":c.get("name",""),
122
- "state":nm,"state_type":nm.lower().replace(" ","_"),
123
- "updated_at":c.get("dateLastActivity",""),"assignee":"","url":c.get("url","")})
124
- print(json.dumps(out))'
125
- ;;
126
-
127
- get_issue)
128
- ID="${1:?usage: get_issue <id>}"
129
- CARD="$(api GET "cards/$ID" "fields=name,desc,idList")"
130
- COMM="$(api GET "cards/$ID/actions" "filter=commentCard")"
131
- printf '%s\n%s\n' "$CARD" "$COMM" | python3 -c 'import sys,json
132
- parts=sys.stdin.read().split("\n",1)
133
- c=json.loads(parts[0] or "{}"); acts=json.loads(parts[1] or "[]")
134
- cs=[{"id":a.get("id",""),"body":(a.get("data") or {}).get("text",""),"author":(a.get("memberCreator") or {}).get("fullName","")} for a in acts]
135
- print(json.dumps({"id":c.get("id",""),"key":c.get("id",""),"title":c.get("name",""),
136
- "description":c.get("desc",""),"acceptance":c.get("desc",""),
137
- "state":"","state_type":"","comments":cs}))'
138
- ;;
139
-
140
- comment)
141
- ID="${1:?usage: comment <id> <body>}"; BODY="${2:?}"
142
- api POST "cards/$ID/actions/comments" "text=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$BODY")" \
143
- | EXPECTED_ID="$ID" python3 -c 'import sys,json,os
144
- action=json.load(sys.stdin)
145
- if not isinstance(action,dict):
146
- raise SystemExit("trello: comment response was not an action object")
147
- action_id=action.get("id")
148
- if not isinstance(action_id,str) or not action_id.strip():
149
- raise SystemExit("trello: comment response omitted its action id")
150
- data=action.get("data")
151
- card=data.get("card") if isinstance(data,dict) else None
152
- if not isinstance(card,dict) or str(card.get("id") or "").strip()!=os.environ["EXPECTED_ID"]:
153
- raise SystemExit("trello: comment response did not identify the requested card")
154
- print(action_id.strip())'
155
- ;;
156
-
157
- transition)
158
- ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
159
- LID="$(list_id_for "$TARGET")"
160
- [ -n "$LID" ] || die "no Trello list mapped for normalized '$TARGET' (check state_map)"
161
- api PUT "cards/$ID" "idList=$LID" >/dev/null
162
- api GET "cards/$ID" "fields=id,idList" \
163
- | EXPECTED_ID="$ID" EXPECTED_LIST_ID="$LID" python3 -c 'import sys,json,os
164
- card=json.load(sys.stdin)
165
- if str(card.get("id") or "") != os.environ["EXPECTED_ID"]:
166
- raise SystemExit("trello: transition read-back did not identify the requested card")
167
- if str(card.get("idList") or "") != os.environ["EXPECTED_LIST_ID"]:
168
- raise SystemExit("trello: transition read-back did not confirm the exact target list")
169
- print("ok " + str(card.get("id") or ""))'
170
- ;;
171
-
172
- describe_board)
173
- # Read-only board lookup against an EXPLICIT workspace argument, mirroring
174
- # the Plane op, so no ambient binding can send the query somewhere else.
175
- # Trello mints NO project key, so `identifier` is ALWAYS empty here: an
176
- # empty identifier is this provider's authoritative answer, not a lookup
177
- # failure, and callers must never promote it to a confirmed value.
178
- DWS="${1:?usage: describe_board <workspace> <board_id>}"
179
- DBID="${2:?usage: describe_board <workspace> <board_id>}"
180
- api GET "boards/$DBID" "fields=id,name,idOrganization" \
181
- | WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
182
- b = json.load(sys.stdin)
183
- print(json.dumps({
184
- "board_id": str(b.get("id") or os.environ["BID"]),
185
- "identifier": "",
186
- "workspace": str(b.get("idOrganization") or os.environ["WS"]),
187
- "name": str(b.get("name") or ""),
188
- }))'
189
- ;;
190
-
191
- create_board)
192
- NAME="${1:?usage: create_board <name> <ident> <desc>}"
193
- EXIST="$(api GET "members/me/boards" "fields=name" | NM="$NAME" python3 -c 'import sys,json,os
194
- rows=json.load(sys.stdin); nm=os.environ["NM"].lower()
195
- print(next((b["id"] for b in rows if b.get("name","").lower()==nm), ""))')"
196
- if [ -n "$EXIST" ]; then BID="$EXIST"; else
197
- BID="$(api POST "boards/" "name=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$NAME")&defaultLists=true" \
198
- | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))')"
199
- fi
200
- [ -n "$BID" ] || die "create_board failed"
201
- # Trello assigns no project key, so the caller's prefix IS the identity and
202
- # this adapter is the only thing that can confirm it. Echo it back: the
203
- # create_board envelope always carries the identifier the board is bound
204
- # under, so no caller ever has to invent one.
205
- api GET "boards/$BID" "fields=url" | BID="$BID" IDENT="${2:-}" python3 -c 'import sys,json,os
206
- b=json.load(sys.stdin); print(json.dumps({"board_id":os.environ["BID"],"board_url":b.get("url",""),"identifier":os.environ.get("IDENT","")}))'
207
- ;;
208
-
209
- create_issue)
210
- # File a new card on the bound board. Board comes from resolved config,
211
- # never from an argument. Deliberately NOT idempotent by default: two cards
212
- # may legitimately share a title. Pass --if-absent to reuse a card whose
213
- # name already matches exactly (case-insensitive) instead.
214
- IF_ABSENT=0
215
- case "${1:-}" in --if-absent) IF_ABSENT=1; shift ;; esac
216
- TITLE="${1:?usage: create_issue [--if-absent] <title> [description]}"; DESC="${2:-}"
217
- [ -n "$BOARD" ] || die "board not set (run 42-ticket-provider.sh)"
218
- CID=""; CREATED=true
219
- if [ "$IF_ABSENT" = 1 ]; then
220
- CID="$(api GET "boards/$BOARD/cards" "fields=name" | NM="$TITLE" python3 -c 'import sys,json,os
221
- rows=json.load(sys.stdin); nm=os.environ["NM"].strip().lower()
222
- print(next((c["id"] for c in rows if (c.get("name") or "").strip().lower()==nm), ""))')"
223
- [ -z "$CID" ] || CREATED=false
224
- fi
225
- if [ -z "$CID" ]; then
226
- # New cards land in the backlog list, falling back to the unstarted one.
227
- LID="$(list_id_for backlog)"
228
- [ -n "$LID" ] || LID="$(list_id_for unstarted)"
229
- [ -n "$LID" ] || die "no Trello list mapped for 'backlog' or 'unstarted' (check state_map)"
230
- CID="$(api POST "cards" "idList=$LID&name=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$TITLE")&desc=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$DESC")" \
231
- | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))')"
232
- fi
233
- [ -n "$CID" ] || die "create_issue failed"
234
- api GET "cards/$CID" "fields=url,shortLink" | CID="$CID" CREATED="$CREATED" python3 -c 'import sys,json,os
235
- c=json.load(sys.stdin)
236
- print(json.dumps({"issue_id":os.environ["CID"],"key":c.get("shortLink",""),
237
- "issue_url":c.get("url",""),"created":os.environ["CREATED"]=="true"}))'
238
- ;;
239
-
240
- *) die "unknown op: $OP" ;;
241
- esac
@@ -1,242 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Gateway-independent health watchdog for a Hermes reporter profile.
3
-
4
- Defaults target the DeLoNET company reporter; override the REPORTER_* environment
5
- variables below to point it at another reporter deployment.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import argparse
11
- import datetime as dt
12
- import hashlib
13
- import json
14
- import os
15
- import re
16
- import subprocess
17
- import tempfile
18
- import urllib.request
19
- from pathlib import Path
20
- from zoneinfo import ZoneInfo
21
-
22
- # Deployment-specific constants. The defaults describe the DeLoNET company
23
- # reporter this watchdog was written for; every one is overridable so a second
24
- # reporter does not need a forked copy of this file.
25
- PROFILE_NAME = os.environ.get("REPORTER_PROFILE_NAME", "delonet-company-reporter")
26
- REPORT_SLUG = os.environ.get("REPORTER_REPORT_SLUG", "delonet-daily-report")
27
- JOB_PREFIX = os.environ.get("REPORTER_JOB_PREFIX", "ddr")
28
- EXPECTED_JOBS = {
29
- job.strip()
30
- for job in os.environ.get(
31
- "REPORTER_EXPECTED_JOBS",
32
- f"{JOB_PREFIX}:daily,"
33
- f"{JOB_PREFIX}:journal:hermes-fleet-health,"
34
- f"{JOB_PREFIX}:journal:nightly-pr-maintenance,"
35
- f"{JOB_PREFIX}:journal:report-delivery-health",
36
- ).split(",")
37
- if job.strip()
38
- }
39
- REPORT_TIMEZONE = os.environ.get("REPORTER_TIMEZONE", "America/New_York")
40
- NTFY_URL = os.environ.get("REPORTER_NTFY_URL", "https://ntfy.delo.sh/bloodbank")
41
- NTFY_TOKEN_REF = os.environ.get(
42
- "REPORTER_NTFY_TOKEN_REF", "op://DeLoSecrets/ntfy/add more/accessToken"
43
- )
44
- HOME = Path.home()
45
- PROFILE = HOME / ".hermes" / "profiles" / PROFILE_NAME
46
- CONFIG = HOME / ".config" / REPORT_SLUG / "report.json"
47
- STATE_DIR = HOME / ".local" / "state" / REPORT_SLUG / "watchdog"
48
- STATUS_PATH = STATE_DIR / "status.json"
49
- ALERT_PATH = STATE_DIR / "last-alert.json"
50
- TICK_MAX_AGE_SECONDS = 180
51
-
52
-
53
- def load_json(path: Path) -> object:
54
- return json.loads(path.read_text(encoding="utf-8"))
55
-
56
-
57
- def atomic_json(path: Path, value: object) -> None:
58
- path.parent.mkdir(parents=True, exist_ok=True)
59
- fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
60
- try:
61
- with os.fdopen(fd, "w", encoding="utf-8") as handle:
62
- json.dump(value, handle, indent=2, sort_keys=True)
63
- handle.write("\n")
64
- handle.flush()
65
- os.fsync(handle.fileno())
66
- os.replace(temporary, path)
67
- finally:
68
- try:
69
- os.unlink(temporary)
70
- except FileNotFoundError:
71
- pass
72
-
73
-
74
- def gateway_homes() -> dict[str, list[str]]:
75
- homes: dict[str, list[str]] = {}
76
- for unit in (HOME / ".config" / "systemd" / "user").glob("hermes*gateway.service"):
77
- text = unit.read_text(encoding="utf-8", errors="replace")
78
- match = re.search(r"^Environment=HERMES_HOME=(.+)$", text, re.MULTILINE)
79
- if not match:
80
- continue
81
- raw = match.group(1).strip().strip('"')
82
- resolved = str(Path(os.path.expandvars(raw)).expanduser().resolve(strict=False))
83
- homes.setdefault(resolved, []).append(unit.name)
84
- return homes
85
-
86
-
87
- def alert_ntfy(codes: list[str]) -> str | None:
88
- try:
89
- token = subprocess.run(
90
- ["op", "read", NTFY_TOKEN_REF],
91
- check=True,
92
- capture_output=True,
93
- text=True,
94
- timeout=15,
95
- ).stdout.strip()
96
- if not token:
97
- return "ntfy access token unavailable"
98
- body = (
99
- f"Critical {PROFILE_NAME} health failure: "
100
- + ", ".join(sorted(codes))
101
- + ". Inspect the profile-scoped watchdog status."
102
- ).encode()
103
- request = urllib.request.Request(
104
- NTFY_URL,
105
- data=body,
106
- method="POST",
107
- headers={
108
- "Authorization": f"Bearer {token}",
109
- "Title": f"{PROFILE_NAME} critical",
110
- "Priority": "urgent",
111
- "Tags": "rotating_light",
112
- },
113
- )
114
- with urllib.request.urlopen(request, timeout=15) as response:
115
- if not 200 <= response.status < 300:
116
- return f"ntfy returned HTTP {response.status}"
117
- except Exception as exc: # fail open; status captures the alert failure
118
- return f"ntfy alert failed: {type(exc).__name__}"
119
- return None
120
-
121
-
122
- def main() -> int:
123
- parser = argparse.ArgumentParser()
124
- parser.add_argument("--no-alert", action="store_true")
125
- parser.add_argument("--tick-max-age", type=int, default=TICK_MAX_AGE_SECONDS)
126
- args = parser.parse_args()
127
-
128
- now = dt.datetime.now(dt.UTC)
129
- eastern = now.astimezone(ZoneInfo(REPORT_TIMEZONE))
130
- critical: list[dict[str, str]] = []
131
- warnings: list[dict[str, str]] = []
132
-
133
- target = PROFILE.resolve(strict=False)
134
- if not PROFILE.is_symlink():
135
- critical.append({"code": "profile_not_symlinked", "detail": str(PROFILE)})
136
-
137
- jobs_path = PROFILE / "cron" / "jobs.json"
138
- try:
139
- raw_jobs = load_json(jobs_path)
140
- jobs = raw_jobs.get("jobs", []) if isinstance(raw_jobs, dict) else []
141
- except (OSError, json.JSONDecodeError):
142
- jobs = []
143
- critical.append({"code": "jobs_store_unreadable", "detail": str(jobs_path)})
144
-
145
- names = [job.get("name") for job in jobs if isinstance(job, dict)]
146
- duplicates = sorted({name for name in names if name and names.count(name) > 1})
147
- missing = sorted(EXPECTED_JOBS - set(names))
148
- unexpected = sorted({name for name in names if name and name.startswith(f"{JOB_PREFIX}:")} - EXPECTED_JOBS)
149
- if duplicates:
150
- critical.append({"code": "duplicate_managed_jobs", "detail": ",".join(duplicates)})
151
- if missing:
152
- critical.append({"code": "missing_managed_jobs", "detail": ",".join(missing)})
153
- if unexpected:
154
- warnings.append({"code": "unexpected_managed_jobs", "detail": ",".join(unexpected)})
155
- paused = sorted(
156
- job.get("name")
157
- for job in jobs
158
- if isinstance(job, dict)
159
- and job.get("name") in EXPECTED_JOBS
160
- and not job.get("enabled", False)
161
- )
162
- if paused:
163
- warnings.append({"code": "managed_jobs_paused", "detail": ",".join(paused)})
164
-
165
- tick_lock = PROFILE / "cron" / ".tick.lock"
166
- tick_age = None
167
- if tick_lock.exists():
168
- tick_age = max(0, int(now.timestamp() - tick_lock.stat().st_mtime))
169
- if tick_age > args.tick_max_age:
170
- critical.append({"code": "stale_cron_tick", "detail": f"age_seconds={tick_age}"})
171
- else:
172
- critical.append({"code": "missing_cron_tick", "detail": str(tick_lock)})
173
-
174
- daily = next((job for job in jobs if isinstance(job, dict) and job.get("name") == f"{JOB_PREFIX}:daily"), {})
175
- state = str(daily.get("last_status") or daily.get("status") or "").lower()
176
- if state in {"failed", "error"}:
177
- critical.append({"code": "daily_job_failed", "detail": state})
178
-
179
- try:
180
- config = load_json(CONFIG)
181
- except (OSError, json.JSONDecodeError):
182
- config = {}
183
- critical.append({"code": "config_unreadable", "detail": str(CONFIG)})
184
- daily_config = (
185
- config.get("daily", {}) if isinstance(config, dict) and isinstance(config.get("daily"), dict) else {}
186
- )
187
- deliver = (
188
- daily_config.get("deliver")
189
- )
190
- if deliver != "telegram":
191
- warnings.append({"code": "telegram_delivery_not_activated", "detail": str(deliver)})
192
-
193
- archive_dir = Path(config.get("archive_dir", "")) if isinstance(config, dict) else Path()
194
- current = archive_dir / eastern.strftime("%Y/%m/%Y-%m-%d/current.json")
195
- if daily_config.get("enabled") and (eastern.hour, eastern.minute) >= (8, 15) and not current.is_file():
196
- critical.append({"code": "daily_report_missed", "detail": str(current)})
197
-
198
- homes = gateway_homes()
199
- profile_units = sorted(homes.get(str(target), []))
200
- if len(profile_units) > 1:
201
- critical.append({"code": "reporter_gateway_duplicated", "detail": ",".join(profile_units)})
202
- for home, units in sorted(homes.items()):
203
- if len(units) > 1 and home != str(target):
204
- warnings.append(
205
- {"code": "fleet_gateway_home_duplicated", "detail": f"{home}: {','.join(sorted(units))}"}
206
- )
207
-
208
- codes = sorted(item["code"] for item in critical)
209
- fingerprint = hashlib.sha256("\n".join(codes).encode()).hexdigest() if codes else ""
210
- previous = {}
211
- try:
212
- previous = load_json(ALERT_PATH)
213
- except (OSError, json.JSONDecodeError):
214
- pass
215
- alert_error = None
216
- if codes and fingerprint != previous.get("fingerprint") and not args.no_alert:
217
- alert_error = alert_ntfy(codes)
218
- if alert_error is None:
219
- atomic_json(ALERT_PATH, {"fingerprint": fingerprint, "sent_at": now.isoformat()})
220
- elif not codes and previous.get("fingerprint"):
221
- atomic_json(ALERT_PATH, {"fingerprint": "", "cleared_at": now.isoformat()})
222
-
223
- status = {
224
- "checked_at": now.isoformat(),
225
- "profile": PROFILE_NAME,
226
- "profile_home": str(PROFILE),
227
- "profile_target": str(target),
228
- "healthy": not critical,
229
- "critical": critical,
230
- "warnings": warnings,
231
- "tick_age_seconds": tick_age,
232
- "managed_jobs": sorted(name for name in names if name and name.startswith(f"{JOB_PREFIX}:")),
233
- "delivery": deliver,
234
- "alert_error": alert_error,
235
- }
236
- atomic_json(STATUS_PATH, status)
237
- print(json.dumps(status, indent=2, sort_keys=True))
238
- return 1 if critical else 0
239
-
240
-
241
- if __name__ == "__main__":
242
- raise SystemExit(main())
@@ -1,134 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Fail closed when a runtime tree contains likely credential material."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import re
8
- from pathlib import Path
9
-
10
- KEY_VALUE = re.compile(
11
- r"(?im)^\s*[\"']?(?P<key>api[_-]?key|token|secret|password|authorization|cookie|client[_-]?secret|private[_-]?key)[\"']?\s*[:=]\s*(?P<value>[^\r\n#]*)"
12
- )
13
- TOKEN = re.compile(
14
- r"(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|tvly-[A-Za-z0-9_-]{10,}|fc-[A-Za-z0-9_-]{10,}|[0-9]{6,}:[A-Za-z0-9_-]{20,})"
15
- )
16
- PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
17
- SAFE_VALUES = {
18
- "openrouter",
19
- "openai",
20
- "anthropic",
21
- "auto",
22
- "none",
23
- "null",
24
- "false",
25
- "true",
26
- }
27
- SAFE_PREFIXES = ("${", "$", "op://", "env:", "[REDACTED]", "{{", "{%")
28
- COMPUTED_VALUE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*\s*(?:\(|\[)")
29
- INTERPOLATED_VALUE = re.compile(
30
- r"(?:\$\{[A-Za-z_][A-Za-z0-9_]*[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_.]*\})"
31
- )
32
- ENV_ASSIGNMENT = re.compile(
33
- r"(?m)^\s*(?:export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<value>[^\r\n#]*)"
34
- )
35
- SENSITIVE_ENV_KEY = re.compile(
36
- r"(?i)(?:^|_)(?:api_key|token|secret|password|authorization|cookie|client_secret|private_key)(?:_|$)"
37
- )
38
- ALLOWED_ENV_FILES = {".env.example", ".env.op"}
39
- SKIP_PARTS = {
40
- ".cache",
41
- ".direnv",
42
- ".eggs",
43
- ".git",
44
- ".mypy_cache",
45
- ".nox",
46
- ".npm",
47
- ".pnpm-store",
48
- ".pytest_cache",
49
- ".ruff_cache",
50
- ".tox",
51
- ".turbo",
52
- ".uv-cache",
53
- ".venv",
54
- ".yarn",
55
- "__pycache__",
56
- "dist-packages",
57
- "node_modules",
58
- "site-packages",
59
- "venv",
60
- }
61
-
62
-
63
- def safe_assignment(raw_value: str) -> bool:
64
- """Return true only for an explicit reference, sentinel, or computation."""
65
-
66
- value = raw_value.strip().rstrip(",").strip().strip("\"'")
67
- if not value or value.lower() in SAFE_VALUES:
68
- return True
69
- if value.startswith(SAFE_PREFIXES) or value.endswith("_ENV"):
70
- return True
71
- if INTERPOLATED_VALUE.search(value):
72
- return True
73
- # Source code commonly binds credentials from a runtime provider. A bare
74
- # literal does not have a call/index boundary and remains fail-closed.
75
- return COMPUTED_VALUE.match(value) is not None
76
-
77
-
78
- def safe_env_reference(raw_value: str) -> bool:
79
- """Keep a tracked .env.op declarative: sensitive values stay references."""
80
-
81
- value = raw_value.strip().rstrip(",").strip().strip("\"'")
82
- return not value or value.startswith(SAFE_PREFIXES)
83
-
84
-
85
- def findings(root: Path) -> list[str]:
86
- result: list[str] = []
87
- for path in root.rglob("*"):
88
- relative = path.relative_to(root)
89
- if not path.is_file() or any(part in SKIP_PARTS for part in relative.parts):
90
- continue
91
- if path.name == "secret-scan.py":
92
- continue
93
- if path.name.startswith(".env") and path.name not in ALLOWED_ENV_FILES:
94
- result.append(f"forbidden secret file: {relative}")
95
- continue
96
- if path.name in {"auth.json", "auth.lock"} or path.suffix in {".pem", ".key"}:
97
- result.append(f"forbidden credential file: {relative}")
98
- continue
99
- try:
100
- text = path.read_text(encoding="utf-8")
101
- except (UnicodeDecodeError, OSError):
102
- continue
103
- if TOKEN.search(text) or PRIVATE_KEY.search(text):
104
- result.append(f"credential token pattern: {relative}")
105
- if path.name == ".env.op":
106
- for match in ENV_ASSIGNMENT.finditer(text):
107
- if SENSITIVE_ENV_KEY.search(
108
- match.group("key")
109
- ) and not safe_env_reference(match.group("value")):
110
- result.append(f"literal credential assignment: {relative}")
111
- break
112
- for match in KEY_VALUE.finditer(text):
113
- if safe_assignment(match.group("value")):
114
- continue
115
- result.append(f"literal credential assignment: {relative}")
116
- break
117
- return sorted(set(result))
118
-
119
-
120
- def main() -> int:
121
- parser = argparse.ArgumentParser()
122
- parser.add_argument("root", type=Path)
123
- args = parser.parse_args()
124
- issues = findings(args.root.resolve())
125
- if issues:
126
- for issue in issues:
127
- print(issue)
128
- return 1
129
- print("secret scan: clean")
130
- return 0
131
-
132
-
133
- if __name__ == "__main__":
134
- raise SystemExit(main())