@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.
- package/README.md +528 -0
- package/contracts/fleet-contract.yaml +513 -0
- package/dist/index.js +9333 -1509
- package/dist/mcp-server.js +6634 -1096
- package/dist/prompt.js +2 -1
- package/package.json +10 -4
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- package/templates/hermes-agent/template/role.yaml.jinja +15 -4
|
@@ -11,15 +11,27 @@
|
|
|
11
11
|
#
|
|
12
12
|
# Implements the contract in lib/ticket-provider.sh. All Linear access goes
|
|
13
13
|
# through GraphQL so the same envelope works in unattended runs.
|
|
14
|
+
# LINEAR_MAX_PAGES bounds issue pagination (default 1000).
|
|
15
|
+
# GraphQL variable references are intentionally single-quoted shell literals.
|
|
16
|
+
# shellcheck disable=SC2016
|
|
14
17
|
set -eu
|
|
15
18
|
|
|
16
19
|
OP="${1:-}"; shift 2>/dev/null || true
|
|
17
20
|
ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
18
21
|
ROLE_YAML="$ROLE_DIR/role.yaml"
|
|
22
|
+
GRAPHQL_URL="${LINEAR_GRAPHQL_URL:-https://api.linear.app/graphql}"
|
|
19
23
|
|
|
20
24
|
die() { echo "linear: $*" >&2; exit 1; }
|
|
21
25
|
need_key() { [ -n "${LINEAR_API_KEY:-}" ] || die "LINEAR_API_KEY is not set"; }
|
|
22
26
|
|
|
27
|
+
validated_uint() {
|
|
28
|
+
setting="$1"; value="$2"; minimum="$3"; maximum="$4"
|
|
29
|
+
case "$value" in ''|*[!0-9]*) die "$setting must be an integer from $minimum through $maximum" ;; esac
|
|
30
|
+
[ "$value" -ge "$minimum" ] && [ "$value" -le "$maximum" ] \
|
|
31
|
+
|| die "$setting must be an integer from $minimum through $maximum"
|
|
32
|
+
printf '%s' "$value"
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
# tp_cfg KEY — read ticket_provider.<KEY> from role.yaml (best-effort, flat).
|
|
24
36
|
tp_cfg() {
|
|
25
37
|
[ -f "$ROLE_YAML" ] || return 0
|
|
@@ -55,11 +67,11 @@ PY
|
|
|
55
67
|
gql() {
|
|
56
68
|
need_key
|
|
57
69
|
_vars="${2:-}"; [ -n "$_vars" ] || _vars='{}'
|
|
58
|
-
python3 - "$1" "$_vars" <<'PY'
|
|
70
|
+
python3 - "$1" "$_vars" "$GRAPHQL_URL" <<'PY'
|
|
59
71
|
import json, os, sys, urllib.request, urllib.error
|
|
60
72
|
q, variables = sys.argv[1], json.loads(sys.argv[2])
|
|
61
73
|
req = urllib.request.Request(
|
|
62
|
-
|
|
74
|
+
sys.argv[3],
|
|
63
75
|
data=json.dumps({"query": q, "variables": variables}).encode(),
|
|
64
76
|
headers={"Authorization": os.environ["LINEAR_API_KEY"],
|
|
65
77
|
"Content-Type": "application/json"},
|
|
@@ -81,6 +93,10 @@ PROJECT="$(pj_cfg project)"; [ -n "$PROJECT" ] || PROJECT="$(tp_cfg project)"
|
|
|
81
93
|
SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
|
|
82
94
|
SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
|
|
83
95
|
SM_CANCELLED="$(tp_cfg cancelled)"; SM_CANCELLED="${SM_CANCELLED:-Canceled}"
|
|
96
|
+
SM_STARTED="$(tp_cfg started)"
|
|
97
|
+
SM_UNSTARTED="$(tp_cfg unstarted)"
|
|
98
|
+
SM_BACKLOG="$(tp_cfg backlog)"
|
|
99
|
+
MAX_PAGES="$(validated_uint LINEAR_MAX_PAGES "${LINEAR_MAX_PAGES:-1000}" 1 1000)"
|
|
84
100
|
|
|
85
101
|
# All Linear ops require the API key; fail fast and clean before any pipe.
|
|
86
102
|
need_key
|
|
@@ -90,7 +106,7 @@ case "$OP" in
|
|
|
90
106
|
[ -n "$TEAM" ] || die "ticket_provider.team (Linear team key) not set in role.yaml"
|
|
91
107
|
gql 'query($k:String!){ teams(filter:{key:{eq:$k}}){nodes{id key name}} }' \
|
|
92
108
|
"$(printf '{"k":"%s"}' "$TEAM")" \
|
|
93
|
-
| python3 -c 'import sys,json; d=json.load(sys.stdin); t=(d.get("teams",{}).get("nodes") or [{}])[0]; print(json.dumps({"provider":"linear","board_id":t.get("id",""),"board_url":"https://linear.app/team/"+t.get("key","")}))'
|
|
109
|
+
| python3 -c 'import sys,json; d=json.load(sys.stdin); t=(d.get("teams",{}).get("nodes") or [{}])[0]; print(json.dumps({"provider":"linear","board_id":t.get("id",""),"board_url":"https://linear.app/team/"+t.get("key",""),"identifier":t.get("key","")}))'
|
|
94
110
|
;;
|
|
95
111
|
|
|
96
112
|
active_milestone)
|
|
@@ -107,17 +123,57 @@ print(json.dumps({"id":m.get("id",""),"name":m.get("name",""),"state":p.get("sta
|
|
|
107
123
|
|
|
108
124
|
list_issues)
|
|
109
125
|
[ -n "$TEAM" ] || die "ticket_provider.team not set"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
ISSUE_PAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/linear-issues.XXXXXX")" || die "could not create pagination scratch directory"
|
|
127
|
+
ISSUE_PAGE_ROWS="$ISSUE_PAGE_DIR/rows"
|
|
128
|
+
ISSUE_PAGE_CURSORS="$ISSUE_PAGE_DIR/cursors"
|
|
129
|
+
: > "$ISSUE_PAGE_ROWS"
|
|
130
|
+
: > "$ISSUE_PAGE_CURSORS"
|
|
131
|
+
cleanup_issue_pages() {
|
|
132
|
+
rm -f "$ISSUE_PAGE_ROWS" "$ISSUE_PAGE_CURSORS"
|
|
133
|
+
rmdir "$ISSUE_PAGE_DIR" 2>/dev/null || true
|
|
134
|
+
}
|
|
135
|
+
trap cleanup_issue_pages 0
|
|
136
|
+
trap 'cleanup_issue_pages; exit 129' HUP
|
|
137
|
+
trap 'cleanup_issue_pages; exit 130' INT
|
|
138
|
+
trap 'cleanup_issue_pages; exit 143' TERM
|
|
139
|
+
AFTER=""
|
|
140
|
+
PAGE_COUNT=0
|
|
141
|
+
while :; do
|
|
142
|
+
PAGE_COUNT=$((PAGE_COUNT + 1))
|
|
143
|
+
[ "$PAGE_COUNT" -le "$MAX_PAGES" ] \
|
|
144
|
+
|| die "pagination exceeded LINEAR_MAX_PAGES=$MAX_PAGES"
|
|
145
|
+
VARS="$(python3 -c 'import json,sys; print(json.dumps({"k":sys.argv[1],"after":sys.argv[2] or None}))' "$TEAM" "$AFTER")"
|
|
146
|
+
PAGE="$(gql 'query($k:String!,$after:String){ issues(first:100, after:$after, filter:{team:{key:{eq:$k}}}){nodes{ id identifier title updatedAt url state{name type} assignee{name} } pageInfo{hasNextPage endCursor}} }' "$VARS")"
|
|
147
|
+
printf '%s' "$PAGE" | python3 -c 'import sys,json
|
|
148
|
+
d=json.load(sys.stdin)
|
|
149
|
+
for n in (d.get("issues") or {}).get("nodes") or []:
|
|
115
150
|
st=n.get("state") or {}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
151
|
+
print(json.dumps({"id":n["id"],"key":n.get("identifier",""),"title":n.get("title",""),
|
|
152
|
+
"state":st.get("name",""),"state_type":st.get("type",""),
|
|
153
|
+
"updated_at":n.get("updatedAt",""),
|
|
154
|
+
"assignee":(n.get("assignee") or {}).get("name",""),"url":n.get("url","")}))' \
|
|
155
|
+
>> "$ISSUE_PAGE_ROWS"
|
|
156
|
+
HAS_NEXT="$(printf '%s' "$PAGE" | python3 -c 'import sys,json; print("true" if ((json.load(sys.stdin).get("issues") or {}).get("pageInfo") or {}).get("hasNextPage") else "false")')"
|
|
157
|
+
[ "$HAS_NEXT" = "true" ] || break
|
|
158
|
+
NEXT="$(printf '%s' "$PAGE" | python3 -c 'import sys,json; print(str(((json.load(sys.stdin).get("issues") or {}).get("pageInfo") or {}).get("endCursor") or ""))')"
|
|
159
|
+
[ -n "$NEXT" ] || die "Linear pagination reported another page without an end cursor"
|
|
160
|
+
CURSOR_FINGERPRINT="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "$NEXT")"
|
|
161
|
+
if grep -Fqx "$CURSOR_FINGERPRINT" "$ISSUE_PAGE_CURSORS"; then
|
|
162
|
+
die "Linear pagination cursor repeated"
|
|
163
|
+
fi
|
|
164
|
+
printf '%s\n' "$CURSOR_FINGERPRINT" >> "$ISSUE_PAGE_CURSORS"
|
|
165
|
+
AFTER="$NEXT"
|
|
166
|
+
done
|
|
167
|
+
python3 - "$ISSUE_PAGE_ROWS" <<'PY'
|
|
168
|
+
import json
|
|
169
|
+
import pathlib
|
|
170
|
+
import sys
|
|
171
|
+
|
|
172
|
+
rows = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines() if line]
|
|
173
|
+
print(json.dumps(rows))
|
|
174
|
+
PY
|
|
175
|
+
cleanup_issue_pages
|
|
176
|
+
trap - 0 HUP INT TERM
|
|
121
177
|
;;
|
|
122
178
|
|
|
123
179
|
get_issue)
|
|
@@ -135,9 +191,23 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get
|
|
|
135
191
|
|
|
136
192
|
comment)
|
|
137
193
|
ID="${1:?usage: comment <id> <body>}"; BODY="${2:?usage: comment <id> <body>}"
|
|
138
|
-
gql 'mutation($id:String!,$b:String!){ commentCreate(input:{issueId:$id,body:$b}){ comment{id} success } }' \
|
|
194
|
+
gql 'mutation($id:String!,$b:String!){ commentCreate(input:{issueId:$id,body:$b}){ comment{id issue{id}} success } }' \
|
|
139
195
|
"$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"b":sys.argv[2]}))' "$ID" "$BODY")" \
|
|
140
|
-
| python3 -c 'import sys,json
|
|
196
|
+
| EXPECTED_ID="$ID" python3 -c 'import sys,json,os
|
|
197
|
+
document=json.load(sys.stdin)
|
|
198
|
+
created=document.get("commentCreate") if isinstance(document,dict) else None
|
|
199
|
+
if not isinstance(created,dict) or created.get("success") is not True:
|
|
200
|
+
raise SystemExit("linear: commentCreate did not report success")
|
|
201
|
+
comment=created.get("comment")
|
|
202
|
+
if not isinstance(comment,dict):
|
|
203
|
+
raise SystemExit("linear: commentCreate response omitted its comment object")
|
|
204
|
+
comment_id=comment.get("id")
|
|
205
|
+
if not isinstance(comment_id,str) or not comment_id.strip():
|
|
206
|
+
raise SystemExit("linear: commentCreate response omitted its comment id")
|
|
207
|
+
issue=comment.get("issue")
|
|
208
|
+
if not isinstance(issue,dict) or str(issue.get("id") or "").strip()!=os.environ["EXPECTED_ID"]:
|
|
209
|
+
raise SystemExit("linear: commentCreate response did not identify the requested issue")
|
|
210
|
+
print(comment_id.strip())'
|
|
141
211
|
;;
|
|
142
212
|
|
|
143
213
|
transition)
|
|
@@ -147,9 +217,9 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get
|
|
|
147
217
|
completed) WANT_TYPE=completed; WANT_NAME="$SM_DONE" ;;
|
|
148
218
|
cancelled) WANT_TYPE=canceled; WANT_NAME="$SM_CANCELLED" ;;
|
|
149
219
|
in_review) WANT_TYPE=started; WANT_NAME="$SM_IN_REVIEW" ;;
|
|
150
|
-
started) WANT_TYPE=started; WANT_NAME="" ;;
|
|
151
|
-
unstarted) WANT_TYPE=unstarted; WANT_NAME="" ;;
|
|
152
|
-
backlog) WANT_TYPE=backlog; WANT_NAME="" ;;
|
|
220
|
+
started) WANT_TYPE=started; WANT_NAME="$SM_STARTED" ;;
|
|
221
|
+
unstarted) WANT_TYPE=unstarted; WANT_NAME="$SM_UNSTARTED" ;;
|
|
222
|
+
backlog) WANT_TYPE=backlog; WANT_NAME="$SM_BACKLOG" ;;
|
|
153
223
|
*) die "invalid normalized state: $TARGET" ;;
|
|
154
224
|
esac
|
|
155
225
|
STATE_ID="$(gql 'query($id:String!){ issue(id:$id){ team{ states{nodes{id name type}} } } }' \
|
|
@@ -158,15 +228,58 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get
|
|
|
158
228
|
d=json.load(sys.stdin)
|
|
159
229
|
states=((d.get("issue") or {}).get("team") or {}).get("states",{}).get("nodes") or []
|
|
160
230
|
want_t=os.environ["WANT_TYPE"]; want_n=os.environ.get("WANT_NAME","")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
231
|
+
if want_n:
|
|
232
|
+
candidates=[s for s in states if str(s.get("name") or "").strip().casefold()==want_n.strip().casefold()]
|
|
233
|
+
basis=f"configured name {want_n!r}"
|
|
234
|
+
else:
|
|
235
|
+
candidates=[s for s in states if s.get("type")==want_t]
|
|
236
|
+
basis=f"workflow type {want_t!r}"
|
|
237
|
+
if len(candidates) != 1:
|
|
238
|
+
raise SystemExit(f"linear: {basis} resolved {len(candidates)} states; exactly one is required")
|
|
239
|
+
pick=candidates[0]
|
|
240
|
+
if pick.get("type") != want_t:
|
|
241
|
+
raise SystemExit(f"linear: configured state {want_n!r} has type {pick.get('type')!r}, expected {want_t!r}")
|
|
242
|
+
state_id=str(pick.get("id") or "")
|
|
243
|
+
if not state_id:
|
|
244
|
+
raise SystemExit("linear: resolved state omitted its id")
|
|
245
|
+
print(state_id)')"
|
|
165
246
|
[ -n "$STATE_ID" ] || die "no Linear state for normalized '$TARGET'"
|
|
166
|
-
gql 'mutation($id:String!,$s:String!){ issueUpdate(id:$id,input:{stateId:$s}){ success issue{identifier state{name}} } }' \
|
|
247
|
+
gql 'mutation($id:String!,$s:String!){ issueUpdate(id:$id,input:{stateId:$s}){ success issue{id identifier state{id name type}} } }' \
|
|
167
248
|
"$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"s":sys.argv[2]}))' "$ID" "$STATE_ID")" \
|
|
168
|
-
| python3 -c 'import sys,json
|
|
169
|
-
|
|
249
|
+
| EXPECTED_ID="$ID" EXPECTED_STATE_ID="$STATE_ID" python3 -c 'import sys,json,os
|
|
250
|
+
u=json.load(sys.stdin).get("issueUpdate") or {}
|
|
251
|
+
if u.get("success") is not True:
|
|
252
|
+
raise SystemExit("linear: issueUpdate did not report success")
|
|
253
|
+
issue=u.get("issue") or {}
|
|
254
|
+
if str(issue.get("id") or "") != os.environ["EXPECTED_ID"]:
|
|
255
|
+
raise SystemExit("linear: issueUpdate read-back did not identify the requested issue")
|
|
256
|
+
state=issue.get("state") or {}
|
|
257
|
+
if str(state.get("id") or "") != os.environ["EXPECTED_STATE_ID"]:
|
|
258
|
+
raise SystemExit("linear: issueUpdate read-back did not confirm the exact target state")
|
|
259
|
+
print("ok " + str(issue.get("identifier") or issue.get("id") or ""))'
|
|
260
|
+
;;
|
|
261
|
+
|
|
262
|
+
describe_board)
|
|
263
|
+
# Read-only team lookup by id, against an EXPLICIT workspace argument so no
|
|
264
|
+
# ambient binding can answer for the wrong org. Linear's team KEY is a real
|
|
265
|
+
# authoritative identifier — it prefixes every issue — so it is read back
|
|
266
|
+
# from Linear itself and never proposed locally.
|
|
267
|
+
DWS="${1:?usage: describe_board <workspace> <board_id>}"
|
|
268
|
+
DBID="${2:?usage: describe_board <workspace> <board_id>}"
|
|
269
|
+
gql 'query($id:String!){ team(id:$id){ id key name } organization{ urlKey } }' \
|
|
270
|
+
"$(printf '{"id":"%s"}' "$DBID")" \
|
|
271
|
+
| WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
|
|
272
|
+
d = json.load(sys.stdin)
|
|
273
|
+
t = d.get("team") or {}
|
|
274
|
+
ident = str(t.get("key") or "")
|
|
275
|
+
if not ident:
|
|
276
|
+
raise SystemExit("linear: team %s reported no authoritative key" % os.environ["BID"])
|
|
277
|
+
print(json.dumps({
|
|
278
|
+
"board_id": str(t.get("id") or os.environ["BID"]),
|
|
279
|
+
"identifier": ident,
|
|
280
|
+
"workspace": str((d.get("organization") or {}).get("urlKey") or os.environ["WS"]),
|
|
281
|
+
"name": str(t.get("name") or ""),
|
|
282
|
+
}))'
|
|
170
283
|
;;
|
|
171
284
|
|
|
172
285
|
create_board)
|