@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.
- package/README.md +41 -596
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +9385 -18379
- package/dist/mcp-server.js +9432 -17852
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -584
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- 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())
|