@delorenj/pjangler 1.3.7 → 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 (27) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +20 -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 +14320 -7847
  16. package/dist/mcp-server.js +13482 -8146
  17. package/package.json +4 -3
  18. package/templates/commonproject/copier.yml +14 -5
  19. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  20. package/templates/commonproject/template/.mise/scripts/provision-packs.py +60 -2
  21. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  22. package/templates/commonproject/template/mise.toml.jinja +12 -6
  23. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  24. package/templates/hermes-agent/template/.scripts/70-systemd.sh +11 -7
  25. package/templates/hermes-agent/template/.scripts/_lib.sh +54 -10
  26. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +29 -2
  27. package/templates/hermes-agent/template/.scripts/providers/plane.sh +18 -2
@@ -1,9 +1,42 @@
1
1
  #!/bin/bash
2
+ set -eu
2
3
 
3
- if [ -f AGENTS.md ]; then
4
- ln -sf AGENTS.md CLAUDE.md
5
- ln -sf AGENTS.md GEMINI.md
6
- echo "✅ AI agent symlinks verified"
4
+ # Symlink the per-CLI agent instruction files at AGENTS.md.
5
+ #
6
+ # A mise ENTER hook runs with cwd set to the directory the user cd'd into, NOT
7
+ # to config_root measured on mise 2026.8.10, and true even for a parent config
8
+ # when the entered directory is a nested child. `mise run <task>` does run at
9
+ # config_root, which is why the old cwd-relative version looked correct for
10
+ # years: only the enter-hook path was wrong. So take the root explicitly, and
11
+ # fall back to this script's own location — never to cwd.
12
+ root="${1:-${MISE_CONFIG_ROOT:-}}"
13
+ own_root=$(CDPATH= cd "$(dirname "$0")/../.." && pwd -P)
14
+ if [ -z "$root" ]; then
15
+ root="$own_root"
7
16
  else
8
- echo "No AGENTS.md file found. Can't symlink."
17
+ root=$(CDPATH= cd "$root" && pwd -P)
9
18
  fi
19
+ if [ "$root" != "$own_root" ]; then
20
+ echo "link-agentfiles: refusing to act on $root; this script belongs to $own_root" >&2
21
+ echo "link-agentfiles: a nested repo must ship its own .mise/scripts copy" >&2
22
+ exit 1
23
+ fi
24
+
25
+ CDPATH= cd "$root"
26
+ if [ ! -f AGENTS.md ]; then
27
+ echo "No AGENTS.md in $root. Nothing to symlink."
28
+ exit 0
29
+ fi
30
+
31
+ # `ln -sf` cannot tell a stale link it owns from a real file it must not touch:
32
+ # -f means "unlink whatever is there". A hand-written CLAUDE.md was silently
33
+ # replaced by a symlink, and the script printed a green checkmark while doing it.
34
+ for name in CLAUDE.md GEMINI.md; do
35
+ if [ -e "$name" ] && [ ! -L "$name" ]; then
36
+ echo "link-agentfiles: refusing to replace the real file $root/$name with a symlink" >&2
37
+ echo "link-agentfiles: move its content into AGENTS.md, then delete $name" >&2
38
+ exit 1
39
+ fi
40
+ ln -sfn AGENTS.md "$name"
41
+ done
42
+ echo "✅ AGENTS.md links verified in $root"
package/README.md CHANGED
@@ -18,6 +18,26 @@ node dist/index.js --help
18
18
  pjangler --help
19
19
  ```
20
20
 
21
+ ## Project notebook
22
+
23
+ `pjangler notebook` manages one companion Open Notebook for a registered
24
+ repository. The binding and stable Overview note IDs live in `.project.json`
25
+ and the project registry; endpoint, authentication mode, defaults, and finite
26
+ capture limits live in the registry's global `notebook` configuration.
27
+
28
+ ```bash
29
+ pjangler notebook status . --json
30
+ pjangler notebook create . --live --json
31
+ pjangler notebook list notes . --json
32
+ pjangler notebook search notes "release evidence" . --json
33
+ pjangler notebook audit . --json
34
+ pjangler notebook migrate . --live --apply --json
35
+ ```
36
+
37
+ True `SessionStart` and `SessionEnd` hooks are projected separately from the
38
+ project-scoped hook masters. Their per-repository policy stays fail-open and
39
+ disabled until explicitly enabled in `.project.json`.
40
+
21
41
  ## Orienting in a repo
22
42
 
23
43
  `describe` reads a repo and reports what it actually is — detected type,
@@ -0,0 +1,10 @@
1
+ dba418f3b40d2e1362e615bdfda6cb1616ec5e49e2e8bd81ca48144ef219b29f agents/openai.yaml
2
+ 8a469caf88ed05dc466ffaad611cb45dd0c26046595d9538415d7c89287c8800 hooks/claude.settings.json
3
+ 8a469caf88ed05dc466ffaad611cb45dd0c26046595d9538415d7c89287c8800 hooks/hooks.master.json
4
+ 848d2f5ba9000c2538e21d5a21257c72a5e4799ee3a9145a4eb9d7b00179e52b hooks/session-end.sh
5
+ f146a81c3e6cfbbce28c85c3c2163255974abef61339638c2a0ac9902346905e hooks/session-start.sh
6
+ a25d1d34f9960b2b3bf17a00191d5477b81a05bffe5d4e183c2954cc3975b535 references/configuration.md
7
+ 6a61d9dd0d5dd32f8a7fefcb82001bbad90523704fa1500b7e0becc3a848545c references/recovery.md
8
+ 95d1b6199b0416f7fdf3306215bc10a9a647f64654ccbb26786cc228fc35ac37 scripts/project-hooks.py
9
+ 8567ab2567ec412eb8d673dd713340b81a45e69374d9baee3cb7e827999be437 SKILL.md
10
+ 7e16380d079277ee08b8ea64767b1596dc4e8f7ce1bd89c45d9d4821f6d493d3 tests/test_project_hooks.py
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: project-notebook
3
+ description: Operate PJangler Project Notebook as bounded, Git-grounded project memory. Use when an agent needs to inspect notebook status, read or update the stable Overview, manage scoped notes, search project knowledge, audit or migrate a binding, recover capture receipts, or install/check the canonical global Project Notebook hooks without changing foreign hooks.
4
+ ---
5
+
6
+ # Project Notebook
7
+
8
+ Treat Git and the repository's Project Manifest as authoritative. Treat the
9
+ remote notebook as scoped, derivative context that PJangler owns and can audit.
10
+
11
+ ## Work through PJangler
12
+
13
+ 1. Resolve the repository explicitly when the current directory is ambiguous.
14
+ 2. Inspect `pj notebook status [repo]` before assuming a healthy binding.
15
+ 3. Use `--json` when another tool will consume the result. Check `ok`, symbolic
16
+ error code, and `next_actions`; do not infer success from prose.
17
+ 4. Keep reads and mutations project-scoped. Never call the notebook service
18
+ directly or accept a result whose membership in the resolved binding is not
19
+ proven.
20
+ 5. Use `pj notebook audit [repo]` to diagnose Drift. Use
21
+ `pj notebook migrate [repo]` for an owned repair plan, adding `--apply` for
22
+ local changes and `--live` only when remote repair is intended.
23
+ 6. Preserve repository files as the source of truth. Never overwrite Git
24
+ content from notebook content.
25
+
26
+ ## Use the public command surface
27
+
28
+ Use these command families rather than internal hook or worker entrypoints:
29
+
30
+ - `pj notebook status|create|overview|audit|migrate`
31
+ - `pj notebook list notes`, `get note`, `add note`, `update note`, `delete note`
32
+ - `pj notebook search notes`
33
+ - `pj notebook capture list|retry`
34
+
35
+ Direct note mutation authorizes only that selected operation. `create` and live
36
+ migration require `--live`; note deletion requires confirmation or `--yes`.
37
+ Never delete the stable Overview note.
38
+
39
+ For complete command forms, policy precedence, hook projection, and safe
40
+ configuration, read [configuration.md](references/configuration.md). For
41
+ capture pressure, Drift, snapshots, uninstall, and failure recovery, read
42
+ [recovery.md](references/recovery.md).
43
+
44
+ ## Handle session context safely
45
+
46
+ - Let the global wrappers scope themselves at runtime. Global installation does
47
+ not enable a repository.
48
+ - Use true `SessionStart` and `SessionEnd` only. Never substitute `Stop` for a
49
+ session boundary.
50
+ - Keep hook payloads and user content out of argv and logs.
51
+ - Treat hook failures as bounded, actionable, fail-open diagnostics.
52
+ - Treat `PROJECT NOTEBOOK OVERVIEW DRIFT` as stale context. Run the exact audit
53
+ or migration action reported before relying on the stored Overview.
54
+ - Treat retention pressure as an admission diagnostic, not a receipt state.
55
+ Never delete or compact an unresolved receipt to make room.
56
+
57
+ ## Protect credentials and foreign configuration
58
+
59
+ Resolve endpoint and authentication through PJangler runtime configuration;
60
+ never store them in a repository Manifest, note, hook command, or skill file.
61
+ Use `scripts/project-hooks.py` for global Claude projection. Its owner marker is
62
+ exactly `project-notebook.v1`; do not hand-edit live settings or remove
63
+ Bloodbank, Hindsight, Git checkpoint, notification, CommonProject, or other
64
+ foreign hooks.
@@ -0,0 +1,6 @@
1
+ interface:
2
+ display_name: "Project Notebook"
3
+ short_description: "Operate Git-grounded project memory safely"
4
+ default_prompt: "Use $project-notebook to inspect this repository's notebook status and take the safest scoped next action."
5
+ policy:
6
+ allow_implicit_invocation: true
@@ -0,0 +1,56 @@
1
+ {
2
+ "schema_version": 1,
3
+ "skill": "project-notebook",
4
+ "files": [
5
+ {
6
+ "path": "agents/openai.yaml",
7
+ "sha256": "dba418f3b40d2e1362e615bdfda6cb1616ec5e49e2e8bd81ca48144ef219b29f",
8
+ "mode": "0644"
9
+ },
10
+ {
11
+ "path": "hooks/claude.settings.json",
12
+ "sha256": "8a469caf88ed05dc466ffaad611cb45dd0c26046595d9538415d7c89287c8800",
13
+ "mode": "0644"
14
+ },
15
+ {
16
+ "path": "hooks/hooks.master.json",
17
+ "sha256": "8a469caf88ed05dc466ffaad611cb45dd0c26046595d9538415d7c89287c8800",
18
+ "mode": "0644"
19
+ },
20
+ {
21
+ "path": "hooks/session-end.sh",
22
+ "sha256": "848d2f5ba9000c2538e21d5a21257c72a5e4799ee3a9145a4eb9d7b00179e52b",
23
+ "mode": "0755"
24
+ },
25
+ {
26
+ "path": "hooks/session-start.sh",
27
+ "sha256": "f146a81c3e6cfbbce28c85c3c2163255974abef61339638c2a0ac9902346905e",
28
+ "mode": "0755"
29
+ },
30
+ {
31
+ "path": "references/configuration.md",
32
+ "sha256": "a25d1d34f9960b2b3bf17a00191d5477b81a05bffe5d4e183c2954cc3975b535",
33
+ "mode": "0644"
34
+ },
35
+ {
36
+ "path": "references/recovery.md",
37
+ "sha256": "6a61d9dd0d5dd32f8a7fefcb82001bbad90523704fa1500b7e0becc3a848545c",
38
+ "mode": "0644"
39
+ },
40
+ {
41
+ "path": "scripts/project-hooks.py",
42
+ "sha256": "95d1b6199b0416f7fdf3306215bc10a9a647f64654ccbb26786cc228fc35ac37",
43
+ "mode": "0755"
44
+ },
45
+ {
46
+ "path": "SKILL.md",
47
+ "sha256": "8567ab2567ec412eb8d673dd713340b81a45e69374d9baee3cb7e827999be437",
48
+ "mode": "0644"
49
+ },
50
+ {
51
+ "path": "tests/test_project_hooks.py",
52
+ "sha256": "7e16380d079277ee08b8ea64767b1596dc4e8f7ce1bd89c45d9d4821f6d493d3",
53
+ "mode": "0644"
54
+ }
55
+ ]
56
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "PJ_HOOK_OWNER=project-notebook.v1 \"$HOME/.agents/skills/project-notebook/hooks/session-start.sh\"",
9
+ "timeout": 3
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "SessionEnd": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "PJ_HOOK_OWNER=project-notebook.v1 \"$HOME/.agents/skills/project-notebook/hooks/session-end.sh\"",
20
+ "timeout": 1
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "PJ_HOOK_OWNER=project-notebook.v1 \"$HOME/.agents/skills/project-notebook/hooks/session-start.sh\"",
9
+ "timeout": 3
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "SessionEnd": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "PJ_HOOK_OWNER=project-notebook.v1 \"$HOME/.agents/skills/project-notebook/hooks/session-end.sh\"",
20
+ "timeout": 1
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ }
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/python3 -I
2
+ """Fail-open SessionEnd 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 = "SessionEnd"
16
+ HOOK_EVENT = "session-close"
17
+ REQUEST_LIMIT_BYTES = 1_048_576
18
+ STREAM_LIMIT_BYTES = REQUEST_LIMIT_BYTES + 1
19
+ CHILD_TIMEOUT_SECONDS = 0.75
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 capture list")
206
+ if process.returncode != 0:
207
+ return fail_open("failed open; run pj notebook capture list")
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 capture list")
228
+ raise SystemExit(result)