@delorenj/pjangler 1.2.18 → 1.2.21

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 (57) hide show
  1. package/dist/index.js +884 -295
  2. package/dist/mcp-server.js +892 -303
  3. package/package.json +8 -2
  4. package/templates/commonproject/AGENTS.md +3 -3
  5. package/templates/commonproject/README.md +11 -12
  6. package/templates/commonproject/copier.yml +11 -31
  7. package/templates/commonproject/template/.agents/hooks/README.md +13 -26
  8. package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
  9. package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
  10. package/templates/commonproject/template/.agents/local.example.json +2 -8
  11. package/templates/commonproject/template/.agents/skills.json +6 -0
  12. package/templates/commonproject/template/.project.json.jinja +9 -13
  13. package/templates/commonproject/template/mise.toml.jinja +9 -16
  14. package/templates/hermes-agent/README.md +9 -9
  15. package/templates/hermes-agent/config.example.toml +1 -66
  16. package/templates/hermes-agent/copier.yml +4 -3
  17. package/templates/hermes-agent/docs/architecture.md +9 -12
  18. package/templates/hermes-agent/docs/fleet-control-plane/README.md +1 -1
  19. package/templates/hermes-agent/docs/operations.md +6 -5
  20. package/templates/hermes-agent/docs/sentinel/README.md +9 -7
  21. package/templates/hermes-agent/docs/sentinel/architecture.md +1 -2
  22. package/templates/hermes-agent/docs/sentinel/development.md +12 -13
  23. package/templates/hermes-agent/docs/sentinel/providers.md +34 -15
  24. package/templates/hermes-agent/install-local.sh +15 -14
  25. package/templates/hermes-agent/runtime-scaffold/README.md +1 -1
  26. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +46 -14
  27. package/templates/hermes-agent/runtime-scaffold/memories/MEMORY.md +2 -2
  28. package/templates/hermes-agent/scripts/fleet-sync.sh +1 -64
  29. package/templates/hermes-agent/template/.gitignore.jinja +0 -2
  30. package/templates/hermes-agent/template/.runtime-scaffold/README.md +1 -1
  31. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +43 -9
  32. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +2 -2
  33. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -1
  34. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -9
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +3 -22
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -22
  37. package/templates/hermes-agent/template/.scripts/40-plane.sh +51 -0
  38. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +59 -21
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +2 -1
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +1 -10
  41. package/templates/hermes-agent/template/.scripts/_lib.sh +3 -61
  42. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -5
  43. package/templates/hermes-agent/template/.scripts/heartbeat.sh +33 -66
  44. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +5 -9
  45. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
  46. package/templates/hermes-agent/template/.scripts/providers/linear.sh +176 -0
  47. package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -29
  48. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +2 -2
  49. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +1 -33
  50. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +22 -27
  51. package/templates/hermes-agent/template/SOUL.md.jinja +49 -30
  52. package/templates/hermes-agent/template/role.yaml.jinja +35 -4
  53. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +138 -0
  54. package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
  55. package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
  56. package/templates/hermes-agent/docs/bloodbank-gateway.md +0 -57
  57. package/templates/hermes-agent/docs/fleet-control-plane/n8n-service-hub.md +0 -60
@@ -62,41 +62,6 @@ p.write_text(new)
62
62
  PYEOF
63
63
  }
64
64
 
65
- # Locate the repo-root .project.json for this role, if present.
66
- project_json_path() {
67
- local root
68
- root="$(project_repo_path 2>/dev/null || true)"
69
- [[ -n "$root" && -f "$root/.project.json" ]] && { printf '%s' "$root/.project.json"; return 0; }
70
- return 1
71
- }
72
-
73
- # project_json_get <dotted.key> — read a string/scalar from repo-root
74
- # .project.json. This is the deterministic SOT for project wiring.
75
- project_json_get() {
76
- local key="$1" path
77
- path="$(project_json_path 2>/dev/null || true)"
78
- [[ -f "$path" ]] || { printf ''; return 0; }
79
- python3 - "$path" "$key" <<'PYEOF'
80
- import json
81
- import pathlib
82
- import sys
83
-
84
- path, key = sys.argv[1:3]
85
- try:
86
- cur = json.loads(pathlib.Path(path).read_text())
87
- except Exception:
88
- print("", end="")
89
- raise SystemExit(0)
90
- for part in key.split("."):
91
- if isinstance(cur, dict) and part in cur:
92
- cur = cur[part]
93
- else:
94
- print("", end="")
95
- raise SystemExit(0)
96
- print(cur if isinstance(cur, (str, int, float, bool)) else "", end="")
97
- PYEOF
98
- }
99
-
100
65
  # ─── Distributable config (~/.config/hermes-agent-template/config.toml) ──────
101
66
  # Single source of truth for environment-specific defaults so this template can
102
67
  # be handed to someone else without editing any script. Ship config.example.toml
@@ -147,10 +112,8 @@ load_role_env() {
147
112
  BOT_HANDLE=$(yaml_get telegram.bot_username)
148
113
  PROFILE_NAME=$(yaml_get profile)
149
114
 
150
- # Plane workspace: repo-root .project.json wins; role.yaml is legacy fallback.
151
- PLANE_WORKSPACE=$(project_json_get ticket_provider.workspace)
152
- [[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(yaml_get ticket_provider.workspace)
153
- [[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(yaml_get plane.workspace)
115
+ # Plane workspace: empty in role.yaml -> resolve from config.toml.
116
+ PLANE_WORKSPACE=$(yaml_get plane.workspace)
154
117
  [[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(config_get plane.workspace "33god")
155
118
 
156
119
  # Runtime repo: role.yaml stores the bare repo name plus an optional owner.
@@ -166,27 +129,6 @@ load_role_env() {
166
129
  PLANE_WORKSPACE RUNTIME_REPO PROFILE_NAME
167
130
  }
168
131
 
169
- plane_workspace_key() {
170
- local workspace="${1:-${PLANE_WORKSPACE:-}}"
171
- workspace="$(printf '%s' "$workspace" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')"
172
- [[ -n "$workspace" ]] || workspace="DEFAULT"
173
- printf 'PLANE_%s_API_KEY' "$workspace"
174
- }
175
-
176
- resolve_plane_api_key() {
177
- if [[ -n "${PLANE_API_KEY:-}" ]]; then
178
- export PLANE_API_KEY
179
- return 0
180
- fi
181
- local key value
182
- key="$(plane_workspace_key "${1:-${PLANE_WORKSPACE:-}}")"
183
- value="${!key:-}"
184
- if [[ -n "$value" ]]; then
185
- PLANE_API_KEY="$value"
186
- export PLANE_API_KEY
187
- fi
188
- }
189
-
190
132
  # Skip a step if previously completed (idempotent reruns).
191
133
  already_done() {
192
134
  local marker="$ROLE_DIR/.scripts/.done-$1"
@@ -223,7 +165,7 @@ BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_d
223
165
 
224
166
  # Plane
225
167
  PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
226
- PLANE_API_KEY="${PLANE_API_KEY:-}"
168
+ PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
227
169
 
228
170
  export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO HERMES_OAUTH_FILE CODEX_HOME \
229
171
  RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
@@ -27,11 +27,6 @@ oauth_file = "~/.hermes/auth.json"
27
27
  codex_home = "~/.codex"
28
28
  # Canonical external skills dir mirrored into each agent profile.
29
29
  canonical_skills_dir = "/home/delorenj/.agents/skills"
30
- # PM-only external skill libraries surfaced alongside Hermes built-ins.
31
- pm_external_skill_dirs = [
32
- "~/code/skillex/skill-sets/global/.system",
33
- "~/code/skillex/packs/bmad/6.10.2",
34
- ]
35
30
  # Voxxy plugin + service defaults for Hermes PM agents.
36
31
  voxxy_plugin_dir = "~/code/voxxy/plugins/tts/voxxy"
37
32
  vox_url = "https://vox.delo.sh"
@@ -6,7 +6,7 @@
6
6
  # reconciliation pass when local state says no worker is active, the worker
7
7
  # heartbeat is stale, or the last full run is outside the cooldown window. The
8
8
  # full pass executes the role's sentinel.prompt.md, which reasons about tickets
9
- # through the ticket-provider adapter (Plane | Trello) — never a
9
+ # through the ticket-provider adapter (Linear | Plane | Trello) — never a
10
10
  # hardcoded backend. After the sentinel decision (skip OR full), it
11
11
  # opportunistically checkpoints the runtime submodule (commit+push) at most once
12
12
  # per HEARTBEAT_CHECKPOINT_MIN_INTERVAL_SECONDS, so memory/session state stays
@@ -19,18 +19,12 @@ PROMPT_FILE="$ROLE_DIR/.scripts/sentinel.prompt.md"
19
19
  STATE_FILE="$RUNTIME/continuous-ticket-sentinel-state.json"
20
20
  LOCK_FILE="$RUNTIME/continuous-ticket-sentinel.lock"
21
21
  ROLE_YAML="$ROLE_DIR/role.yaml"
22
- FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
23
22
  LOG_FILE="$RUNTIME/logs/heartbeat.log"
24
23
  CHECKPOINT_BIN="$ROLE_DIR/.scripts/checkpoint.sh"
25
24
  CHECKPOINT_STAMP="$RUNTIME/.last-checkpoint"
26
25
 
27
- if [[ -f "$FLEET_ENV" ]]; then
28
- # shellcheck disable=SC1090
29
- source "$FLEET_ENV"
30
- fi
31
-
32
26
  # Hermes binary: explicit env > ~/.config/hermes-agent/hermes-bin > PATH.
33
- HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
27
+ HERMES_BIN="${HERMES_BIN:-}"
34
28
  if [[ -z "$HERMES_BIN" ]]; then
35
29
  if [[ -r "$HOME/.config/hermes-agent/hermes-bin" ]]; then
36
30
  HERMES_BIN="$(cat "$HOME/.config/hermes-agent/hermes-bin")"
@@ -86,61 +80,13 @@ print(value.strip().strip('"').strip("'"))
86
80
  PYEOF
87
81
  }
88
82
 
89
- repo_root() {
90
- local dir="$ROLE_DIR"
91
- for _ in 1 2 3 4 5; do
92
- dir="$(dirname "$dir")"
93
- if [[ -d "$dir/.git" || -f "$dir/.git" ]]; then printf '%s\n' "$dir"; return 0; fi
94
- done
95
- return 1
96
- }
97
- REPO_ROOT="$(repo_root)"
98
- PROJECT_JSON="$REPO_ROOT/.project.json"
99
-
100
- project_json_value() {
101
- python3 - "$PROJECT_JSON" "$1" <<'PYEOF'
102
- import json
103
- import pathlib
104
- import sys
105
-
106
- path, key = sys.argv[1:3]
107
- try:
108
- cur = json.loads(pathlib.Path(path).read_text())
109
- except Exception:
110
- print("")
111
- raise SystemExit(0)
112
- for part in key.split("."):
113
- if isinstance(cur, dict) and part in cur:
114
- cur = cur[part]
115
- else:
116
- print("")
117
- raise SystemExit(0)
118
- print(cur if isinstance(cur, (str, int, float, bool)) else "")
119
- PYEOF
120
- }
121
-
122
- # Project-owned automation gate. Legacy role.yaml reconcile.enabled is honored
123
- # only for older agents that have not migrated their .project.json yet.
83
+ # True only when role.yaml has a reconcile: block with enabled: true. Block-aware
84
+ # so an unrelated `enabled:` leaf elsewhere in the file can't flip it on.
124
85
  reconcile_enabled() {
125
- python3 - "$PROJECT_JSON" "$ROLE_YAML" <<'PYEOF'
126
- import json
127
- import re
128
- import sys
86
+ python3 - "$ROLE_YAML" <<'PYEOF'
87
+ import re, sys
129
88
  from pathlib import Path
130
- project_path, role_path = map(Path, sys.argv[1:3])
131
- try:
132
- project = json.loads(project_path.read_text())
133
- except Exception:
134
- project = {}
135
- reconcile = ((project.get("automation") or {}).get("reconcile") or {}) if isinstance(project, dict) else {}
136
- if "enabled" in reconcile:
137
- value = reconcile.get("enabled")
138
- print("true" if str(value).lower() == "true" else "false")
139
- raise SystemExit(0)
140
- try:
141
- text = role_path.read_text()
142
- except Exception:
143
- text = ""
89
+ text = Path(sys.argv[1]).read_text()
144
90
  m = re.search(r'(?m)^reconcile:[ \t]*\n((?:[ \t]+\S.*\n?)*)', text)
145
91
  block = m.group(1) if m else ""
146
92
  me = re.search(r'(?m)^[ \t]+enabled:[ \t]*"?([A-Za-z]+)"?', block)
@@ -150,8 +96,17 @@ PYEOF
150
96
 
151
97
  AGENT_ID="$(yaml_value agent_id)"
152
98
  REPO_NAME="$(yaml_value repo)"
153
- PROVIDER="$(project_json_value ticket_provider.type)"
154
- [[ -n "$PROVIDER" ]] || PROVIDER="$(yaml_block_value ticket_provider name)"
99
+ PROVIDER="$(yaml_block_value ticket_provider name)"
100
+
101
+ repo_root() {
102
+ local dir="$ROLE_DIR"
103
+ for _ in 1 2 3 4 5; do
104
+ dir="$(dirname "$dir")"
105
+ if [[ -d "$dir/.git" || -f "$dir/.git" ]]; then printf '%s\n' "$dir"; return 0; fi
106
+ done
107
+ return 1
108
+ }
109
+ REPO_ROOT="$(repo_root)"
155
110
  cd "$REPO_ROOT"
156
111
  mkdir -p "$RUNTIME/logs"
157
112
 
@@ -177,10 +132,11 @@ else
177
132
  fi
178
133
 
179
134
  # Reconcile gate: the autonomous board-reconciliation pass runs only when
180
- # repo-root .project.json automation.reconcile.enabled is true. Default off →
181
- # the heartbeat just checkpoints (legacy checkpoint-timer behavior).
135
+ # role.yaml's reconcile.enabled is true. Default off → the heartbeat just
136
+ # checkpoints (behaves like the legacy hourly checkpoint timer). Flip
137
+ # reconcile.enabled to opt a repo into autonomous board reconciliation.
182
138
  if [[ "$(reconcile_enabled)" != "true" ]]; then
183
- printf '[heartbeat] reconcile disabled (automation.reconcile.enabled != true) — checkpoint-only tick\n'
139
+ printf '[heartbeat] reconcile disabled (reconcile.enabled != true) — checkpoint-only tick\n'
184
140
  maybe_checkpoint
185
141
  exit 0
186
142
  fi
@@ -273,6 +229,17 @@ state.update({"source":"hermes-continuous-ticket-sentinel","agent_id":agent_id,"
273
229
  tmp = path.with_suffix(path.suffix + ".tmp"); tmp.write_text(json.dumps(state, indent=2, sort_keys=True)+"\n"); tmp.replace(path)
274
230
  PYEOF
275
231
 
232
+ # Coexistence WIP=1 lease (momo E2/S2.3): don't full-drive if the human-drivable
233
+ # Momo holds it — it's driving the same board. A crashed holder's lease expires
234
+ # (ttl) so the board is never wedged. Release on any exit.
235
+ WIP_LOCK="$RUNTIME/wip-driver.lock"
236
+ if ! python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" acquire "$WIP_LOCK" "hermes:$AGENT_ID" --ttl 3600 >/dev/null 2>&1; then
237
+ printf '[heartbeat] WIP lease held by Momo — skipping full reconcile pass this tick\n'
238
+ maybe_checkpoint
239
+ exit 0
240
+ fi
241
+ trap 'python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" release "$WIP_LOCK" "hermes:$AGENT_ID" >/dev/null 2>&1 || true' EXIT
242
+
276
243
  prompt="$(<"$PROMPT_FILE")"
277
244
  set +e
278
245
  env HERMES_HOME="$RUNTIME" "$HERMES_BIN" chat -Q --source cron --max-turns 90 -q "$prompt"
@@ -1,10 +1,10 @@
1
1
  # shellcheck shell=bash
2
2
  # Ticket-provider adapter dispatcher — the single seam between the heartbeat
3
- # sentinel engine and a concrete ticket system (Plane | Trello).
3
+ # sentinel engine and a concrete ticket system (Linear | Plane | Trello).
4
4
  #
5
5
  # The engine NEVER calls a provider directly. It calls `tp <op> [args...]`,
6
- # which dispatches to providers/<provider>.sh. Repo-root .project.json owns the
7
- # provider/board binding; role.yaml is only a legacy provider-name fallback.
6
+ # which dispatches to providers/<provider>.sh. Swapping providers is a one-line
7
+ # config change in role.yaml (ticket_provider.name) no engine edits.
8
8
  #
9
9
  # Contract (operations every provider must implement):
10
10
  # resolve -> JSON {provider, board_id, board_url}
@@ -19,7 +19,7 @@
19
19
  # create_board <name> <id> <d> -> JSON {board_id, board_url}
20
20
  #
21
21
  # Each provider reads its credentials from the environment (see providers/*.sh
22
- # headers) and the board binding from repo-root .project.json.
22
+ # headers) and the board binding from role.yaml under `ticket_provider:`.
23
23
 
24
24
  # Resolve the provider name: explicit env wins, then repo-root .project.json
25
25
  # (the SOT), then role.yaml (self-parsed so this works even when _lib.sh /
@@ -64,7 +64,7 @@ PY
64
64
  )"
65
65
  [ -n "$name" ] && { printf '%s\n' "$name"; return 0; }
66
66
  fi
67
- printf 'plane\n'
67
+ printf 'linear\n'
68
68
  }
69
69
 
70
70
  # Directory holding provider implementations (sibling of this lib).
@@ -81,10 +81,6 @@ tp() {
81
81
 
82
82
  local name impl
83
83
  name="$(tp_provider_name)"
84
- case "$name" in
85
- plane|trello) ;;
86
- *) echo "tp: unsupported ticket provider '$name' (expected plane|trello)" >&2; return 2 ;;
87
- esac
88
84
  impl="$(tp_providers_dir)/${name}.sh"
89
85
 
90
86
  if [ ! -f "$impl" ]; then
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """momo-wip-lock — shared WIP=1 driver lease so interactive Momo and the Hermes
3
+ sentinel never double-drive one board (momo E2/S2.3, the coexistence gate).
4
+
5
+ Both drivers acquire the SAME advisory lease (a JSON file, conventionally
6
+ <runtime>/wip-driver.lock) before a board-driving pass. The lease PERSISTS across
7
+ processes — it is deliberately NOT a held flock, because a Momo pass spans many
8
+ separate tool-call/bash invocations over minutes. flock is used only to serialize
9
+ the check-and-set so two acquirers can't race. A lease is respected while its
10
+ heartbeat is fresh (now - heartbeat_at < ttl); a stale lease (holder died without
11
+ releasing) can be stolen after it expires.
12
+
13
+ Owners are free strings by convention: "momo" (interactive) or
14
+ "hermes:<agent_id>" (the sentinel). WIP=1 is per BOARD == per runtime, so there
15
+ is one lease file per runtime.
16
+
17
+ Protocol:
18
+ * Driver start: acquire <lock> <me> -> exit 0 → drive; exit 1 → HELD, back off.
19
+ * While driving: refresh <lock> <me> periodically (< ttl) so the lease stays fresh.
20
+ * Driver end: release <lock> <me>.
21
+ * A holder that crashed leaves a lease that expires after `ttl`; the next driver
22
+ acquires it normally (or `--steal` to take an explicitly stale one immediately).
23
+
24
+ Commands (exit 0 = you hold it; 1 = someone else holds it fresh; 2 = usage/error):
25
+ acquire <lockfile> <owner> [--ttl S] [--steal]
26
+ refresh <lockfile> <owner>
27
+ release <lockfile> <owner>
28
+ status <lockfile> # always exit 0
29
+ """
30
+ from __future__ import annotations
31
+ import argparse, fcntl, json, os, socket, sys, tempfile, time
32
+
33
+
34
+ def _read(path: str):
35
+ try:
36
+ with open(path) as f:
37
+ return json.load(f)
38
+ except Exception:
39
+ return None
40
+
41
+
42
+ def _fresh(lease: dict | None, now: float) -> bool:
43
+ return bool(lease) and (now - lease.get("heartbeat_at", 0)) < lease.get("ttl", 300)
44
+
45
+
46
+ def _write_atomic(path: str, data: dict) -> None:
47
+ d = os.path.dirname(os.path.abspath(path)) or "."
48
+ fd, tmp = tempfile.mkstemp(dir=d)
49
+ with os.fdopen(fd, "w") as f:
50
+ json.dump(data, f)
51
+ os.replace(tmp, path)
52
+
53
+
54
+ def _guard(lockfile: str):
55
+ """Sibling .flock file held only for the read-modify-write critical section."""
56
+ g = open(lockfile + ".flock", "a")
57
+ fcntl.flock(g, fcntl.LOCK_EX)
58
+ return g
59
+
60
+
61
+ def acquire(lockfile: str, owner: str, ttl: int, steal: bool) -> int:
62
+ now = time.time(); g = _guard(lockfile)
63
+ try:
64
+ cur = _read(lockfile)
65
+ if cur and cur.get("owner") != owner and _fresh(cur, now) and not steal:
66
+ print(f"HELD by {cur['owner']} (fresh, {int(now - cur['heartbeat_at'])}s ago) — back off")
67
+ return 1
68
+ started = cur["started_at"] if (cur and cur.get("owner") == owner and "started_at" in cur) else now
69
+ _write_atomic(lockfile, {
70
+ "owner": owner, "pid": os.getpid(), "host": socket.gethostname(),
71
+ "started_at": started, "heartbeat_at": now, "ttl": ttl,
72
+ })
73
+ print(f"ACQUIRED by {owner}" + (" (stole stale lease)" if (cur and cur.get('owner') != owner) else ""))
74
+ return 0
75
+ finally:
76
+ fcntl.flock(g, fcntl.LOCK_UN); g.close()
77
+
78
+
79
+ def refresh(lockfile: str, owner: str) -> int:
80
+ now = time.time(); g = _guard(lockfile)
81
+ try:
82
+ cur = _read(lockfile)
83
+ if not cur or cur.get("owner") != owner:
84
+ print(f"NOT OWNER (held by {cur.get('owner') if cur else 'nobody'}) — cannot refresh")
85
+ return 1
86
+ cur["heartbeat_at"] = now
87
+ _write_atomic(lockfile, cur); print("REFRESHED"); return 0
88
+ finally:
89
+ fcntl.flock(g, fcntl.LOCK_UN); g.close()
90
+
91
+
92
+ def release(lockfile: str, owner: str) -> int:
93
+ g = _guard(lockfile)
94
+ try:
95
+ cur = _read(lockfile)
96
+ if cur and cur.get("owner") != owner:
97
+ print(f"NOT OWNER (held by {cur['owner']}) — not releasing"); return 1
98
+ try:
99
+ os.remove(lockfile)
100
+ except FileNotFoundError:
101
+ pass
102
+ print("RELEASED"); return 0
103
+ finally:
104
+ fcntl.flock(g, fcntl.LOCK_UN); g.close()
105
+
106
+
107
+ def status(lockfile: str) -> int:
108
+ now = time.time(); cur = _read(lockfile)
109
+ if not cur:
110
+ print("FREE (no lease)"); return 0
111
+ state = "fresh" if _fresh(cur, now) else "STALE"
112
+ print(f"{cur['owner']} — {state} (heartbeat {int(now - cur.get('heartbeat_at', 0))}s ago, "
113
+ f"ttl {cur.get('ttl')}s, pid {cur.get('pid')}@{cur.get('host')})")
114
+ return 0
115
+
116
+
117
+ def main() -> int:
118
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
119
+ ap.add_argument("cmd", choices=["acquire", "refresh", "release", "status"])
120
+ ap.add_argument("lockfile")
121
+ ap.add_argument("owner", nargs="?")
122
+ ap.add_argument("--ttl", type=int, default=300, help="freshness window in seconds (default 300)")
123
+ ap.add_argument("--steal", action="store_true", help="take an explicitly stale lease immediately")
124
+ a = ap.parse_args()
125
+ if a.cmd in ("acquire", "refresh", "release") and not a.owner:
126
+ print("owner required", file=sys.stderr); return 2
127
+ if a.cmd == "acquire":
128
+ return acquire(a.lockfile, a.owner, a.ttl, a.steal)
129
+ if a.cmd == "refresh":
130
+ return refresh(a.lockfile, a.owner)
131
+ if a.cmd == "release":
132
+ return release(a.lockfile, a.owner)
133
+ return status(a.lockfile)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env sh
2
+ # Linear ticket-provider adapter (reference implementation).
3
+ #
4
+ # Credentials: LINEAR_API_KEY
5
+ # Board binding (role.yaml `ticket_provider:`):
6
+ # name: linear
7
+ # team: <TEAM_KEY> e.g. DEL
8
+ # project: "<Project name>" optional; scopes milestone/issue queries
9
+ # state_map: { in_review: "In Review", completed: "Done" } optional overrides
10
+ #
11
+ # Implements the contract in lib/ticket-provider.sh. All Linear access goes
12
+ # through GraphQL so the same envelope works in unattended runs.
13
+ set -eu
14
+
15
+ OP="${1:-}"; shift 2>/dev/null || true
16
+ ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
17
+ ROLE_YAML="$ROLE_DIR/role.yaml"
18
+
19
+ die() { echo "linear: $*" >&2; exit 1; }
20
+ need_key() { [ -n "${LINEAR_API_KEY:-}" ] || die "LINEAR_API_KEY is not set"; }
21
+
22
+ # tp_cfg KEY — read ticket_provider.<KEY> from role.yaml (best-effort, flat).
23
+ tp_cfg() {
24
+ [ -f "$ROLE_YAML" ] || return 0
25
+ python3 - "$ROLE_YAML" "$1" <<'PY'
26
+ import sys, re, pathlib
27
+ text = pathlib.Path(sys.argv[1]).read_text()
28
+ m = re.search(r'(?ms)^ticket_provider:\s*$(.*?)(?=^\S)', text + "\n\x00")
29
+ block = m.group(1) if m else ""
30
+ key = sys.argv[2]
31
+ mm = re.search(rf'(?m)^\s*{re.escape(key)}:\s*"?([^"\n]*)"?\s*$', block)
32
+ print(mm.group(1).strip() if mm else "")
33
+ PY
34
+ }
35
+
36
+ # pj_cfg KEY — read ticket_provider.<KEY> from the repo-root .project.json (the
37
+ # SOT), walking up from the role dir. Preferred over role.yaml.
38
+ pj_cfg() {
39
+ python3 - "$ROLE_DIR" "$1" <<'PY'
40
+ import sys, json, pathlib
41
+ start = pathlib.Path(sys.argv[1]).resolve(); key = sys.argv[2]
42
+ for parent in [start, *start.parents]:
43
+ f = parent / ".project.json"
44
+ if f.is_file():
45
+ try: tp = (json.loads(f.read_text()).get("ticket_provider") or {})
46
+ except Exception: tp = {}
47
+ print(tp.get(key, "") if isinstance(tp, dict) else ""); break
48
+ else:
49
+ print("")
50
+ PY
51
+ }
52
+
53
+ # gql QUERY [VARS_JSON] — POST a GraphQL request, print data JSON, fail on errors.
54
+ gql() {
55
+ need_key
56
+ _vars="${2:-}"; [ -n "$_vars" ] || _vars='{}'
57
+ python3 - "$1" "$_vars" <<'PY'
58
+ import json, os, sys, urllib.request, urllib.error
59
+ q, variables = sys.argv[1], json.loads(sys.argv[2])
60
+ req = urllib.request.Request(
61
+ "https://api.linear.app/graphql",
62
+ data=json.dumps({"query": q, "variables": variables}).encode(),
63
+ headers={"Authorization": os.environ["LINEAR_API_KEY"],
64
+ "Content-Type": "application/json"},
65
+ method="POST")
66
+ try:
67
+ body = json.loads(urllib.request.urlopen(req, timeout=30).read())
68
+ except urllib.error.HTTPError as e:
69
+ body = json.loads(e.read() or "{}")
70
+ except urllib.error.URLError as e:
71
+ print(f"linear request failed: {e}", file=sys.stderr); sys.exit(1)
72
+ if body.get("errors"):
73
+ print(json.dumps(body["errors"]), file=sys.stderr); sys.exit(1)
74
+ print(json.dumps(body.get("data") or {}))
75
+ PY
76
+ }
77
+
78
+ TEAM="$(pj_cfg team)"; [ -n "$TEAM" ] || TEAM="$(tp_cfg team)"
79
+ PROJECT="$(pj_cfg project)"; [ -n "$PROJECT" ] || PROJECT="$(tp_cfg project)"
80
+ SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
81
+ SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
82
+
83
+ # All Linear ops require the API key; fail fast and clean before any pipe.
84
+ need_key
85
+
86
+ case "$OP" in
87
+ resolve)
88
+ [ -n "$TEAM" ] || die "ticket_provider.team (Linear team key) not set in role.yaml"
89
+ gql 'query($k:String!){ teams(filter:{key:{eq:$k}}){nodes{id key name}} }' \
90
+ "$(printf '{"k":"%s"}' "$TEAM")" \
91
+ | 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","")}))'
92
+ ;;
93
+
94
+ active_milestone)
95
+ # Linear project milestones; pick the first non-completed milestone in the project.
96
+ gql 'query($p:String){ projects(filter:{name:{eq:$p}}){nodes{ id name projectMilestones{nodes{id name targetDate}} state }} }' \
97
+ "$(printf '{"p":"%s"}' "$PROJECT")" \
98
+ | python3 -c 'import sys,json
99
+ d=json.load(sys.stdin); ps=d.get("projects",{}).get("nodes") or []
100
+ p=ps[0] if ps else {}
101
+ ms=(p.get("projectMilestones",{}) or {}).get("nodes") or []
102
+ m=ms[0] if ms else {"id":p.get("id",""),"name":p.get("name","")}
103
+ print(json.dumps({"id":m.get("id",""),"name":m.get("name",""),"state":p.get("state","")}))'
104
+ ;;
105
+
106
+ list_issues)
107
+ [ -n "$TEAM" ] || die "ticket_provider.team not set"
108
+ gql 'query($k:String!){ issues(first:100, filter:{team:{key:{eq:$k}}}){nodes{ id identifier title updatedAt url state{name type} assignee{name} }} }' \
109
+ "$(printf '{"k":"%s"}' "$TEAM")" \
110
+ | python3 -c 'import sys,json
111
+ d=json.load(sys.stdin); out=[]
112
+ for n in d.get("issues",{}).get("nodes") or []:
113
+ st=n.get("state") or {}
114
+ out.append({"id":n["id"],"key":n.get("identifier",""),"title":n.get("title",""),
115
+ "state":st.get("name",""),"state_type":st.get("type",""),
116
+ "updated_at":n.get("updatedAt",""),
117
+ "assignee":(n.get("assignee") or {}).get("name",""),"url":n.get("url","")})
118
+ print(json.dumps(out))'
119
+ ;;
120
+
121
+ get_issue)
122
+ ID="${1:?usage: get_issue <id>}"
123
+ gql 'query($id:String!){ issue(id:$id){ id identifier title description state{name type} comments{nodes{id body user{name}}} } }' \
124
+ "$(printf '{"id":"%s"}' "$ID")" \
125
+ | python3 -c 'import sys,json
126
+ d=json.load(sys.stdin); i=d.get("issue") or {}
127
+ st=i.get("state") or {}
128
+ cs=[{"id":c["id"],"body":c.get("body",""),"author":(c.get("user") or {}).get("name","")} for c in (i.get("comments",{}) or {}).get("nodes") or []]
129
+ print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get("title",""),
130
+ "description":i.get("description",""),"acceptance":i.get("description",""),
131
+ "state":st.get("name",""),"state_type":st.get("type",""),"comments":cs}))'
132
+ ;;
133
+
134
+ comment)
135
+ ID="${1:?usage: comment <id> <body>}"; BODY="${2:?usage: comment <id> <body>}"
136
+ gql 'mutation($id:String!,$b:String!){ commentCreate(input:{issueId:$id,body:$b}){ comment{id} success } }' \
137
+ "$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"b":sys.argv[2]}))' "$ID" "$BODY")" \
138
+ | python3 -c 'import sys,json; d=json.load(sys.stdin); print((d.get("commentCreate",{}).get("comment") or {}).get("id",""))'
139
+ ;;
140
+
141
+ transition)
142
+ ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
143
+ # Map normalized -> a concrete Linear state name, then resolve its id on the team.
144
+ case "$TARGET" in
145
+ completed) WANT_TYPE=completed; WANT_NAME="$SM_DONE" ;;
146
+ in_review) WANT_TYPE=started; WANT_NAME="$SM_IN_REVIEW" ;;
147
+ started) WANT_TYPE=started; WANT_NAME="" ;;
148
+ unstarted) WANT_TYPE=unstarted; WANT_NAME="" ;;
149
+ backlog) WANT_TYPE=backlog; WANT_NAME="" ;;
150
+ *) die "invalid normalized state: $TARGET" ;;
151
+ esac
152
+ STATE_ID="$(gql 'query($id:String!){ issue(id:$id){ team{ states{nodes{id name type}} } } }' \
153
+ "$(printf '{"id":"%s"}' "$ID")" \
154
+ | WANT_TYPE="$WANT_TYPE" WANT_NAME="$WANT_NAME" python3 -c 'import sys,json,os
155
+ d=json.load(sys.stdin)
156
+ states=((d.get("issue") or {}).get("team") or {}).get("states",{}).get("nodes") or []
157
+ want_t=os.environ["WANT_TYPE"]; want_n=os.environ.get("WANT_NAME","")
158
+ named=[s for s in states if want_n and s["name"].lower()==want_n.lower()]
159
+ typed=[s for s in states if s.get("type")==want_t]
160
+ pick=(named or typed or [{}])[0]
161
+ print(pick.get("id",""))')"
162
+ [ -n "$STATE_ID" ] || die "no Linear state for normalized '$TARGET'"
163
+ gql 'mutation($id:String!,$s:String!){ issueUpdate(id:$id,input:{stateId:$s}){ success issue{identifier state{name}} } }' \
164
+ "$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"s":sys.argv[2]}))' "$ID" "$STATE_ID")" \
165
+ | python3 -c 'import sys,json; u=json.load(sys.stdin).get("issueUpdate",{});
166
+ print(("ok " + (u.get("issue") or {}).get("identifier","")) if u.get("success") else "FAILED"); sys.exit(0 if u.get("success") else 1)'
167
+ ;;
168
+
169
+ create_board)
170
+ # Linear teams/projects are created by humans; the adapter resolves, not creates.
171
+ echo "linear: create_board is a no-op (Linear team/project created via Linear UI); using resolve" >&2
172
+ exec sh "$0" resolve
173
+ ;;
174
+
175
+ *) die "unknown op: $OP" ;;
176
+ esac