@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
@@ -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
- "https://api.linear.app/graphql",
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
- gql 'query($k:String!){ issues(first:100, filter:{team:{key:{eq:$k}}}){nodes{ id identifier title updatedAt url state{name type} assignee{name} }} }' \
111
- "$(printf '{"k":"%s"}' "$TEAM")" \
112
- | python3 -c 'import sys,json
113
- d=json.load(sys.stdin); out=[]
114
- for n in d.get("issues",{}).get("nodes") or []:
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
- out.append({"id":n["id"],"key":n.get("identifier",""),"title":n.get("title",""),
117
- "state":st.get("name",""),"state_type":st.get("type",""),
118
- "updated_at":n.get("updatedAt",""),
119
- "assignee":(n.get("assignee") or {}).get("name",""),"url":n.get("url","")})
120
- print(json.dumps(out))'
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; d=json.load(sys.stdin); print((d.get("commentCreate",{}).get("comment") or {}).get("id",""))'
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
- named=[s for s in states if want_n and s["name"].lower()==want_n.lower()]
162
- typed=[s for s in states if s.get("type")==want_t]
163
- pick=(named or typed or [{}])[0]
164
- print(pick.get("id",""))')"
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; u=json.load(sys.stdin).get("issueUpdate",{});
169
- print(("ok " + (u.get("issue") or {}).get("identifier","")) if u.get("success") else "FAILED"); sys.exit(0 if u.get("success") else 1)'
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)