@delorenj/pjangler 1.4.6 → 1.5.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/README.md +41 -596
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +9385 -18379
- package/dist/mcp-server.js +9432 -17852
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -584
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- package/templates/hermes-agent/template/role.yaml.jinja +0 -112
|
@@ -1,546 +0,0 @@
|
|
|
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())
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Lifecycle helper — resolves the CANONICAL Krebs ticket lifecycle for this role.
|
|
3
|
-
#
|
|
4
|
-
# The state machine is NOT defined here. It lives in
|
|
5
|
-
# krebs/spec/lifecycle.v1.yaml, which is the contract between ticket providers
|
|
6
|
-
# and fleet orchestrators. This script is a reader: it answers "which tp band
|
|
7
|
-
# does phase X map to" and "is this ticket stale" without any repo growing its
|
|
8
|
-
# own private copy of the phase list. A second copy is how a PM ends up
|
|
9
|
-
# transitioning against labels the board no longer uses.
|
|
10
|
-
#
|
|
11
|
-
# Provider LABELS are deliberately absent — those belong to the tp adapter's
|
|
12
|
-
# normalized-state map (.scripts/providers/<provider>.sh). This layer speaks
|
|
13
|
-
# only phases and the five tp bands.
|
|
14
|
-
#
|
|
15
|
-
# Usage:
|
|
16
|
-
# lifecycle.sh phases list every phase with band + staleness
|
|
17
|
-
# lifecycle.sh band <phase> print the tp band for a phase
|
|
18
|
-
# lifecycle.sh staleness <phase> print staleness minutes ("-" if none)
|
|
19
|
-
# lifecycle.sh is-terminal <phase> exit 0 when the phase is terminal
|
|
20
|
-
# lifecycle.sh stale <phase> <iso8601> exit 0 when that timestamp is stale
|
|
21
|
-
# lifecycle.sh spec print the resolved spec path
|
|
22
|
-
set -euo pipefail
|
|
23
|
-
|
|
24
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
25
|
-
|
|
26
|
-
# Resolution order: explicit override, the 33GOD checkout, then a sibling walk
|
|
27
|
-
# for hosts that keep the platform elsewhere.
|
|
28
|
-
resolve_spec() {
|
|
29
|
-
if [ -n "${KREBS_LIFECYCLE_SPEC:-}" ] && [ -r "$KREBS_LIFECYCLE_SPEC" ]; then
|
|
30
|
-
printf '%s\n' "$KREBS_LIFECYCLE_SPEC"; return 0
|
|
31
|
-
fi
|
|
32
|
-
local candidates=(
|
|
33
|
-
"$HOME/code/33GOD/krebs/spec/lifecycle.v1.yaml"
|
|
34
|
-
"$SCRIPT_DIR/../../../../krebs/spec/lifecycle.v1.yaml"
|
|
35
|
-
"$SCRIPT_DIR/../../../../../krebs/spec/lifecycle.v1.yaml"
|
|
36
|
-
)
|
|
37
|
-
local c
|
|
38
|
-
for c in "${candidates[@]}"; do
|
|
39
|
-
[ -r "$c" ] && { printf '%s\n' "$c"; return 0; }
|
|
40
|
-
done
|
|
41
|
-
return 1
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
SPEC="$(resolve_spec || true)"
|
|
45
|
-
|
|
46
|
-
need_spec() {
|
|
47
|
-
if [ -z "$SPEC" ]; then
|
|
48
|
-
echo "lifecycle: canonical spec not found (set KREBS_LIFECYCLE_SPEC)" >&2
|
|
49
|
-
echo "lifecycle: expected krebs/spec/lifecycle.v1.yaml in the 33GOD checkout" >&2
|
|
50
|
-
exit 2
|
|
51
|
-
fi
|
|
52
|
-
if ! command -v python3 >/dev/null 2>&1; then
|
|
53
|
-
echo "lifecycle: python3 required to read $SPEC" >&2
|
|
54
|
-
exit 2
|
|
55
|
-
fi
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
# One python entrypoint for every query keeps YAML parsing in exactly one place.
|
|
59
|
-
query() {
|
|
60
|
-
need_spec
|
|
61
|
-
python3 - "$SPEC" "$@" <<'PY'
|
|
62
|
-
import sys
|
|
63
|
-
try:
|
|
64
|
-
import yaml
|
|
65
|
-
except ImportError:
|
|
66
|
-
sys.exit("lifecycle: PyYAML required to read the lifecycle spec")
|
|
67
|
-
|
|
68
|
-
spec_path, op = sys.argv[1], sys.argv[2]
|
|
69
|
-
arg = sys.argv[3] if len(sys.argv) > 3 else None
|
|
70
|
-
arg2 = sys.argv[4] if len(sys.argv) > 4 else None
|
|
71
|
-
|
|
72
|
-
spec = yaml.safe_load(open(spec_path)) or {}
|
|
73
|
-
states = {s["phase"]: s for s in (spec.get("states") or []) if s.get("phase")}
|
|
74
|
-
if not states:
|
|
75
|
-
sys.exit(f"lifecycle: no states in {spec_path}")
|
|
76
|
-
|
|
77
|
-
def need_phase(p):
|
|
78
|
-
if p not in states:
|
|
79
|
-
sys.exit(f"lifecycle: unknown phase {p!r}; known: {', '.join(states)}")
|
|
80
|
-
return states[p]
|
|
81
|
-
|
|
82
|
-
if op == "phases":
|
|
83
|
-
print(f"{'phase':<14} {'tp_band':<12} {'terminal':<9} stale_after")
|
|
84
|
-
for name, s in states.items():
|
|
85
|
-
stale = s.get("staleness_minutes")
|
|
86
|
-
print(f"{name:<14} {str(s.get('tp_band')):<12} "
|
|
87
|
-
f"{str(bool(s.get('terminal'))).lower():<9} "
|
|
88
|
-
f"{(str(stale) + 'm') if stale else '-'}")
|
|
89
|
-
elif op == "band":
|
|
90
|
-
print(need_phase(arg)["tp_band"])
|
|
91
|
-
elif op == "staleness":
|
|
92
|
-
print(need_phase(arg).get("staleness_minutes") or "-")
|
|
93
|
-
elif op == "is-terminal":
|
|
94
|
-
sys.exit(0 if need_phase(arg).get("terminal") else 1)
|
|
95
|
-
elif op == "stale":
|
|
96
|
-
from datetime import datetime, timezone
|
|
97
|
-
mins = need_phase(arg).get("staleness_minutes")
|
|
98
|
-
if not mins:
|
|
99
|
-
sys.exit(1) # phase has no staleness budget -> never stale
|
|
100
|
-
try:
|
|
101
|
-
ts = datetime.fromisoformat(arg2.replace("Z", "+00:00"))
|
|
102
|
-
except Exception:
|
|
103
|
-
sys.exit(f"lifecycle: unparseable timestamp {arg2!r} (want ISO 8601)")
|
|
104
|
-
if ts.tzinfo is None:
|
|
105
|
-
ts = ts.replace(tzinfo=timezone.utc)
|
|
106
|
-
age = (datetime.now(timezone.utc) - ts).total_seconds() / 60
|
|
107
|
-
sys.exit(0 if age > mins else 1)
|
|
108
|
-
else:
|
|
109
|
-
sys.exit(f"lifecycle: unknown operation {op!r}")
|
|
110
|
-
PY
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
case "${1:-phases}" in
|
|
114
|
-
phases) query phases ;;
|
|
115
|
-
band) query band "${2:?phase required}" ;;
|
|
116
|
-
staleness) query staleness "${2:?phase required}" ;;
|
|
117
|
-
is-terminal) query is-terminal "${2:?phase required}" ;;
|
|
118
|
-
stale) query stale "${2:?phase required}" "${3:?iso8601 timestamp required}" ;;
|
|
119
|
-
spec) need_spec; printf '%s\n' "$SPEC" ;;
|
|
120
|
-
--help|-h|help)
|
|
121
|
-
sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
|
|
122
|
-
*)
|
|
123
|
-
echo "lifecycle: unknown operation ${1}" >&2
|
|
124
|
-
echo "try: phases | band <phase> | staleness <phase> | is-terminal <phase> | stale <phase> <ts> | spec" >&2
|
|
125
|
-
exit 2 ;;
|
|
126
|
-
esac
|