@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.
Files changed (39) hide show
  1. package/dist/index.js +3336 -1359
  2. package/dist/index.js.map +7 -0
  3. package/dist/mcp-server.js +1520 -838
  4. package/dist/mcp-server.js.map +7 -0
  5. package/dist/prompt.js +2 -1
  6. package/dist/prompt.js.map +7 -0
  7. package/package.json +8 -4
  8. package/templates/hermes-agent/copier.yml +16 -3
  9. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  10. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  11. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  12. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  13. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  14. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  15. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  16. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  17. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  18. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  19. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  20. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  21. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  22. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  23. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  25. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  26. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
  27. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  28. package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
  29. package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
  30. package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
  32. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +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
- # Voxxy plugin + service defaults for Hermes PM agents.
39
- voxxy_plugin_dir = "~/code/voxxy/plugins/tts/voxxy"
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. Default off the heartbeat just
149
- # checkpoints (behaves like the legacy hourly checkpoint timer). Flip
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.