@delorenj/pjangler 1.4.5 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +41 -520
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +5747 -13483
  10. package/dist/mcp-server.js +6853 -14059
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -513
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. package/templates/hermes-agent/template/role.yaml.jinja +0 -112
@@ -1,671 +0,0 @@
1
- #!/usr/bin/env sh
2
- # Plane ticket-provider adapter.
3
- #
4
- # Credentials: PLANE_API_KEY or PLANE_<WORKSPACE>_API_KEY (raw at runtime or
5
- # an op:// reference resolved immediately before use)
6
- # Endpoint: PLANE_BASE (default https://plane.delo.sh)
7
- # Board binding (repo-root .project.json `ticket_provider:`):
8
- # workspace: <workspace-slug> (or env PLANE_WORKSPACE)
9
- # board_id: <project-uuid> (set by create_board / 42-ticket-provider)
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.
22
- #
23
- # Plane model: project = board, cycle = milestone, state.group in
24
- # backlog|unstarted|started|completed|cancelled.
25
- #
26
- # NOTE: REST paths follow Plane's v1 public API. Verify against a live board on
27
- # first use; state/cycle naming varies per workspace.
28
- set -eu
29
-
30
- OP="${1:-}"; shift 2>/dev/null || true
31
- ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
32
- ROLE_YAML="$ROLE_DIR/role.yaml"
33
- BASE="${PLANE_BASE:-https://plane.delo.sh}"
34
-
35
- FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
36
-
37
- die() { echo "plane: $*" >&2; exit 1; }
38
- need_key() { [ -n "${PLANE_API_KEY:-}" ] || die "PLANE_API_KEY is not set"; }
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
-
48
- workspace_key() {
49
- key="$(printf '%s' "${1:-default}" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')"
50
- [ -n "$key" ] || key="DEFAULT"
51
- printf 'PLANE_%s_API_KEY' "$key"
52
- }
53
-
54
- # dotenv_value FILE KEY — read one exact dotenv assignment as inert data.
55
- # Never source the shared fleet file: it may contain unrelated command
56
- # substitutions or credential helpers that this provider must not execute.
57
- dotenv_value() {
58
- python3 - "$1" "$2" <<'PY'
59
- import pathlib, sys
60
-
61
- path = pathlib.Path(sys.argv[1])
62
- key = sys.argv[2]
63
- value = ""
64
- for raw in path.read_text(encoding="utf-8").splitlines():
65
- line = raw.strip()
66
- if not line or line.startswith("#"):
67
- continue
68
- if line.startswith("export "):
69
- line = line[7:].lstrip()
70
- name, sep, candidate = line.partition("=")
71
- if sep and name.strip() == key:
72
- candidate = candidate.strip()
73
- if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in "'\"":
74
- candidate = candidate[1:-1]
75
- value = candidate
76
- break
77
- print(value, end="")
78
- PY
79
- }
80
-
81
- # Resolve an approved secret reference only after selecting the exact provider
82
- # key. The shared dotenv stays inert and the resolved value exists only in this
83
- # provider process.
84
- resolve_secret_value() {
85
- value="${1:-}"
86
- case "$value" in
87
- op://*)
88
- command -v op >/dev/null 2>&1 || die "1Password CLI is required for the configured Plane credential"
89
- op read "$value" || die "failed to resolve the configured Plane credential"
90
- ;;
91
- *) printf '%s' "$value" ;;
92
- esac
93
- }
94
-
95
- tp_cfg() {
96
- [ -f "$ROLE_YAML" ] || return 0
97
- python3 - "$ROLE_YAML" "$1" <<'PY'
98
- import sys, re, pathlib
99
- text = pathlib.Path(sys.argv[1]).read_text()
100
- m = re.search(r'(?ms)^ticket_provider:\s*$(.*?)(?=^\S)', text + "\n\x00")
101
- block = m.group(1) if m else ""
102
- mm = re.search(rf'(?m)^\s*{re.escape(sys.argv[2])}:\s*"?([^"\n]*)"?\s*$', block)
103
- print(mm.group(1).strip() if mm else "")
104
- PY
105
- }
106
-
107
- # pj_cfg KEY — read ticket_provider.<KEY> from the repo-root .project.json (the
108
- # SOT), walking up from the role dir. This is preferred over role.yaml so all of
109
- # a repo's agents resolve to the same board.
110
- pj_cfg() {
111
- python3 - "$ROLE_DIR" "$1" <<'PY'
112
- import sys, json, pathlib
113
- start = pathlib.Path(sys.argv[1]).resolve(); key = sys.argv[2]
114
- for parent in [start, *start.parents]:
115
- f = parent / ".project.json"
116
- if f.is_file():
117
- try: tp = (json.loads(f.read_text()).get("ticket_provider") or {})
118
- except Exception: tp = {}
119
- print(tp.get(key, "") if isinstance(tp, dict) else ""); break
120
- else:
121
- print("")
122
- PY
123
- }
124
-
125
- # Board binding: .project.json (SOT) first, then legacy role.yaml, then env.
126
- WS="$(pj_cfg workspace)"; [ -n "$WS" ] || WS="$(tp_cfg workspace)"; WS="${WS:-${PLANE_WORKSPACE:-}}"
127
- PROJ="$(pj_cfg board_id)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg project)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg board_id)"
128
- SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
129
- SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
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:-}}}"
136
- API="$BASE/api/v1/workspaces/$WS"
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
-
143
- if [ -z "${PLANE_API_KEY:-}" ]; then
144
- KEY="$(workspace_key "$WS")"
145
- PLANE_API_KEY="$(printenv "$KEY" 2>/dev/null || true)"
146
- if [ -z "${PLANE_API_KEY:-}" ] && [ -f "$FLEET_ENV" ]; then
147
- PLANE_API_KEY="$(dotenv_value "$FLEET_ENV" "$KEY")"
148
- fi
149
- fi
150
- PLANE_API_KEY="$(resolve_secret_value "${PLANE_API_KEY:-}")"
151
- export PLANE_API_KEY
152
-
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.
158
- api() {
159
- need_key
160
- method="$1"; path="$2"; body="${3:-}"
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
256
- }
257
-
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.
376
- resolve_state_id() {
377
- want="$1"
378
- [ -n "$PROJ" ] || die "ticket_provider.project not set"
379
- case "$want" in
380
- completed) grp=completed; nm="$SM_DONE" ;;
381
- cancelled) grp=cancelled; nm="$SM_CANCELLED" ;;
382
- in_review) grp=started; nm="$SM_IN_REVIEW" ;;
383
- started) grp=started; nm="$SM_STARTED" ;;
384
- unstarted) grp=unstarted; nm="$SM_UNSTARTED" ;;
385
- backlog) grp=backlog; nm="$SM_BACKLOG" ;;
386
- *) die "invalid normalized state: $want" ;;
387
- esac
388
- api_all "projects/$PROJ/states/" | GRP="$grp" NM="$nm" WANT="$want" python3 -c 'import sys,json,os
389
- d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
390
- grp=os.environ["GRP"]; nm=os.environ.get("NM","").strip(); want=os.environ["WANT"]
391
- grouped=[s for s in rows if s.get("group")==grp]
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)'
405
- }
406
-
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
410
-
411
- case "$OP" in
412
- resolve)
413
- [ -n "$WS" ] || die "workspace not set (.project.json ticket_provider.workspace or PLANE_WORKSPACE)"
414
- [ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
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"
422
- ;;
423
-
424
- active_milestone)
425
- [ -n "$PROJ" ] || die "project not set"
426
- api_all "projects/$PROJ/cycles/" | current_cycle
427
- ;;
428
-
429
- list_issues)
430
- [ -n "$PROJ" ] || die "project not set"
431
- # Plane v1 returns issue.state as a bare UUID, so join against the states map.
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)
443
- srows=json.loads(parts[0] or "{}"); srows=srows if isinstance(srows,list) else srows.get("results", []) if isinstance(srows,dict) else []
444
- smap={s.get("id"):(s.get("name",""),s.get("group","")) for s in srows}
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}
449
- base,ws,proj=os.environ["BASE"],os.environ["WS"],os.environ["PROJ"]
450
- out=[]
451
- for n in rows:
452
- iid=n.get("id","")
453
- name,group=smap.get(n.get("state",""),("",""))
454
- out.append({"id":iid,"key":n.get("sequence_id",iid),
455
- "title":n.get("name",""),"state":name,"state_type":group,
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),
460
- "url":base+"/"+ws+"/projects/"+proj+"/issues/"+str(iid)})
461
- print(json.dumps(out))'
462
- ;;
463
-
464
- get_issue)
465
- ID="${1:?usage: get_issue <id>}"
466
- STATES="$(api_all "projects/$PROJ/states/")"
467
- ISSUE="$(api GET "projects/$PROJ/issues/$ID/")"
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 '[]')"
470
- printf '%s\n%s\n%s\n%s\n' "$STATES" "$ISSUE" "$COMM" "$ATTACH" | python3 -c 'import sys,json,re
471
- parts=sys.stdin.read().split("\n",3)
472
- srows=json.loads(parts[0] or "{}"); srows=srows if isinstance(srows,list) else srows.get("results", []) if isinstance(srows,dict) else []
473
- smap={s.get("id"):(s.get("name",""),s.get("group","")) for s in srows}
474
- i=json.loads(parts[1] or "{}"); c=json.loads(parts[2] or "[]"); a=json.loads(parts[3] or "[]")
475
- rows=c if isinstance(c,list) else c.get("results", []) if isinstance(c,dict) else []
476
- arows=a if isinstance(a,list) else a.get("results", []) if isinstance(a,dict) else []
477
- def strip(h): return re.sub(r"<[^>]+>","",h or "").strip()
478
- name,group=smap.get(i.get("state",""),("",""))
479
- desc=strip(i.get("description_html",""))
480
- cs=[{"id":x.get("id",""),"body":strip(x.get("comment_html","")),"author":""} for x in rows]
481
- ats=[]
482
- for x in arows:
483
- attrs=x.get("attributes") or {}
484
- ats.append({"id":x.get("id",""),"name":attrs.get("name",x.get("name","")),
485
- "type":attrs.get("type",x.get("type","")),
486
- "size":attrs.get("size",x.get("size",0)),"asset":x.get("asset",""),
487
- "url":x.get("asset_url",x.get("url","")),
488
- "created_at":x.get("created_at",""),"updated_at":x.get("updated_at",""),
489
- "is_uploaded":x.get("is_uploaded",False)})
490
- print(json.dumps({"id":i.get("id",""),"key":i.get("sequence_id",""),"title":i.get("name",""),
491
- "description":desc,"acceptance":desc,
492
- "state":name,"state_type":group,"comments":cs,"attachments":ats}))'
493
- ;;
494
-
495
- comment)
496
- ID="${1:?usage: comment <id> <body>}"; BODY="${2:?}"
497
- api POST "projects/$PROJ/issues/$ID/comments/" \
498
- "$(python3 -c 'import json,sys; print(json.dumps({"comment_html":"<p>"+sys.argv[1]+"</p>"}))' "$BODY")" \
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())'
514
- ;;
515
-
516
- transition)
517
- ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
518
- SID="$(resolve_state_id "$TARGET")"
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
- }))'
600
- ;;
601
-
602
- create_board)
603
- NAME="${1:?usage: create_board <name> <ident> <desc>}"; IDENT="${2:-}"; DESC="${3:-}"
604
- [ -n "$WS" ] || die "workspace not set"
605
- EXIST="$(api_all "projects/?per_page=200" | NAME="$NAME" IDENT="$IDENT" python3 -c 'import sys,json,os
606
- d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
607
- name=os.environ["NAME"].strip().lower(); ident=os.environ["IDENT"].upper()
608
- # Repo NAME is the primary key — links an existing repo board even if its
609
- # identifier differs (Plane does not enforce unique names, so this prevents
610
- # duplicate boards). Fall back to identifier match; empty -> create new.
611
- pid=next((p["id"] for p in rows if str(p.get("name","")).strip().lower()==name), "")
612
- if not pid and ident:
613
- pid=next((p["id"] for p in rows if (p.get("identifier") or "").upper()==ident), "")
614
- print(pid)')"
615
- LIVE_IDENTIFIER=""
616
- if [ -n "$EXIST" ]; then PID="$EXIST"; else
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 "")')"
621
- fi
622
- [ -n "$PID" ] || die "create_board failed"
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"
632
- ;;
633
-
634
- create_issue)
635
- # File a new ticket on the bound board. Board/workspace come from resolved
636
- # config, never from an argument. Deliberately NOT idempotent by default:
637
- # two issues may legitimately share a title. Pass --if-absent to reuse an
638
- # issue whose title already matches exactly (case-insensitive) instead.
639
- IF_ABSENT=0
640
- case "${1:-}" in --if-absent) IF_ABSENT=1; shift ;; esac
641
- TITLE="${1:?usage: create_issue [--if-absent] <title> [description]}"; DESC="${2:-}"
642
- [ -n "$WS" ] || die "workspace not set (.project.json ticket_provider.workspace or PLANE_WORKSPACE)"
643
- [ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
644
- IID=""; SEQ=""; CREATED=true
645
- if [ "$IF_ABSENT" = 1 ]; then
646
- HIT="$(api_all "projects/$PROJ/issues/?per_page=200" | TITLE="$TITLE" python3 -c 'import sys,json,os
647
- d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
648
- want=os.environ["TITLE"].strip().lower()
649
- m=next((i for i in rows if (i.get("name") or "").strip().lower()==want), None)
650
- print((str(m.get("id","")) + " " + str(m.get("sequence_id","") or "")) if m else "")')"
651
- if [ -n "$HIT" ]; then IID="${HIT%% *}"; SEQ="${HIT#* }"; CREATED=false; fi
652
- fi
653
- if [ -z "$IID" ]; then
654
- NEW="$(api POST "projects/$PROJ/issues/" \
655
- "$(python3 -c 'import html,json,sys
656
- body={"name": sys.argv[1]}
657
- desc=sys.argv[2]
658
- if desc: body["description_html"]="<p>"+html.escape(desc)+"</p>"
659
- print(json.dumps(body))' "$TITLE" "$DESC")" \
660
- | python3 -c 'import sys,json
661
- d=json.load(sys.stdin)
662
- print(str(d.get("id","")) + " " + str(d.get("sequence_id","") or ""))')"
663
- IID="${NEW%% *}"; SEQ="${NEW#* }"
664
- fi
665
- [ -n "$IID" ] || die "create_issue failed"
666
- printf '{"issue_id":"%s","key":"%s","issue_url":"%s/%s/projects/%s/issues/%s","created":%s}\n' \
667
- "$IID" "$SEQ" "$BASE" "$WS" "$PROJ" "$IID" "$CREATED"
668
- ;;
669
-
670
- *) die "unknown op: $OP" ;;
671
- esac