@delorenj/pjangler 1.2.21 → 1.2.25

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 (63) hide show
  1. package/dist/index.js +1852 -368
  2. package/dist/mcp-server.js +1782 -296
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +3 -0
  5. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  6. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  7. package/templates/commonproject/template/mise.toml.jinja +9 -2
  8. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  9. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  10. package/templates/hermes-agent/README.md +70 -29
  11. package/templates/hermes-agent/copier.yml +9 -6
  12. package/templates/hermes-agent/docs/architecture.md +75 -51
  13. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  14. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  15. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  16. package/templates/hermes-agent/docs/operations.md +179 -81
  17. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  18. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  19. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  20. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  21. package/templates/hermes-agent/docs/sentinel.md +1 -1
  22. package/templates/hermes-agent/install-local.sh +41 -4
  23. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  24. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  25. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  26. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  27. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  28. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  29. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  30. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  31. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  32. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  33. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  34. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  37. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  38. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  41. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  42. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  43. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  44. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  45. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  46. package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
  47. package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
  48. package/templates/hermes-agent/template/hermes.jinja +6 -3
  49. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  50. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  51. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  52. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  53. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  54. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  55. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  56. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  57. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  58. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  59. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  60. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  61. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  62. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  63. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+
11
+ ROOT = Path(__file__).parents[1]
12
+ FLEET_SYNC = ROOT / "scripts" / "fleet-sync.sh"
13
+
14
+
15
+ def _fixture(tmp_path: Path) -> tuple[dict[str, str], Path, Path, Path]:
16
+ home = tmp_path / "home"
17
+ role = tmp_path / "role"
18
+ runtime = role / "runtime"
19
+ runtime.mkdir(parents=True)
20
+ (runtime / "profile.yaml").write_text(
21
+ "config:\n inherit_from: default\n save_mode: delta\n", encoding="utf-8"
22
+ )
23
+ (role / "role.yaml").write_text("profile: demo-pm\n", encoding="utf-8")
24
+ registry = tmp_path / "agents-registry.yaml"
25
+ registry.write_text(
26
+ yaml.safe_dump(
27
+ {
28
+ "schema_version": 1,
29
+ "agents": {
30
+ "demo-pm": {
31
+ "role_dir": str(role),
32
+ "profile_name": "demo-pm",
33
+ "systemd": {
34
+ "gateway_unit": "hermes-demo-pm-gateway.service",
35
+ "consumer_unit": "hermes-demo-pm-consumer.service",
36
+ },
37
+ }
38
+ },
39
+ },
40
+ sort_keys=False,
41
+ ),
42
+ encoding="utf-8",
43
+ )
44
+ unit_dir = home / ".config" / "systemd" / "user"
45
+ unit_dir.mkdir(parents=True)
46
+ consumer = unit_dir / "hermes-demo-pm-consumer.service"
47
+ consumer.write_text("legacy\n", encoding="utf-8")
48
+ fake_bin = tmp_path / "bin"
49
+ fake_bin.mkdir()
50
+ retired = tmp_path / "retired"
51
+ systemctl = fake_bin / "systemctl"
52
+ systemctl.write_text(
53
+ """#!/usr/bin/env bash
54
+ case "$*" in
55
+ *"disable --now hermes-demo-pm-consumer.service"*)
56
+ [[ "$SYSTEMCTL_MODE" == "disable-fail" ]] && exit 1
57
+ touch "$SYSTEMCTL_RETIRED"; exit 0 ;;
58
+ *"is-active hermes-demo-pm-consumer.service"*)
59
+ [[ "$SYSTEMCTL_MODE" == "active-query-error" ]] && { echo "Failed to connect to bus" >&2; exit 1; }
60
+ if [[ -f "$SYSTEMCTL_RETIRED" ]]; then echo inactive; exit 3; else echo active; exit 0; fi ;;
61
+ *"is-enabled hermes-demo-pm-consumer.service"*)
62
+ [[ "$SYSTEMCTL_MODE" == "enabled-query-error" ]] && { echo "Failed to connect to bus" >&2; exit 1; }
63
+ if [[ -f "$SYSTEMCTL_RETIRED" ]]; then echo disabled; exit 1; else echo enabled; exit 0; fi ;;
64
+ *"daemon-reload"*) exit 0 ;;
65
+ esac
66
+ exit 1
67
+ """,
68
+ encoding="utf-8",
69
+ )
70
+ systemctl.chmod(0o755)
71
+ env = dict(os.environ)
72
+ env.update(
73
+ {
74
+ "HOME": str(home),
75
+ "PATH": f"{fake_bin}:{env['PATH']}",
76
+ "HERMES_FLEET_REGISTRY_FILE": str(registry),
77
+ "HERMES_FLEET_HOME": str(home / ".hermes"),
78
+ "SYSTEMCTL_RETIRED": str(retired),
79
+ "SYSTEMCTL_MODE": "success",
80
+ }
81
+ )
82
+ return env, registry, consumer, role
83
+
84
+
85
+ def test_fleet_audit_reports_and_apply_retires_legacy_consumer(tmp_path: Path) -> None:
86
+ env, registry, consumer, _ = _fixture(tmp_path)
87
+
88
+ audit = subprocess.run(
89
+ ["bash", str(FLEET_SYNC), "--agent", "demo-pm"],
90
+ env=env,
91
+ text=True,
92
+ capture_output=True,
93
+ check=False,
94
+ )
95
+ assert audit.returncode == 1
96
+ assert "legacy per-profile Bloodbank consumer remains" in audit.stdout
97
+
98
+ applied = subprocess.run(
99
+ ["bash", str(FLEET_SYNC), "--apply", "--no-restart", "--agent", "demo-pm"],
100
+ env=env,
101
+ text=True,
102
+ capture_output=True,
103
+ check=False,
104
+ )
105
+ assert applied.returncode == 0, applied.stdout + applied.stderr
106
+ assert not consumer.exists()
107
+ entry = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]["demo-pm"]
108
+ assert "consumer_unit" not in entry["systemd"]
109
+ assert stat.S_IMODE(registry.stat().st_mode) == 0o600
110
+
111
+
112
+ def test_fleet_apply_preserves_unit_and_metadata_when_disable_fails(tmp_path: Path) -> None:
113
+ env, registry, consumer, _ = _fixture(tmp_path)
114
+ env["SYSTEMCTL_MODE"] = "disable-fail"
115
+
116
+ result = subprocess.run(
117
+ ["bash", str(FLEET_SYNC), "--apply", "--no-restart", "--agent", "demo-pm"],
118
+ env=env,
119
+ text=True,
120
+ capture_output=True,
121
+ check=False,
122
+ )
123
+
124
+ assert result.returncode != 0
125
+ assert "disable failed; unit and metadata preserved" in result.stdout
126
+ assert consumer.read_text(encoding="utf-8") == "legacy\n"
127
+ entry = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]["demo-pm"]
128
+ assert entry["systemd"]["consumer_unit"] == "hermes-demo-pm-consumer.service"
129
+ assert not Path(env["SYSTEMCTL_RETIRED"]).exists()
130
+
131
+
132
+ def test_fleet_audit_and_apply_fail_closed_on_state_query_errors(tmp_path: Path) -> None:
133
+ for mode in ("active-query-error", "enabled-query-error"):
134
+ case_dir = tmp_path / mode
135
+ case_dir.mkdir()
136
+ env, registry, consumer, _ = _fixture(case_dir)
137
+ env["SYSTEMCTL_MODE"] = mode
138
+
139
+ for apply_args in ([], ["--apply", "--no-restart"]):
140
+ result = subprocess.run(
141
+ ["bash", str(FLEET_SYNC), *apply_args, "--agent", "demo-pm"],
142
+ env=env,
143
+ text=True,
144
+ capture_output=True,
145
+ check=False,
146
+ )
147
+ assert result.returncode != 0
148
+ assert "state query failed; unit and metadata preserved" in result.stdout
149
+ assert consumer.read_text(encoding="utf-8") == "legacy\n"
150
+ entry = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]["demo-pm"]
151
+ assert entry["systemd"]["consumer_unit"] == "hermes-demo-pm-consumer.service"
152
+ assert not Path(env["SYSTEMCTL_RETIRED"]).exists()
153
+
154
+
155
+ def test_pinned_fork_publication_replaces_upstream_clean_install_path() -> None:
156
+ installer = (ROOT / "install-local.sh").read_text(encoding="utf-8")
157
+ config = (ROOT / "template" / ".scripts" / "config.example.toml").read_text(
158
+ encoding="utf-8"
159
+ )
160
+ expected_sha = "113e1b182b6d72a7dd02a191f134a41668ceaf0e"
161
+
162
+ assert "raw.githubusercontent.com/NousResearch/hermes-agent" not in installer
163
+ assert 'HERMES_RUNTIME_GIT_URL="https://github.com/delorenj/hermes-agent.git"' in installer
164
+ assert 'HERMES_RUNTIME_GIT_REF="feature/PJAN-19-routing-publication"' in installer
165
+ assert f'HERMES_RUNTIME_GIT_SHA="{expected_sha}"' in installer
166
+ assert "merge-base --is-ancestor" in installer
167
+ assert f'hermes_git_sha = "{expected_sha}"' in config
168
+
169
+
170
+ def test_runtime_templatizer_pins_canonical_bmad_next31_pack() -> None:
171
+ templatizer = (ROOT / "scripts" / "hermes-runtime-templatize.py").read_text(
172
+ encoding="utf-8"
173
+ )
174
+
175
+ assert "/packs/bmad/6.10.1-next.31" in templatizer
176
+ assert "/packs/bmad/6.10.2" not in templatizer
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ ROOT = Path(__file__).parents[1]
10
+ LAUNCHER_TEMPLATE = ROOT / "template" / "hermes.jinja"
11
+
12
+
13
+ def test_launcher_drops_shared_identity_credentials_but_keeps_policy(tmp_path: Path) -> None:
14
+ home = tmp_path / "home"
15
+ role = tmp_path / "role"
16
+ runtime = role / "runtime"
17
+ runtime.mkdir(parents=True)
18
+ fleet = home / ".hermes" / "fleet.env"
19
+ fleet.parent.mkdir(parents=True)
20
+ fake_hermes = tmp_path / "fake-hermes"
21
+ fake_hermes.write_text(
22
+ """#!/usr/bin/env python3
23
+ import json
24
+ import os
25
+
26
+ keys = (
27
+ "TELEGRAM_BOT_TOKEN",
28
+ "SLACK_BOT_TOKEN",
29
+ "SLACK_APP_TOKEN",
30
+ "TELEGRAM_ALLOWED_USERS",
31
+ "SLACK_ALLOWED_USERS",
32
+ "HERMES_HOME",
33
+ )
34
+ print(json.dumps({key: os.environ.get(key) for key in keys}))
35
+ """,
36
+ encoding="utf-8",
37
+ )
38
+ fake_hermes.chmod(0o755)
39
+ fleet.write_text(
40
+ f"export HERMES_FLEET_BIN={fake_hermes}\n"
41
+ "export TELEGRAM_BOT_TOKEN=123456:shared-secret\n"
42
+ "export SLACK_BOT_TOKEN=xoxb-shared-secret\n"
43
+ "export SLACK_APP_TOKEN=xapp-shared-secret\n"
44
+ "export TELEGRAM_ALLOWED_USERS=111\n"
45
+ "export SLACK_ALLOWED_USERS=U111\n",
46
+ encoding="utf-8",
47
+ )
48
+ launcher = role / "hermes"
49
+ launcher.write_text(
50
+ LAUNCHER_TEMPLATE.read_text(encoding="utf-8").replace("{{ agent_id }}", "demo-pm"),
51
+ encoding="utf-8",
52
+ )
53
+ launcher.chmod(0o755)
54
+ env = {
55
+ key: value
56
+ for key, value in os.environ.items()
57
+ if key
58
+ not in {
59
+ "TELEGRAM_BOT_TOKEN",
60
+ "SLACK_BOT_TOKEN",
61
+ "SLACK_APP_TOKEN",
62
+ "TELEGRAM_ALLOWED_USERS",
63
+ "SLACK_ALLOWED_USERS",
64
+ "HERMES_BIN",
65
+ "HERMES_FLEET_BIN",
66
+ }
67
+ }
68
+ env.update({"HOME": str(home), "HERMES_FLEET_ENV": str(fleet)})
69
+
70
+ result = subprocess.run(
71
+ [str(launcher), "gateway", "run"],
72
+ env=env,
73
+ text=True,
74
+ capture_output=True,
75
+ check=False,
76
+ )
77
+
78
+ assert result.returncode == 0, result.stderr
79
+ observed = json.loads(result.stdout)
80
+ assert observed["TELEGRAM_BOT_TOKEN"] is None
81
+ assert observed["SLACK_BOT_TOKEN"] is None
82
+ assert observed["SLACK_APP_TOKEN"] is None
83
+ assert observed["TELEGRAM_ALLOWED_USERS"] == "111"
84
+ assert observed["SLACK_ALLOWED_USERS"] == "U111"
85
+ assert observed["HERMES_HOME"] == str(runtime)
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ ROOT = Path(__file__).parents[1]
10
+ RUNTIME_SCRIPT = ROOT / "template" / ".scripts" / "20-runtime-repo.sh"
11
+ LIB_SCRIPT = ROOT / "template" / ".scripts" / "_lib.sh"
12
+
13
+
14
+ def _fixture(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]:
15
+ project = tmp_path / "project"
16
+ role = project / "agents" / "hermes" / "pm"
17
+ scripts = role / ".scripts"
18
+ scaffold = role / ".runtime-scaffold"
19
+ scripts.mkdir(parents=True)
20
+ scaffold.mkdir()
21
+ shutil.copy2(RUNTIME_SCRIPT, scripts / RUNTIME_SCRIPT.name)
22
+ shutil.copy2(LIB_SCRIPT, scripts / LIB_SCRIPT.name)
23
+ (scaffold / "MEMORY.md").write_text("agent={{agent_id}}\n", encoding="utf-8")
24
+ (role / "SOUL.md").write_text("local soul\n", encoding="utf-8")
25
+ (role / ".gitignore").write_text("runtime/\n", encoding="utf-8")
26
+ (role / "role.yaml").write_text(
27
+ """repo: demo
28
+ role: pm
29
+ agent_id: demo-pm
30
+ display_name: "Demo PM"
31
+ profile: demo-pm
32
+ telegram:
33
+ bot_username: demo_pm_bot
34
+ plane:
35
+ workspace: test
36
+ runtime:
37
+ github_owner: legacy
38
+ github_repo: agent-hm-demo-pm
39
+ local_path: ./runtime
40
+ """,
41
+ encoding="utf-8",
42
+ )
43
+ subprocess.run(["git", "init", "--quiet"], cwd=project, check=True)
44
+
45
+ home = tmp_path / "home"
46
+ profiles = home / ".hermes" / "profiles"
47
+ profiles.mkdir(parents=True)
48
+ fake_bin = tmp_path / "bin"
49
+ fake_bin.mkdir()
50
+ hermes = fake_bin / "hermes"
51
+ hermes.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
52
+ hermes.chmod(0o755)
53
+ env = dict(os.environ)
54
+ env.update(
55
+ {
56
+ "HOME": str(home),
57
+ "HERMES_BIN": str(hermes),
58
+ "HERMES_FLEET_ENV": str(home / ".hermes" / "fleet.env"),
59
+ "RUNTIME_SCAFFOLD_DIR": str(scaffold),
60
+ "VOXXY_PLUGIN_DIR": str(tmp_path / "missing-voxxy"),
61
+ }
62
+ )
63
+ return project, role, env
64
+
65
+
66
+ def _run(role: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
67
+ return subprocess.run(
68
+ ["bash", str(role / ".scripts" / "20-runtime-repo.sh")],
69
+ env=env,
70
+ text=True,
71
+ capture_output=True,
72
+ check=False,
73
+ )
74
+
75
+
76
+ def test_provisioner_creates_ignored_local_runtime_without_git(tmp_path: Path) -> None:
77
+ project, role, env = _fixture(tmp_path)
78
+
79
+ result = _run(role, env)
80
+
81
+ assert result.returncode == 0, result.stdout + result.stderr
82
+ runtime = role / "runtime"
83
+ assert (runtime / "MEMORY.md").read_text(encoding="utf-8") == "agent=demo-pm\n"
84
+ assert (runtime / "SOUL.md").read_text(encoding="utf-8") == "local soul\n"
85
+ assert not (runtime / ".git").exists()
86
+ assert (Path(env["HOME"]) / ".hermes" / "profiles" / "demo-pm").resolve() == runtime
87
+ assert subprocess.run(
88
+ ["git", "check-ignore", "-q", "agents/hermes/pm/runtime/"],
89
+ cwd=project,
90
+ check=False,
91
+ ).returncode == 0
92
+
93
+
94
+ def test_provisioner_preserves_existing_runtime_and_refuses_stale_mapping(tmp_path: Path) -> None:
95
+ project, role, env = _fixture(tmp_path)
96
+ runtime = role / "runtime"
97
+ runtime.mkdir()
98
+ private_state = runtime / "private-state.txt"
99
+ private_state.write_text("preserve exactly\n", encoding="utf-8")
100
+ (project / ".gitmodules").write_text(
101
+ """[submodule "legacy-runtime"]
102
+ \tpath = agents/hermes/pm/runtime
103
+ \turl = git@github.com:example/legacy.git
104
+ """,
105
+ encoding="utf-8",
106
+ )
107
+
108
+ result = _run(role, env)
109
+
110
+ assert result.returncode != 0
111
+ assert "stale .gitmodules mapping" in result.stderr
112
+ assert private_state.read_text(encoding="utf-8") == "preserve exactly\n"
113
+ assert not (runtime / ".git").exists()
114
+
115
+
116
+ def test_active_provisioner_has_no_runtime_repo_or_submodule_mutation() -> None:
117
+ script = RUNTIME_SCRIPT.read_text(encoding="utf-8")
118
+
119
+ for forbidden in (
120
+ "gh repo create",
121
+ "gh repo view",
122
+ "git submodule add",
123
+ "git submodule update",
124
+ 'rm -rf "$RUNTIME_LOCAL"',
125
+ "git push",
126
+ "git init",
127
+ ):
128
+ assert forbidden not in script
129
+ assert "stale .gitmodules mapping" in script
130
+ assert 'cp -an "$TMP/." "$RUNTIME_LOCAL/"' in script
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+
8
+ ROOT = Path(__file__).parents[1]
9
+ ACTIVE_OPERATOR_SURFACES = (
10
+ ROOT / "README.md",
11
+ ROOT / "docs" / "operations.md",
12
+ ROOT / "docs" / "architecture.md",
13
+ ROOT / "docs" / "sentinel.md",
14
+ ROOT / "docs" / "sentinel" / "README.md",
15
+ ROOT / "docs" / "sentinel" / "architecture.md",
16
+ ROOT / "docs" / "sentinel" / "development.md",
17
+ ROOT / "template" / ".scripts" / "config.example.toml",
18
+ )
19
+
20
+
21
+ def test_active_operator_surfaces_describe_only_pure_local_runtime() -> None:
22
+ forbidden = {
23
+ "per-agent remote runtime name": r"agent-hm-",
24
+ "active runtime repository": r"(?:github\s+)?runtime repositor(?:y|ies)|runtime repo(?:s)?\b",
25
+ "runtime submodule": r"runtime.{0,60}submodule|submodule.{0,60}runtime",
26
+ "runtime LFS": r"git\s+lfs",
27
+ "runtime push": r"git\s+push|commit\s*\+\s*push",
28
+ "cascading profile deletion": r"hermes\s+profile\s+delete|cascad(?:e|es|ing)",
29
+ "blanket runtime deletion": r"rm\s+-rf[^\n]*(?:runtime|agents/hermes|\$RUNTIME|\$ROLE_DIR)",
30
+ "runtime submodule deletion": r"git\s+submodule\s+deinit|\.git/modules/agents/hermes|git\s+rm[^\n]*runtime",
31
+ "runtime find deletion": r"find[^\n]*(?:runtime|\$RUNTIME)[^\n]*-delete",
32
+ "runtime directory removal": r"rmdir[^\n]*(?:runtime|\$RUNTIME)",
33
+ "runtime deletion confirmation shell": r"read[^\n]*REMOVE LOCAL RUNTIME",
34
+ "step-20 GitHub creation claim": r"20-runtime-repo\.sh.{0,160}gh\s+repo\s+create",
35
+ }
36
+
37
+ for path in ACTIVE_OPERATOR_SURFACES:
38
+ content = path.read_text(encoding="utf-8")
39
+ for label, pattern in forbidden.items():
40
+ assert re.search(pattern, content, re.IGNORECASE | re.DOTALL) is None, (
41
+ f"{path.relative_to(ROOT)} contains obsolete {label} guidance"
42
+ )
43
+
44
+
45
+ def test_operations_make_recovery_and_retirement_boundaries_explicit() -> None:
46
+ operations = (ROOT / "docs" / "operations.md").read_text(encoding="utf-8")
47
+ normalized = " ".join(operations.split())
48
+
49
+ for required in (
50
+ "does **not** ship automatic backup",
51
+ "verified filesystem backup",
52
+ "Hindsight can restore only",
53
+ "secret manager can restore only",
54
+ "Retire an agent (preserves runtime by default)",
55
+ "Profile and service retirement always preserves the local runtime",
56
+ "This release intentionally provides no automated runtime purge",
57
+ "separately reviewed, path-safe tool",
58
+ "canonicalizes both the repository root and role path",
59
+ "verifies that the backup archive contains the expected runtime members",
60
+ ):
61
+ assert required in normalized
62
+
63
+
64
+ def test_legacy_runtime_owner_config_is_explicitly_inert() -> None:
65
+ config = (ROOT / "template" / ".scripts" / "config.example.toml").read_text(
66
+ encoding="utf-8"
67
+ )
68
+ github_block = config.split("[github]", 1)[1].split("[plane]", 1)[0]
69
+
70
+ assert "LEGACY INERT COMPATIBILITY METADATA ONLY" in github_block
71
+ assert 'runtime_repo_owner = ""' in github_block
72
+ assert "does not create, attach" in github_block
73
+
74
+
75
+ def test_retired_checkpoint_runbook_is_historical_evidence_only() -> None:
76
+ runbook = (
77
+ ROOT / "docs" / "runbooks" / "runtime-checkpoint-repair.md"
78
+ ).read_text(encoding="utf-8")
79
+
80
+ assert "Historical evidence only" in runbook
81
+ assert "do not execute" in runbook
82
+ assert "commands have intentionally been removed" in runbook
83
+
84
+
85
+ def test_step_20_is_documented_as_ignored_local_population() -> None:
86
+ development = (
87
+ ROOT / "docs" / "sentinel" / "development.md"
88
+ ).read_text(encoding="utf-8")
89
+
90
+ assert "`20-runtime-repo.sh` populates the ignored local runtime" in development
91
+ assert "it creates no GitHub storage" in development
92
+ assert "creates a private GitHub repo" not in development
93
+
94
+
95
+ def test_retired_checkpoint_repair_is_a_non_mutating_tombstone(tmp_path: Path) -> None:
96
+ script = ROOT / "scripts" / "repair-runtime-checkpoint.sh"
97
+ source = script.read_text(encoding="utf-8")
98
+ forbidden = (
99
+ r"\bgit\s+(?:init|fetch|reset|lfs|push|remote|submodule|checkout|restore)\b",
100
+ r"\b(?:rm|mv|cp|touch|truncate)\b",
101
+ r">>",
102
+ )
103
+ for pattern in forbidden:
104
+ assert re.search(pattern, source, re.IGNORECASE) is None
105
+
106
+ runtime = tmp_path / "project" / "agents" / "hermes" / "pm" / "runtime"
107
+ private = runtime / "sessions" / "private-state.bin"
108
+ private.parent.mkdir(parents=True)
109
+ private.write_bytes(bytes((0, 17, 34, 255)))
110
+ (runtime / ".env").write_text("TOKEN=preserve-exactly\n", encoding="utf-8")
111
+
112
+ def snapshot() -> list[tuple[str, str, int, bytes]]:
113
+ entries: list[tuple[str, str, int, bytes]] = []
114
+ for path in sorted(runtime.rglob("*")):
115
+ kind = "dir" if path.is_dir() else "file"
116
+ payload = b"" if path.is_dir() else path.read_bytes()
117
+ entries.append((str(path.relative_to(runtime)), kind, path.stat().st_mode, payload))
118
+ return entries
119
+
120
+ before = snapshot()
121
+ result = subprocess.run(
122
+ [str(script), "--apply", str(runtime)],
123
+ text=True,
124
+ capture_output=True,
125
+ check=False,
126
+ )
127
+
128
+ assert result.returncode != 0
129
+ assert result.stdout == ""
130
+ assert "retired and intentionally non-operational" in result.stderr
131
+ assert "changed nothing" in result.stderr
132
+ assert snapshot() == before
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+
3
+
4
+ SCRIPT = Path(__file__).parents[1] / "template" / ".scripts" / "10-hermes-profile.sh"
5
+
6
+
7
+ def test_profile_env_symlink_is_detached_before_sanitization() -> None:
8
+ text = SCRIPT.read_text(encoding="utf-8")
9
+ detach = text.index('if [[ -L "$PROFILE_ENV" ]]')
10
+ dereference_copy = text.index('cp -L "$PROFILE_ENV" "$PROFILE_ENV_COPY"')
11
+ replace_link = text.index('mv -fT "$PROFILE_ENV_COPY" "$PROFILE_ENV"')
12
+ sanitize = text.index('python3 - "$PROFILE_ENV"')
13
+
14
+ assert detach < dereference_copy < replace_link < sanitize
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import stat
5
+ from pathlib import Path
6
+
7
+
8
+ SCRIPT = Path(__file__).parents[1] / "scripts" / "migrate-profile-scoped-chat-secrets.py"
9
+ SPEC = importlib.util.spec_from_file_location("chat_secret_migration", SCRIPT)
10
+ assert SPEC and SPEC.loader
11
+ MODULE = importlib.util.module_from_spec(SPEC)
12
+ SPEC.loader.exec_module(MODULE)
13
+
14
+
15
+ def _write(path: Path, text: str) -> None:
16
+ path.write_text(text, encoding="utf-8")
17
+ path.chmod(0o600)
18
+
19
+
20
+ def test_dry_run_never_changes_files_or_reports_values(tmp_path: Path) -> None:
21
+ central = tmp_path / "central.env"
22
+ target = tmp_path / "profile.env"
23
+ _write(
24
+ central,
25
+ "KIMI_API_KEY=provider-secret\nSLACK_APP_TOKEN=app-secret\n"
26
+ "SLACK_BOT_TOKEN=bot-secret\nSLACK_ALLOWED_USERS=U123\n",
27
+ )
28
+
29
+ moved = MODULE.migrate(central, target, MODULE.DEFAULT_KEYS, apply=False)
30
+
31
+ assert moved == list(MODULE.DEFAULT_KEYS)
32
+ assert not target.exists()
33
+ assert "SLACK_BOT_TOKEN=bot-secret" in central.read_text(encoding="utf-8")
34
+
35
+
36
+ def test_apply_moves_only_chat_keys_and_creates_private_backup(tmp_path: Path) -> None:
37
+ central = tmp_path / "central.env"
38
+ target = tmp_path / "profile.env"
39
+ _write(
40
+ central,
41
+ "KIMI_API_KEY=provider-secret\nSLACK_APP_TOKEN=app-secret\n"
42
+ "SLACK_BOT_TOKEN=bot-secret\nSLACK_ALLOWED_USERS=U123\n",
43
+ )
44
+
45
+ MODULE.migrate(central, target, MODULE.DEFAULT_KEYS, apply=True)
46
+
47
+ assert central.read_text(encoding="utf-8") == "KIMI_API_KEY=provider-secret\n"
48
+ target_text = target.read_text(encoding="utf-8")
49
+ assert "SLACK_APP_TOKEN=app-secret" in target_text
50
+ assert "SLACK_BOT_TOKEN=bot-secret" in target_text
51
+ assert "SLACK_ALLOWED_USERS=U123" in target_text
52
+ assert stat.S_IMODE(target.stat().st_mode) == 0o600
53
+ backups = list(tmp_path.glob("central.env.bak-*-before-chat-scope"))
54
+ assert len(backups) == 1
55
+ assert stat.S_IMODE(backups[0].stat().st_mode) == 0o600
56
+
57
+
58
+ def test_refuses_target_symlink_to_shared_env(tmp_path: Path) -> None:
59
+ central = tmp_path / "central.env"
60
+ target = tmp_path / "profile.env"
61
+ _write(
62
+ central,
63
+ "SLACK_APP_TOKEN=app\nSLACK_BOT_TOKEN=bot\nSLACK_ALLOWED_USERS=user\n",
64
+ )
65
+ target.symlink_to(central)
66
+
67
+ try:
68
+ MODULE.migrate(central, target, MODULE.DEFAULT_KEYS, apply=True)
69
+ except ValueError as exc:
70
+ assert "not a symlink" in str(exc)
71
+ else:
72
+ raise AssertionError("expected symlink safety failure")
73
+
74
+
75
+ def test_refuses_conflicting_target_value(tmp_path: Path) -> None:
76
+ central = tmp_path / "central.env"
77
+ target = tmp_path / "profile.env"
78
+ _write(
79
+ central,
80
+ "SLACK_APP_TOKEN=app\nSLACK_BOT_TOKEN=bot\nSLACK_ALLOWED_USERS=user\n",
81
+ )
82
+ _write(target, "SLACK_BOT_TOKEN=different\n")
83
+
84
+ try:
85
+ MODULE.migrate(central, target, MODULE.DEFAULT_KEYS, apply=True)
86
+ except ValueError as exc:
87
+ assert "different values" in str(exc)
88
+ else:
89
+ raise AssertionError("expected conflict safety failure")
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+
8
+ ROOT = Path(__file__).parents[1]
9
+
10
+
11
+ def test_host_local_runtime_payloads_are_ignored() -> None:
12
+ gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
13
+
14
+ assert ".codegraph/daemon.pid" in gitignore
15
+ assert ".omo/run-continuation/" in gitignore
16
+
17
+
18
+ def test_host_local_runtime_payloads_are_not_tracked() -> None:
19
+ tracked = subprocess.run(
20
+ ["git", "ls-files", "-z"],
21
+ cwd=ROOT,
22
+ check=True,
23
+ capture_output=True,
24
+ ).stdout.decode("utf-8").split("\0")
25
+ forbidden = re.compile(
26
+ r"(?:^|/)(?:\.omo/run-continuation/|\.codegraph/[^/]+\.(?:pid|sock|socket)$)"
27
+ r"|(?:^|/)(?:sessions?|runtime-state)/"
28
+ r"|(?:^|/)[^/]+\.(?:pid|sock|socket)$"
29
+ )
30
+
31
+ offenders = sorted(path for path in tracked if path and forbidden.search(path))
32
+ assert offenders == [], f"tracked host-local runtime payloads: {offenders}"