@delorenj/pjangler 1.4.2 → 1.4.3
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 +3336 -1359
- package/dist/index.js.map +7 -0
- package/dist/mcp-server.js +1520 -838
- package/dist/mcp-server.js.map +7 -0
- package/dist/prompt.js +2 -1
- package/dist/prompt.js.map +7 -0
- package/package.json +8 -4
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- package/templates/hermes-agent/template/role.yaml.jinja +11 -4
|
@@ -35,9 +35,15 @@ oauth_file = "~/.hermes/auth.json"
|
|
|
35
35
|
codex_home = "~/.codex"
|
|
36
36
|
# Canonical external skills dir mirrored into each agent profile.
|
|
37
37
|
canonical_skills_dir = "/home/delorenj/.agents/skills"
|
|
38
|
-
#
|
|
39
|
-
|
|
38
|
+
# Optional Vox plugin + service defaults for Hermes PM agents.
|
|
39
|
+
vox_plugin_name = "vox"
|
|
40
|
+
vox_plugin_dir = "~/code/voxxy/plugins/tts/vox"
|
|
41
|
+
vox_voice = "carlin"
|
|
40
42
|
vox_url = "https://vox.delo.sh"
|
|
43
|
+
# Channel credentials are written directly to Password items in this vault;
|
|
44
|
+
# only op:// references are recorded in named-profile config.delta.yaml.
|
|
45
|
+
onepassword_vault = "DeLoSecrets"
|
|
46
|
+
onepassword_item_prefix = "hermes-agent"
|
|
41
47
|
# Canonical PM config.yaml the provisioner seeds each runtime's config.yaml from.
|
|
42
48
|
# This is the fleet-wide Hermes inference/skill config (model, toolsets, etc.)
|
|
43
49
|
# every agent runtime starts from. ~ is expanded.
|
|
@@ -45,6 +45,11 @@ if ! REPO_ROOT="$(git -C "$ROLE_DIR" rev-parse --show-toplevel 2>/dev/null)"; th
|
|
|
45
45
|
fi
|
|
46
46
|
export TERMINAL_CWD="$REPO_ROOT"
|
|
47
47
|
|
|
48
|
+
# Do not forward a stale/raw channel value inherited from the service manager,
|
|
49
|
+
# login shell, or a legacy runtime EnvironmentFile. Hermes hydrates the named
|
|
50
|
+
# profile's validated 1Password references after this launcher execs it.
|
|
51
|
+
unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
|
|
52
|
+
|
|
48
53
|
load_credential() {
|
|
49
54
|
local credential_id="$1" env_name="$2" credential_file value
|
|
50
55
|
[[ "$env_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
|
|
@@ -57,7 +62,6 @@ load_credential() {
|
|
|
57
62
|
unset value
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
load_credential telegram_bot_token TELEGRAM_BOT_TOKEN
|
|
61
65
|
MODEL_KEY_ENV="$(yaml_get model.key_env)"
|
|
62
66
|
if [[ -n "$MODEL_KEY_ENV" ]]; then
|
|
63
67
|
load_credential model_api_key "$MODEL_KEY_ENV"
|
|
@@ -145,9 +145,8 @@ else
|
|
|
145
145
|
fi
|
|
146
146
|
|
|
147
147
|
# Reconcile gate: the autonomous board-reconciliation pass runs only when
|
|
148
|
-
# role.yaml's reconcile.enabled is true.
|
|
149
|
-
#
|
|
150
|
-
# reconcile.enabled to opt a repo into autonomous board reconciliation.
|
|
148
|
+
# role.yaml's reconcile.enabled is true. Deployed PMs default on; setting it
|
|
149
|
+
# false explicitly opts into checkpoint-only operation.
|
|
151
150
|
if [[ "$(reconcile_enabled)" != "true" ]]; then
|
|
152
151
|
printf '[heartbeat] reconcile disabled (reconcile.enabled != true) — checkpoint-only tick\n'
|
|
153
152
|
maybe_checkpoint
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared serialization for one Hermes profile's generated configuration.
|
|
3
|
+
|
|
4
|
+
All code that can read and then rewrite ``config.delta.yaml`` or ``config.yaml``
|
|
5
|
+
must hold this lock from topology validation through commit or rollback. A
|
|
6
|
+
channel transaction already holds the fleet registry lock before it enters this
|
|
7
|
+
lock; config-only reconcilers never acquire the registry lock. The only valid
|
|
8
|
+
ordering is therefore::
|
|
9
|
+
|
|
10
|
+
fleet registry lock -> profile config lock
|
|
11
|
+
|
|
12
|
+
Never acquire the fleet registry lock while holding a profile config lock.
|
|
13
|
+
``flock`` ownership is kernel-scoped, so process exit releases the lock even
|
|
14
|
+
though the deliberately persistent lock file remains on disk.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import fcntl
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
import stat
|
|
23
|
+
import time
|
|
24
|
+
from types import TracebackType
|
|
25
|
+
from typing import Self
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
29
|
+
LOCK_TIMEOUT_ENV = "HERMES_PROFILE_CONFIG_LOCK_TIMEOUT_SECONDS"
|
|
30
|
+
TEST_BARRIER_ENV = "PJANGLER_TEST_PROFILE_CONFIG_BARRIER"
|
|
31
|
+
TEST_BARRIER_TIMEOUT_ENV = "PJANGLER_TEST_PROFILE_CONFIG_BARRIER_TIMEOUT_SECONDS"
|
|
32
|
+
TEST_BARRIER_LABEL_ENV = "PJANGLER_TEST_PROFILE_CONFIG_BARRIER_LABEL"
|
|
33
|
+
TEST_LOCK_ATTEMPT_ENV = "PJANGLER_TEST_PROFILE_CONFIG_LOCK_ATTEMPT"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProfileConfigLockError(RuntimeError):
|
|
37
|
+
"""The profile lock could not be acquired safely."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def lock_path(profile: pathlib.Path) -> pathlib.Path:
|
|
41
|
+
"""Return a lock path that does not require following the profile root."""
|
|
42
|
+
|
|
43
|
+
return profile.parent / f".{profile.name}.config.lock"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _timeout_seconds() -> float:
|
|
47
|
+
raw = os.environ.get(LOCK_TIMEOUT_ENV, str(DEFAULT_TIMEOUT_SECONDS))
|
|
48
|
+
try:
|
|
49
|
+
timeout = float(raw)
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
raise ProfileConfigLockError(
|
|
52
|
+
f"{LOCK_TIMEOUT_ENV} must be a non-negative number"
|
|
53
|
+
) from exc
|
|
54
|
+
if timeout < 0 or timeout != timeout:
|
|
55
|
+
raise ProfileConfigLockError(
|
|
56
|
+
f"{LOCK_TIMEOUT_ENV} must be a non-negative number"
|
|
57
|
+
)
|
|
58
|
+
return timeout
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProfileConfigLock:
|
|
62
|
+
"""Exclusive, bounded, symlink-safe lock for one profile config pair."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, profile: pathlib.Path | str, timeout: float | None = None):
|
|
65
|
+
self.profile = pathlib.Path(profile)
|
|
66
|
+
self.path = lock_path(self.profile)
|
|
67
|
+
self.timeout = _timeout_seconds() if timeout is None else timeout
|
|
68
|
+
self.fd: int | None = None
|
|
69
|
+
|
|
70
|
+
def acquire(self) -> Self:
|
|
71
|
+
parent = self.profile.parent
|
|
72
|
+
if parent.is_symlink() or not parent.is_dir():
|
|
73
|
+
raise ProfileConfigLockError(
|
|
74
|
+
f"profile parent must be a real directory before locking: {parent}"
|
|
75
|
+
)
|
|
76
|
+
if self.path.is_symlink():
|
|
77
|
+
raise ProfileConfigLockError(
|
|
78
|
+
f"refusing profile config lock symlink: {self.path}"
|
|
79
|
+
)
|
|
80
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
|
|
81
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
82
|
+
try:
|
|
83
|
+
fd = os.open(self.path, flags, 0o600)
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
raise ProfileConfigLockError(
|
|
86
|
+
f"cannot open profile config lock {self.path}: {exc.strerror or type(exc).__name__}"
|
|
87
|
+
) from exc
|
|
88
|
+
try:
|
|
89
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
90
|
+
raise ProfileConfigLockError(
|
|
91
|
+
f"profile config lock is not a regular file: {self.path}"
|
|
92
|
+
)
|
|
93
|
+
os.fchmod(fd, 0o600)
|
|
94
|
+
_notify_test_lock_attempt(self.path)
|
|
95
|
+
deadline = time.monotonic() + self.timeout
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
99
|
+
break
|
|
100
|
+
except BlockingIOError as exc:
|
|
101
|
+
if time.monotonic() >= deadline:
|
|
102
|
+
raise ProfileConfigLockError(
|
|
103
|
+
f"timed out waiting for profile config lock: {self.path}"
|
|
104
|
+
) from exc
|
|
105
|
+
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
106
|
+
except BaseException:
|
|
107
|
+
os.close(fd)
|
|
108
|
+
raise
|
|
109
|
+
self.fd = fd
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def release(self) -> None:
|
|
113
|
+
if self.fd is None:
|
|
114
|
+
return
|
|
115
|
+
fd, self.fd = self.fd, None
|
|
116
|
+
try:
|
|
117
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
118
|
+
finally:
|
|
119
|
+
os.close(fd)
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> Self:
|
|
122
|
+
return self.acquire()
|
|
123
|
+
|
|
124
|
+
def __exit__(
|
|
125
|
+
self,
|
|
126
|
+
exc_type: type[BaseException] | None,
|
|
127
|
+
exc: BaseException | None,
|
|
128
|
+
traceback: TracebackType | None,
|
|
129
|
+
) -> None:
|
|
130
|
+
self.release()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _test_path_from_env(name: str) -> pathlib.Path | None:
|
|
134
|
+
configured = os.environ.get(name)
|
|
135
|
+
if not configured:
|
|
136
|
+
return None
|
|
137
|
+
if "PYTEST_CURRENT_TEST" not in os.environ:
|
|
138
|
+
raise ProfileConfigLockError(
|
|
139
|
+
f"{name} is test-only and requires a pytest process"
|
|
140
|
+
)
|
|
141
|
+
path = pathlib.Path(configured)
|
|
142
|
+
if path.is_symlink() or path.parent.is_symlink() or not path.parent.is_dir():
|
|
143
|
+
raise ProfileConfigLockError(f"unsafe test profile-config path: {path}")
|
|
144
|
+
return path
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _notify_test_lock_attempt(lock: pathlib.Path) -> None:
|
|
148
|
+
target = _test_path_from_env(TEST_LOCK_ATTEMPT_ENV)
|
|
149
|
+
if target is not None:
|
|
150
|
+
target.write_text(str(lock) + "\n", encoding="utf-8")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_snapshot_barrier(label: str) -> None:
|
|
154
|
+
"""Pause a test process after its locked snapshot has been captured.
|
|
155
|
+
|
|
156
|
+
This hook is inert outside pytest. It exists so the regression suite can
|
|
157
|
+
deterministically exercise the stale-snapshot interleaving that motivated
|
|
158
|
+
this lock without widening the production interface.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
selected_label = os.environ.get(TEST_BARRIER_LABEL_ENV)
|
|
162
|
+
if selected_label and selected_label != label:
|
|
163
|
+
return
|
|
164
|
+
barrier = _test_path_from_env(TEST_BARRIER_ENV)
|
|
165
|
+
if barrier is None:
|
|
166
|
+
return
|
|
167
|
+
ready = pathlib.Path(f"{barrier}.ready")
|
|
168
|
+
resume = pathlib.Path(f"{barrier}.resume")
|
|
169
|
+
ready.write_text(label + "\n", encoding="utf-8")
|
|
170
|
+
try:
|
|
171
|
+
timeout = float(os.environ.get(TEST_BARRIER_TIMEOUT_ENV, "15"))
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise ProfileConfigLockError(
|
|
174
|
+
f"{TEST_BARRIER_TIMEOUT_ENV} must be a positive number"
|
|
175
|
+
) from exc
|
|
176
|
+
deadline = time.monotonic() + timeout
|
|
177
|
+
while not resume.is_file():
|
|
178
|
+
if time.monotonic() >= deadline:
|
|
179
|
+
raise ProfileConfigLockError(
|
|
180
|
+
f"timed out waiting at test profile-config barrier: {barrier}"
|
|
181
|
+
)
|
|
182
|
+
time.sleep(0.02)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Seed a named profile's override delta under the shared profile lock."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import errno
|
|
8
|
+
import importlib.util
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
SEED_CONTENT = (
|
|
15
|
+
"# Override-only delta for this Hermes profile.\n"
|
|
16
|
+
"# Merged over ~/.hermes/config.yaml to produce config.yaml (which is GENERATED).\n"
|
|
17
|
+
"# Empty == identical to the fleet base. Add ONLY what must differ.\n"
|
|
18
|
+
"{}\n"
|
|
19
|
+
).encode("utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_profile_lock_module():
|
|
23
|
+
source = pathlib.Path(__file__).parent / "profile-config-lock.py"
|
|
24
|
+
if source.is_symlink() or not source.is_file():
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
f"trusted profile config lock helper is unavailable: {source}"
|
|
27
|
+
)
|
|
28
|
+
spec = importlib.util.spec_from_file_location(
|
|
29
|
+
"pjangler_profile_config_lock", source
|
|
30
|
+
)
|
|
31
|
+
if spec is None or spec.loader is None:
|
|
32
|
+
raise RuntimeError(f"cannot load profile config lock helper: {source}")
|
|
33
|
+
module = importlib.util.module_from_spec(spec)
|
|
34
|
+
spec.loader.exec_module(module)
|
|
35
|
+
return module
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
PROFILE_LOCK = load_profile_lock_module()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fsync_parent(path: pathlib.Path) -> None:
|
|
42
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
43
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
44
|
+
try:
|
|
45
|
+
directory_fd = os.open(path.parent, flags)
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
if exc.errno in unsupported:
|
|
48
|
+
return
|
|
49
|
+
raise
|
|
50
|
+
try:
|
|
51
|
+
try:
|
|
52
|
+
os.fsync(directory_fd)
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
if exc.errno not in unsupported:
|
|
55
|
+
raise
|
|
56
|
+
finally:
|
|
57
|
+
os.close(directory_fd)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def seed(profile: pathlib.Path) -> str:
|
|
61
|
+
with PROFILE_LOCK.ProfileConfigLock(profile):
|
|
62
|
+
PROFILE_LOCK.test_snapshot_barrier("seed")
|
|
63
|
+
if profile.is_symlink() or not profile.is_dir():
|
|
64
|
+
raise RuntimeError(f"profile root must be a real directory: {profile}")
|
|
65
|
+
target = profile / "config.delta.yaml"
|
|
66
|
+
if target.is_symlink():
|
|
67
|
+
raise RuntimeError(f"refusing config delta symlink: {target}")
|
|
68
|
+
if target.exists():
|
|
69
|
+
if not target.is_file():
|
|
70
|
+
raise RuntimeError(f"config delta is not a regular file: {target}")
|
|
71
|
+
return "exists"
|
|
72
|
+
|
|
73
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
74
|
+
prefix=".config.delta.yaml.seed-", dir=profile
|
|
75
|
+
)
|
|
76
|
+
temporary = pathlib.Path(temporary_name)
|
|
77
|
+
try:
|
|
78
|
+
os.fchmod(descriptor, 0o600)
|
|
79
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
80
|
+
handle.write(SEED_CONTENT)
|
|
81
|
+
handle.flush()
|
|
82
|
+
os.fsync(handle.fileno())
|
|
83
|
+
os.replace(temporary, target)
|
|
84
|
+
os.chmod(target, stat.S_IRUSR | stat.S_IWUSR)
|
|
85
|
+
fsync_parent(target)
|
|
86
|
+
except BaseException:
|
|
87
|
+
try:
|
|
88
|
+
temporary.unlink()
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
pass
|
|
91
|
+
raise
|
|
92
|
+
return "seeded"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> None:
|
|
96
|
+
parser = argparse.ArgumentParser()
|
|
97
|
+
parser.add_argument("--profile", required=True)
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
try:
|
|
100
|
+
print(seed(pathlib.Path(args.profile)))
|
|
101
|
+
except PROFILE_LOCK.ProfileConfigLockError as exc:
|
|
102
|
+
raise SystemExit(f"profile config seed failed: {exc}") from exc
|
|
103
|
+
except (OSError, RuntimeError) as exc:
|
|
104
|
+
raise SystemExit(f"profile config seed failed: {exc}") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
# backlog|unstarted|started|in_review|completed|
|
|
20
20
|
# cancelled
|
|
21
21
|
# create_board <name> <id> <d> -> JSON {board_id, board_url}
|
|
22
|
+
# describe_board <ws> <board_id>
|
|
23
|
+
# -> JSON {board_id, identifier, workspace,
|
|
24
|
+
# name}
|
|
25
|
+
# Read-only lookup against an EXPLICIT
|
|
26
|
+
# workspace, so no ambient binding can send
|
|
27
|
+
# the query elsewhere. `identifier` is
|
|
28
|
+
# whatever the provider itself reports, and
|
|
29
|
+
# is empty when the provider mints none
|
|
30
|
+
# (Trello). Never a locally-computed guess.
|
|
22
31
|
# create_issue [--if-absent] <title> [desc]
|
|
23
32
|
# -> JSON {issue_id, key, issue_url, created}
|
|
24
33
|
# Files a new ticket on the bound board.
|