@delorenj/pjangler 1.3.0 → 1.4.2

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 (43) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +92 -0
  3. package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
  4. package/dist/assets/project-notebook-skill/SKILL.md +64 -0
  5. package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
  6. package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
  7. package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
  8. package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
  9. package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
  10. package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
  11. package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
  12. package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
  13. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
  14. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
  15. package/dist/index.js +11307 -2809
  16. package/dist/mcp-server.js +9637 -2094
  17. package/dist/prompt.js +404 -0
  18. package/package.json +8 -5
  19. package/templates/commonproject/copier.yml +19 -5
  20. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  21. package/templates/commonproject/template/.mise/scripts/provision-packs.py +74 -52
  22. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  23. package/templates/commonproject/template/mise.toml.jinja +12 -6
  24. package/templates/hermes-agent/copier.yml +8 -11
  25. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  26. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  27. package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
  28. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  29. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
  30. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  31. package/templates/hermes-agent/template/.scripts/70-systemd.sh +77 -43
  32. package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
  33. package/templates/hermes-agent/template/.scripts/_lib.sh +116 -16
  34. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  35. package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
  36. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  37. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +761 -0
  38. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  39. package/templates/hermes-agent/template/.scripts/providers/plane.sh +19 -3
  40. package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
  41. package/templates/hermes-agent/template/hermes.jinja +20 -8
  42. package/templates/hermes-agent/template/momo.jinja +177 -0
  43. package/templates/hermes-agent/template/role.yaml.jinja +19 -19
@@ -0,0 +1,865 @@
1
+ #!/usr/bin/env python3
2
+ """Project the canonical Project Notebook hooks into Claude settings safely."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import contextlib
8
+ import copy
9
+ import fcntl
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import posixpath
14
+ import secrets
15
+ import shlex
16
+ import stat
17
+ import sys
18
+ from collections.abc import Iterator, Sequence
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
24
+ DEFAULT_MASTER = SKILL_ROOT / "hooks" / "hooks.master.json"
25
+ DEFAULT_FRAGMENT = SKILL_ROOT / "hooks" / "claude.settings.json"
26
+ OWNER_PREFIX = "PJ_HOOK_OWNER=project-notebook.v1 "
27
+ EVENT_WRAPPERS = {
28
+ "SessionStart": "$HOME/.agents/skills/project-notebook/hooks/session-start.sh",
29
+ "SessionEnd": "$HOME/.agents/skills/project-notebook/hooks/session-end.sh",
30
+ }
31
+ EVENT_ORDER = tuple(EVENT_WRAPPERS)
32
+ MAX_JSON_BYTES = 8 * 1024 * 1024
33
+
34
+
35
+ class ProjectorError(RuntimeError):
36
+ """A bounded validation or projection failure."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Generation:
41
+ exists: bool
42
+ device: int | None
43
+ inode: int | None
44
+ size: int
45
+ modified_ns: int | None
46
+ digest: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class LockedState:
51
+ hook_install_fd: int
52
+ snapshots_fd: int
53
+
54
+
55
+ def _canonical_command(event: str) -> str:
56
+ return f'{OWNER_PREFIX}"{EVENT_WRAPPERS[event]}"'
57
+
58
+
59
+ def _absolute_path(raw: str | os.PathLike[str], label: str) -> Path:
60
+ path = Path(raw).expanduser()
61
+ if not path.is_absolute():
62
+ raise ProjectorError(f"{label} must be an absolute path")
63
+ if any(component in (".", "..") for component in path.parts[1:]):
64
+ raise ProjectorError(f"{label} must not contain dot path components")
65
+ return path
66
+
67
+
68
+ def _directory_flags() -> int:
69
+ if not hasattr(os, "O_DIRECTORY") or not hasattr(os, "O_NOFOLLOW"):
70
+ raise ProjectorError("platform lacks required no-follow directory APIs")
71
+ return os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
72
+
73
+
74
+ def _regular_read_flags() -> int:
75
+ if not hasattr(os, "O_NOFOLLOW"):
76
+ raise ProjectorError("platform lacks required no-follow file APIs")
77
+ return os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
78
+
79
+
80
+ def _leaf_name(name: str, label: str) -> str:
81
+ if not name or name in (".", "..") or "/" in name or "\0" in name:
82
+ raise ProjectorError(f"{label} has an unsafe leaf name")
83
+ return name
84
+
85
+
86
+ def _open_absolute_directory(
87
+ path: Path,
88
+ label: str,
89
+ *,
90
+ create_missing: bool,
91
+ require_final_owner: bool = False,
92
+ ) -> int | None:
93
+ if not path.is_absolute():
94
+ raise ProjectorError(f"{label} must be an absolute path")
95
+ components = path.parts[1:]
96
+ if any(component in ("", ".", "..") for component in components):
97
+ raise ProjectorError(f"{label} contains an unsafe path component")
98
+ try:
99
+ current_fd = os.open("/", _directory_flags())
100
+ except OSError as exc:
101
+ raise ProjectorError(f"cannot open filesystem root for {label}: {exc.strerror}") from exc
102
+ try:
103
+ for component in components:
104
+ created = False
105
+ try:
106
+ child_fd = os.open(component, _directory_flags(), dir_fd=current_fd)
107
+ except FileNotFoundError:
108
+ if not create_missing:
109
+ os.close(current_fd)
110
+ return None
111
+ try:
112
+ os.mkdir(component, 0o700, dir_fd=current_fd)
113
+ created = True
114
+ except FileExistsError:
115
+ pass
116
+ except (OSError, TypeError) as exc:
117
+ detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
118
+ raise ProjectorError(
119
+ f"cannot create descriptor-relative {label}: {detail}"
120
+ ) from exc
121
+ try:
122
+ child_fd = os.open(component, _directory_flags(), dir_fd=current_fd)
123
+ except (OSError, TypeError) as exc:
124
+ detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
125
+ raise ProjectorError(
126
+ f"cannot open descriptor-relative {label}: {detail}"
127
+ ) from exc
128
+ except (OSError, TypeError) as exc:
129
+ detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
130
+ raise ProjectorError(f"cannot open descriptor-relative {label}: {detail}") from exc
131
+ os.close(current_fd)
132
+ current_fd = child_fd
133
+ information = os.fstat(current_fd)
134
+ if not stat.S_ISDIR(information.st_mode):
135
+ raise ProjectorError(f"{label} must contain only real directories")
136
+ if created and (
137
+ information.st_uid != os.getuid() or stat.S_IMODE(information.st_mode) != 0o700
138
+ ):
139
+ raise ProjectorError(f"new {label} directory must be current-user mode 0700")
140
+ information = os.fstat(current_fd)
141
+ if require_final_owner and information.st_uid != os.getuid():
142
+ raise ProjectorError(f"{label} must be owned by the current user")
143
+ return current_fd
144
+ except Exception:
145
+ os.close(current_fd)
146
+ raise
147
+
148
+
149
+ def _open_private_directory_at(parent_fd: int, name: str, label: str) -> int:
150
+ name = _leaf_name(name, label)
151
+ try:
152
+ os.mkdir(name, 0o700, dir_fd=parent_fd)
153
+ except FileExistsError:
154
+ pass
155
+ except (OSError, TypeError) as exc:
156
+ detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
157
+ raise ProjectorError(f"cannot create descriptor-relative {label}: {detail}") from exc
158
+ try:
159
+ descriptor = os.open(name, _directory_flags(), dir_fd=parent_fd)
160
+ except (OSError, TypeError) as exc:
161
+ detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
162
+ raise ProjectorError(f"cannot open descriptor-relative {label}: {detail}") from exc
163
+ try:
164
+ information = os.fstat(descriptor)
165
+ if (
166
+ not stat.S_ISDIR(information.st_mode)
167
+ or information.st_uid != os.getuid()
168
+ or stat.S_IMODE(information.st_mode) != 0o700
169
+ ):
170
+ raise ProjectorError(f"{label} must be a current-user mode 0700 directory")
171
+ return descriptor
172
+ except Exception:
173
+ os.close(descriptor)
174
+ raise
175
+
176
+
177
+ def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
178
+ result: dict[str, Any] = {}
179
+ for key, value in pairs:
180
+ if key in result:
181
+ raise ProjectorError(f"duplicate JSON key: {key}")
182
+ result[key] = value
183
+ return result
184
+
185
+
186
+ def _parse_json_object(raw: bytes, label: str) -> dict[str, Any]:
187
+ if len(raw) > MAX_JSON_BYTES:
188
+ raise ProjectorError(f"{label} exceeds {MAX_JSON_BYTES} bytes")
189
+ try:
190
+ text = raw.decode("utf-8")
191
+ except UnicodeDecodeError as exc:
192
+ raise ProjectorError(f"{label} is not UTF-8") from exc
193
+ try:
194
+ value = json.loads(text, object_pairs_hook=_reject_duplicate_keys)
195
+ except ProjectorError:
196
+ raise
197
+ except json.JSONDecodeError as exc:
198
+ raise ProjectorError(
199
+ f"{label} is invalid JSON at line {exc.lineno}, column {exc.colno}"
200
+ ) from exc
201
+ if not isinstance(value, dict):
202
+ raise ProjectorError(f"{label} must contain one JSON object")
203
+ return value
204
+
205
+
206
+ def _open_regular_at(parent_fd: int, name: str, label: str) -> tuple[int, os.stat_result]:
207
+ name = _leaf_name(name, label)
208
+ try:
209
+ descriptor = os.open(name, _regular_read_flags(), dir_fd=parent_fd)
210
+ except FileNotFoundError:
211
+ raise
212
+ except (OSError, TypeError) as exc:
213
+ if isinstance(exc, TypeError):
214
+ raise ProjectorError(f"platform cannot safely open {label}") from exc
215
+ raise ProjectorError(f"cannot open {label}: {exc.strerror}") from exc
216
+ try:
217
+ info = os.fstat(descriptor)
218
+ if not stat.S_ISREG(info.st_mode):
219
+ raise ProjectorError(f"{label} must be a regular file")
220
+ if info.st_uid != os.getuid():
221
+ raise ProjectorError(f"{label} must be owned by the current user")
222
+ if info.st_size > MAX_JSON_BYTES:
223
+ raise ProjectorError(f"{label} exceeds {MAX_JSON_BYTES} bytes")
224
+ return descriptor, info
225
+ except Exception:
226
+ os.close(descriptor)
227
+ raise
228
+
229
+
230
+ def _read_descriptor(descriptor: int, expected_size: int, label: str) -> bytes:
231
+ chunks: list[bytes] = []
232
+ total = 0
233
+ while True:
234
+ chunk = os.read(descriptor, min(65536, MAX_JSON_BYTES + 1 - total))
235
+ if not chunk:
236
+ break
237
+ chunks.append(chunk)
238
+ total += len(chunk)
239
+ if total > MAX_JSON_BYTES:
240
+ raise ProjectorError(f"{label} exceeds {MAX_JSON_BYTES} bytes")
241
+ raw = b"".join(chunks)
242
+ if len(raw) != expected_size:
243
+ raise ProjectorError(f"{label} changed while it was being read")
244
+ return raw
245
+
246
+
247
+ def _read_regular_at_bytes(parent_fd: int, name: str, label: str) -> bytes:
248
+ descriptor, information = _open_regular_at(parent_fd, name, label)
249
+ try:
250
+ return _read_descriptor(descriptor, information.st_size, label)
251
+ finally:
252
+ os.close(descriptor)
253
+
254
+
255
+ def _read_regular_bytes(path: Path, label: str) -> bytes:
256
+ parent_fd = _open_absolute_directory(path.parent, f"{label} parent", create_missing=False)
257
+ if parent_fd is None:
258
+ raise ProjectorError(f"{label} does not exist")
259
+ try:
260
+ try:
261
+ return _read_regular_at_bytes(parent_fd, path.name, label)
262
+ except FileNotFoundError as exc:
263
+ raise ProjectorError(f"{label} does not exist") from exc
264
+ finally:
265
+ os.close(parent_fd)
266
+
267
+
268
+ def _absent_target() -> tuple[bytes, dict[str, Any], Generation]:
269
+ return (
270
+ b"",
271
+ {},
272
+ Generation(False, None, None, 0, None, hashlib.sha256(b"").hexdigest()),
273
+ )
274
+
275
+
276
+ def _read_target_at(parent_fd: int, name: str) -> tuple[bytes, dict[str, Any], Generation]:
277
+ try:
278
+ descriptor, info = _open_regular_at(parent_fd, name, "Claude settings target")
279
+ except FileNotFoundError:
280
+ return _absent_target()
281
+ try:
282
+ raw = _read_descriptor(descriptor, info.st_size, "Claude settings target")
283
+ finally:
284
+ os.close(descriptor)
285
+ value = _parse_json_object(raw, "Claude settings target")
286
+ _validate_live_shape(value)
287
+ generation = Generation(
288
+ True,
289
+ info.st_dev,
290
+ info.st_ino,
291
+ info.st_size,
292
+ info.st_mtime_ns,
293
+ hashlib.sha256(raw).hexdigest(),
294
+ )
295
+ return raw, value, generation
296
+
297
+
298
+ def _read_target(path: Path) -> tuple[bytes, dict[str, Any], Generation]:
299
+ parent_fd = _open_absolute_directory(
300
+ path.parent,
301
+ "Claude settings parent",
302
+ create_missing=False,
303
+ require_final_owner=True,
304
+ )
305
+ if parent_fd is None:
306
+ return _absent_target()
307
+ try:
308
+ return _read_target_at(parent_fd, path.name)
309
+ finally:
310
+ os.close(parent_fd)
311
+
312
+
313
+ def _validate_master(master: dict[str, Any]) -> None:
314
+ if list(master) != ["hooks"]:
315
+ raise ProjectorError("hook master must contain only the hooks key")
316
+ hooks = master.get("hooks")
317
+ if not isinstance(hooks, dict) or list(hooks) != list(EVENT_ORDER):
318
+ raise ProjectorError("hook master events must be SessionStart then SessionEnd")
319
+ if "Stop" in hooks:
320
+ raise ProjectorError("hook master must never contain Stop")
321
+ for event in EVENT_ORDER:
322
+ groups = hooks[event]
323
+ if not isinstance(groups, list) or len(groups) != 1:
324
+ raise ProjectorError(f"hook master {event} must contain one group")
325
+ group = groups[0]
326
+ if not isinstance(group, dict) or list(group) != ["hooks"]:
327
+ raise ProjectorError(f"hook master {event} group must contain only hooks")
328
+ entries = group["hooks"]
329
+ if not isinstance(entries, list) or len(entries) != 1:
330
+ raise ProjectorError(f"hook master {event} must contain one hook")
331
+ hook = entries[0]
332
+ if not isinstance(hook, dict) or list(hook) != ["type", "command", "timeout"]:
333
+ raise ProjectorError(f"hook master {event} hook keys must be type, command, timeout")
334
+ if hook["type"] != "command":
335
+ raise ProjectorError(f"hook master {event} type must be command")
336
+ if hook["command"] != _canonical_command(event):
337
+ raise ProjectorError(f"hook master {event} command is not canonical")
338
+ timeout = hook["timeout"]
339
+ if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 60:
340
+ raise ProjectorError(f"hook master {event} timeout must be an integer 1..60")
341
+
342
+
343
+ def _deterministic_bytes(value: dict[str, Any]) -> bytes:
344
+ return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
345
+
346
+
347
+ def _load_master(path: Path) -> tuple[dict[str, Any], bytes]:
348
+ raw = _read_regular_bytes(path, "hook master")
349
+ master = _parse_json_object(raw, "hook master")
350
+ _validate_master(master)
351
+ return master, _deterministic_bytes(master)
352
+
353
+
354
+ def _load_fragment(path: Path) -> tuple[dict[str, Any], bytes]:
355
+ raw = _read_regular_bytes(path, "Claude generated fragment")
356
+ fragment = _parse_json_object(raw, "Claude generated fragment")
357
+ return fragment, raw
358
+
359
+
360
+ def _load_assets(master_path: Path, fragment_path: Path) -> dict[str, Any]:
361
+ master, expected = _load_master(master_path)
362
+ fragment, raw = _load_fragment(fragment_path)
363
+ if fragment != master or raw != expected:
364
+ raise ProjectorError("Claude generated fragment is stale; run project-hooks.py render")
365
+ return master
366
+
367
+
368
+ def _validate_live_shape(value: dict[str, Any]) -> None:
369
+ if "hooks" not in value:
370
+ return
371
+ hooks = value["hooks"]
372
+ if not isinstance(hooks, dict):
373
+ raise ProjectorError("Claude settings hooks must be an object")
374
+ for event, groups in hooks.items():
375
+ if not isinstance(event, str) or not isinstance(groups, list):
376
+ raise ProjectorError("each Claude hook event must contain a group array")
377
+ for group in groups:
378
+ if not isinstance(group, dict):
379
+ raise ProjectorError(f"Claude hook group for {event} must be an object")
380
+ if "hooks" not in group:
381
+ continue
382
+ entries = group["hooks"]
383
+ if not isinstance(entries, list):
384
+ raise ProjectorError(f"Claude hook group hooks for {event} must be an array")
385
+ if not all(isinstance(entry, dict) for entry in entries):
386
+ raise ProjectorError(f"Claude hook entries for {event} must be objects")
387
+
388
+
389
+ def _normalized_wrapper(token: str) -> str | None:
390
+ if token.startswith("${HOME}/"):
391
+ token = "$HOME/" + token[len("${HOME}/") :]
392
+ elif token.startswith("~/"):
393
+ token = "$HOME/" + token[2:]
394
+ if not token.startswith("$HOME/"):
395
+ return None
396
+ suffix = posixpath.normpath(token[len("$HOME/") :])
397
+ if suffix == "." or suffix.startswith("../") or suffix == "..":
398
+ return None
399
+ return "$HOME/" + suffix
400
+
401
+
402
+ def _marked_wrapper(command: Any) -> tuple[bool, str | None]:
403
+ if not isinstance(command, str) or not command.startswith(OWNER_PREFIX):
404
+ return False, None
405
+ tail = command[len(OWNER_PREFIX) :]
406
+ try:
407
+ tokens = shlex.split(tail, posix=True)
408
+ except ValueError:
409
+ return True, None
410
+ if len(tokens) != 1:
411
+ return True, None
412
+ normalized = _normalized_wrapper(tokens[0])
413
+ if normalized is None:
414
+ return True, None
415
+ for event, wrapper in EVENT_WRAPPERS.items():
416
+ if normalized == wrapper:
417
+ return True, event
418
+ return True, None
419
+
420
+
421
+ def _is_owned(hook: dict[str, Any], event: str) -> bool:
422
+ marked, wrapper_event = _marked_wrapper(hook.get("command"))
423
+ return marked and wrapper_event == event
424
+
425
+
426
+ def _dedicated_owned_group(group: dict[str, Any], event: str) -> bool:
427
+ entries = group.get("hooks")
428
+ return (
429
+ set(group) == {"hooks"}
430
+ and isinstance(entries, list)
431
+ and bool(entries)
432
+ and all(_is_owned(entry, event) for entry in entries)
433
+ )
434
+
435
+
436
+ def _install_projection(live: dict[str, Any], fragment: dict[str, Any]) -> dict[str, Any]:
437
+ merged = copy.deepcopy(live)
438
+ hooks = merged.setdefault("hooks", {})
439
+ for event in EVENT_ORDER:
440
+ canonical_group = fragment["hooks"][event][0]
441
+ canonical_hook = canonical_group["hooks"][0]
442
+ groups = hooks.setdefault(event, [])
443
+ seen = False
444
+ next_groups: list[dict[str, Any]] = []
445
+ for group in groups:
446
+ entries = group.get("hooks")
447
+ if entries is None:
448
+ next_groups.append(group)
449
+ continue
450
+ was_dedicated = _dedicated_owned_group(group, event)
451
+ next_entries: list[dict[str, Any]] = []
452
+ removed = False
453
+ for hook in entries:
454
+ if not _is_owned(hook, event):
455
+ next_entries.append(hook)
456
+ continue
457
+ if not seen:
458
+ next_entries.append(copy.deepcopy(canonical_hook))
459
+ seen = True
460
+ else:
461
+ removed = True
462
+ group["hooks"] = next_entries
463
+ if removed and not next_entries and was_dedicated:
464
+ continue
465
+ next_groups.append(group)
466
+ if not seen:
467
+ next_groups.append(copy.deepcopy(canonical_group))
468
+ hooks[event] = next_groups
469
+ return merged
470
+
471
+
472
+ def _uninstall_projection(live: dict[str, Any]) -> dict[str, Any]:
473
+ merged = copy.deepcopy(live)
474
+ hooks = merged.get("hooks")
475
+ if not isinstance(hooks, dict):
476
+ return merged
477
+ for event in EVENT_ORDER:
478
+ groups = hooks.get(event)
479
+ if not isinstance(groups, list):
480
+ continue
481
+ event_removed = False
482
+ next_groups: list[dict[str, Any]] = []
483
+ for group in groups:
484
+ entries = group.get("hooks")
485
+ if entries is None:
486
+ next_groups.append(group)
487
+ continue
488
+ was_dedicated = _dedicated_owned_group(group, event)
489
+ next_entries = [entry for entry in entries if not _is_owned(entry, event)]
490
+ removed = len(next_entries) != len(entries)
491
+ event_removed = event_removed or removed
492
+ group["hooks"] = next_entries
493
+ if removed and not next_entries and was_dedicated:
494
+ continue
495
+ next_groups.append(group)
496
+ if event_removed and not next_groups:
497
+ del hooks[event]
498
+ else:
499
+ hooks[event] = next_groups
500
+ return merged
501
+
502
+
503
+ def _check_findings(live: dict[str, Any], fragment: dict[str, Any]) -> list[dict[str, Any]]:
504
+ findings: list[dict[str, Any]] = []
505
+ hooks = live.get("hooks")
506
+ if not isinstance(hooks, dict):
507
+ hooks = {}
508
+
509
+ owned: dict[str, list[dict[str, Any]]] = {event: [] for event in EVENT_ORDER}
510
+ for actual_event, groups in hooks.items():
511
+ for group_index, group in enumerate(groups):
512
+ for hook_index, hook in enumerate(group.get("hooks", [])):
513
+ marked, wrapper_event = _marked_wrapper(hook.get("command"))
514
+ if not marked:
515
+ continue
516
+ if wrapper_event != actual_event or actual_event not in EVENT_ORDER:
517
+ findings.append(
518
+ {
519
+ "kind": "foreign-conflict",
520
+ "event": actual_event,
521
+ "group": group_index,
522
+ "hook": hook_index,
523
+ "message": "owner marker has an unknown or event-mismatched wrapper",
524
+ }
525
+ )
526
+ continue
527
+ owned[actual_event].append(hook)
528
+
529
+ for event in EVENT_ORDER:
530
+ entries = owned[event]
531
+ if not entries:
532
+ findings.append(
533
+ {"kind": "missing", "event": event, "message": "canonical hook is absent"}
534
+ )
535
+ continue
536
+ if len(entries) > 1:
537
+ findings.append(
538
+ {
539
+ "kind": "duplicate",
540
+ "event": event,
541
+ "count": len(entries),
542
+ "message": "multiple owned hooks are installed",
543
+ }
544
+ )
545
+ canonical = fragment["hooks"][event][0]["hooks"][0]
546
+ if entries[0] != canonical:
547
+ findings.append(
548
+ {"kind": "stale", "event": event, "message": "owned hook differs from master"}
549
+ )
550
+ return findings
551
+
552
+
553
+ def _state_home(raw: str | None) -> Path:
554
+ if raw:
555
+ return _absolute_path(raw, "state home")
556
+ configured = os.environ.get("XDG_STATE_HOME")
557
+ if configured:
558
+ return _absolute_path(configured, "XDG_STATE_HOME")
559
+ home = os.environ.get("HOME")
560
+ if not home:
561
+ raise ProjectorError("HOME or XDG_STATE_HOME is required for mutation")
562
+ return _absolute_path(str(Path(home) / ".local" / "state"), "state home")
563
+
564
+
565
+ @contextlib.contextmanager
566
+ def _projector_lock(state_home: Path) -> Iterator[LockedState]:
567
+ state_fd = _open_absolute_directory(state_home, "state home", create_missing=True)
568
+ if state_fd is None:
569
+ raise AssertionError("create_missing directory traversal returned no descriptor")
570
+ current_fd = state_fd
571
+ hook_install_fd = -1
572
+ snapshots_fd = -1
573
+ lock_fd = -1
574
+ try:
575
+ for name in ("pjangler", "notebook", "v1", "hook-install"):
576
+ child_fd = _open_private_directory_at(current_fd, name, "state directory")
577
+ os.close(current_fd)
578
+ current_fd = child_fd
579
+ hook_install_fd = current_fd
580
+ current_fd = -1
581
+ snapshots_fd = _open_private_directory_at(
582
+ hook_install_fd, "snapshots", "snapshot directory"
583
+ )
584
+ flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
585
+ try:
586
+ lock_fd = os.open("lock", flags, 0o600, dir_fd=hook_install_fd)
587
+ except (OSError, TypeError) as exc:
588
+ detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
589
+ raise ProjectorError(
590
+ f"cannot open descriptor-relative projector lock: {detail}"
591
+ ) from exc
592
+ info = os.fstat(lock_fd)
593
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
594
+ raise ProjectorError("projector lock must be a current-user regular file")
595
+ os.fchmod(lock_fd, 0o600)
596
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
597
+ yield LockedState(hook_install_fd, snapshots_fd)
598
+ finally:
599
+ if lock_fd >= 0:
600
+ try:
601
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
602
+ finally:
603
+ os.close(lock_fd)
604
+ if snapshots_fd >= 0:
605
+ os.close(snapshots_fd)
606
+ if hook_install_fd >= 0:
607
+ os.close(hook_install_fd)
608
+ if current_fd >= 0:
609
+ os.close(current_fd)
610
+
611
+
612
+ def _atomic_write_at(parent_fd: int, name: str, raw: bytes, mode: int) -> None:
613
+ name = _leaf_name(name, "atomic write target")
614
+ temporary_name = ""
615
+ descriptor = -1
616
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
617
+ for _ in range(16):
618
+ candidate = f".{name}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
619
+ try:
620
+ descriptor = os.open(candidate, flags, mode, dir_fd=parent_fd)
621
+ except FileExistsError:
622
+ continue
623
+ except (OSError, TypeError) as exc:
624
+ detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
625
+ raise ProjectorError(
626
+ f"cannot create descriptor-relative temporary file: {detail}"
627
+ ) from exc
628
+ temporary_name = candidate
629
+ break
630
+ if descriptor < 0:
631
+ raise ProjectorError("cannot allocate a unique descriptor-relative temporary file")
632
+ try:
633
+ os.fchmod(descriptor, mode)
634
+ written = 0
635
+ while written < len(raw):
636
+ count = os.write(descriptor, raw[written:])
637
+ if count <= 0:
638
+ raise ProjectorError("short write to descriptor-relative temporary file")
639
+ written += count
640
+ os.fsync(descriptor)
641
+ os.close(descriptor)
642
+ descriptor = -1
643
+ try:
644
+ os.replace(
645
+ temporary_name,
646
+ name,
647
+ src_dir_fd=parent_fd,
648
+ dst_dir_fd=parent_fd,
649
+ )
650
+ except (OSError, TypeError) as exc:
651
+ detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
652
+ raise ProjectorError(f"descriptor-relative atomic replace failed: {detail}") from exc
653
+ temporary_name = ""
654
+ os.fsync(parent_fd)
655
+ finally:
656
+ if descriptor >= 0:
657
+ os.close(descriptor)
658
+ if temporary_name:
659
+ try:
660
+ os.unlink(temporary_name, dir_fd=parent_fd)
661
+ except FileNotFoundError:
662
+ pass
663
+
664
+
665
+ def _same_generation_at(parent_fd: int, name: str, expected: Generation) -> bool:
666
+ raw, _, actual = _read_target_at(parent_fd, name)
667
+ del raw
668
+ return actual == expected
669
+
670
+
671
+ def _write_snapshot(snapshots_fd: int, raw: bytes) -> str:
672
+ digest = hashlib.sha256(raw).hexdigest()
673
+ name = f"{digest}.json"
674
+ try:
675
+ descriptor, information = _open_regular_at(snapshots_fd, name, "recovery snapshot")
676
+ except FileNotFoundError:
677
+ _atomic_write_at(snapshots_fd, name, raw, 0o600)
678
+ else:
679
+ try:
680
+ existing = _read_descriptor(descriptor, information.st_size, "recovery snapshot")
681
+ finally:
682
+ os.close(descriptor)
683
+ if existing != raw:
684
+ raise ProjectorError("content-addressed recovery snapshot collision")
685
+ if stat.S_IMODE(information.st_mode) != 0o600:
686
+ raise ProjectorError("recovery snapshot must be mode 0600")
687
+ return name
688
+
689
+
690
+ def _mutate(
691
+ operation: str,
692
+ master_path: Path,
693
+ fragment_path: Path,
694
+ target: Path,
695
+ state_home: Path,
696
+ ) -> bool:
697
+ fragment = _load_assets(master_path, fragment_path)
698
+ _read_target(target) # Preflight JSON before creating lock or recovery state.
699
+ with _projector_lock(state_home) as locked:
700
+ target_parent_fd = _open_absolute_directory(
701
+ target.parent,
702
+ "Claude settings parent",
703
+ create_missing=True,
704
+ require_final_owner=True,
705
+ )
706
+ if target_parent_fd is None:
707
+ raise AssertionError("create_missing target traversal returned no descriptor")
708
+ try:
709
+ raw, live, generation = _read_target_at(
710
+ target_parent_fd, target.name
711
+ ) # Required under-lock re-read.
712
+ if operation == "install":
713
+ desired = _install_projection(live, fragment)
714
+ elif operation == "uninstall":
715
+ desired = _uninstall_projection(live)
716
+ else:
717
+ raise AssertionError(operation)
718
+ if desired == live:
719
+ return False
720
+ if not _same_generation_at(target_parent_fd, target.name, generation):
721
+ raise ProjectorError("Claude settings changed concurrently; retry")
722
+ snapshot_raw = raw if generation.exists else b"{}\n"
723
+ _write_snapshot(locked.snapshots_fd, snapshot_raw)
724
+ if not _same_generation_at(target_parent_fd, target.name, generation):
725
+ raise ProjectorError("Claude settings changed concurrently; retry")
726
+ _atomic_write_at(
727
+ target_parent_fd,
728
+ target.name,
729
+ _deterministic_bytes(desired),
730
+ 0o600,
731
+ )
732
+ return True
733
+ finally:
734
+ os.close(target_parent_fd)
735
+
736
+
737
+ def _render(master_path: Path, fragment_path: Path) -> bool:
738
+ _, expected = _load_master(master_path)
739
+ parent_fd = _open_absolute_directory(
740
+ fragment_path.parent,
741
+ "generated fragment parent",
742
+ create_missing=False,
743
+ require_final_owner=True,
744
+ )
745
+ if parent_fd is None:
746
+ raise ProjectorError("generated fragment parent does not exist")
747
+ try:
748
+ try:
749
+ current = _read_regular_at_bytes(
750
+ parent_fd, fragment_path.name, "Claude generated fragment"
751
+ )
752
+ except FileNotFoundError:
753
+ current = None
754
+ if current is not None:
755
+ _parse_json_object(current, "Claude generated fragment")
756
+ if current == expected:
757
+ return False
758
+ _atomic_write_at(parent_fd, fragment_path.name, expected, 0o644)
759
+ return True
760
+ finally:
761
+ os.close(parent_fd)
762
+
763
+
764
+ def _check(master_path: Path, fragment_path: Path, target: Path) -> list[dict[str, Any]]:
765
+ master, expected = _load_master(master_path)
766
+ fragment, fragment_raw = _load_fragment(fragment_path)
767
+ findings: list[dict[str, Any]] = []
768
+ if fragment != master or fragment_raw != expected:
769
+ findings.append(
770
+ {
771
+ "kind": "stale",
772
+ "event": "generated-fragment",
773
+ "message": "generated fragment differs from hook master",
774
+ }
775
+ )
776
+ _, live, _ = _read_target(target)
777
+ findings.extend(_check_findings(live, master))
778
+ return findings
779
+
780
+
781
+ def _add_asset_arguments(parser: argparse.ArgumentParser) -> None:
782
+ parser.add_argument("--master", default=str(DEFAULT_MASTER), help="absolute hook master")
783
+ parser.add_argument(
784
+ "--fragment", default=str(DEFAULT_FRAGMENT), help="absolute generated fragment"
785
+ )
786
+
787
+
788
+ def _add_target_argument(parser: argparse.ArgumentParser) -> None:
789
+ default = os.environ.get(
790
+ "PJ_PROJECT_NOTEBOOK_CLAUDE_SETTINGS", str(Path.home() / ".claude" / "settings.json")
791
+ )
792
+ parser.add_argument("--target", default=default, help="absolute Claude settings target")
793
+
794
+
795
+ def _parser() -> argparse.ArgumentParser:
796
+ parser = argparse.ArgumentParser(description=__doc__)
797
+ commands = parser.add_subparsers(dest="command", required=True)
798
+
799
+ render = commands.add_parser("render", help="render the deterministic Claude fragment")
800
+ _add_asset_arguments(render)
801
+
802
+ check = commands.add_parser("check", help="report hook projection drift without writes")
803
+ _add_asset_arguments(check)
804
+ _add_target_argument(check)
805
+ check.add_argument("--json", action="store_true", help="emit a machine-readable result")
806
+
807
+ for name in ("install", "uninstall"):
808
+ command = commands.add_parser(name, help=f"{name} only recognized Project Notebook hooks")
809
+ _add_asset_arguments(command)
810
+ _add_target_argument(command)
811
+ command.add_argument(
812
+ "--state-home",
813
+ help="absolute XDG state home override (tests and packaged installers)",
814
+ )
815
+ return parser
816
+
817
+
818
+ def main(argv: Sequence[str] | None = None) -> int:
819
+ args = _parser().parse_args(argv)
820
+ try:
821
+ master_path = _absolute_path(args.master, "hook master")
822
+ fragment_path = _absolute_path(args.fragment, "generated fragment")
823
+ if master_path == fragment_path:
824
+ raise ProjectorError("hook master and generated fragment must be distinct")
825
+
826
+ if args.command == "render":
827
+ changed = _render(master_path, fragment_path)
828
+ print(f"project-hooks: render {'changed' if changed else 'up to date'}")
829
+ return 0
830
+
831
+ target = _absolute_path(args.target, "Claude settings target")
832
+ if target in (master_path, fragment_path):
833
+ raise ProjectorError("Claude settings target must be distinct from skill assets")
834
+ if args.command == "check":
835
+ findings = _check(master_path, fragment_path, target)
836
+ if args.json:
837
+ print(
838
+ json.dumps(
839
+ {"ok": not findings, "findings": findings},
840
+ separators=(",", ":"),
841
+ ensure_ascii=False,
842
+ )
843
+ )
844
+ elif findings:
845
+ for finding in findings:
846
+ print(
847
+ f"project-hooks: {finding['kind']}: {finding['event']}: "
848
+ f"{finding['message']}"
849
+ )
850
+ else:
851
+ print("project-hooks: check clean")
852
+ return 0 if not findings else 1
853
+
854
+ state_home = _state_home(args.state_home)
855
+ changed = _mutate(args.command, master_path, fragment_path, target, state_home)
856
+ print(f"project-hooks: {args.command} {'changed' if changed else 'up to date'}")
857
+ return 0
858
+ except (OSError, ProjectorError) as exc:
859
+ message = exc.strerror if isinstance(exc, OSError) and exc.strerror else str(exc)
860
+ print(f"project-hooks: error: {message}", file=sys.stderr)
861
+ return 2
862
+
863
+
864
+ if __name__ == "__main__":
865
+ raise SystemExit(main())