@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,228 @@
1
+ #!/usr/bin/python3 -I
2
+ """Fail-open SessionStart bridge to the fixed PJangler user installation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import grp
7
+ import os
8
+ import pwd
9
+ import signal
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ DISPLAY_EVENT = "SessionStart"
16
+ HOOK_EVENT = "session-start"
17
+ REQUEST_LIMIT_BYTES = 1_048_576
18
+ STREAM_LIMIT_BYTES = REQUEST_LIMIT_BYTES + 1
19
+ CHILD_TIMEOUT_SECONDS = 2.25
20
+ NODE_BINARY = Path("/usr/bin/node")
21
+ AUTH_VARIABLE = "OPEN_NOTEBOOK_PASSWORD"
22
+
23
+
24
+ class LauncherError(RuntimeError):
25
+ """A bounded launcher-validation failure."""
26
+
27
+
28
+ def fail_open(reason: str) -> int:
29
+ print(f"project-notebook: {DISPLAY_EVENT} {reason}", file=sys.stderr)
30
+ return 0
31
+
32
+
33
+ def canonical_identity() -> pwd.struct_passwd:
34
+ entry = pwd.getpwuid(os.geteuid())
35
+ if entry.pw_uid != os.geteuid():
36
+ raise LauncherError("skipped; canonical user identity is invalid")
37
+ home = Path(entry.pw_dir)
38
+ if not home.is_absolute() or home == Path("/") or ".." in home.parts:
39
+ raise LauncherError("skipped; canonical user home is invalid")
40
+ return entry
41
+
42
+
43
+ def private_primary_group(entry: pwd.struct_passwd) -> bool:
44
+ try:
45
+ group = grp.getgrgid(entry.pw_gid)
46
+ primary_users = {
47
+ candidate.pw_name for candidate in pwd.getpwall() if candidate.pw_gid == entry.pw_gid
48
+ }
49
+ except (KeyError, OSError):
50
+ return False
51
+ if entry.pw_name not in primary_users:
52
+ return False
53
+ writers = primary_users | set(group.gr_mem)
54
+ return writers <= {entry.pw_name}
55
+
56
+
57
+ def path_components(path: Path) -> list[Path]:
58
+ if not path.is_absolute() or path == Path("/"):
59
+ raise LauncherError("skipped; trusted path is invalid")
60
+ components: list[Path] = []
61
+ current = Path("/")
62
+ for component in path.parts[1:]:
63
+ if component in ("", ".", ".."):
64
+ raise LauncherError("skipped; trusted path is invalid")
65
+ current /= component
66
+ components.append(current)
67
+ return components
68
+
69
+
70
+ def reject_symlink_components(path: Path) -> None:
71
+ for component in path_components(path):
72
+ try:
73
+ information = component.lstat()
74
+ except OSError as exc:
75
+ raise LauncherError("skipped; trusted path is unavailable") from exc
76
+ if stat.S_ISLNK(information.st_mode):
77
+ raise LauncherError("skipped; trusted path contains a symlink")
78
+
79
+
80
+ def validate_owned_component(
81
+ path: Path,
82
+ entry: pwd.struct_passwd,
83
+ *,
84
+ directory: bool,
85
+ private_group: bool,
86
+ ) -> None:
87
+ try:
88
+ information = path.lstat()
89
+ except OSError as exc:
90
+ raise LauncherError("skipped; trusted path is unavailable") from exc
91
+ expected_type = stat.S_ISDIR if directory else stat.S_ISREG
92
+ if stat.S_ISLNK(information.st_mode) or not expected_type(information.st_mode):
93
+ raise LauncherError("skipped; trusted path has an unsafe type")
94
+ if information.st_uid != entry.pw_uid or information.st_gid != entry.pw_gid:
95
+ raise LauncherError("skipped; trusted path ownership is unsafe")
96
+ mode = stat.S_IMODE(information.st_mode)
97
+ special = stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX
98
+ if mode & (special | stat.S_IWOTH):
99
+ raise LauncherError("skipped; trusted path permissions are unsafe")
100
+ if mode & stat.S_IWGRP and not private_group:
101
+ raise LauncherError("skipped; trusted path group is not private")
102
+ if not directory and not mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH):
103
+ raise LauncherError("skipped; trusted pj is not executable")
104
+
105
+
106
+ def validate_owned_tree(
107
+ home: Path,
108
+ target: Path,
109
+ entry: pwd.struct_passwd,
110
+ *,
111
+ final_directory: bool,
112
+ private_group: bool,
113
+ ) -> None:
114
+ try:
115
+ relative = target.relative_to(home)
116
+ except ValueError as exc:
117
+ raise LauncherError("skipped; resolved pj is outside canonical home") from exc
118
+ reject_symlink_components(target)
119
+ validate_owned_component(home, entry, directory=True, private_group=private_group)
120
+ current = home
121
+ for index, component in enumerate(relative.parts):
122
+ current /= component
123
+ validate_owned_component(
124
+ current,
125
+ entry,
126
+ directory=final_directory or index < len(relative.parts) - 1,
127
+ private_group=private_group,
128
+ )
129
+
130
+
131
+ def validate_node_binary() -> None:
132
+ try:
133
+ information = NODE_BINARY.lstat()
134
+ except OSError as exc:
135
+ raise LauncherError("skipped; fixed node runtime is unavailable") from exc
136
+ mode = stat.S_IMODE(information.st_mode)
137
+ special = stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX
138
+ if (
139
+ stat.S_ISLNK(information.st_mode)
140
+ or not stat.S_ISREG(information.st_mode)
141
+ or information.st_uid != 0
142
+ or mode & (special | stat.S_IWGRP | stat.S_IWOTH)
143
+ or not mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
144
+ ):
145
+ raise LauncherError("skipped; fixed node runtime is unsafe")
146
+
147
+
148
+ def resolve_launcher(entry: pwd.struct_passwd) -> Path:
149
+ home = Path(entry.pw_dir)
150
+ launcher = home / ".local" / "bin" / "pj"
151
+ private_group = private_primary_group(entry)
152
+ reject_symlink_components(launcher.parent)
153
+ validate_owned_tree(
154
+ home,
155
+ launcher.parent,
156
+ entry,
157
+ final_directory=True,
158
+ private_group=private_group,
159
+ )
160
+ try:
161
+ resolved = launcher.resolve(strict=True)
162
+ except OSError as exc:
163
+ raise LauncherError("skipped; trusted pj launcher cannot be resolved") from exc
164
+ validate_owned_tree(
165
+ home,
166
+ resolved,
167
+ entry,
168
+ final_directory=False,
169
+ private_group=private_group,
170
+ )
171
+ return resolved
172
+
173
+
174
+ def sanitized_environment(entry: pwd.struct_passwd) -> dict[str, str]:
175
+ environment = {
176
+ "HOME": entry.pw_dir,
177
+ "USER": entry.pw_name,
178
+ "LOGNAME": entry.pw_name,
179
+ "PATH": "/usr/bin:/bin",
180
+ "LANG": "C.UTF-8",
181
+ "LC_ALL": "C.UTF-8",
182
+ }
183
+ authentication = os.environ.get(AUTH_VARIABLE)
184
+ if authentication is not None:
185
+ environment[AUTH_VARIABLE] = authentication
186
+ return environment
187
+
188
+
189
+ def invoke(entry: pwd.struct_passwd, launcher: Path, payload: bytes) -> int:
190
+ process = subprocess.Popen(
191
+ [str(NODE_BINARY), str(launcher), "notebook", "hook", HOOK_EVENT],
192
+ stdin=subprocess.PIPE,
193
+ env=sanitized_environment(entry),
194
+ close_fds=True,
195
+ start_new_session=True,
196
+ )
197
+ try:
198
+ process.communicate(payload, timeout=CHILD_TIMEOUT_SECONDS)
199
+ except subprocess.TimeoutExpired:
200
+ try:
201
+ os.killpg(process.pid, signal.SIGKILL)
202
+ except ProcessLookupError:
203
+ pass
204
+ process.wait()
205
+ return fail_open("timed out; run pj notebook audit")
206
+ if process.returncode != 0:
207
+ return fail_open("failed open; run pj notebook audit")
208
+ return 0
209
+
210
+
211
+ def main() -> int:
212
+ entry = canonical_identity()
213
+ validate_node_binary()
214
+ launcher = resolve_launcher(entry)
215
+ payload = sys.stdin.buffer.read(STREAM_LIMIT_BYTES)
216
+ if len(payload) > REQUEST_LIMIT_BYTES:
217
+ return fail_open("skipped; hook payload exceeds 1048576 bytes")
218
+ return invoke(entry, launcher, payload)
219
+
220
+
221
+ if __name__ == "__main__":
222
+ try:
223
+ result = main()
224
+ except LauncherError as exc:
225
+ result = fail_open(str(exc))
226
+ except Exception:
227
+ result = fail_open("failed open; run pj notebook audit")
228
+ raise SystemExit(result)
@@ -0,0 +1,93 @@
1
+ # Project Notebook configuration
2
+
3
+ ## Public commands
4
+
5
+ ```text
6
+ pj notebook status [repo] [--local-only] [--json]
7
+ pj notebook create [repo] --live [--json]
8
+ pj notebook list notes [repo] [--limit N] [--cursor VALUE] [--json]
9
+ pj notebook add note [repo] --title TEXT (--text TEXT | --file PATH) [--json]
10
+ pj notebook get note NOTE_ID [repo] [--json]
11
+ pj notebook update note NOTE_ID [repo] [--title TEXT] (--text TEXT | --file PATH) [--json]
12
+ pj notebook delete note NOTE_ID [repo] [--yes] [--json]
13
+ pj notebook search notes QUERY [repo] [--limit N] [--json]
14
+ pj notebook overview [repo] [--set-file PATH] [--json]
15
+ pj notebook capture list [repo] [--state VALUE] [--json]
16
+ pj notebook capture retry RECEIPT_ID [repo] [--baseline GIT_REF] [--json]
17
+ pj notebook audit [repo] [--local-only] [--json]
18
+ pj notebook migrate [repo] [--apply] [--live] [--json]
19
+ ```
20
+
21
+ The `hook` and `worker` command families are internal compatibility surfaces.
22
+ Do not invoke them for ordinary notebook work.
23
+
24
+ ## Policy and credentials
25
+
26
+ Effective policy precedence is built-in safe defaults, global Project Registry
27
+ defaults, Project Manifest policy, then an explicit option for one invocation.
28
+ An explicit disable wins for hook behavior.
29
+
30
+ The Project Registry owns binding identifiers and binding state. The Project
31
+ Manifest mirrors binding fields for inspection and owns repository policy. It
32
+ must not contain a service URL, credential, or derived authentication value.
33
+ Resolve endpoint and authentication at runtime through PJangler's configured
34
+ secret boundary. Never put user content or secrets in argv or logs.
35
+
36
+ ## Canonical Claude projection
37
+
38
+ Run the projector from this skill directory:
39
+
40
+ ```text
41
+ python3 scripts/project-hooks.py check [--target PATH] [--json]
42
+ python3 scripts/project-hooks.py render
43
+ python3 scripts/project-hooks.py install [--target PATH]
44
+ python3 scripts/project-hooks.py uninstall [--target PATH]
45
+ ```
46
+
47
+ The default target is `~/.claude/settings.json`; tests and packaged installers
48
+ may supply an isolated absolute target. `render` deterministically derives
49
+ `hooks/claude.settings.json` from `hooks/hooks.master.json`. `check` is
50
+ read-only. Install and uninstall take the Project Notebook advisory lock,
51
+ re-read live settings while locked, snapshot the exact preimage, and replace
52
+ the target only when owned semantics change. Directory traversal, private-state
53
+ creation, lock/snapshot access, temporary-file creation, and atomic replacement
54
+ are descriptor-relative with `O_DIRECTORY|O_NOFOLLOW`; an ancestor pathname
55
+ swap cannot redirect a write outside the directory already opened.
56
+
57
+ The only owned commands are an anchored `PJ_HOOK_OWNER=project-notebook.v1 `
58
+ prefix followed by exactly one recognized wrapper path for the same event:
59
+
60
+ ```text
61
+ SessionStart "$HOME/.agents/skills/project-notebook/hooks/session-start.sh" timeout 3
62
+ SessionEnd "$HOME/.agents/skills/project-notebook/hooks/session-end.sh" timeout 1
63
+ ```
64
+
65
+ `Stop` is not a session-close event and is always foreign. Prefix-similar,
66
+ unknown-wrapper, extra-argument, and event-mismatched commands are preserved
67
+ and reported rather than claimed.
68
+
69
+ Despite their stable `.sh` filenames, the wrappers are isolated
70
+ `/usr/bin/python3 -I` launchers. They derive the canonical user home from the
71
+ passwd database, then use only `<canonical-home>/.local/bin/pj`. Intermediate
72
+ launcher-parent symlinks are rejected. The launcher itself may be a symlink,
73
+ but its resolved absolute target and every target-path component must be owned
74
+ by the current user's primary user/group, must not be world-writable or carry
75
+ special mode bits, and the target must be a regular executable file. Private
76
+ primary-group write is accepted only when passwd and group enumeration proves
77
+ that no other group member or primary user can write through that group.
78
+
79
+ The resolved launcher is executed as an argument to fixed `/usr/bin/node`, not
80
+ through its shebang or inherited `PATH`. The child environment contains only
81
+ canonical `HOME`, `USER`, and `LOGNAME`; fixed `PATH` and locale values; and, if
82
+ present, `OPEN_NOTEBOOK_PASSWORD`. Unattended hooks do not forward an arbitrary
83
+ registry `auth.env_var`; configure this exact allowlisted variable for hook
84
+ authentication or use an interactive/public command path. Inputs such as
85
+ `BASH_ENV`, `NODE_OPTIONS`, `NODE_PATH`, and project-controlled environment
86
+ variables cannot redirect or preload the child.
87
+
88
+ Hook JSON is never staged to disk. Each wrapper reads at most 1,048,577 bytes;
89
+ if the sentinel byte is present, it fails open before creating a child because
90
+ the 1,048,576-byte request ceiling was exceeded. Valid-size input is sent over
91
+ stdin with an explicit child timeout shorter than the outer hook timeout.
92
+ Resolution, validation, input, timeout, and PJangler failures are bounded and
93
+ fail open without creating wrapper-local state.
@@ -0,0 +1,54 @@
1
+ # Project Notebook recovery
2
+
3
+ ## Diagnose before repairing
4
+
5
+ Start with `pj notebook status [repo]`, then run
6
+ `pj notebook audit [repo]`. Use `--local-only` when remote observation is not
7
+ authorized or available. Follow the exact bounded `next_actions` returned by
8
+ the command.
9
+
10
+ For `PROJECT NOTEBOOK OVERVIEW DRIFT`, treat the stored Overview as stale. Run
11
+ the reported `notebook.overview-note` audit and use
12
+ `pj notebook migrate [repo] --apply --live` only when same-note remote repair is
13
+ intended. Do not create a replacement Overview ID and never copy notebook text
14
+ back over authoritative repository documents.
15
+
16
+ ## Capture receipts and admission pressure
17
+
18
+ List visible work with `pj notebook capture list [repo]`. Receipt states are
19
+ `queued`, `processing`, `succeeded`, `failed`, `retry-exhausted`, and
20
+ `blocked-missing-baseline`. Retention pressure is a current admission finding,
21
+ not another state.
22
+
23
+ Never delete or compact unresolved receipts. Succeeded receipts may age out
24
+ under configured retention. At a count or byte cap, the refused session has no
25
+ receipt and was not captured; use the exact list/retry actions in the bounded
26
+ diagnostic. Admission resumes only below both prospective caps.
27
+
28
+ One explicit `pj notebook capture retry RECEIPT_ID [repo]` invocation grants
29
+ one attempt on that same failed or retry-exhausted receipt. A
30
+ `blocked-missing-baseline` receipt additionally needs a validated explicit
31
+ `--baseline GIT_REF`.
32
+
33
+ ## Restore hook settings
34
+
35
+ Projector mutations store the exact prior JSON bytes under:
36
+
37
+ ```text
38
+ $XDG_STATE_HOME/pjangler/notebook/v1/hook-install/snapshots/<sha256>.json
39
+ ```
40
+
41
+ The fallback state home is `~/.local/state`. Projector-owned directories are
42
+ mode `0700`; lock and snapshot files are mode `0600`. An absent original target
43
+ is represented by the empty JSON object snapshot. Snapshot filenames are
44
+ content-addressed, so repeated identical preimages do not create duplicates.
45
+ All projector state and target mutations are relative to no-follow directory
46
+ descriptors, so replacing an ancestor pathname with a symlink cannot redirect
47
+ the snapshot, temporary file, or final atomic replacement.
48
+
49
+ Inspect the target and snapshot before manual restoration. Do not print their
50
+ contents because operator settings may be sensitive. `uninstall` is normally
51
+ safer: it removes only recognized Project Notebook hooks and prunes only the
52
+ group/event made empty by that removal. It leaves the skill source, remote
53
+ notebook, bindings, Bloodbank, Hindsight, Git checkpoint, notifications, and
54
+ all other foreign settings intact.