@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.
Files changed (39) hide show
  1. package/dist/index.js +3336 -1359
  2. package/dist/index.js.map +7 -0
  3. package/dist/mcp-server.js +1520 -838
  4. package/dist/mcp-server.js.map +7 -0
  5. package/dist/prompt.js +2 -1
  6. package/dist/prompt.js.map +7 -0
  7. package/package.json +8 -4
  8. package/templates/hermes-agent/copier.yml +16 -3
  9. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  10. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  11. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  12. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  13. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  14. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  15. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  16. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  17. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  18. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  19. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  20. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  21. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  22. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  23. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  25. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  26. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
  27. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  28. package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
  29. package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
  30. package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
  32. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  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 +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 require the API key; fail fast and clean before any pipe.
164
- need_key
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
- printf '{"provider":"plane","board_id":"%s","board_url":"%s/%s/projects/%s/issues/"}\n' \
171
- "$PROJ" "$BASE" "$WS" "$PROJ"
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
- PID="$(api POST "projects/" \
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
- | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))')"
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
- printf '{"board_id":"%s","board_url":"%s/%s/projects/%s/issues/"}\n' "$PID" "$BASE" "$WS" "$PID"
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)