@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.
- package/dist/index.js +884 -295
- package/dist/mcp-server.js +892 -303
- package/package.json +8 -2
- package/templates/commonproject/AGENTS.md +3 -3
- package/templates/commonproject/README.md +11 -12
- package/templates/commonproject/copier.yml +11 -31
- package/templates/commonproject/template/.agents/hooks/README.md +13 -26
- package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
- package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
- package/templates/commonproject/template/.agents/local.example.json +2 -8
- package/templates/commonproject/template/.agents/skills.json +6 -0
- package/templates/commonproject/template/.project.json.jinja +9 -13
- package/templates/commonproject/template/mise.toml.jinja +9 -16
- package/templates/hermes-agent/README.md +9 -9
- package/templates/hermes-agent/config.example.toml +1 -66
- package/templates/hermes-agent/copier.yml +4 -3
- package/templates/hermes-agent/docs/architecture.md +9 -12
- package/templates/hermes-agent/docs/fleet-control-plane/README.md +1 -1
- package/templates/hermes-agent/docs/operations.md +6 -5
- package/templates/hermes-agent/docs/sentinel/README.md +9 -7
- package/templates/hermes-agent/docs/sentinel/architecture.md +1 -2
- package/templates/hermes-agent/docs/sentinel/development.md +12 -13
- package/templates/hermes-agent/docs/sentinel/providers.md +34 -15
- package/templates/hermes-agent/install-local.sh +15 -14
- package/templates/hermes-agent/runtime-scaffold/README.md +1 -1
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +46 -14
- package/templates/hermes-agent/runtime-scaffold/memories/MEMORY.md +2 -2
- package/templates/hermes-agent/scripts/fleet-sync.sh +1 -64
- package/templates/hermes-agent/template/.gitignore.jinja +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +1 -1
- package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +43 -9
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +2 -2
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -1
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -9
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +3 -22
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -22
- package/templates/hermes-agent/template/.scripts/40-plane.sh +51 -0
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +59 -21
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +2 -1
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +1 -10
- package/templates/hermes-agent/template/.scripts/_lib.sh +3 -61
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -5
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +33 -66
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +5 -9
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +176 -0
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -29
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +2 -2
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +1 -33
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +22 -27
- package/templates/hermes-agent/template/SOUL.md.jinja +49 -30
- package/templates/hermes-agent/template/role.yaml.jinja +35 -4
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +138 -0
- package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
- package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
- package/templates/hermes-agent/docs/bloodbank-gateway.md +0 -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:
|
|
151
|
-
PLANE_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
|
|
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
|
-
|
|
90
|
-
|
|
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 - "$
|
|
126
|
-
import
|
|
127
|
-
import re
|
|
128
|
-
import sys
|
|
86
|
+
python3 - "$ROLE_YAML" <<'PYEOF'
|
|
87
|
+
import re, sys
|
|
129
88
|
from pathlib import Path
|
|
130
|
-
|
|
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="$(
|
|
154
|
-
|
|
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
|
-
#
|
|
181
|
-
#
|
|
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 (
|
|
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.
|
|
7
|
-
#
|
|
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
|
|
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 '
|
|
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
|