@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.
- package/.mise/scripts/link-agentfiles.sh +38 -5
- package/README.md +92 -0
- package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
- package/dist/assets/project-notebook-skill/SKILL.md +64 -0
- package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
- package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
- package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
- package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
- package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
- package/dist/index.js +11307 -2809
- package/dist/mcp-server.js +9637 -2094
- package/dist/prompt.js +404 -0
- package/package.json +8 -5
- package/templates/commonproject/copier.yml +19 -5
- package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +74 -52
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
- package/templates/commonproject/template/mise.toml.jinja +12 -6
- package/templates/hermes-agent/copier.yml +8 -11
- package/templates/hermes-agent/template/.gitignore.jinja +1 -0
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
- package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +77 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
- package/templates/hermes-agent/template/.scripts/_lib.sh +116 -16
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +761 -0
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +19 -3
- package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
- package/templates/hermes-agent/template/hermes.jinja +20 -8
- package/templates/hermes-agent/template/momo.jinja +177 -0
- 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.
|