@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.
Files changed (39) hide show
  1. package/README.md +528 -0
  2. package/contracts/fleet-contract.yaml +513 -0
  3. package/dist/index.js +9333 -1509
  4. package/dist/mcp-server.js +6634 -1096
  5. package/dist/prompt.js +2 -1
  6. package/package.json +10 -4
  7. package/templates/hermes-agent/copier.yml +16 -3
  8. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  9. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  10. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  11. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  12. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  13. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  14. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  15. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  16. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  17. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  18. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  19. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  20. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  21. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  22. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  23. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  25. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
  26. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  27. package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
  28. package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
  29. package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
  30. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
  32. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. 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())