@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.
Files changed (72) hide show
  1. package/dist/index.js +2353 -518
  2. package/dist/mcp-server.js +2331 -494
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +6 -4
  5. package/templates/commonproject/template/.agents/hooks/README.md +13 -26
  6. package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
  7. package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
  8. package/templates/commonproject/template/.agents/local.example.json +2 -8
  9. package/templates/commonproject/template/.agents/skills.json +6 -0
  10. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  11. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  12. package/templates/commonproject/template/mise.toml.jinja +16 -16
  13. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  14. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  15. package/templates/hermes-agent/README.md +70 -29
  16. package/templates/hermes-agent/copier.yml +9 -6
  17. package/templates/hermes-agent/docs/architecture.md +75 -51
  18. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  19. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  20. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  21. package/templates/hermes-agent/docs/operations.md +179 -81
  22. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  23. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  24. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  25. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  26. package/templates/hermes-agent/docs/sentinel.md +1 -1
  27. package/templates/hermes-agent/install-local.sh +41 -4
  28. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  29. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  30. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  31. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  32. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  33. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  34. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  35. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  36. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  38. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  39. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  40. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  41. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  42. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  43. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  44. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  45. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  46. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  47. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  48. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  49. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  50. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  51. package/templates/hermes-agent/template/.scripts/heartbeat.sh +12 -1
  52. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
  53. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +21 -0
  54. package/templates/hermes-agent/template/SOUL.md.jinja +36 -15
  55. package/templates/hermes-agent/template/hermes.jinja +6 -3
  56. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  57. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  58. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  59. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  60. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  61. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  62. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  63. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  64. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  65. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  66. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  67. package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
  68. package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
  69. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  70. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  71. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  72. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
@@ -0,0 +1,475 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import stat
6
+ import subprocess
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+
13
+ ROOT = Path(__file__).parents[1]
14
+ TELEGRAM_SCRIPT = ROOT / "template" / ".scripts" / "30-telegram.sh"
15
+ LIB_SCRIPT = ROOT / "template" / ".scripts" / "_lib.sh"
16
+ REGISTRY_SCRIPT = ROOT / "template" / ".scripts" / "80-registry.sh"
17
+
18
+ BOT_TOKEN = "123456:profile-only-secret"
19
+ OTHER_TOKEN = "654321:different-profile-secret"
20
+
21
+
22
+ def _make_role(tmp_path: Path) -> tuple[Path, Path, Path]:
23
+ role = tmp_path / "role"
24
+ scripts = role / ".scripts"
25
+ runtime = role / "runtime"
26
+ scripts.mkdir(parents=True)
27
+ runtime.mkdir()
28
+ shutil.copy2(TELEGRAM_SCRIPT, scripts / TELEGRAM_SCRIPT.name)
29
+ shutil.copy2(LIB_SCRIPT, scripts / LIB_SCRIPT.name)
30
+ (role / "role.yaml").write_text(
31
+ """repo: demo
32
+ role: pm
33
+ agent_id: demo-pm
34
+ display_name: "Demo PM"
35
+ profile: demo-pm
36
+ telegram:
37
+ provisioning_status: "deferred"
38
+ bot_username: "demo_pm_bot"
39
+ bot_id: ""
40
+ slack:
41
+ provisioning_status: "deferred"
42
+ team_id: ""
43
+ team_name: ""
44
+ bot_user_id: ""
45
+ bot_id: ""
46
+ bot_username: ""
47
+ bloodbank:
48
+ gateway_scope: fleet
49
+ target_agent_id: demo-pm
50
+ plane:
51
+ workspace: "test"
52
+ identifier: ""
53
+ runtime:
54
+ github_owner: "test"
55
+ github_repo: "agent-hm-demo-pm"
56
+ """,
57
+ encoding="utf-8",
58
+ )
59
+ registry = tmp_path / "agents-registry.yaml"
60
+ registry.write_text("schema_version: 1\nagents: {}\n", encoding="utf-8")
61
+ return role, runtime, registry
62
+
63
+
64
+ def _fake_bin(tmp_path: Path) -> Path:
65
+ bindir = tmp_path / "bin"
66
+ bindir.mkdir(exist_ok=True)
67
+ curl = bindir / "curl"
68
+ curl.write_text(
69
+ """#!/usr/bin/env python3
70
+ import json
71
+ import os
72
+ import sys
73
+
74
+ url = sys.argv[-1]
75
+ expected = os.environ.get("EXPECTED_TELEGRAM_TOKEN", "")
76
+ assert expected and url == f"https://api.telegram.org/bot{expected}/getMe"
77
+ print(json.dumps({"ok": True, "result": {"id": 424242, "username": "verified_demo_bot"}}))
78
+ """,
79
+ encoding="utf-8",
80
+ )
81
+ curl.chmod(0o755)
82
+ hermes = bindir / "hermes"
83
+ hermes.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
84
+ hermes.chmod(0o755)
85
+ return bindir
86
+
87
+
88
+ def _run(
89
+ role: Path,
90
+ registry: Path,
91
+ home: Path,
92
+ bindir: Path,
93
+ overrides: dict[str, str] | None = None,
94
+ ) -> subprocess.CompletedProcess[str]:
95
+ env = {
96
+ key: value
97
+ for key, value in os.environ.items()
98
+ if not key.startswith("TELEGRAM_") and key != "SKIP_TELEGRAM"
99
+ }
100
+ env.update(
101
+ {
102
+ "HOME": str(home),
103
+ "PATH": f"{bindir}:{env['PATH']}",
104
+ "HERMES_BIN": str(bindir / "hermes"),
105
+ "HERMES_FLEET_ENV": str(home / ".hermes" / "fleet.env"),
106
+ "REGISTRY_FILE": str(registry),
107
+ }
108
+ )
109
+ env.update(overrides or {})
110
+ return subprocess.run(
111
+ ["bash", str(role / ".scripts" / "30-telegram.sh")],
112
+ env=env,
113
+ input="",
114
+ text=True,
115
+ capture_output=True,
116
+ check=False,
117
+ )
118
+
119
+
120
+ def _inject_parent_fsync_failure(
121
+ tmp_path: Path, overrides: dict[str, str], parent: Path
122
+ ) -> None:
123
+ site_dir = tmp_path / "fsync-failure-site"
124
+ site_dir.mkdir()
125
+ (site_dir / "sitecustomize.py").write_text(
126
+ """import errno
127
+ import os
128
+ import stat
129
+
130
+ _real_fsync = os.fsync
131
+ _failed_parent = os.path.realpath(os.environ["FAIL_PARENT_FSYNC"])
132
+
133
+ def _fsync(fd):
134
+ if stat.S_ISDIR(os.fstat(fd).st_mode):
135
+ resolved = os.path.realpath(f"/proc/self/fd/{fd}")
136
+ if resolved == _failed_parent:
137
+ raise OSError(errno.EIO, "injected parent directory fsync failure")
138
+ return _real_fsync(fd)
139
+
140
+ os.fsync = _fsync
141
+ """,
142
+ encoding="utf-8",
143
+ )
144
+ overrides["PYTHONPATH"] = str(site_dir)
145
+ overrides["FAIL_PARENT_FSYNC"] = str(parent)
146
+
147
+
148
+ def test_telegram_ignores_shared_fleet_token_and_defers_noninteractive(tmp_path: Path) -> None:
149
+ role, runtime, registry = _make_role(tmp_path)
150
+ home = tmp_path / "home"
151
+ fleet = home / ".hermes" / "fleet.env"
152
+ fleet.parent.mkdir(parents=True)
153
+ fleet.write_text(
154
+ f"TELEGRAM_BOT_TOKEN={BOT_TOKEN}\nTELEGRAM_ALLOWED_USERS=111\n",
155
+ encoding="utf-8",
156
+ )
157
+
158
+ result = _run(role, registry, home, _fake_bin(tmp_path))
159
+
160
+ assert result.returncode == 0, result.stderr
161
+ assert "deferred" in result.stderr
162
+ assert not (runtime / ".env").exists()
163
+ assert BOT_TOKEN not in result.stdout + result.stderr
164
+ assert BOT_TOKEN in fleet.read_text(encoding="utf-8")
165
+
166
+
167
+ def test_explicit_token_writes_only_private_runtime_env_and_identity(tmp_path: Path) -> None:
168
+ role, runtime, registry = _make_role(tmp_path)
169
+ home = tmp_path / "home"
170
+ fleet = home / ".hermes" / "fleet.env"
171
+ fleet.parent.mkdir(parents=True)
172
+ fleet.write_text("TELEGRAM_ALLOWED_USERS=111,222\n", encoding="utf-8")
173
+ shared = home / ".hermes" / ".env"
174
+ shared.write_text("PROVIDER_KEY=keep-me\n", encoding="utf-8")
175
+
176
+ result = _run(
177
+ role,
178
+ registry,
179
+ home,
180
+ _fake_bin(tmp_path),
181
+ {"TELEGRAM_BOT_TOKEN": BOT_TOKEN, "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN},
182
+ )
183
+
184
+ assert result.returncode == 0, result.stderr
185
+ env_file = runtime / ".env"
186
+ env_text = env_file.read_text(encoding="utf-8")
187
+ assert f'TELEGRAM_BOT_TOKEN="{BOT_TOKEN}"' in env_text
188
+ assert 'TELEGRAM_ALLOWED_USERS="111,222"' in env_text
189
+ assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
190
+ assert shared.read_text(encoding="utf-8") == "PROVIDER_KEY=keep-me\n"
191
+ assert fleet.read_text(encoding="utf-8") == "TELEGRAM_ALLOWED_USERS=111,222\n"
192
+ assert BOT_TOKEN not in result.stdout + result.stderr
193
+
194
+ telegram = yaml.safe_load((role / "role.yaml").read_text(encoding="utf-8"))["telegram"]
195
+ assert telegram == {
196
+ "provisioning_status": "verified",
197
+ "bot_username": "verified_demo_bot",
198
+ "bot_id": "424242",
199
+ }
200
+
201
+
202
+ def test_credential_parent_fsync_failure_is_reported_and_retryable(tmp_path: Path) -> None:
203
+ role, runtime, registry = _make_role(tmp_path)
204
+ home = tmp_path / "home"
205
+ fleet = home / ".hermes" / "fleet.env"
206
+ fleet.parent.mkdir(parents=True)
207
+ fleet.write_text("TELEGRAM_ALLOWED_USERS=111\n", encoding="utf-8")
208
+ bindir = _fake_bin(tmp_path)
209
+ overrides = {
210
+ "TELEGRAM_BOT_TOKEN": BOT_TOKEN,
211
+ "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
212
+ }
213
+ _inject_parent_fsync_failure(tmp_path, overrides, runtime)
214
+
215
+ failed = _run(role, registry, home, bindir, overrides)
216
+
217
+ assert failed.returncode != 0
218
+ assert "injected parent directory fsync failure" in failed.stderr
219
+ env_file = runtime / ".env"
220
+ assert f'TELEGRAM_BOT_TOKEN="{BOT_TOKEN}"' in env_file.read_text(encoding="utf-8")
221
+ assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
222
+ assert BOT_TOKEN not in failed.stdout + failed.stderr
223
+ assert "demo-pm" in yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]
224
+ assert not (role / ".scripts" / ".done-30-telegram").exists()
225
+
226
+ retried = _run(
227
+ role,
228
+ registry,
229
+ home,
230
+ bindir,
231
+ {"TELEGRAM_BOT_TOKEN": BOT_TOKEN, "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN},
232
+ )
233
+
234
+ assert retried.returncode == 0, retried.stderr
235
+ assert (role / ".scripts" / ".done-30-telegram").exists()
236
+
237
+
238
+ def test_rejects_token_parked_in_shared_fleet_env(tmp_path: Path) -> None:
239
+ role, runtime, registry = _make_role(tmp_path)
240
+ home = tmp_path / "home"
241
+ fleet = home / ".hermes" / "fleet.env"
242
+ fleet.parent.mkdir(parents=True)
243
+ fleet.write_text(f"TELEGRAM_BOT_TOKEN={BOT_TOKEN}\n", encoding="utf-8")
244
+
245
+ result = _run(
246
+ role,
247
+ registry,
248
+ home,
249
+ _fake_bin(tmp_path),
250
+ {
251
+ "TELEGRAM_BOT_TOKEN": BOT_TOKEN,
252
+ "TELEGRAM_ALLOWED_USERS": "111",
253
+ "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
254
+ },
255
+ )
256
+
257
+ assert result.returncode != 0
258
+ assert "already assigned to shared fleet environment" in result.stderr
259
+ assert BOT_TOKEN not in result.stdout + result.stderr
260
+ assert not (runtime / ".env").exists()
261
+
262
+
263
+ def test_rejects_token_reused_by_another_profile(tmp_path: Path) -> None:
264
+ role, runtime, registry = _make_role(tmp_path)
265
+ home = tmp_path / "home"
266
+ home.mkdir()
267
+ other_role = tmp_path / "other-role"
268
+ other_runtime = other_role / "runtime"
269
+ other_runtime.mkdir(parents=True)
270
+ (other_runtime / ".env").write_text(
271
+ f'TELEGRAM_BOT_TOKEN="{BOT_TOKEN}"\n', encoding="utf-8"
272
+ )
273
+ registry.write_text(
274
+ yaml.safe_dump(
275
+ {
276
+ "schema_version": 1,
277
+ "agents": {
278
+ "other-pm": {
279
+ "role_dir": str(other_role),
280
+ "telegram": {"bot_id": "999999"},
281
+ }
282
+ },
283
+ },
284
+ sort_keys=False,
285
+ ),
286
+ encoding="utf-8",
287
+ )
288
+
289
+ result = _run(
290
+ role,
291
+ registry,
292
+ home,
293
+ _fake_bin(tmp_path),
294
+ {
295
+ "TELEGRAM_BOT_TOKEN": BOT_TOKEN,
296
+ "TELEGRAM_ALLOWED_USERS": "111",
297
+ "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
298
+ },
299
+ )
300
+
301
+ assert result.returncode != 0
302
+ assert "already assigned to agent other-pm" in result.stderr
303
+ assert BOT_TOKEN not in result.stdout + result.stderr
304
+ assert not (runtime / ".env").exists()
305
+
306
+
307
+ def test_rejects_verified_bot_identity_owned_by_another_agent(tmp_path: Path) -> None:
308
+ role, runtime, registry = _make_role(tmp_path)
309
+ home = tmp_path / "home"
310
+ home.mkdir()
311
+ registry.write_text(
312
+ yaml.safe_dump(
313
+ {
314
+ "schema_version": 1,
315
+ "agents": {
316
+ "other-pm": {
317
+ "telegram": {
318
+ "provisioning_status": "verified",
319
+ "bot_username": "verified_demo_bot",
320
+ "bot_id": "424242",
321
+ }
322
+ }
323
+ },
324
+ },
325
+ sort_keys=False,
326
+ ),
327
+ encoding="utf-8",
328
+ )
329
+
330
+ result = _run(
331
+ role,
332
+ registry,
333
+ home,
334
+ _fake_bin(tmp_path),
335
+ {
336
+ "TELEGRAM_BOT_TOKEN": OTHER_TOKEN,
337
+ "TELEGRAM_ALLOWED_USERS": "111",
338
+ "EXPECTED_TELEGRAM_TOKEN": OTHER_TOKEN,
339
+ },
340
+ )
341
+
342
+ assert result.returncode != 0
343
+ assert "bot identity is already assigned to agent other-pm" in result.stderr
344
+ assert OTHER_TOKEN not in result.stdout + result.stderr
345
+ assert not (runtime / ".env").exists()
346
+
347
+
348
+ def test_refuses_runtime_env_symlink_before_get_me(tmp_path: Path) -> None:
349
+ role, runtime, registry = _make_role(tmp_path)
350
+ home = tmp_path / "home"
351
+ shared = home / ".hermes" / ".env"
352
+ shared.parent.mkdir(parents=True)
353
+ shared.write_text("PROVIDER_KEY=keep-me\n", encoding="utf-8")
354
+ (runtime / ".env").symlink_to(shared)
355
+
356
+ result = _run(
357
+ role,
358
+ registry,
359
+ home,
360
+ _fake_bin(tmp_path),
361
+ {
362
+ "TELEGRAM_BOT_TOKEN": BOT_TOKEN,
363
+ "TELEGRAM_ALLOWED_USERS": "111",
364
+ "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
365
+ },
366
+ )
367
+
368
+ assert result.returncode != 0
369
+ assert "refusing to write Telegram credentials through symlink" in result.stderr
370
+ assert shared.read_text(encoding="utf-8") == "PROVIDER_KEY=keep-me\n"
371
+
372
+
373
+ def test_registry_persists_telegram_identity_without_token(tmp_path: Path) -> None:
374
+ role, _, registry = _make_role(tmp_path)
375
+ home = tmp_path / "home"
376
+ home.mkdir()
377
+ shutil.copy2(REGISTRY_SCRIPT, role / ".scripts" / REGISTRY_SCRIPT.name)
378
+ role_yaml = role / "role.yaml"
379
+ role_yaml.write_text(
380
+ role_yaml.read_text(encoding="utf-8")
381
+ .replace('provisioning_status: "deferred"', 'provisioning_status: "verified"', 1)
382
+ .replace('bot_username: "demo_pm_bot"', 'bot_username: "verified_demo_bot"')
383
+ .replace('bot_id: ""', 'bot_id: "424242"', 1),
384
+ encoding="utf-8",
385
+ )
386
+ env = {
387
+ key: value for key, value in os.environ.items() if not key.startswith("TELEGRAM_")
388
+ }
389
+ env.update(
390
+ {
391
+ "HOME": str(home),
392
+ "HERMES_FLEET_ENV": str(home / ".hermes" / "fleet.env"),
393
+ "REGISTRY_FILE": str(registry),
394
+ }
395
+ )
396
+
397
+ result = subprocess.run(
398
+ ["bash", str(role / ".scripts" / "80-registry.sh")],
399
+ env=env,
400
+ text=True,
401
+ capture_output=True,
402
+ check=False,
403
+ )
404
+
405
+ assert result.returncode == 0, result.stderr
406
+ serialized = registry.read_text(encoding="utf-8")
407
+ entry = yaml.safe_load(serialized)["agents"]["demo-pm"]
408
+ assert entry["telegram"] == {
409
+ "provisioning_status": "verified",
410
+ "bot_username": "verified_demo_bot",
411
+ "bot_id": "424242",
412
+ }
413
+ assert "TELEGRAM_BOT_TOKEN" not in serialized
414
+
415
+
416
+ def test_concurrent_profiles_cannot_claim_same_telegram_identity(tmp_path: Path) -> None:
417
+ role_a, _, _ = _make_role(tmp_path / "a")
418
+ role_b, _, _ = _make_role(tmp_path / "b")
419
+ role_b_yaml = role_b / "role.yaml"
420
+ role_b_yaml.write_text(
421
+ role_b_yaml.read_text(encoding="utf-8").replace("demo-pm", "demo-reviewer"),
422
+ encoding="utf-8",
423
+ )
424
+ registry = tmp_path / "agents-registry.yaml"
425
+ registry.write_text("schema_version: 1\nagents: {}\n", encoding="utf-8")
426
+ home = tmp_path / "home"
427
+ home.mkdir()
428
+ bindir = _fake_bin(tmp_path)
429
+ overrides = {
430
+ "TELEGRAM_BOT_TOKEN": BOT_TOKEN,
431
+ "TELEGRAM_ALLOWED_USERS": "111",
432
+ "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
433
+ }
434
+
435
+ with ThreadPoolExecutor(max_workers=2) as pool:
436
+ results = list(
437
+ pool.map(
438
+ lambda role: _run(role, registry, home, bindir, overrides),
439
+ (role_a, role_b),
440
+ )
441
+ )
442
+
443
+ assert sorted(result.returncode for result in results) == [0, 1]
444
+ combined = "".join(result.stdout + result.stderr for result in results)
445
+ assert "bot identity is already assigned" in combined
446
+ assert BOT_TOKEN not in combined
447
+ agents = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]
448
+ assert len(agents) == 1
449
+ assert next(iter(agents.values()))["telegram"]["bot_id"] == "424242"
450
+ assert stat.S_IMODE(registry.stat().st_mode) == 0o600
451
+ assert stat.S_IMODE(Path(f"{registry}.lock").stat().st_mode) == 0o600
452
+
453
+
454
+ def test_telegram_refuses_registry_and_lock_symlinks(tmp_path: Path) -> None:
455
+ role, runtime, _ = _make_role(tmp_path)
456
+ home = tmp_path / "home"
457
+ home.mkdir()
458
+ bindir = _fake_bin(tmp_path)
459
+ target = tmp_path / "registry-target.yaml"
460
+ target.write_text("schema_version: 1\nagents: {}\n", encoding="utf-8")
461
+ registry = tmp_path / "agents-registry.yaml"
462
+ registry.unlink()
463
+ registry.symlink_to(target)
464
+ overrides = {
465
+ "TELEGRAM_BOT_TOKEN": BOT_TOKEN,
466
+ "TELEGRAM_ALLOWED_USERS": "111",
467
+ "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
468
+ }
469
+
470
+ result = _run(role, registry, home, bindir, overrides)
471
+
472
+ assert result.returncode != 0
473
+ assert "refusing" in result.stderr and "symlink" in result.stderr
474
+ assert not (runtime / ".env").exists()
475
+ assert target.read_text(encoding="utf-8") == "schema_version: 1\nagents: {}\n"
@@ -1,110 +0,0 @@
1
- #!/bin/bash
2
- # Fan the project's enabled skill set out to every agent CLI that does NOT read
3
- # .agents/ natively, as per-skill symlinks.
4
- #
5
- # SSOT: .agents/skills/ (committed inherited skills + on-enter ./skills/* links)
6
- # Targets: a table of per-CLI skill dirs, each with a SCOPE:
7
- # global -> a shared per-user dir (e.g. ~/.codex/skills): link ours in, never
8
- # clobber foreign entries, removed on leave (unlink-*-from-clis.sh).
9
- # local -> a project-scoped mirror dir (e.g. ./.kimi-code/skills) that is fully
10
- # ours: stale real copies / foreign symlinks are replaced, and
11
- # disabled/deferred skills are pruned. Persists across leave.
12
- #
13
- # Per-dev controls — .agents/local.json (see .agents/local.example.json):
14
- # skills.disabled[] never link these
15
- # skills.defer_to_global if true, skip any skill that ALSO exists in your global
16
- # SSOT (~/.agents/skills) so your global copy wins and you
17
- # get zero duplicates. Teammates omit it → inherit all.
18
- #
19
- # Add a CLI: append one "dir|scope" line to SKILL_TARGETS. That's the whole change.
20
- set -euo pipefail
21
-
22
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
- project_root="$(cd "${script_dir}/../.." && pwd)"
24
- agents_skills="${project_root}/.agents/skills"
25
-
26
- SKILL_TARGETS=(
27
- "${CODEX_HOME:-$HOME/.codex}/skills|global"
28
- "${project_root}/.kimi-code/skills|local"
29
- )
30
-
31
- local_config="${project_root}/.agents/hooks/lib/local-config.sh"
32
- if [ -f "$local_config" ]; then
33
- # shellcheck source=/dev/null
34
- source "$local_config"
35
- else
36
- skill_disabled() { return 1; }
37
- skills_defer_to_global() { return 1; }
38
- skill_is_global() { return 1; }
39
- fi
40
-
41
- if [ ! -d "$agents_skills" ]; then
42
- echo "No .agents/skills/ found. Nothing to fan out."
43
- exit 0
44
- fi
45
-
46
- defer=0
47
- skills_defer_to_global && defer=1
48
-
49
- # Should this skill be skipped (disabled, or deferred to the dev's global system)?
50
- is_skipped() {
51
- local name="$1"
52
- skill_disabled "$name" && return 0
53
- [ "$defer" = "1" ] && skill_is_global "$name" && return 0
54
- return 1
55
- }
56
-
57
- # readlink with any trailing slash stripped (older links were stored with one).
58
- _readlink_norm() {
59
- local l; l="$(readlink "$1" 2>/dev/null || echo "")"
60
- printf '%s' "${l%/}"
61
- }
62
-
63
- link_into() {
64
- local dir="$1" scope="$2" linked=0 skipped=0 pruned=0
65
- mkdir -p "$dir"
66
- for skill_path in "$agents_skills"/*; do
67
- [ -d "$skill_path" ] || continue
68
- local name target
69
- name="$(basename "$skill_path")"
70
- [[ "$name" == ".system" ]] && continue
71
- target="${dir}/${name}"
72
-
73
- if is_skipped "$name"; then
74
- # Yield the slot: remove a link WE previously made (so the dev's global copy
75
- # can claim it), and prune stale entries from our fully-managed mirror.
76
- if [ -L "$target" ] && [ "$(_readlink_norm "$target")" = "$skill_path" ]; then
77
- rm -f "$target"
78
- pruned=$((pruned + 1))
79
- elif [ "$scope" = "local" ] && { [ -L "$target" ] || [ -e "$target" ]; }; then
80
- rm -rf "$target"
81
- pruned=$((pruned + 1))
82
- fi
83
- skipped=$((skipped + 1))
84
- continue
85
- fi
86
-
87
- if [ -L "$target" ] && [ "$(_readlink_norm "$target")" = "$skill_path" ]; then
88
- linked=$((linked + 1))
89
- continue
90
- fi
91
-
92
- if [ -e "$target" ] || [ -L "$target" ]; then
93
- if [ "$scope" = "local" ]; then
94
- rm -rf "$target" # our mirror: replace stale copy / foreign link
95
- else
96
- skipped=$((skipped + 1)) # shared dir: leave foreign entries untouched
97
- continue
98
- fi
99
- fi
100
-
101
- ln -s "$skill_path" "$target"
102
- linked=$((linked + 1))
103
- done
104
- echo " ${dir} (${scope}): ${linked} linked, ${skipped} skipped, ${pruned} pruned"
105
- }
106
-
107
- echo "Fanning .agents/skills -> agent CLIs (defer_to_global=${defer})"
108
- for entry in "${SKILL_TARGETS[@]}"; do
109
- link_into "${entry%%|*}" "${entry##*|}"
110
- done
@@ -1,45 +0,0 @@
1
- #!/bin/bash
2
- # Remove this project's skill symlinks from shared (global-scope) per-CLI dirs on
3
- # leave, so other projects / your global setup aren't polluted. Project-scoped
4
- # (local-scope) mirrors like ./.kimi-code/skills are left in place — they belong
5
- # to the repo and cost nothing to keep.
6
- #
7
- # Mirrors the SKILL_TARGETS table in link-project-skills-to-clis.sh.
8
- set -euo pipefail
9
-
10
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
- project_root="$(cd "${script_dir}/../.." && pwd)"
12
- agents_skills="${project_root}/.agents/skills"
13
-
14
- SKILL_TARGETS=(
15
- "${CODEX_HOME:-$HOME/.codex}/skills|global"
16
- "${project_root}/.kimi-code/skills|local"
17
- )
18
-
19
- [ -d "$agents_skills" ] || exit 0
20
-
21
- _readlink_norm() {
22
- local l; l="$(readlink "$1" 2>/dev/null || echo "")"
23
- printf '%s' "${l%/}"
24
- }
25
-
26
- for entry in "${SKILL_TARGETS[@]}"; do
27
- dir="${entry%%|*}"
28
- scope="${entry##*|}"
29
- [ "$scope" = "global" ] || continue
30
- [ -d "$dir" ] || continue
31
-
32
- unlinked=0
33
- for skill_path in "$agents_skills"/*; do
34
- [ -e "$skill_path" ] || continue
35
- name="$(basename "$skill_path")"
36
- [[ "$name" == ".system" ]] && continue
37
- target="${dir}/${name}"
38
- if [ -L "$target" ] && [ "$(_readlink_norm "$target")" = "$skill_path" ]; then
39
- rm "$target"
40
- unlinked=$((unlinked + 1))
41
- fi
42
- done
43
- [ "$unlinked" -gt 0 ] && echo "Unlinked ${unlinked} project skill(s) from ${dir}"
44
- done
45
- exit 0
@@ -1,6 +0,0 @@
1
- {
2
- "pid": 868618,
3
- "version": "0.9.9",
4
- "socketPath": "/home/delorenj/code/hermes-agent-template/.codegraph/daemon.sock",
5
- "startedAt": 1782538939056
6
- }
@@ -1,10 +0,0 @@
1
- {
2
- "sessionID": "ses_0e5b28303ffeXxL53hZjKggXDW",
3
- "updatedAt": "2026-06-30T21:06:55.034Z",
4
- "sources": {
5
- "background-task": {
6
- "state": "idle",
7
- "updatedAt": "2026-06-30T21:06:55.034Z"
8
- }
9
- }
10
- }