@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
@@ -7,8 +7,18 @@
7
7
  # Board binding (repo-root .project.json `ticket_provider:`):
8
8
  # workspace: <workspace-slug> (or env PLANE_WORKSPACE)
9
9
  # board_id: <project-uuid> (set by create_board / 42-ticket-provider)
10
- # state_map: { in_review: "In Review", completed: "Done",
11
- # cancelled: "Cancelled" } optional
10
+ # timezone: <IANA timezone> optional project calendar override
11
+ # state_map: { started: "In Progress", in_review: "In Review",
12
+ # completed: "Done", cancelled: "Cancelled" } optional
13
+ #
14
+ # Rate limiting: reads retry HTTP 429 up to PLANE_READ_MAX_ATTEMPTS (default 4)
15
+ # times, sleeping the server's Retry-After capped at PLANE_429_MAX_DELAY
16
+ # (default 30s). Mutations are never retried in the transport layer, and
17
+ # transition sends at most ONE PATCH: without a version/precondition guard a
18
+ # repeated PATCH can stomp a concurrent actor, so the live read-back after the
19
+ # single attempt is the only success proof. PLANE_MAX_PAGES bounds every
20
+ # paginated collection (default 1000). All numeric overrides are validated
21
+ # before any request.
12
22
  #
13
23
  # Plane model: project = board, cycle = milestone, state.group in
14
24
  # backlog|unstarted|started|completed|cancelled.
@@ -27,6 +37,14 @@ FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
27
37
  die() { echo "plane: $*" >&2; exit 1; }
28
38
  need_key() { [ -n "${PLANE_API_KEY:-}" ] || die "PLANE_API_KEY is not set"; }
29
39
 
40
+ validated_uint() {
41
+ setting="$1"; value="$2"; minimum="$3"; maximum="$4"
42
+ case "$value" in ''|*[!0-9]*) die "$setting must be an integer from $minimum through $maximum" ;; esac
43
+ [ "$value" -ge "$minimum" ] && [ "$value" -le "$maximum" ] \
44
+ || die "$setting must be an integer from $minimum through $maximum"
45
+ printf '%s' "$value"
46
+ }
47
+
30
48
  workspace_key() {
31
49
  key="$(printf '%s' "${1:-default}" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')"
32
50
  [ -n "$key" ] || key="DEFAULT"
@@ -110,8 +128,18 @@ PROJ="$(pj_cfg board_id)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg project)"; [ -n "$PR
110
128
  SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
111
129
  SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
112
130
  SM_CANCELLED="$(tp_cfg cancelled)"; SM_CANCELLED="${SM_CANCELLED:-Cancelled}"
131
+ SM_STARTED="$(tp_cfg started)"
132
+ SM_UNSTARTED="$(tp_cfg unstarted)"
133
+ SM_BACKLOG="$(tp_cfg backlog)"
134
+ CALENDAR_TZ="$(pj_cfg timezone)"; [ -n "$CALENDAR_TZ" ] || CALENDAR_TZ="$(tp_cfg timezone)"
135
+ CALENDAR_TZ="${CALENDAR_TZ:-${TICKET_PROVIDER_TIMEZONE:-${TZ:-}}}"
113
136
  API="$BASE/api/v1/workspaces/$WS"
114
137
 
138
+ READ_MAX_ATTEMPTS="$(validated_uint PLANE_READ_MAX_ATTEMPTS "${PLANE_READ_MAX_ATTEMPTS:-4}" 1 20)"
139
+ RETRY_DEFAULT_DELAY="$(validated_uint PLANE_429_RETRY_DELAY "${PLANE_429_RETRY_DELAY:-1}" 0 3600)"
140
+ RETRY_MAX_DELAY="$(validated_uint PLANE_429_MAX_DELAY "${PLANE_429_MAX_DELAY:-30}" 0 3600)"
141
+ MAX_PAGES="$(validated_uint PLANE_MAX_PAGES "${PLANE_MAX_PAGES:-1000}" 1 1000)"
142
+
115
143
  if [ -z "${PLANE_API_KEY:-}" ]; then
116
144
  KEY="$(workspace_key "$WS")"
117
145
  PLANE_API_KEY="$(printenv "$KEY" 2>/dev/null || true)"
@@ -123,22 +151,228 @@ PLANE_API_KEY="$(resolve_secret_value "${PLANE_API_KEY:-}")"
123
151
  export PLANE_API_KEY
124
152
 
125
153
  # api METHOD PATH [JSON_BODY] — call Plane REST, print response body.
154
+ # Reads (GET) are idempotent: on HTTP 429 they retry a bounded number of times,
155
+ # sleeping the server's Retry-After (capped, default when absent). Mutations
156
+ # are never retried here — a caller must check whether a failed mutation landed
157
+ # before repeating it. Any non-2xx outcome dies with the explicit status.
126
158
  api() {
127
159
  need_key
128
160
  method="$1"; path="$2"; body="${3:-}"
129
- if [ -n "$body" ]; then
130
- curl -fsS -X "$method" "$API/$path" \
131
- -H "X-API-Key: $PLANE_API_KEY" -H "Content-Type: application/json" \
132
- -H "User-Agent: curl/8.0" \
133
- -d "$body"
134
- else
135
- curl -fsS -X "$method" "$API/$path" \
136
- -H "X-API-Key: $PLANE_API_KEY" \
137
- -H "User-Agent: curl/8.0"
138
- fi
161
+ max_attempts=1
162
+ case "$method" in GET) max_attempts="$READ_MAX_ATTEMPTS" ;; esac
163
+ api_scratch="$(mktemp -d "${TMPDIR:-/tmp}/plane-api.XXXXXX")" \
164
+ || die "could not create API scratch directory"
165
+ response_file="$api_scratch/response"
166
+ headers_file="$api_scratch/headers"
167
+ cleanup_api_scratch() {
168
+ rm -f "$response_file" "$headers_file"
169
+ rmdir "$api_scratch" 2>/dev/null || true
170
+ }
171
+ trap cleanup_api_scratch 0
172
+ trap 'cleanup_api_scratch; exit 129' HUP
173
+ trap 'cleanup_api_scratch; exit 130' INT
174
+ trap 'cleanup_api_scratch; exit 143' TERM
175
+ attempt=0
176
+ while :; do
177
+ attempt=$((attempt + 1))
178
+ : > "$response_file"
179
+ : > "$headers_file"
180
+ curl_exit=0
181
+ if [ -n "$body" ]; then
182
+ captured="$(curl -sS -o "$response_file" -D "$headers_file" -w '%{http_code}' -X "$method" "$API/$path" \
183
+ -H "X-API-Key: $PLANE_API_KEY" -H "Content-Type: application/json" \
184
+ -H "User-Agent: curl/8.0" \
185
+ -d "$body")" || curl_exit=$?
186
+ else
187
+ captured="$(curl -sS -o "$response_file" -D "$headers_file" -w '%{http_code}' -X "$method" "$API/$path" \
188
+ -H "X-API-Key: $PLANE_API_KEY" \
189
+ -H "User-Agent: curl/8.0")" || curl_exit=$?
190
+ fi
191
+ if [ "$curl_exit" -ne 0 ]; then
192
+ cleanup_api_scratch
193
+ trap - 0 HUP INT TERM
194
+ die "$method $path failed at transport (curl exit $curl_exit)"
195
+ fi
196
+ case "$captured" in
197
+ [1-5][0-9][0-9])
198
+ status="$captured"
199
+ ;;
200
+ *)
201
+ cleanup_api_scratch
202
+ trap - 0 HUP INT TERM
203
+ die "$method $path returned invalid HTTP status ${captured:-empty}"
204
+ ;;
205
+ esac
206
+ output="$(cat "$response_file")"
207
+ if [ "$status" = 429 ] && [ "$attempt" -lt "$max_attempts" ]; then
208
+ delay="$(python3 - "$headers_file" "$RETRY_DEFAULT_DELAY" "$RETRY_MAX_DELAY" <<'PY'
209
+ import datetime
210
+ import email.utils
211
+ import math
212
+ import sys
213
+
214
+ raw = ""
215
+ with open(sys.argv[1], encoding="utf-8", errors="replace") as stream:
216
+ for line in stream:
217
+ name, _, value = line.partition(":")
218
+ if name.strip().lower() == "retry-after":
219
+ raw = value.strip()
220
+ fallback = int(sys.argv[2])
221
+ maximum = int(sys.argv[3])
222
+ if raw.isdigit():
223
+ delay = int(raw)
224
+ else:
225
+ try:
226
+ target = email.utils.parsedate_to_datetime(raw)
227
+ if target.tzinfo is None:
228
+ target = target.replace(tzinfo=datetime.timezone.utc)
229
+ delay = max(
230
+ 0,
231
+ math.ceil(
232
+ (target - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
233
+ ),
234
+ )
235
+ except (TypeError, ValueError, OverflowError):
236
+ delay = fallback
237
+ print(min(delay, maximum))
238
+ PY
239
+ )"
240
+ sleep "$delay"
241
+ continue
242
+ fi
243
+ case "$status" in
244
+ 2*)
245
+ printf '%s' "$output"
246
+ cleanup_api_scratch
247
+ trap - 0 HUP INT TERM
248
+ return 0
249
+ ;;
250
+ esac
251
+ detail="$(printf '%s' "$output" | head -c 300 | tr '\n' ' ')"
252
+ cleanup_api_scratch
253
+ trap - 0 HUP INT TERM
254
+ die "$method $path failed (HTTP $status)${detail:+: $detail}"
255
+ done
139
256
  }
140
257
 
141
- # Map a normalized state -> a concrete Plane state id in this project.
258
+ # api_all PATH GET every page of a Plane list endpoint and print one merged
259
+ # JSON array. Plane v1 paginates with a next_cursor query token and a
260
+ # next_page_results flag; bare-list and single-page responses pass through.
261
+ api_all() {
262
+ path="$1"
263
+ page_scratch="$(mktemp -d "${TMPDIR:-/tmp}/plane-pages.XXXXXX")" \
264
+ || die "could not create pagination scratch directory"
265
+ pages_file="$page_scratch/pages"
266
+ cursors_file="$page_scratch/cursors"
267
+ : > "$pages_file"
268
+ : > "$cursors_file"
269
+ cleanup_page_scratch() {
270
+ rm -f "$pages_file" "$cursors_file"
271
+ rmdir "$page_scratch" 2>/dev/null || true
272
+ }
273
+ trap cleanup_page_scratch 0
274
+ trap 'cleanup_page_scratch; exit 129' HUP
275
+ trap 'cleanup_page_scratch; exit 130' INT
276
+ trap 'cleanup_page_scratch; exit 143' TERM
277
+ cursor=""
278
+ page_count=0
279
+ while :; do
280
+ page_count=$((page_count + 1))
281
+ [ "$page_count" -le "$MAX_PAGES" ] \
282
+ || die "pagination for $path exceeded PLANE_MAX_PAGES=$MAX_PAGES"
283
+ case "$path" in *\?*) sep="&" ;; *) sep="?" ;; esac
284
+ if [ -n "$cursor" ]; then
285
+ encoded_cursor="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$cursor")"
286
+ page_path="$path${sep}cursor=$encoded_cursor"
287
+ else
288
+ page_path="$path"
289
+ fi
290
+ if ! page="$(api GET "$page_path")"; then
291
+ cleanup_page_scratch
292
+ trap - 0 HUP INT TERM
293
+ return 1
294
+ fi
295
+ printf '%s\0' "$page" >> "$pages_file"
296
+ page_meta="$(printf '%s' "$page" | python3 -c 'import sys,json
297
+ d=json.load(sys.stdin)
298
+ if isinstance(d,list):
299
+ print("done")
300
+ elif isinstance(d,dict):
301
+ more=d.get("next_page_results", False)
302
+ if more is True:
303
+ cursor=str(d.get("next_cursor") or "")
304
+ if not cursor:
305
+ raise SystemExit("plane: pagination reported another page without a cursor")
306
+ print("next\t"+cursor)
307
+ else:
308
+ print("done")
309
+ else:
310
+ raise SystemExit("plane: paginated endpoint returned neither an object nor a list")')"
311
+ case "$page_meta" in
312
+ done) break ;;
313
+ next*) next="${page_meta#* }" ;;
314
+ *) die "invalid pagination metadata for $path" ;;
315
+ esac
316
+ cursor_fingerprint="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "$next")"
317
+ if grep -Fqx "$cursor_fingerprint" "$cursors_file"; then
318
+ die "pagination cursor for $path repeated"
319
+ fi
320
+ printf '%s\n' "$cursor_fingerprint" >> "$cursors_file"
321
+ cursor="$next"
322
+ done
323
+ python3 - "$pages_file" <<'PY'
324
+ import json
325
+ import pathlib
326
+ import sys
327
+
328
+ rows = []
329
+ for chunk in pathlib.Path(sys.argv[1]).read_bytes().split(b"\0"):
330
+ if not chunk:
331
+ continue
332
+ d = json.loads(chunk)
333
+ if isinstance(d, list):
334
+ rows.extend(d)
335
+ elif isinstance(d, dict):
336
+ rows.extend(d.get("results") or [])
337
+ print(json.dumps(rows))
338
+ PY
339
+ cleanup_page_scratch
340
+ trap - 0 HUP INT TERM
341
+ }
342
+
343
+ # Select the one date-current cycle. An empty result is explicit and must never
344
+ # fall back to the first historical cycle returned by Plane.
345
+ current_cycle() {
346
+ CALENDAR_TZ="$CALENDAR_TZ" TICKET_PROVIDER_NOW="${TICKET_PROVIDER_NOW:-}" python3 -c 'import sys,json,datetime,os
347
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
348
+ d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
349
+ tz_name=os.environ.get("CALENDAR_TZ", "").strip()
350
+ try:
351
+ calendar_tz=ZoneInfo(tz_name) if tz_name else (datetime.datetime.now().astimezone().tzinfo or datetime.timezone.utc)
352
+ except (ZoneInfoNotFoundError, ValueError):
353
+ raise SystemExit(f"plane: invalid project timezone {tz_name!r}")
354
+ clock=os.environ.get("TICKET_PROVIDER_NOW", "").strip()
355
+ if clock:
356
+ try: now=datetime.datetime.fromisoformat(clock.replace("Z", "+00:00"))
357
+ except ValueError: raise SystemExit(f"plane: invalid TICKET_PROVIDER_NOW {clock!r}")
358
+ if now.tzinfo is None: raise SystemExit("plane: TICKET_PROVIDER_NOW must include a UTC offset")
359
+ else:
360
+ now=datetime.datetime.now(datetime.timezone.utc)
361
+ today=now.astimezone(calendar_tz).date()
362
+ def boundary(value):
363
+ try: return datetime.date.fromisoformat(str(value)[:10])
364
+ except (TypeError, ValueError): return None
365
+ def current(c):
366
+ start,end=boundary(c.get("start_date")),boundary(c.get("end_date"))
367
+ return bool(start and end and start <= today <= end)
368
+ active=sorted((c for c in rows if current(c)), key=lambda c:(str(c.get("start_date") or ""),str(c.get("id") or "")), reverse=True)
369
+ m=active[0] if active else {}
370
+ print(json.dumps({"id":m.get("id", ""),"name":m.get("name", ""),"state":"active" if m else "inactive"}))'
371
+ }
372
+
373
+ # Map a normalized state -> exactly one concrete Plane state id. A configured
374
+ # name must exist in the expected group; it never falls back to another state in
375
+ # the same group. An unnamed group is safe only when the group has one member.
142
376
  resolve_state_id() {
143
377
  want="$1"
144
378
  [ -n "$PROJ" ] || die "ticket_provider.project not set"
@@ -146,54 +380,72 @@ resolve_state_id() {
146
380
  completed) grp=completed; nm="$SM_DONE" ;;
147
381
  cancelled) grp=cancelled; nm="$SM_CANCELLED" ;;
148
382
  in_review) grp=started; nm="$SM_IN_REVIEW" ;;
149
- started) grp=started; nm="" ;;
150
- unstarted) grp=unstarted; nm="" ;;
151
- backlog) grp=backlog; nm="" ;;
383
+ started) grp=started; nm="$SM_STARTED" ;;
384
+ unstarted) grp=unstarted; nm="$SM_UNSTARTED" ;;
385
+ backlog) grp=backlog; nm="$SM_BACKLOG" ;;
152
386
  *) die "invalid normalized state: $want" ;;
153
387
  esac
154
- api GET "projects/$PROJ/states/" | GRP="$grp" NM="$nm" python3 -c 'import sys,json,os
388
+ api_all "projects/$PROJ/states/" | GRP="$grp" NM="$nm" WANT="$want" python3 -c 'import sys,json,os
155
389
  d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
156
- grp=os.environ["GRP"]; nm=os.environ.get("NM","")
157
- named=[s for s in rows if nm and (s.get("name","").lower()==nm.lower())]
390
+ grp=os.environ["GRP"]; nm=os.environ.get("NM","").strip(); want=os.environ["WANT"]
158
391
  grouped=[s for s in rows if s.get("group")==grp]
159
- pick=(named or grouped or [{}])[0]
160
- print(pick.get("id",""))'
392
+ if nm:
393
+ candidates=[s for s in grouped if str(s.get("name","")).strip().casefold()==nm.casefold()]
394
+ if len(candidates) != 1:
395
+ raise SystemExit(f"plane: exact Plane state {nm!r} for normalized {want!r} was not resolved uniquely in group {grp!r}")
396
+ else:
397
+ candidates=grouped
398
+ if len(candidates) != 1:
399
+ names=", ".join(str(s.get("name") or "") for s in candidates) or "none"
400
+ raise SystemExit(f"plane: normalized state {want!r} is ambiguous in group {grp!r} ({names}); configure ticket_provider.{want}")
401
+ state_id=str(candidates[0].get("id") or "")
402
+ if not state_id:
403
+ raise SystemExit(f"plane: resolved Plane state for normalized {want!r} has no id")
404
+ print(state_id)'
161
405
  }
162
406
 
163
- # All Plane ops require the API key; fail fast and clean before any pipe.
164
- need_key
407
+ # All Plane ops except the explicit-workspace read below require the bound
408
+ # workspace API key; fail fast and clean before any pipe.
409
+ case "$OP" in describe_board) ;; *) need_key ;; esac
165
410
 
166
411
  case "$OP" in
167
412
  resolve)
168
413
  [ -n "$WS" ] || die "workspace not set (.project.json ticket_provider.workspace or PLANE_WORKSPACE)"
169
414
  [ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
170
- printf '{"provider":"plane","board_id":"%s","board_url":"%s/%s/projects/%s/issues/"}\n' \
171
- "$PROJ" "$BASE" "$WS" "$PROJ"
415
+ PROJECT_DETAIL="$(api GET "projects/$PROJ/")"
416
+ LIVE_IDENTIFIER="$(printf '%s' "$PROJECT_DETAIL" | python3 -c 'import sys,json
417
+ try: print(str(json.load(sys.stdin).get("identifier") or ""))
418
+ except Exception: print("")')"
419
+ [ -n "$LIVE_IDENTIFIER" ] || die "live Plane project omitted its authoritative identifier"
420
+ printf '{"provider":"plane","board_id":"%s","board_url":"%s/%s/projects/%s/issues/","identifier":"%s"}\n' \
421
+ "$PROJ" "$BASE" "$WS" "$PROJ" "$LIVE_IDENTIFIER"
172
422
  ;;
173
423
 
174
424
  active_milestone)
175
425
  [ -n "$PROJ" ] || die "project not set"
176
- api GET "projects/$PROJ/cycles/" | python3 -c 'import sys,json,datetime
177
- d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
178
- now=datetime.datetime.now(datetime.timezone.utc)
179
- def cur(c):
180
- s,e=c.get("start_date"),c.get("end_date")
181
- return bool(s and e and s<=now.date().isoformat()<=e)
182
- active=[c for c in rows if cur(c)] or rows
183
- m=active[0] if active else {}
184
- print(json.dumps({"id":m.get("id",""),"name":m.get("name",""),"state":"active" if active else ""}))'
426
+ api_all "projects/$PROJ/cycles/" | current_cycle
185
427
  ;;
186
428
 
187
429
  list_issues)
188
430
  [ -n "$PROJ" ] || die "project not set"
189
431
  # Plane v1 returns issue.state as a bare UUID, so join against the states map.
190
- STATES="$(api GET "projects/$PROJ/states/")"
191
- ISSUES="$(api GET "projects/$PROJ/issues/")"
192
- printf '%s\n%s\n' "$STATES" "$ISSUES" | BASE="$BASE" WS="$WS" PROJ="$PROJ" python3 -c 'import sys,json,os
193
- parts=sys.stdin.read().split("\n",1)
432
+ STATES="$(api_all "projects/$PROJ/states/")"
433
+ ISSUES="$(api_all "projects/$PROJ/issues/")"
434
+ MILESTONE="$(api_all "projects/$PROJ/cycles/" | current_cycle)"
435
+ MILESTONE_ID="$(printf '%s' "$MILESTONE" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id", ""))')"
436
+ MILESTONE_ISSUES='[]'
437
+ if [ -n "$MILESTONE_ID" ]; then
438
+ MILESTONE_ISSUES="$(api_all "projects/$PROJ/cycles/$MILESTONE_ID/cycle-issues/")"
439
+ fi
440
+ printf '%s\0%s\0%s\0%s' "$STATES" "$ISSUES" "$MILESTONE" "$MILESTONE_ISSUES" \
441
+ | BASE="$BASE" WS="$WS" PROJ="$PROJ" python3 -c 'import sys,json,os
442
+ parts=sys.stdin.buffer.read().split(b"\0",3)
194
443
  srows=json.loads(parts[0] or "{}"); srows=srows if isinstance(srows,list) else srows.get("results", []) if isinstance(srows,dict) else []
195
444
  smap={s.get("id"):(s.get("name",""),s.get("group","")) for s in srows}
196
445
  d=json.loads(parts[1] or "{}"); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
446
+ milestone=json.loads(parts[2] or "{}"); milestone_id=str(milestone.get("id") or ""); milestone_name=str(milestone.get("name") or "")
447
+ md=json.loads(parts[3] or "{}"); mrows=md if isinstance(md,list) else md.get("results", []) if isinstance(md,dict) else []
448
+ member_ids={str(issue.get("id") or "") for issue in mrows}
197
449
  base,ws,proj=os.environ["BASE"],os.environ["WS"],os.environ["PROJ"]
198
450
  out=[]
199
451
  for n in rows:
@@ -202,16 +454,19 @@ for n in rows:
202
454
  out.append({"id":iid,"key":n.get("sequence_id",iid),
203
455
  "title":n.get("name",""),"state":name,"state_type":group,
204
456
  "updated_at":n.get("updated_at",""),"assignee":"",
457
+ "active_milestone_id":milestone_id,
458
+ "active_milestone_name":milestone_name,
459
+ "in_active_milestone":bool(milestone_id and str(iid) in member_ids),
205
460
  "url":base+"/"+ws+"/projects/"+proj+"/issues/"+str(iid)})
206
461
  print(json.dumps(out))'
207
462
  ;;
208
463
 
209
464
  get_issue)
210
465
  ID="${1:?usage: get_issue <id>}"
211
- STATES="$(api GET "projects/$PROJ/states/")"
466
+ STATES="$(api_all "projects/$PROJ/states/")"
212
467
  ISSUE="$(api GET "projects/$PROJ/issues/$ID/")"
213
- COMM="$(api GET "projects/$PROJ/issues/$ID/comments/" 2>/dev/null || echo '[]')"
214
- ATTACH="$(api GET "projects/$PROJ/issues/$ID/issue-attachments/" 2>/dev/null || echo '[]')"
468
+ COMM="$(api_all "projects/$PROJ/issues/$ID/comments/" 2>/dev/null || echo '[]')"
469
+ ATTACH="$(api_all "projects/$PROJ/issues/$ID/issue-attachments/" 2>/dev/null || echo '[]')"
215
470
  printf '%s\n%s\n%s\n%s\n' "$STATES" "$ISSUE" "$COMM" "$ATTACH" | python3 -c 'import sys,json,re
216
471
  parts=sys.stdin.read().split("\n",3)
217
472
  srows=json.loads(parts[0] or "{}"); srows=srows if isinstance(srows,list) else srows.get("results", []) if isinstance(srows,dict) else []
@@ -241,21 +496,113 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("sequence_id",""),"title":i.ge
241
496
  ID="${1:?usage: comment <id> <body>}"; BODY="${2:?}"
242
497
  api POST "projects/$PROJ/issues/$ID/comments/" \
243
498
  "$(python3 -c 'import json,sys; print(json.dumps({"comment_html":"<p>"+sys.argv[1]+"</p>"}))' "$BODY")" \
244
- | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))'
499
+ | EXPECTED_ID="$ID" python3 -c 'import sys,json,os
500
+ comment=json.load(sys.stdin)
501
+ if not isinstance(comment,dict):
502
+ raise SystemExit("plane: comment response was not an object")
503
+ comment_id=comment.get("id")
504
+ if not isinstance(comment_id,str) or not comment_id.strip():
505
+ raise SystemExit("plane: comment response omitted its comment id")
506
+ for key in ("issue", "issue_id"):
507
+ if key not in comment:
508
+ continue
509
+ linked=comment[key]
510
+ linked_id=linked.get("id") if isinstance(linked,dict) else linked
511
+ if not isinstance(linked_id,str) or linked_id.strip()!=os.environ["EXPECTED_ID"]:
512
+ raise SystemExit("plane: comment response identified a different issue")
513
+ print(comment_id.strip())'
245
514
  ;;
246
515
 
247
516
  transition)
248
517
  ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
249
518
  SID="$(resolve_state_id "$TARGET")"
250
- [ -n "$SID" ] || die "no Plane state for normalized '$TARGET'"
251
- api PATCH "projects/$PROJ/issues/$ID/" "$(printf '{"state":"%s"}' "$SID")" \
252
- | python3 -c 'import sys,json; d=json.load(sys.stdin); print("ok "+str(d.get("sequence_id","")) )'
519
+ # Without a version or precondition guard, a repeated PATCH can stomp a
520
+ # concurrent actor, so exactly one PATCH mutation attempt is ever sent.
521
+ # The live read-back after that attempt is the only success proof: an exact
522
+ # same-issue match on the intended state id yields `ok`. A 2xx PATCH whose
523
+ # read-back disagrees is reported as concurrent divergence; an ambiguous
524
+ # (transport-level or non-2xx) PATCH may still be confirmed landed by an
525
+ # exact read-back. Any other read-back outcome fails without a second PATCH.
526
+ # Subshell: a transport-level die inside api must not kill this op.
527
+ if ( api PATCH "projects/$PROJ/issues/$ID/" "$(printf '{"state":"%s"}' "$SID")" ) >/dev/null; then
528
+ PATCH_2XX=1
529
+ else
530
+ PATCH_2XX=0
531
+ fi
532
+ if ! READBACK="$(api GET "projects/$PROJ/issues/$ID/")"; then
533
+ die "transition read-back failed after the single PATCH attempt; refusing to repeat the PATCH"
534
+ fi
535
+ if ! READBACK_RESULT="$(printf '%s' "$READBACK" | EXPECTED_ID="$ID" EXPECTED_SID="$SID" python3 -c 'import sys,json,os
536
+ try:
537
+ d=json.load(sys.stdin)
538
+ except (TypeError, ValueError) as exc:
539
+ raise SystemExit(f"plane: transition read-back was not valid JSON: {exc}")
540
+ if not isinstance(d,dict) or str(d.get("id") or "") != os.environ["EXPECTED_ID"]:
541
+ raise SystemExit("plane: transition read-back did not identify the requested issue")
542
+ state=d.get("state", "")
543
+ actual=str(state.get("id") or "") if isinstance(state,dict) else str(state or "")
544
+ if not actual:
545
+ raise SystemExit("plane: transition read-back omitted its state id")
546
+ if actual == os.environ["EXPECTED_SID"]:
547
+ sequence=str(d.get("sequence_id") or "")
548
+ if not sequence:
549
+ raise SystemExit("plane: transition read-back omitted its sequence id")
550
+ print("match\t"+sequence)
551
+ else:
552
+ print("mismatch\t"+actual)')"; then
553
+ die "transition read-back could not be verified after the single PATCH attempt; refusing to repeat the PATCH"
554
+ fi
555
+ case "$READBACK_RESULT" in
556
+ match*)
557
+ printf 'ok %s\n' "${READBACK_RESULT#* }"
558
+ ;;
559
+ mismatch*)
560
+ ACTUAL="${READBACK_RESULT#* }"
561
+ if [ "$PATCH_2XX" -eq 1 ]; then
562
+ die "transition read-back state $ACTUAL did not match intended state id $SID after a 2xx PATCH response; concurrent divergence suspected; refusing to claim the transition completed"
563
+ fi
564
+ die "transition PATCH outcome was ambiguous and the read-back state $ACTUAL did not match intended state id $SID; refusing to claim the transition completed"
565
+ ;;
566
+ *)
567
+ die "transition read-back returned an invalid verification outcome; refusing to repeat the PATCH"
568
+ ;;
569
+ esac
570
+ ;;
571
+
572
+ describe_board)
573
+ # Read-only board lookup against an EXPLICIT workspace argument, so the
574
+ # .project.json / role.yaml / env workspace precedence can never silently
575
+ # query the wrong workspace. Emits Plane's own identifier, never a guess.
576
+ DWS="${1:?usage: describe_board <workspace> <board_id>}"
577
+ DBID="${2:?usage: describe_board <workspace> <board_id>}"
578
+ DKEYVAR="$(workspace_key "$DWS")"
579
+ DKEY="$(printenv "$DKEYVAR" 2>/dev/null || true)"
580
+ if [ -z "$DKEY" ] && [ -f "$FLEET_ENV" ]; then
581
+ DKEY="$(dotenv_value "$FLEET_ENV" "$DKEYVAR")"
582
+ fi
583
+ [ -n "$DKEY" ] || DKEY="${PLANE_API_KEY:-}"
584
+ DKEY="$(resolve_secret_value "$DKEY")"
585
+ [ -n "$DKEY" ] || die "no Plane API key for workspace '$DWS' (looked for $DKEYVAR)"
586
+ DETAIL="$(curl -fsS "$BASE/api/v1/workspaces/$DWS/projects/$DBID/" \
587
+ -H "X-API-Key: $DKEY" -H "User-Agent: curl/8.0")" \
588
+ || die "describe_board failed for $DWS/$DBID"
589
+ printf '%s' "$DETAIL" | WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
590
+ d = json.load(sys.stdin)
591
+ ident = str(d.get("identifier") or "")
592
+ if not ident:
593
+ raise SystemExit("plane: live Plane project omitted its authoritative identifier")
594
+ print(json.dumps({
595
+ "board_id": str(d.get("id") or os.environ["BID"]),
596
+ "identifier": ident,
597
+ "workspace": os.environ["WS"],
598
+ "name": str(d.get("name") or ""),
599
+ }))'
253
600
  ;;
254
601
 
255
602
  create_board)
256
603
  NAME="${1:?usage: create_board <name> <ident> <desc>}"; IDENT="${2:-}"; DESC="${3:-}"
257
604
  [ -n "$WS" ] || die "workspace not set"
258
- EXIST="$(api GET "projects/?per_page=200" | NAME="$NAME" IDENT="$IDENT" python3 -c 'import sys,json,os
605
+ EXIST="$(api_all "projects/?per_page=200" | NAME="$NAME" IDENT="$IDENT" python3 -c 'import sys,json,os
259
606
  d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
260
607
  name=os.environ["NAME"].strip().lower(); ident=os.environ["IDENT"].upper()
261
608
  # Repo NAME is the primary key — links an existing repo board even if its
@@ -265,13 +612,23 @@ pid=next((p["id"] for p in rows if str(p.get("name","")).strip().lower()==name),
265
612
  if not pid and ident:
266
613
  pid=next((p["id"] for p in rows if (p.get("identifier") or "").upper()==ident), "")
267
614
  print(pid)')"
615
+ LIVE_IDENTIFIER=""
268
616
  if [ -n "$EXIST" ]; then PID="$EXIST"; else
269
- PID="$(api POST "projects/" \
270
- "$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"identifier":sys.argv[2],"description":sys.argv[3]}))' "$NAME" "$IDENT" "$DESC")" \
271
- | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))')"
617
+ CREATED="$(api POST "projects/" \
618
+ "$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"identifier":sys.argv[2],"description":sys.argv[3]}))' "$NAME" "$IDENT" "$DESC")")"
619
+ PID="$(printf '%s' "$CREATED" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))')"
620
+ LIVE_IDENTIFIER="$(printf '%s' "$CREATED" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("identifier","") or "")')"
272
621
  fi
273
622
  [ -n "$PID" ] || die "create_board failed"
274
- printf '{"board_id":"%s","board_url":"%s/%s/projects/%s/issues/"}\n' "$PID" "$BASE" "$WS" "$PID"
623
+ if [ -z "$LIVE_IDENTIFIER" ]; then
624
+ DETAIL="$(api GET "projects/$PID/")"
625
+ LIVE_IDENTIFIER="$(printf '%s' "$DETAIL" | python3 -c 'import sys,json
626
+ try: print(str(json.load(sys.stdin).get("identifier") or ""))
627
+ except Exception: print("")')"
628
+ fi
629
+ [ -n "$LIVE_IDENTIFIER" ] || die "live Plane project omitted its authoritative identifier"
630
+ printf '{"board_id":"%s","board_url":"%s/%s/projects/%s/issues/","identifier":"%s"}\n' \
631
+ "$PID" "$BASE" "$WS" "$PID" "$LIVE_IDENTIFIER"
275
632
  ;;
276
633
 
277
634
  create_issue)
@@ -286,7 +643,7 @@ print(pid)')"
286
643
  [ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
287
644
  IID=""; SEQ=""; CREATED=true
288
645
  if [ "$IF_ABSENT" = 1 ]; then
289
- HIT="$(api GET "projects/$PROJ/issues/?per_page=200" | TITLE="$TITLE" python3 -c 'import sys,json,os
646
+ HIT="$(api_all "projects/$PROJ/issues/?per_page=200" | TITLE="$TITLE" python3 -c 'import sys,json,os
290
647
  d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
291
648
  want=os.environ["TITLE"].strip().lower()
292
649
  m=next((i for i in rows if (i.get("name") or "").strip().lower()==want), None)