@delorenj/pjangler 1.4.2 → 1.4.4
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/README.md +528 -0
- package/contracts/fleet-contract.yaml +513 -0
- package/dist/index.js +9333 -1509
- package/dist/mcp-server.js +6634 -1096
- package/dist/prompt.js +2 -1
- package/package.json +10 -4
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- package/templates/hermes-agent/template/role.yaml.jinja +15 -4
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reconcile the PM Vox delta without freezing inherited fleet plugins.
|
|
3
|
+
|
|
4
|
+
Commit 52d9445 accidentally copied the then-current fleet plugin list into
|
|
5
|
+
profile deltas. Those files had no provenance marker. This tool is shared by
|
|
6
|
+
first-run provisioning and fleet-sync and contains the sealed historical base
|
|
7
|
+
needed to distinguish that generated snapshot from an intentional replacement.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import copy
|
|
14
|
+
import errno
|
|
15
|
+
import hashlib
|
|
16
|
+
import importlib.util
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import re
|
|
21
|
+
import stat
|
|
22
|
+
import tempfile
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_profile_lock_module():
|
|
29
|
+
source = pathlib.Path(__file__).with_name("profile-config-lock.py")
|
|
30
|
+
if source.is_symlink() or not source.is_file():
|
|
31
|
+
raise RuntimeError(f"trusted profile config lock helper is unavailable: {source}")
|
|
32
|
+
spec = importlib.util.spec_from_file_location(
|
|
33
|
+
"pjangler_profile_config_lock", source
|
|
34
|
+
)
|
|
35
|
+
if spec is None or spec.loader is None:
|
|
36
|
+
raise RuntimeError(f"cannot load profile config lock helper: {source}")
|
|
37
|
+
module = importlib.util.module_from_spec(spec)
|
|
38
|
+
spec.loader.exec_module(module)
|
|
39
|
+
return module
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
PROFILE_LOCK = load_profile_lock_module()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
LIST_PATCH_KEY = "x-pjangler-merge"
|
|
46
|
+
MIGRATION_KEY = "plugins_enabled_52d9445"
|
|
47
|
+
LEGACY_PROVENANCE_KEY = "plugins_enabled_snapshot"
|
|
48
|
+
|
|
49
|
+
# Byte-order-equivalent plugin set emitted by 52d9445 for the deployed fleet
|
|
50
|
+
# base. The digest is deliberately sealed next to the values: changing either
|
|
51
|
+
# requires adding a new historical record, never silently redefining existing
|
|
52
|
+
# provenance.
|
|
53
|
+
SEALED_HISTORICAL_BASES = (
|
|
54
|
+
{
|
|
55
|
+
"id": "delo-fleet-52d9445-generated-2026-08",
|
|
56
|
+
"sha256": "99656898b5bc80a24c42cdf0720abb9042d86bd8415fbbab534282d245dd618e",
|
|
57
|
+
"plugins": (
|
|
58
|
+
"bloodbank-platform",
|
|
59
|
+
"copilot-provider",
|
|
60
|
+
"fal",
|
|
61
|
+
"gemini-provider",
|
|
62
|
+
"google_meet",
|
|
63
|
+
"kimi-coding-provider",
|
|
64
|
+
"ntfy-platform",
|
|
65
|
+
"openai-codex",
|
|
66
|
+
"openrouter",
|
|
67
|
+
"self-hosted",
|
|
68
|
+
"slack-platform",
|
|
69
|
+
"teams-platform",
|
|
70
|
+
"teams_pipeline",
|
|
71
|
+
"telegram-platform",
|
|
72
|
+
"tts/vox",
|
|
73
|
+
"web-brave-free",
|
|
74
|
+
"web-tavily",
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ContractError(RuntimeError):
|
|
81
|
+
"""A malformed config cannot be reconciled automatically."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UnresolvedLegacySnapshot(ContractError):
|
|
85
|
+
"""An unmarked legacy list cannot be classified safely."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class FileSnapshot:
|
|
90
|
+
existed: bool
|
|
91
|
+
content: bytes
|
|
92
|
+
mode: int
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def canonical_digest(values: tuple[str, ...]) -> str:
|
|
96
|
+
payload = json.dumps(values, separators=(",", ":")).encode("utf-8")
|
|
97
|
+
return hashlib.sha256(payload).hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
for _record in SEALED_HISTORICAL_BASES:
|
|
101
|
+
if canonical_digest(_record["plugins"]) != _record["sha256"]:
|
|
102
|
+
raise RuntimeError(f"sealed plugin history digest mismatch: {_record['id']}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def require_regular(path: pathlib.Path, *, required: bool = True) -> None:
|
|
106
|
+
if path.is_symlink():
|
|
107
|
+
raise ContractError(f"refusing symlinked config path: {path}")
|
|
108
|
+
if required and not path.is_file():
|
|
109
|
+
raise ContractError(f"required config source is unavailable: {path}")
|
|
110
|
+
if path.exists() and not path.is_file():
|
|
111
|
+
raise ContractError(f"config path is not a regular file: {path}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_mapping(path: pathlib.Path, *, required: bool = True) -> dict:
|
|
115
|
+
require_regular(path, required=required)
|
|
116
|
+
if not path.exists():
|
|
117
|
+
return {}
|
|
118
|
+
try:
|
|
119
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
120
|
+
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
121
|
+
raise ContractError(f"invalid YAML in {path}: {type(exc).__name__}") from exc
|
|
122
|
+
if not isinstance(data, dict):
|
|
123
|
+
raise ContractError(f"config root must be a mapping: {path}")
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def plain_merge(base: dict, override: dict) -> dict:
|
|
128
|
+
result = copy.deepcopy(base)
|
|
129
|
+
for key, value in override.items():
|
|
130
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
131
|
+
result[key] = plain_merge(result[key], value)
|
|
132
|
+
elif key in result and isinstance(result[key], dict) and value is None:
|
|
133
|
+
continue
|
|
134
|
+
else:
|
|
135
|
+
result[key] = copy.deepcopy(value)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply_list_patches(result: dict, directive: object) -> None:
|
|
140
|
+
if directive is None:
|
|
141
|
+
return
|
|
142
|
+
if not isinstance(directive, dict):
|
|
143
|
+
raise ContractError(f"{LIST_PATCH_KEY} must be a mapping")
|
|
144
|
+
patches = directive.get("list_patches", {})
|
|
145
|
+
if not isinstance(patches, dict):
|
|
146
|
+
raise ContractError(f"{LIST_PATCH_KEY}.list_patches must be a mapping")
|
|
147
|
+
for dotted, rule in patches.items():
|
|
148
|
+
if not isinstance(dotted, str) or not dotted or not isinstance(rule, dict):
|
|
149
|
+
raise ContractError("invalid list patch")
|
|
150
|
+
additions = rule.get("add", []) or []
|
|
151
|
+
removals = rule.get("remove", []) or []
|
|
152
|
+
if not isinstance(additions, list) or not isinstance(removals, list) or not all(
|
|
153
|
+
isinstance(item, str) for item in [*additions, *removals]
|
|
154
|
+
):
|
|
155
|
+
raise ContractError(f"list patch for {dotted} must contain string lists")
|
|
156
|
+
cursor = result
|
|
157
|
+
parts = dotted.split(".")
|
|
158
|
+
for part in parts[:-1]:
|
|
159
|
+
child = cursor.setdefault(part, {})
|
|
160
|
+
if not isinstance(child, dict):
|
|
161
|
+
raise ContractError(f"list patch parent for {dotted} is not a mapping")
|
|
162
|
+
cursor = child
|
|
163
|
+
current = cursor.get(parts[-1], []) or []
|
|
164
|
+
if not isinstance(current, list):
|
|
165
|
+
raise ContractError(f"list patch target {dotted} is not a list")
|
|
166
|
+
removed = set(removals)
|
|
167
|
+
merged = [item for item in current if item not in removed]
|
|
168
|
+
for item in additions:
|
|
169
|
+
if item not in merged:
|
|
170
|
+
merged.append(item)
|
|
171
|
+
cursor[parts[-1]] = merged
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def merge(base: dict, delta: dict) -> dict:
|
|
175
|
+
ordinary = {key: value for key, value in delta.items() if key != LIST_PATCH_KEY}
|
|
176
|
+
result = plain_merge(base, ordinary)
|
|
177
|
+
apply_list_patches(result, delta.get(LIST_PATCH_KEY))
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def role_transform(values: tuple[str, ...], role_plugin: str) -> tuple[str, ...]:
|
|
182
|
+
transformed: list[str] = []
|
|
183
|
+
for entry in (*values, role_plugin):
|
|
184
|
+
if entry == "tts/voxxy" or entry in transformed:
|
|
185
|
+
continue
|
|
186
|
+
transformed.append(entry)
|
|
187
|
+
return tuple(transformed)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def string_list(value: object, label: str) -> list[str]:
|
|
191
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
192
|
+
raise ContractError(f"{label} must be a string list")
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def ensure_patch(delta: dict) -> tuple[dict, list[str], list[str]]:
|
|
197
|
+
directive = delta.setdefault(LIST_PATCH_KEY, {})
|
|
198
|
+
if not isinstance(directive, dict):
|
|
199
|
+
raise ContractError(f"{LIST_PATCH_KEY} must be a mapping")
|
|
200
|
+
patches = directive.setdefault("list_patches", {})
|
|
201
|
+
if not isinstance(patches, dict):
|
|
202
|
+
raise ContractError(f"{LIST_PATCH_KEY}.list_patches must be a mapping")
|
|
203
|
+
patch = patches.setdefault("plugins.enabled", {})
|
|
204
|
+
if not isinstance(patch, dict):
|
|
205
|
+
raise ContractError("plugins.enabled list patch must be a mapping")
|
|
206
|
+
additions = patch.setdefault("add", [])
|
|
207
|
+
removals = patch.setdefault("remove", [])
|
|
208
|
+
return directive, string_list(additions, "plugins.enabled add patch"), string_list(
|
|
209
|
+
removals, "plugins.enabled remove patch"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def migrate_marked_snapshot(
|
|
214
|
+
delta: dict,
|
|
215
|
+
directive: dict,
|
|
216
|
+
additions: list[str],
|
|
217
|
+
removals: list[str],
|
|
218
|
+
explicit: list[str] | None,
|
|
219
|
+
role_plugin: str,
|
|
220
|
+
) -> bool:
|
|
221
|
+
migrations = directive.get("migrations", {})
|
|
222
|
+
if not isinstance(migrations, dict):
|
|
223
|
+
raise ContractError(f"{LIST_PATCH_KEY}.migrations must be a mapping")
|
|
224
|
+
marker = migrations.get(LEGACY_PROVENANCE_KEY)
|
|
225
|
+
if marker is None:
|
|
226
|
+
return False
|
|
227
|
+
if not isinstance(marker, dict):
|
|
228
|
+
raise ContractError(f"{LEGACY_PROVENANCE_KEY} migration must be a mapping")
|
|
229
|
+
if marker.get("source") != "pjangler-52d9445":
|
|
230
|
+
raise ContractError(f"{LEGACY_PROVENANCE_KEY} has unknown provenance")
|
|
231
|
+
inherited = string_list(marker.get("inherited"), f"{LEGACY_PROVENANCE_KEY}.inherited")
|
|
232
|
+
state = marker.get("state", "pending")
|
|
233
|
+
if state == "completed":
|
|
234
|
+
return True
|
|
235
|
+
if state != "pending" or explicit is None:
|
|
236
|
+
raise ContractError(f"{LEGACY_PROVENANCE_KEY} is not safely migratable")
|
|
237
|
+
inherited_role = set(role_transform(tuple(inherited), role_plugin))
|
|
238
|
+
for entry in explicit:
|
|
239
|
+
if (
|
|
240
|
+
entry not in inherited_role
|
|
241
|
+
and entry not in {role_plugin, "tts/voxxy"}
|
|
242
|
+
and entry not in additions
|
|
243
|
+
and entry not in removals
|
|
244
|
+
):
|
|
245
|
+
additions.append(entry)
|
|
246
|
+
plugins = delta["plugins"]
|
|
247
|
+
plugins.pop("enabled")
|
|
248
|
+
if not plugins:
|
|
249
|
+
delta.pop("plugins", None)
|
|
250
|
+
marker["state"] = "completed"
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def classify_unmarked_snapshot(
|
|
255
|
+
delta: dict,
|
|
256
|
+
directive: dict,
|
|
257
|
+
additions: list[str],
|
|
258
|
+
explicit: list[str],
|
|
259
|
+
role_plugin: str,
|
|
260
|
+
) -> None:
|
|
261
|
+
migrations = directive.setdefault("migrations", {})
|
|
262
|
+
if not isinstance(migrations, dict):
|
|
263
|
+
raise ContractError(f"{LIST_PATCH_KEY}.migrations must be a mapping")
|
|
264
|
+
existing = migrations.get(MIGRATION_KEY)
|
|
265
|
+
if existing is not None:
|
|
266
|
+
if not isinstance(existing, dict):
|
|
267
|
+
raise ContractError(f"{MIGRATION_KEY} must be a mapping")
|
|
268
|
+
if existing.get("source") != "pjangler-52d9445" or existing.get("state") != "completed":
|
|
269
|
+
raise ContractError(f"{MIGRATION_KEY} has invalid provenance")
|
|
270
|
+
mode = existing.get("mode")
|
|
271
|
+
if mode == "explicit-replacement":
|
|
272
|
+
return
|
|
273
|
+
if mode == "inherited-snapshot" and "enabled" not in (delta.get("plugins") or {}):
|
|
274
|
+
return
|
|
275
|
+
raise ContractError(f"{MIGRATION_KEY} state does not match plugins.enabled")
|
|
276
|
+
|
|
277
|
+
explicit_set = set(explicit)
|
|
278
|
+
matches: list[tuple[dict, tuple[str, ...]]] = []
|
|
279
|
+
for record in SEALED_HISTORICAL_BASES:
|
|
280
|
+
transformed = role_transform(record["plugins"], role_plugin)
|
|
281
|
+
if set(transformed).issubset(explicit_set):
|
|
282
|
+
matches.append((record, transformed))
|
|
283
|
+
if len(matches) > 1:
|
|
284
|
+
raise UnresolvedLegacySnapshot(
|
|
285
|
+
"unmarked plugins.enabled matches multiple sealed 52d histories; "
|
|
286
|
+
"manual provenance selection is required"
|
|
287
|
+
)
|
|
288
|
+
if len(matches) == 1:
|
|
289
|
+
record, inherited = matches[0]
|
|
290
|
+
inherited_set = set(inherited)
|
|
291
|
+
for entry in explicit:
|
|
292
|
+
if entry not in inherited_set and entry not in additions:
|
|
293
|
+
additions.append(entry)
|
|
294
|
+
plugins = delta["plugins"]
|
|
295
|
+
plugins.pop("enabled")
|
|
296
|
+
if not plugins:
|
|
297
|
+
delta.pop("plugins", None)
|
|
298
|
+
migrations[MIGRATION_KEY] = {
|
|
299
|
+
"source": "pjangler-52d9445",
|
|
300
|
+
"state": "completed",
|
|
301
|
+
"mode": "inherited-snapshot",
|
|
302
|
+
"sealed_base_id": record["id"],
|
|
303
|
+
"sealed_base_sha256": record["sha256"],
|
|
304
|
+
}
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
# A list containing no historical inherited member (apart from the
|
|
308
|
+
# role-owned Vox plugin) is demonstrably an operator replacement. A partial
|
|
309
|
+
# historical overlap is ambiguous: it might be an unknown generated fleet
|
|
310
|
+
# snapshot, so preserve its bytes and require manual provenance instead of
|
|
311
|
+
# silently freezing or deleting entries.
|
|
312
|
+
omissions = [
|
|
313
|
+
set(role_transform(record["plugins"], role_plugin)) - explicit_set
|
|
314
|
+
for record in SEALED_HISTORICAL_BASES
|
|
315
|
+
]
|
|
316
|
+
historical_inherited = set().union(
|
|
317
|
+
*(
|
|
318
|
+
set(role_transform(record["plugins"], role_plugin)) - {role_plugin}
|
|
319
|
+
for record in SEALED_HISTORICAL_BASES
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
if omissions and all(missing for missing in omissions) and not (
|
|
323
|
+
explicit_set & historical_inherited
|
|
324
|
+
):
|
|
325
|
+
migrations[MIGRATION_KEY] = {
|
|
326
|
+
"source": "pjangler-52d9445",
|
|
327
|
+
"state": "completed",
|
|
328
|
+
"mode": "explicit-replacement",
|
|
329
|
+
}
|
|
330
|
+
return
|
|
331
|
+
raise UnresolvedLegacySnapshot(
|
|
332
|
+
"unmarked plugins.enabled only partially matches sealed 52d history; "
|
|
333
|
+
"preserved it unchanged and requires manual provenance before Vox can be verified"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def reconcile_delta(delta: dict, plugin: str, voice: str) -> None:
|
|
338
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", plugin):
|
|
339
|
+
raise ContractError("invalid TTS plugin name")
|
|
340
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", voice):
|
|
341
|
+
raise ContractError("invalid TTS voice name")
|
|
342
|
+
plugins = delta.get("plugins") or {}
|
|
343
|
+
if not isinstance(plugins, dict):
|
|
344
|
+
raise ContractError("plugins delta must be a mapping")
|
|
345
|
+
explicit: list[str] | None = None
|
|
346
|
+
if "enabled" in plugins:
|
|
347
|
+
explicit = string_list(plugins["enabled"], "plugins.enabled delta")
|
|
348
|
+
directive, additions, removals = ensure_patch(delta)
|
|
349
|
+
role_plugin = f"tts/{plugin}"
|
|
350
|
+
marked = migrate_marked_snapshot(
|
|
351
|
+
delta, directive, additions, removals, explicit, role_plugin
|
|
352
|
+
)
|
|
353
|
+
if explicit is not None and not marked:
|
|
354
|
+
classify_unmarked_snapshot(delta, directive, additions, explicit, role_plugin)
|
|
355
|
+
additions[:] = [entry for entry in additions if entry not in {role_plugin, "tts/voxxy"}]
|
|
356
|
+
additions.append(role_plugin)
|
|
357
|
+
removals[:] = [entry for entry in removals if entry != role_plugin]
|
|
358
|
+
if "tts/voxxy" not in removals:
|
|
359
|
+
removals.append("tts/voxxy")
|
|
360
|
+
tts = delta.setdefault("tts", {})
|
|
361
|
+
if not isinstance(tts, dict):
|
|
362
|
+
raise ContractError("tts delta must be a mapping")
|
|
363
|
+
tts.pop("voxxy", None)
|
|
364
|
+
tts["provider"] = plugin
|
|
365
|
+
tts["voice"] = voice
|
|
366
|
+
provider = tts.setdefault(plugin, {})
|
|
367
|
+
if not isinstance(provider, dict):
|
|
368
|
+
raise ContractError(f"tts.{plugin} delta must be a mapping")
|
|
369
|
+
provider["voice"] = voice
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def comments(original: bytes) -> list[str]:
|
|
373
|
+
existing: list[str] = []
|
|
374
|
+
for line in original.decode("utf-8").splitlines() if original else []:
|
|
375
|
+
if line.lstrip().startswith("#") and line not in existing:
|
|
376
|
+
existing.append(line)
|
|
377
|
+
standard = [
|
|
378
|
+
"# Override-only delta for this Hermes profile.",
|
|
379
|
+
"# Contains configuration and secret references only; secret values remain in 1Password.",
|
|
380
|
+
]
|
|
381
|
+
return [*standard, *(line for line in existing if line not in standard)]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def render_delta(delta: dict, original: bytes) -> bytes:
|
|
385
|
+
return (
|
|
386
|
+
"\n".join(comments(original))
|
|
387
|
+
+ "\n"
|
|
388
|
+
+ yaml.safe_dump(delta, sort_keys=False)
|
|
389
|
+
).encode("utf-8")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def render_generated(base: dict, delta: dict) -> bytes:
|
|
393
|
+
header = (
|
|
394
|
+
"# GENERATED FILE -- DO NOT EDIT.\n"
|
|
395
|
+
"# source: fleet config.yaml + profile config.delta.yaml\n"
|
|
396
|
+
)
|
|
397
|
+
return (header + yaml.safe_dump(merge(base, delta), sort_keys=False)).encode("utf-8")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def snapshot(path: pathlib.Path, mode: int) -> FileSnapshot:
|
|
401
|
+
require_regular(path, required=False)
|
|
402
|
+
if not path.exists():
|
|
403
|
+
return FileSnapshot(False, b"", mode)
|
|
404
|
+
return FileSnapshot(True, path.read_bytes(), stat.S_IMODE(path.stat().st_mode))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def fsync_parent(path: pathlib.Path) -> None:
|
|
408
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
409
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
410
|
+
try:
|
|
411
|
+
directory_fd = os.open(path.parent, flags)
|
|
412
|
+
except OSError as exc:
|
|
413
|
+
if exc.errno in unsupported:
|
|
414
|
+
return
|
|
415
|
+
raise
|
|
416
|
+
try:
|
|
417
|
+
try:
|
|
418
|
+
os.fsync(directory_fd)
|
|
419
|
+
except OSError as exc:
|
|
420
|
+
if exc.errno not in unsupported:
|
|
421
|
+
raise
|
|
422
|
+
finally:
|
|
423
|
+
os.close(directory_fd)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def atomic_write(path: pathlib.Path, content: bytes, mode: int = 0o600) -> None:
|
|
427
|
+
require_regular(path, required=False)
|
|
428
|
+
if path.is_file() and path.read_bytes() == content and stat.S_IMODE(path.stat().st_mode) == mode:
|
|
429
|
+
return
|
|
430
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.voice-", dir=path.parent)
|
|
432
|
+
try:
|
|
433
|
+
os.fchmod(fd, mode)
|
|
434
|
+
with os.fdopen(fd, "wb") as handle:
|
|
435
|
+
handle.write(content)
|
|
436
|
+
handle.flush()
|
|
437
|
+
os.fsync(handle.fileno())
|
|
438
|
+
os.replace(temporary, path)
|
|
439
|
+
os.chmod(path, mode)
|
|
440
|
+
fsync_parent(path)
|
|
441
|
+
except BaseException:
|
|
442
|
+
try:
|
|
443
|
+
os.unlink(temporary)
|
|
444
|
+
except FileNotFoundError:
|
|
445
|
+
pass
|
|
446
|
+
raise
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def restore(path: pathlib.Path, original: FileSnapshot) -> None:
|
|
450
|
+
if original.existed:
|
|
451
|
+
atomic_write(path, original.content, original.mode)
|
|
452
|
+
elif path.is_file() or path.is_symlink():
|
|
453
|
+
path.unlink()
|
|
454
|
+
fsync_parent(path)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def paths(args: argparse.Namespace) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path]:
|
|
458
|
+
base = pathlib.Path(args.base)
|
|
459
|
+
delta = pathlib.Path(args.delta)
|
|
460
|
+
generated = pathlib.Path(args.generated)
|
|
461
|
+
profile = delta.parent
|
|
462
|
+
if profile.is_symlink() or not profile.is_dir():
|
|
463
|
+
raise ContractError(
|
|
464
|
+
f"profile root must be a real directory: {profile}; run the pjangler "
|
|
465
|
+
"Hermes runtime-singleton migration first"
|
|
466
|
+
)
|
|
467
|
+
require_regular(base)
|
|
468
|
+
require_regular(delta, required=False)
|
|
469
|
+
require_regular(generated, required=False)
|
|
470
|
+
return base, delta, generated
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def reconcile(args: argparse.Namespace) -> int:
|
|
474
|
+
profile = pathlib.Path(args.delta).parent
|
|
475
|
+
with PROFILE_LOCK.ProfileConfigLock(profile):
|
|
476
|
+
base_path, delta_path, generated_path = paths(args)
|
|
477
|
+
base = load_mapping(base_path)
|
|
478
|
+
delta_original = snapshot(delta_path, 0o600)
|
|
479
|
+
generated_original = snapshot(generated_path, 0o600)
|
|
480
|
+
PROFILE_LOCK.test_snapshot_barrier("voice")
|
|
481
|
+
delta = load_mapping(delta_path, required=False)
|
|
482
|
+
reconcile_delta(delta, args.plugin, args.voice)
|
|
483
|
+
delta_content = render_delta(delta, delta_original.content)
|
|
484
|
+
generated_content = render_generated(base, delta)
|
|
485
|
+
try:
|
|
486
|
+
atomic_write(delta_path, delta_content)
|
|
487
|
+
atomic_write(generated_path, generated_content)
|
|
488
|
+
except BaseException:
|
|
489
|
+
restore(generated_path, generated_original)
|
|
490
|
+
restore(delta_path, delta_original)
|
|
491
|
+
raise
|
|
492
|
+
return 0
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def check(args: argparse.Namespace) -> int:
|
|
496
|
+
try:
|
|
497
|
+
profile = pathlib.Path(args.delta).parent
|
|
498
|
+
with PROFILE_LOCK.ProfileConfigLock(profile):
|
|
499
|
+
base_path, delta_path, generated_path = paths(args)
|
|
500
|
+
base = load_mapping(base_path)
|
|
501
|
+
original_delta = load_mapping(delta_path, required=False)
|
|
502
|
+
expected_delta = copy.deepcopy(original_delta)
|
|
503
|
+
reconcile_delta(expected_delta, args.plugin, args.voice)
|
|
504
|
+
generated = load_mapping(generated_path, required=False)
|
|
505
|
+
if expected_delta == original_delta and generated == merge(
|
|
506
|
+
base, expected_delta
|
|
507
|
+
):
|
|
508
|
+
print("ok")
|
|
509
|
+
else:
|
|
510
|
+
print("drift")
|
|
511
|
+
except UnresolvedLegacySnapshot as exc:
|
|
512
|
+
print(f"manual|{exc}")
|
|
513
|
+
except ContractError as exc:
|
|
514
|
+
print(f"manual|{exc}")
|
|
515
|
+
except PROFILE_LOCK.ProfileConfigLockError as exc:
|
|
516
|
+
print(f"manual|{exc}")
|
|
517
|
+
return 0
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
521
|
+
parser = argparse.ArgumentParser()
|
|
522
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
523
|
+
for command in ("check", "reconcile"):
|
|
524
|
+
child = subparsers.add_parser(command)
|
|
525
|
+
child.add_argument("--base", required=True)
|
|
526
|
+
child.add_argument("--delta", required=True)
|
|
527
|
+
child.add_argument("--generated", required=True)
|
|
528
|
+
child.add_argument("--plugin", required=True)
|
|
529
|
+
child.add_argument("--voice", required=True)
|
|
530
|
+
return parser
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def main() -> int:
|
|
534
|
+
args = build_parser().parse_args()
|
|
535
|
+
try:
|
|
536
|
+
return check(args) if args.command == "check" else reconcile(args)
|
|
537
|
+
except UnresolvedLegacySnapshot as exc:
|
|
538
|
+
raise SystemExit(f"unresolved legacy plugin drift: {exc}") from exc
|
|
539
|
+
except ContractError as exc:
|
|
540
|
+
raise SystemExit(str(exc)) from exc
|
|
541
|
+
except PROFILE_LOCK.ProfileConfigLockError as exc:
|
|
542
|
+
raise SystemExit(str(exc)) from exc
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
if __name__ == "__main__":
|
|
546
|
+
raise SystemExit(main())
|