@delorenj/pjangler 1.2.19 → 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.
- package/dist/index.js +2353 -518
- package/dist/mcp-server.js +2331 -494
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +6 -4
- 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/.mise/scripts/provision-bmad-skills.py +480 -0
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
- package/templates/commonproject/template/mise.toml.jinja +16 -16
- package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
- package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
- package/templates/hermes-agent/README.md +70 -29
- package/templates/hermes-agent/copier.yml +9 -6
- package/templates/hermes-agent/docs/architecture.md +75 -51
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
- package/templates/hermes-agent/docs/operations.md +179 -81
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
- package/templates/hermes-agent/docs/sentinel/README.md +10 -10
- package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
- package/templates/hermes-agent/docs/sentinel/development.md +12 -15
- package/templates/hermes-agent/docs/sentinel.md +1 -1
- package/templates/hermes-agent/install-local.sh +41 -4
- package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
- package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
- package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
- package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
- package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
- package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
- package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
- package/templates/hermes-agent/template/.gitignore.jinja +5 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
- package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
- package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
- package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
- package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
- package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +12 -1
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +21 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +36 -15
- package/templates/hermes-agent/template/hermes.jinja +6 -3
- package/templates/hermes-agent/template/role.yaml.jinja +24 -13
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
- package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
- package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
- package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
- package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
- package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
- package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
- package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
- package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
- package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -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/.codegraph/daemon.pid +0 -6
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
- package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).parents[1]
|
|
15
|
+
SCRIPT = ROOT / "template" / ".mise" / "scripts" / "provision-bmad-skills.py"
|
|
16
|
+
SPEC = importlib.util.spec_from_file_location("provision_bmad_skills", SCRIPT)
|
|
17
|
+
assert SPEC is not None and SPEC.loader is not None
|
|
18
|
+
PROVISIONER = importlib.util.module_from_spec(SPEC)
|
|
19
|
+
SPEC.loader.exec_module(PROVISIONER)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def working_directory(path: Path):
|
|
24
|
+
previous = Path.cwd()
|
|
25
|
+
os.chdir(path)
|
|
26
|
+
try:
|
|
27
|
+
yield
|
|
28
|
+
finally:
|
|
29
|
+
os.chdir(previous)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def snapshot(path: Path):
|
|
33
|
+
try:
|
|
34
|
+
mode = path.lstat().st_mode
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
return ("missing",)
|
|
37
|
+
permissions = stat.S_IMODE(mode)
|
|
38
|
+
if stat.S_ISLNK(mode):
|
|
39
|
+
return ("symlink", permissions, os.readlink(path))
|
|
40
|
+
if stat.S_ISREG(mode):
|
|
41
|
+
return ("file", permissions, path.read_bytes())
|
|
42
|
+
if stat.S_ISDIR(mode):
|
|
43
|
+
return (
|
|
44
|
+
"directory",
|
|
45
|
+
permissions,
|
|
46
|
+
tuple((child.name, snapshot(child)) for child in sorted(path.iterdir())),
|
|
47
|
+
)
|
|
48
|
+
return ("special", permissions)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProvisionTransactionTests(unittest.TestCase):
|
|
52
|
+
def setUp(self) -> None:
|
|
53
|
+
source = os.environ.get("PJ_BMAD_PACK_ROOT")
|
|
54
|
+
if not source:
|
|
55
|
+
self.skipTest("PJ_BMAD_PACK_ROOT must identify the canonical candidate pack")
|
|
56
|
+
self.temporary = tempfile.TemporaryDirectory(prefix="commonproject-bmad-transaction-")
|
|
57
|
+
self.root = Path(self.temporary.name)
|
|
58
|
+
self.pack = self.root / "pack"
|
|
59
|
+
shutil.copytree(source, self.pack, symlinks=True)
|
|
60
|
+
self.project = self.root / "project"
|
|
61
|
+
skills = self.project / ".agents" / "skills"
|
|
62
|
+
skills.mkdir(parents=True)
|
|
63
|
+
manifest = self.project / ".agents" / "skills.json"
|
|
64
|
+
private = skills / "bmad-private-custom"
|
|
65
|
+
private.mkdir()
|
|
66
|
+
(private / "SKILL.md").write_text("private custom must survive\n")
|
|
67
|
+
manifest.write_text(
|
|
68
|
+
json.dumps(
|
|
69
|
+
{
|
|
70
|
+
"skills": [
|
|
71
|
+
{"name": "custom", "source": "file:///custom"},
|
|
72
|
+
{"name": "bmad-private-custom", "source": private.as_uri()},
|
|
73
|
+
{"name": "bmad-stale", "source": (self.pack / "bmad-stale").as_uri()},
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
+ "\n"
|
|
78
|
+
)
|
|
79
|
+
manifest.chmod(0o600)
|
|
80
|
+
copied = skills / "bmad-agent-pm"
|
|
81
|
+
copied.mkdir()
|
|
82
|
+
(copied / "legacy.txt").write_text("preserve exactly\n")
|
|
83
|
+
(skills / "bmad-agent-analyst").symlink_to(
|
|
84
|
+
self.pack / "bmad-agent-analyst", target_is_directory=True
|
|
85
|
+
)
|
|
86
|
+
(skills / "bmad-stale").symlink_to(
|
|
87
|
+
self.pack / "bmad-stale", target_is_directory=True
|
|
88
|
+
)
|
|
89
|
+
custom = skills / "custom"
|
|
90
|
+
custom.mkdir()
|
|
91
|
+
(custom / "SKILL.md").write_text("custom\n")
|
|
92
|
+
self.previous_pack = os.environ.get("PJ_BMAD_PACK_ROOT")
|
|
93
|
+
os.environ["PJ_BMAD_PACK_ROOT"] = str(self.pack)
|
|
94
|
+
|
|
95
|
+
def tearDown(self) -> None:
|
|
96
|
+
if self.previous_pack is None:
|
|
97
|
+
os.environ.pop("PJ_BMAD_PACK_ROOT", None)
|
|
98
|
+
else:
|
|
99
|
+
os.environ["PJ_BMAD_PACK_ROOT"] = self.previous_pack
|
|
100
|
+
self.temporary.cleanup()
|
|
101
|
+
|
|
102
|
+
def test_nth_link_failure_restores_exact_project_state(self) -> None:
|
|
103
|
+
before = snapshot(self.project)
|
|
104
|
+
|
|
105
|
+
def fail_fifth(target: Path, link: Path, index: int) -> None:
|
|
106
|
+
if index == 5:
|
|
107
|
+
raise OSError("injected fifth-link failure")
|
|
108
|
+
link.symlink_to(target, target_is_directory=True)
|
|
109
|
+
|
|
110
|
+
with working_directory(self.project):
|
|
111
|
+
with self.assertRaisesRegex(OSError, "fifth-link"):
|
|
112
|
+
PROVISIONER.provision(create_link=fail_fifth)
|
|
113
|
+
|
|
114
|
+
self.assertEqual(snapshot(self.project), before)
|
|
115
|
+
|
|
116
|
+
def test_pack_mutation_after_preflight_rolls_back_exactly(self) -> None:
|
|
117
|
+
before = snapshot(self.project)
|
|
118
|
+
|
|
119
|
+
def mutate_pack() -> None:
|
|
120
|
+
(self.pack / "bmad-agent-pm" / "SKILL.md").write_text("mutated after preflight\n")
|
|
121
|
+
|
|
122
|
+
with working_directory(self.project):
|
|
123
|
+
with self.assertRaisesRegex(ValueError, "digest mismatch"):
|
|
124
|
+
PROVISIONER.provision(after_preflight=mutate_pack)
|
|
125
|
+
|
|
126
|
+
self.assertEqual(snapshot(self.project), before)
|
|
127
|
+
|
|
128
|
+
def test_malformed_manifest_fails_before_creating_skills_or_temp_paths(self) -> None:
|
|
129
|
+
project = self.root / "malformed-project"
|
|
130
|
+
agents = project / ".agents"
|
|
131
|
+
agents.mkdir(parents=True)
|
|
132
|
+
manifest = agents / "skills.json"
|
|
133
|
+
manifest.write_bytes(b"{malformed\n")
|
|
134
|
+
manifest.chmod(0o600)
|
|
135
|
+
before = snapshot(project)
|
|
136
|
+
|
|
137
|
+
with working_directory(project):
|
|
138
|
+
with self.assertRaises(json.JSONDecodeError):
|
|
139
|
+
PROVISIONER.provision()
|
|
140
|
+
|
|
141
|
+
self.assertEqual(snapshot(project), before)
|
|
142
|
+
self.assertFalse((agents / "skills").exists())
|
|
143
|
+
self.assertFalse(any(path.name.startswith(".bmad-transaction-") for path in agents.iterdir()))
|
|
144
|
+
|
|
145
|
+
def test_applied_projection_mismatch_rolls_back_exactly(self) -> None:
|
|
146
|
+
before = snapshot(self.project)
|
|
147
|
+
|
|
148
|
+
def corrupt_projection(_manifest: Path, skills: Path) -> None:
|
|
149
|
+
link = skills / "bmad-agent-analyst"
|
|
150
|
+
link.unlink()
|
|
151
|
+
link.symlink_to("/tmp/wrong-after-apply", target_is_directory=True)
|
|
152
|
+
|
|
153
|
+
with working_directory(self.project):
|
|
154
|
+
with self.assertRaisesRegex(ValueError, "link differs from plan"):
|
|
155
|
+
PROVISIONER.provision(after_apply=corrupt_projection)
|
|
156
|
+
|
|
157
|
+
self.assertEqual(snapshot(self.project), before)
|
|
158
|
+
|
|
159
|
+
def test_success_is_complete_and_idempotent(self) -> None:
|
|
160
|
+
with working_directory(self.project):
|
|
161
|
+
self.assertGreater(PROVISIONER.provision(), 0)
|
|
162
|
+
after_first = snapshot(self.project)
|
|
163
|
+
self.assertEqual(PROVISIONER.provision(), 0)
|
|
164
|
+
|
|
165
|
+
self.assertEqual(snapshot(self.project), after_first)
|
|
166
|
+
skills = self.project / ".agents" / "skills"
|
|
167
|
+
self.assertTrue((skills / "bmad-private-custom").is_dir())
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
(skills / "bmad-private-custom" / "SKILL.md").read_text(),
|
|
170
|
+
"private custom must survive\n",
|
|
171
|
+
)
|
|
172
|
+
self.assertFalse((skills / "bmad-stale").exists())
|
|
173
|
+
manifest = json.loads((self.project / ".agents" / "skills.json").read_text())
|
|
174
|
+
self.assertIn(
|
|
175
|
+
{
|
|
176
|
+
"name": "bmad-private-custom",
|
|
177
|
+
"source": (skills / "bmad-private-custom").as_uri(),
|
|
178
|
+
},
|
|
179
|
+
manifest["skills"],
|
|
180
|
+
)
|
|
181
|
+
self.assertNotIn("bmad-stale", [entry.get("name") for entry in manifest["skills"]])
|
|
182
|
+
self.assertTrue(
|
|
183
|
+
all(
|
|
184
|
+
(skills / path.name).is_symlink()
|
|
185
|
+
for path in PROVISIONER.validate_trusted_pack(self.pack)
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def test_unowned_bmad_prefixed_custom_skill_is_preserved(self) -> None:
|
|
190
|
+
custom = self.project / ".agents" / "skills" / "bmad-private-custom"
|
|
191
|
+
before = snapshot(custom)
|
|
192
|
+
|
|
193
|
+
with working_directory(self.project):
|
|
194
|
+
self.assertGreater(PROVISIONER.provision(), 0)
|
|
195
|
+
after_first = snapshot(self.project)
|
|
196
|
+
self.assertEqual(PROVISIONER.provision(), 0)
|
|
197
|
+
|
|
198
|
+
self.assertEqual(snapshot(custom), before)
|
|
199
|
+
self.assertEqual(snapshot(self.project), after_first)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
unittest.main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).parents[1]
|
|
11
|
+
SCRIPT = ROOT / "template" / ".mise" / "scripts" / "sync-skills.py"
|
|
12
|
+
PROVISION_SCRIPT = ROOT / "template" / ".mise" / "scripts" / "provision-bmad-skills.py"
|
|
13
|
+
SPEC = importlib.util.spec_from_file_location("sync_skills", SCRIPT)
|
|
14
|
+
assert SPEC is not None and SPEC.loader is not None
|
|
15
|
+
SYNC = importlib.util.module_from_spec(SPEC)
|
|
16
|
+
SPEC.loader.exec_module(SYNC)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SyncSkillsTopologyTests(unittest.TestCase):
|
|
20
|
+
def setUp(self) -> None:
|
|
21
|
+
self.temporary = tempfile.TemporaryDirectory(prefix="commonproject-sync-topology-")
|
|
22
|
+
self.root = Path(self.temporary.name)
|
|
23
|
+
self.project = self.root / "project"
|
|
24
|
+
self.source = self.root / "source-skill"
|
|
25
|
+
self.project.mkdir()
|
|
26
|
+
self.source.mkdir()
|
|
27
|
+
(self.source / "SKILL.md").write_text("source\n")
|
|
28
|
+
|
|
29
|
+
def tearDown(self) -> None:
|
|
30
|
+
self.temporary.cleanup()
|
|
31
|
+
|
|
32
|
+
def test_shipped_skill_scripts_are_executable(self) -> None:
|
|
33
|
+
for script in (PROVISION_SCRIPT, SCRIPT):
|
|
34
|
+
self.assertNotEqual(
|
|
35
|
+
script.stat().st_mode & 0o111,
|
|
36
|
+
0,
|
|
37
|
+
f"fresh template script must be executable: {script}",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def test_canonical_claude_alias_is_accepted_without_mutating_managed_projection(self) -> None:
|
|
41
|
+
managed = self.project / ".agents" / "skills"
|
|
42
|
+
custom = managed / "custom-real-skill"
|
|
43
|
+
custom.mkdir(parents=True)
|
|
44
|
+
(custom / "SKILL.md").write_text("custom\n")
|
|
45
|
+
claude = self.project / ".claude"
|
|
46
|
+
claude.mkdir()
|
|
47
|
+
(claude / "skills").symlink_to("../.agents/skills", target_is_directory=True)
|
|
48
|
+
|
|
49
|
+
SYNC.fanout_to_cli(self.project, {"managed-example": self.source})
|
|
50
|
+
|
|
51
|
+
self.assertTrue((claude / "skills").is_symlink())
|
|
52
|
+
self.assertEqual(os.readlink(claude / "skills"), "../.agents/skills")
|
|
53
|
+
self.assertEqual((custom / "SKILL.md").read_text(), "custom\n")
|
|
54
|
+
self.assertFalse(
|
|
55
|
+
(managed / "managed-example").exists(),
|
|
56
|
+
"the canonical alias must not make sync-skills mutate the provisioner-owned projection",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def test_external_cli_symlink_fails_before_any_destination_mutation(self) -> None:
|
|
60
|
+
codex = self.project / ".codex"
|
|
61
|
+
codex.mkdir()
|
|
62
|
+
claude = self.project / ".claude"
|
|
63
|
+
claude.mkdir()
|
|
64
|
+
outside = self.root / "outside"
|
|
65
|
+
outside.mkdir()
|
|
66
|
+
(outside / "sentinel").write_text("do-not-touch\n")
|
|
67
|
+
(claude / "skills").symlink_to(outside, target_is_directory=True)
|
|
68
|
+
|
|
69
|
+
with self.assertRaisesRegex(ValueError, "Refusing symlinked CLI skills directory"):
|
|
70
|
+
SYNC.fanout_to_cli(self.project, {"managed-example": self.source})
|
|
71
|
+
|
|
72
|
+
self.assertFalse((codex / "skills").exists())
|
|
73
|
+
self.assertEqual(list(outside.iterdir()), [outside / "sentinel"])
|
|
74
|
+
self.assertEqual((outside / "sentinel").read_text(), "do-not-touch\n")
|
|
75
|
+
|
|
76
|
+
def test_broken_canonical_alias_fails_before_mutation(self) -> None:
|
|
77
|
+
codex = self.project / ".codex"
|
|
78
|
+
codex.mkdir()
|
|
79
|
+
claude = self.project / ".claude"
|
|
80
|
+
claude.mkdir()
|
|
81
|
+
(claude / "skills").symlink_to("../.agents/skills", target_is_directory=True)
|
|
82
|
+
|
|
83
|
+
with self.assertRaisesRegex(ValueError, "alias target is not a real directory"):
|
|
84
|
+
SYNC.fanout_to_cli(self.project, {"managed-example": self.source})
|
|
85
|
+
|
|
86
|
+
self.assertFalse((codex / "skills").exists())
|
|
87
|
+
self.assertTrue((claude / "skills").is_symlink())
|
|
88
|
+
|
|
89
|
+
def test_parent_swap_after_preflight_cannot_mutate_outside_project(self) -> None:
|
|
90
|
+
codex = self.project / ".codex"
|
|
91
|
+
codex.mkdir()
|
|
92
|
+
outside = self.root / "outside"
|
|
93
|
+
outside_skill = outside / "skills" / "managed-example"
|
|
94
|
+
outside_skill.mkdir(parents=True)
|
|
95
|
+
(outside / "sentinel").write_text("outside must survive\n")
|
|
96
|
+
(outside_skill / "user-data").write_text("do not delete\n")
|
|
97
|
+
active = SYNC.preflight_cli_dirs(self.project, ["managed-example"])
|
|
98
|
+
original_codex = self.project / ".codex-original"
|
|
99
|
+
|
|
100
|
+
def swap_parent() -> None:
|
|
101
|
+
codex.rename(original_codex)
|
|
102
|
+
codex.symlink_to(outside, target_is_directory=True)
|
|
103
|
+
|
|
104
|
+
with self.assertRaisesRegex(ValueError, "symlinked destination directory"):
|
|
105
|
+
SYNC.fanout_to_cli(
|
|
106
|
+
self.project,
|
|
107
|
+
{"managed-example": self.source},
|
|
108
|
+
active_cli_dirs=active,
|
|
109
|
+
before_mutation=swap_parent,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self.assertTrue(codex.is_symlink())
|
|
113
|
+
self.assertFalse((original_codex / "skills").exists())
|
|
114
|
+
self.assertEqual((outside / "sentinel").read_text(), "outside must survive\n")
|
|
115
|
+
self.assertEqual((outside_skill / "user-data").read_text(), "do not delete\n")
|
|
116
|
+
self.assertTrue(outside_skill.is_dir())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
unittest.main()
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# hermes-agent-template
|
|
2
2
|
|
|
3
3
|
Copier template that provisions a single Hermes agent role into an existing
|
|
4
|
-
repository, complete with its own per-agent runtime
|
|
5
|
-
memory
|
|
4
|
+
repository, complete with its own ignored per-agent runtime directory for
|
|
5
|
+
memory and session state.
|
|
6
6
|
|
|
7
7
|
## How it relates to CommonProject
|
|
8
8
|
|
|
@@ -11,7 +11,7 @@ memory/state checkpointing.
|
|
|
11
11
|
| What it scaffolds | A new top-level project | An agent role inside an existing project |
|
|
12
12
|
| Copier target | `./my-new-project/` | `./agents/hermes/<role>/` |
|
|
13
13
|
| Asks | project name + description | role + purpose + tone |
|
|
14
|
-
| Post-gen artifacts | Plane project, bmad init, mise tasks | Plane project, Telegram bot wiring,
|
|
14
|
+
| Post-gen artifacts | Plane project, bmad init, mise tasks | Plane project, Telegram bot wiring, optional Slack wiring, pure-local runtime, systemd units |
|
|
15
15
|
|
|
16
16
|
CommonProject runs first to create the umbrella project. hermes-agent-template
|
|
17
17
|
runs second (for each agent role you want) to drop agents into it.
|
|
@@ -22,9 +22,10 @@ The template provisions a single Hermes role per invocation. The `pm` role
|
|
|
22
22
|
handles project management and triage, and also runs the continuous ticket
|
|
23
23
|
sentinel out-of-band: a provider-agnostic board-reconciliation pass (Linear,
|
|
24
24
|
Plane, or Trello) with an autonomous adversarial review (act, do not wait). The
|
|
25
|
-
sentinel runs as the PM's **heartbeat**
|
|
26
|
-
|
|
27
|
-
separate `scrum-master` role; its duties folded into the PM
|
|
25
|
+
sentinel runs as the PM's **heartbeat** systemd timer. Under the current
|
|
26
|
+
pure-local runtime contract, that tick performs board reconciliation only.
|
|
27
|
+
(There is no separate `scrum-master` role; its duties folded into the PM
|
|
28
|
+
heartbeat.)
|
|
28
29
|
|
|
29
30
|
To work on or extend the heartbeat sentinel, start with the [sentinel handoff
|
|
30
31
|
guide](docs/sentinel/README.md).
|
|
@@ -43,13 +44,15 @@ The template will:
|
|
|
43
44
|
1. Seed `~/.config/hermes-agent-template/config.toml` from the shipped example (see [Configuration](#configuration))
|
|
44
45
|
2. Ensure `~/.hermes/fleet.env` exists (single source of truth for shared Hermes binary/repo/registry)
|
|
45
46
|
3. Create the hermes profile `<repo>-pm` via `hermes profile create --clone`
|
|
46
|
-
4. Create
|
|
47
|
-
5. Populate
|
|
48
|
-
6.
|
|
49
|
-
7.
|
|
50
|
-
8.
|
|
51
|
-
9.
|
|
52
|
-
10.
|
|
47
|
+
4. Create ignored local state at `agents/hermes/pm/runtime/` (== HERMES_HOME)
|
|
48
|
+
5. Populate missing files from the runtime scaffold (config.yaml, SOUL.md, memories)
|
|
49
|
+
6. Refuse any stale project gitlink or `.gitmodules` mapping for that runtime
|
|
50
|
+
7. Verify a profile-dedicated BotFather token and store it only in `runtime/.env`
|
|
51
|
+
8. Defer Slack by default, or verify and store an explicitly supplied dedicated Slack app+bot pair in `runtime/.env`
|
|
52
|
+
9. Create a Plane project in your configured workspace
|
|
53
|
+
10. Mark Bloodbank ingress as fleet-scoped, with no per-profile consumer
|
|
54
|
+
11. Install systemd `--user` units: profile gateway and board-reconciliation heartbeat timer
|
|
55
|
+
12. Append the agent and its Bloodbank `target_agent_id` to `~/.hermes/agents-registry.yaml`
|
|
53
56
|
|
|
54
57
|
## Configuration
|
|
55
58
|
|
|
@@ -65,13 +68,16 @@ you to review it. Keys:
|
|
|
65
68
|
| Section | Key | What it sets |
|
|
66
69
|
| --- | --- | --- |
|
|
67
70
|
| `fleet` | `hermes_bin`, `hermes_repo` | Shared Hermes executable + repo checkout |
|
|
71
|
+
| `fleet` | `hermes_git_url`, `hermes_git_ref`, `hermes_git_sha` | Reviewed Hermes fork publication used by clean installs and fleet audit metadata |
|
|
68
72
|
| `fleet` | `fleet_env`, `registry_file` | Fleet source-of-truth + registry locations |
|
|
69
73
|
| `fleet` | `oauth_file`, `codex_home` | Shared Hermes OAuth store + Codex CLI/app-server auth home |
|
|
70
74
|
| `fleet` | `runtime_scaffold_dir` | Fallback scaffold (if agent-local one is missing) |
|
|
71
75
|
| `fleet` | `canonical_skills_dir`, `symlinked_runtime_skills` | Skills mirrored into each profile |
|
|
72
|
-
| `github` | `runtime_repo_owner` | Owner of the `agent-hm-*` runtime repos |
|
|
73
76
|
| `plane` | `base`, `workspace` | Plane URL + workspace slug |
|
|
74
|
-
|
|
77
|
+
|
|
78
|
+
The example configuration retains an inert `[github].runtime_repo_owner` value
|
|
79
|
+
only so older manifests can still be parsed. Current provisioning never reads
|
|
80
|
+
it to create, attach, synchronize, restore, or retire runtime storage.
|
|
75
81
|
|
|
76
82
|
Resolution precedence for every value: **explicit env var → `~/.hermes/fleet.env`
|
|
77
83
|
→ `config.toml` → built-in fallback**. So you can still override any single value
|
|
@@ -87,24 +93,51 @@ your-project/
|
|
|
87
93
|
│ ├── SOUL.md ← personality (canonical)
|
|
88
94
|
│ ├── hermes ← launcher
|
|
89
95
|
│ ├── .scripts/ ← provisioning scripts (idempotent re-run)
|
|
90
|
-
│ └── runtime/ ←
|
|
96
|
+
│ └── runtime/ ← ignored local HERMES_HOME
|
|
91
97
|
│ (HERMES_HOME for this agent)
|
|
98
|
+
│ └── .env ← profile-local channel credentials, mode 0600, gitignored
|
|
92
99
|
└── ...
|
|
93
100
|
|
|
94
|
-
github.com/delorenj/agent-hm-<repo>-pm/ ← new private repo, this agent's state
|
|
95
|
-
├── config.yaml ← cloned from global ~/.hermes
|
|
96
|
-
├── SOUL.md ← evolves over time
|
|
97
|
-
├── memories/{MEMORY,USER}.md
|
|
98
|
-
├── sessions/sessions.db ← LFS-tracked
|
|
99
|
-
├── decisions/ ← markdown files, one per call
|
|
100
|
-
├── bloodbank-consumer.py
|
|
101
|
-
└── .gitattributes / .gitignore ← LFS rules + secret guards
|
|
102
|
-
|
|
103
101
|
~/.hermes/agents-registry.yaml ← fleet roster
|
|
104
102
|
~/.hermes/fleet.env ← shared Hermes binary/repo pointer
|
|
105
|
-
~/.config/systemd/user/ ← gateway
|
|
103
|
+
~/.config/systemd/user/ ← per-profile gateway + heartbeat timer
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Bloodbank command ingress is not a per-profile daemon. One fleet-shared Hermes
|
|
107
|
+
gateway reads the registry and routes canonical commands by
|
|
108
|
+
`data.target_agent_id`; local runtime directories contain no NATS consumer or
|
|
109
|
+
inbox bridge.
|
|
110
|
+
Provisioning and `fleet-sync.sh --apply` also retire the old
|
|
111
|
+
`hermes-<agent>-consumer.service`, even when an older `.done-70-systemd` marker
|
|
112
|
+
exists. Retirement fails closed: a user-manager/query error, failed disable, or
|
|
113
|
+
anything short of explicit `inactive` plus `disabled` leaves the unit file and
|
|
114
|
+
registry metadata intact and reports unhealthy drift.
|
|
115
|
+
|
|
116
|
+
Telegram and Slack ownership checks, identity claims, runtime credential
|
|
117
|
+
writes, and registry upserts serialize on `${registry_file}.lock`. The lock is
|
|
118
|
+
held by `flock`, so a crashed process cannot leave a stale logical lock; registry
|
|
119
|
+
writes use an atomic replace, sync the containing directory where supported, and
|
|
120
|
+
keep both registry and lock at mode `0600`. Profile credential replacements use
|
|
121
|
+
the same file-plus-parent durability boundary and remain safe to retry when a
|
|
122
|
+
durability sync reports an error.
|
|
123
|
+
|
|
124
|
+
## Reviewed Hermes runtime publication
|
|
125
|
+
|
|
126
|
+
Clean installs use only the reviewed fleet fork publication below. The local
|
|
127
|
+
installer verifies the commit belongs to the named ref and refuses an existing
|
|
128
|
+
checkout whose `origin` points elsewhere:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git clone --branch feature/PJAN-19-routing-publication --single-branch \
|
|
132
|
+
https://github.com/delorenj/hermes-agent.git ~/.hermes/hermes-agent
|
|
133
|
+
git -C ~/.hermes/hermes-agent rev-parse HEAD
|
|
134
|
+
# 113e1b182b6d72a7dd02a191f134a41668ceaf0e
|
|
106
135
|
```
|
|
107
136
|
|
|
137
|
+
Do not install this fleet path from `NousResearch/hermes-agent` or mutate its
|
|
138
|
+
`main` branch. Promotion happens on the fork publication ref and is pinned by
|
|
139
|
+
full commit SHA in `config.example.toml` and each registry entry.
|
|
140
|
+
|
|
108
141
|
## Fleet single source-of-truth
|
|
109
142
|
|
|
110
143
|
All generated launchers read `~/.hermes/fleet.env` for:
|
|
@@ -120,6 +153,12 @@ agent wrapper picks it up automatically. `HERMES_FLEET_OAUTH_FILE` is the
|
|
|
120
153
|
shared Hermes provider OAuth store, including `openai-codex`; `HERMES_FLEET_CODEX_HOME`
|
|
121
154
|
is the shared Codex CLI/app-server config/auth home.
|
|
122
155
|
|
|
156
|
+
Telegram bot tokens and Slack bot/app tokens are deliberately excluded from
|
|
157
|
+
this shared layer. They belong only in the enabled agent's `runtime/.env` and
|
|
158
|
+
are checked for local profile ownership during provisioning; fleet config may
|
|
159
|
+
carry the non-secret `TELEGRAM_ALLOWED_USERS` and `SLACK_ALLOWED_USERS`
|
|
160
|
+
policies as a convenience.
|
|
161
|
+
|
|
123
162
|
To retrofit existing wrappers and user systemd units:
|
|
124
163
|
|
|
125
164
|
```bash
|
|
@@ -146,6 +185,8 @@ SKIP_TELEGRAM=1 SKIP_SYSTEMD=1 ./.scripts/40-plane.sh
|
|
|
146
185
|
|
|
147
186
|
(TODO: ship `retire.sh` in v1.1)
|
|
148
187
|
|
|
149
|
-
Manual: stop systemd units,
|
|
150
|
-
|
|
151
|
-
|
|
188
|
+
Manual retirement is deliberately non-destructive: stop the systemd units,
|
|
189
|
+
detach the profile symlink, archive the Plane project, retire messaging bots,
|
|
190
|
+
and remove the registry entry while preserving the ignored runtime directory.
|
|
191
|
+
The verified-backup and no-automated-purge retention policy is in
|
|
192
|
+
[Operations](docs/operations.md#retire-an-agent-preserves-runtime-by-default).
|
|
@@ -10,14 +10,16 @@
|
|
|
10
10
|
#
|
|
11
11
|
# What it does:
|
|
12
12
|
# 1. Renders agents/hermes/<role>/{role.yaml, SOUL.md, hermes, .scripts/}
|
|
13
|
-
# 2. Creates
|
|
14
|
-
#
|
|
13
|
+
# 2. Creates ignored local state at agents/hermes/<role>/runtime/
|
|
14
|
+
# (this is HERMES_HOME; no project gitlink or runtime remote)
|
|
15
15
|
# 4. Creates a Plane project in 33god workspace
|
|
16
16
|
# 6. Prompts for a BotFather Telegram token, stores in runtime/.env
|
|
17
|
-
# 7.
|
|
18
|
-
# 8.
|
|
17
|
+
# 7. Optionally wires a dedicated Slack app+bot pair into runtime/.env
|
|
18
|
+
# 8. Records fleet-shared Bloodbank routing identity (no profile consumer)
|
|
19
|
+
# 9. Installs a profile gateway + heartbeat timer (reconcile + checkpoint)
|
|
20
|
+
# 10. Appends to ~/.hermes/agents-registry.yaml
|
|
19
21
|
#
|
|
20
|
-
# Environment-specific defaults (Hermes binary path,
|
|
22
|
+
# Environment-specific defaults (Hermes binary path, legacy archive owner, Plane
|
|
21
23
|
# workspace/URL, NATS endpoint, skills dir, …) are NOT hardcoded — they live in
|
|
22
24
|
# ~/.config/hermes-agent-template/config.toml, seeded from config.example.toml on
|
|
23
25
|
# first run. Edit that file to retarget the template for a different machine/user.
|
|
@@ -113,7 +115,7 @@ runtime_repo:
|
|
|
113
115
|
|
|
114
116
|
runtime_repo_owner:
|
|
115
117
|
type: str
|
|
116
|
-
|
|
118
|
+
# Legacy archive identity only. Empty by default — resolved at provision time from
|
|
117
119
|
# ~/.config/hermes-agent-template/config.toml [github].runtime_repo_owner.
|
|
118
120
|
# Pass --data runtime_repo_owner=<owner> to override per-run.
|
|
119
121
|
default: ""
|
|
@@ -138,6 +140,7 @@ _tasks:
|
|
|
138
140
|
- "./.scripts/10-hermes-profile.sh"
|
|
139
141
|
- "./.scripts/20-runtime-repo.sh"
|
|
140
142
|
- "./.scripts/30-telegram.sh"
|
|
143
|
+
- "./.scripts/31-slack.sh"
|
|
141
144
|
- "./.scripts/42-ticket-provider.sh"
|
|
142
145
|
- "./.scripts/60-bloodbank.sh"
|
|
143
146
|
- "./.scripts/70-systemd.sh"
|