@delorenj/pjangler 1.3.7 → 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 (60) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +20 -0
  3. package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
  4. package/dist/assets/project-notebook-skill/SKILL.md +64 -0
  5. package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
  6. package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
  7. package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
  8. package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
  9. package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
  10. package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
  11. package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
  12. package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
  13. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
  14. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
  15. package/dist/index.js +16982 -8532
  16. package/dist/index.js.map +7 -0
  17. package/dist/mcp-server.js +14107 -8089
  18. package/dist/mcp-server.js.map +7 -0
  19. package/dist/prompt.js +2 -1
  20. package/dist/prompt.js.map +7 -0
  21. package/package.json +9 -4
  22. package/templates/commonproject/copier.yml +14 -5
  23. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  24. package/templates/commonproject/template/.mise/scripts/provision-packs.py +60 -2
  25. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  26. package/templates/commonproject/template/mise.toml.jinja +12 -6
  27. package/templates/hermes-agent/copier.yml +16 -3
  28. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  29. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  30. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  31. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  32. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  33. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  34. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  35. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  36. package/templates/hermes-agent/template/.scripts/70-systemd.sh +140 -22
  37. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  38. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  39. package/templates/hermes-agent/template/.scripts/_lib.sh +827 -10
  40. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  41. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  42. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  43. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  44. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +29 -2
  45. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  46. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  47. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
  48. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  49. package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
  50. package/templates/hermes-agent/template/.scripts/providers/plane.sh +72 -10
  51. package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
  52. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
  53. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
  54. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  55. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  56. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  57. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  58. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  59. package/templates/hermes-agent/template/hermes.jinja +1 -1
  60. 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
@@ -389,6 +389,28 @@ def serialize_systemd_value(value: str) -> str:
389
389
  return '"' + "".join(escaped) + '"'
390
390
 
391
391
 
392
+ def serialize_systemd_scalar(value: str) -> str:
393
+ """Return a scalar directive value without introducing literal quotes."""
394
+ validate_unicode(value)
395
+ if "\0" in value:
396
+ raise FleetEnvParseError(1, "NUL is not allowed in a systemd value")
397
+ if "\r" in value or "\n" in value:
398
+ raise FleetEnvParseError(1, "newline control characters are not allowed in a systemd value")
399
+ escaped: list[str] = []
400
+ for char in value:
401
+ if char == "\\":
402
+ escaped.append(r"\\")
403
+ elif char == "%":
404
+ escaped.append("%%")
405
+ elif char == "\t":
406
+ escaped.append(r"\t")
407
+ elif ord(char) < 0x20 or ord(char) == 0x7F:
408
+ escaped.append(f"\\x{ord(char):02x}")
409
+ else:
410
+ escaped.append(char)
411
+ return "".join(escaped)
412
+
413
+
392
414
  def serialize_systemd_environment(name: str, value: str) -> str:
393
415
  if not NAME.fullmatch(name):
394
416
  raise FleetEnvParseError(1, "invalid systemd environment variable name")
@@ -685,12 +707,14 @@ def main() -> int:
685
707
  parse_mode = len(sys.argv) == 2
686
708
  upsert_mode = len(sys.argv) == 5 and sys.argv[1] == "--upsert"
687
709
  systemd_value_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-value"
710
+ systemd_scalar_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-scalar"
688
711
  systemd_exec_value_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-exec-value"
689
712
  systemd_environment_mode = len(sys.argv) == 4 and sys.argv[1] == "--systemd-environment"
690
- if not parse_mode and not upsert_mode and not systemd_value_mode and not systemd_exec_value_mode and not systemd_environment_mode:
713
+ if not parse_mode and not upsert_mode and not systemd_value_mode and not systemd_scalar_mode and not systemd_exec_value_mode and not systemd_environment_mode:
691
714
  print(
692
715
  "usage: parse-fleet-env.py PATH | --upsert PATH KEY VALUE | "
693
- "--systemd-value VALUE | --systemd-exec-value VALUE | --systemd-environment NAME VALUE",
716
+ "--systemd-value VALUE | --systemd-scalar VALUE | "
717
+ "--systemd-exec-value VALUE | --systemd-environment NAME VALUE",
694
718
  file=sys.stderr,
695
719
  )
696
720
  return 2
@@ -698,6 +722,9 @@ def main() -> int:
698
722
  if systemd_value_mode:
699
723
  print(serialize_systemd_value(sys.argv[2]), end="")
700
724
  return 0
725
+ if systemd_scalar_mode:
726
+ print(serialize_systemd_scalar(sys.argv[2]), end="")
727
+ return 0
701
728
  if systemd_exec_value_mode:
702
729
  print(serialize_systemd_exec_value(sys.argv[2]), end="")
703
730
  return 0
@@ -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.