@delorenj/pjangler 1.4.2 → 1.4.3
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 +3336 -1359
- package/dist/index.js.map +7 -0
- package/dist/mcp-server.js +1520 -838
- package/dist/mcp-server.js.map +7 -0
- package/dist/prompt.js +2 -1
- package/dist/prompt.js.map +7 -0
- package/package.json +8 -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 +9 -0
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
- 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.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 +11 -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())
|
|
@@ -90,7 +90,7 @@ case "$OP" in
|
|
|
90
90
|
[ -n "$TEAM" ] || die "ticket_provider.team (Linear team key) not set in role.yaml"
|
|
91
91
|
gql 'query($k:String!){ teams(filter:{key:{eq:$k}}){nodes{id key name}} }' \
|
|
92
92
|
"$(printf '{"k":"%s"}' "$TEAM")" \
|
|
93
|
-
| python3 -c 'import sys,json; d=json.load(sys.stdin); t=(d.get("teams",{}).get("nodes") or [{}])[0]; print(json.dumps({"provider":"linear","board_id":t.get("id",""),"board_url":"https://linear.app/team/"+t.get("key","")}))'
|
|
93
|
+
| python3 -c 'import sys,json; d=json.load(sys.stdin); t=(d.get("teams",{}).get("nodes") or [{}])[0]; print(json.dumps({"provider":"linear","board_id":t.get("id",""),"board_url":"https://linear.app/team/"+t.get("key",""),"identifier":t.get("key","")}))'
|
|
94
94
|
;;
|
|
95
95
|
|
|
96
96
|
active_milestone)
|
|
@@ -169,6 +169,29 @@ print(pick.get("id",""))')"
|
|
|
169
169
|
print(("ok " + (u.get("issue") or {}).get("identifier","")) if u.get("success") else "FAILED"); sys.exit(0 if u.get("success") else 1)'
|
|
170
170
|
;;
|
|
171
171
|
|
|
172
|
+
describe_board)
|
|
173
|
+
# Read-only team lookup by id, against an EXPLICIT workspace argument so no
|
|
174
|
+
# ambient binding can answer for the wrong org. Linear's team KEY is a real
|
|
175
|
+
# authoritative identifier — it prefixes every issue — so it is read back
|
|
176
|
+
# from Linear itself and never proposed locally.
|
|
177
|
+
DWS="${1:?usage: describe_board <workspace> <board_id>}"
|
|
178
|
+
DBID="${2:?usage: describe_board <workspace> <board_id>}"
|
|
179
|
+
gql 'query($id:String!){ team(id:$id){ id key name } organization{ urlKey } }' \
|
|
180
|
+
"$(printf '{"id":"%s"}' "$DBID")" \
|
|
181
|
+
| WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
|
|
182
|
+
d = json.load(sys.stdin)
|
|
183
|
+
t = d.get("team") or {}
|
|
184
|
+
ident = str(t.get("key") or "")
|
|
185
|
+
if not ident:
|
|
186
|
+
raise SystemExit("linear: team %s reported no authoritative key" % os.environ["BID"])
|
|
187
|
+
print(json.dumps({
|
|
188
|
+
"board_id": str(t.get("id") or os.environ["BID"]),
|
|
189
|
+
"identifier": ident,
|
|
190
|
+
"workspace": str((d.get("organization") or {}).get("urlKey") or os.environ["WS"]),
|
|
191
|
+
"name": str(t.get("name") or ""),
|
|
192
|
+
}))'
|
|
193
|
+
;;
|
|
194
|
+
|
|
172
195
|
create_board)
|
|
173
196
|
# Linear teams/projects are created by humans; the adapter resolves, not creates.
|
|
174
197
|
echo "linear: create_board is a no-op (Linear team/project created via Linear UI); using resolve" >&2
|
|
@@ -160,15 +160,21 @@ pick=(named or grouped or [{}])[0]
|
|
|
160
160
|
print(pick.get("id",""))'
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
# All Plane ops
|
|
164
|
-
|
|
163
|
+
# All Plane ops except the explicit-workspace read below require the bound
|
|
164
|
+
# workspace API key; fail fast and clean before any pipe.
|
|
165
|
+
case "$OP" in describe_board) ;; *) need_key ;; esac
|
|
165
166
|
|
|
166
167
|
case "$OP" in
|
|
167
168
|
resolve)
|
|
168
169
|
[ -n "$WS" ] || die "workspace not set (.project.json ticket_provider.workspace or PLANE_WORKSPACE)"
|
|
169
170
|
[ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
PROJECT_DETAIL="$(api GET "projects/$PROJ/")"
|
|
172
|
+
LIVE_IDENTIFIER="$(printf '%s' "$PROJECT_DETAIL" | python3 -c 'import sys,json
|
|
173
|
+
try: print(str(json.load(sys.stdin).get("identifier") or ""))
|
|
174
|
+
except Exception: print("")')"
|
|
175
|
+
[ -n "$LIVE_IDENTIFIER" ] || die "live Plane project omitted its authoritative identifier"
|
|
176
|
+
printf '{"provider":"plane","board_id":"%s","board_url":"%s/%s/projects/%s/issues/","identifier":"%s"}\n' \
|
|
177
|
+
"$PROJ" "$BASE" "$WS" "$PROJ" "$LIVE_IDENTIFIER"
|
|
172
178
|
;;
|
|
173
179
|
|
|
174
180
|
active_milestone)
|
|
@@ -252,6 +258,36 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("sequence_id",""),"title":i.ge
|
|
|
252
258
|
| python3 -c 'import sys,json; d=json.load(sys.stdin); print("ok "+str(d.get("sequence_id","")) )'
|
|
253
259
|
;;
|
|
254
260
|
|
|
261
|
+
describe_board)
|
|
262
|
+
# Read-only board lookup against an EXPLICIT workspace argument, so the
|
|
263
|
+
# .project.json / role.yaml / env workspace precedence can never silently
|
|
264
|
+
# query the wrong workspace. Emits Plane's own identifier, never a guess.
|
|
265
|
+
DWS="${1:?usage: describe_board <workspace> <board_id>}"
|
|
266
|
+
DBID="${2:?usage: describe_board <workspace> <board_id>}"
|
|
267
|
+
DKEYVAR="$(workspace_key "$DWS")"
|
|
268
|
+
DKEY="$(printenv "$DKEYVAR" 2>/dev/null || true)"
|
|
269
|
+
if [ -z "$DKEY" ] && [ -f "$FLEET_ENV" ]; then
|
|
270
|
+
DKEY="$(dotenv_value "$FLEET_ENV" "$DKEYVAR")"
|
|
271
|
+
fi
|
|
272
|
+
[ -n "$DKEY" ] || DKEY="${PLANE_API_KEY:-}"
|
|
273
|
+
DKEY="$(resolve_secret_value "$DKEY")"
|
|
274
|
+
[ -n "$DKEY" ] || die "no Plane API key for workspace '$DWS' (looked for $DKEYVAR)"
|
|
275
|
+
DETAIL="$(curl -fsS "$BASE/api/v1/workspaces/$DWS/projects/$DBID/" \
|
|
276
|
+
-H "X-API-Key: $DKEY" -H "User-Agent: curl/8.0")" \
|
|
277
|
+
|| die "describe_board failed for $DWS/$DBID"
|
|
278
|
+
printf '%s' "$DETAIL" | WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
|
|
279
|
+
d = json.load(sys.stdin)
|
|
280
|
+
ident = str(d.get("identifier") or "")
|
|
281
|
+
if not ident:
|
|
282
|
+
raise SystemExit("plane: live Plane project omitted its authoritative identifier")
|
|
283
|
+
print(json.dumps({
|
|
284
|
+
"board_id": str(d.get("id") or os.environ["BID"]),
|
|
285
|
+
"identifier": ident,
|
|
286
|
+
"workspace": os.environ["WS"],
|
|
287
|
+
"name": str(d.get("name") or ""),
|
|
288
|
+
}))'
|
|
289
|
+
;;
|
|
290
|
+
|
|
255
291
|
create_board)
|
|
256
292
|
NAME="${1:?usage: create_board <name> <ident> <desc>}"; IDENT="${2:-}"; DESC="${3:-}"
|
|
257
293
|
[ -n "$WS" ] || die "workspace not set"
|
|
@@ -265,13 +301,23 @@ pid=next((p["id"] for p in rows if str(p.get("name","")).strip().lower()==name),
|
|
|
265
301
|
if not pid and ident:
|
|
266
302
|
pid=next((p["id"] for p in rows if (p.get("identifier") or "").upper()==ident), "")
|
|
267
303
|
print(pid)')"
|
|
304
|
+
LIVE_IDENTIFIER=""
|
|
268
305
|
if [ -n "$EXIST" ]; then PID="$EXIST"; else
|
|
269
|
-
|
|
270
|
-
"$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"identifier":sys.argv[2],"description":sys.argv[3]}))' "$NAME" "$IDENT" "$DESC")"
|
|
271
|
-
|
|
306
|
+
CREATED="$(api POST "projects/" \
|
|
307
|
+
"$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"identifier":sys.argv[2],"description":sys.argv[3]}))' "$NAME" "$IDENT" "$DESC")")"
|
|
308
|
+
PID="$(printf '%s' "$CREATED" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))')"
|
|
309
|
+
LIVE_IDENTIFIER="$(printf '%s' "$CREATED" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("identifier","") or "")')"
|
|
272
310
|
fi
|
|
273
311
|
[ -n "$PID" ] || die "create_board failed"
|
|
274
|
-
|
|
312
|
+
if [ -z "$LIVE_IDENTIFIER" ]; then
|
|
313
|
+
DETAIL="$(api GET "projects/$PID/")"
|
|
314
|
+
LIVE_IDENTIFIER="$(printf '%s' "$DETAIL" | python3 -c 'import sys,json
|
|
315
|
+
try: print(str(json.load(sys.stdin).get("identifier") or ""))
|
|
316
|
+
except Exception: print("")')"
|
|
317
|
+
fi
|
|
318
|
+
[ -n "$LIVE_IDENTIFIER" ] || die "live Plane project omitted its authoritative identifier"
|
|
319
|
+
printf '{"board_id":"%s","board_url":"%s/%s/projects/%s/issues/","identifier":"%s"}\n' \
|
|
320
|
+
"$PID" "$BASE" "$WS" "$PID" "$LIVE_IDENTIFIER"
|
|
275
321
|
;;
|
|
276
322
|
|
|
277
323
|
create_issue)
|