prism-mcp-server 20.17.2 → 20.18.0
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/README.md +74 -5
- package/dist/cli.js +36 -3
- package/dist/postinstall.js +17 -7
- package/dist/promptRouteHostHook.js +223 -114
- package/dist/server.js +5 -3
- package/dist/tools/ledgerHandlers.js +147 -24
- package/dist/utils/skillBudget.js +7 -2
- package/dist/utils/skillDigest.js +363 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* prism-route — self-installing UserPromptSubmit
|
|
2
|
+
* prism-route — self-installing UserPromptSubmit + SessionStart(compact) hook
|
|
3
|
+
* for Claude Code + Codex.
|
|
3
4
|
*
|
|
4
5
|
* WHY A HOST HOOK. An MCP server never sees the user's prompt; the protocol
|
|
5
6
|
* carries only what a tool call carries. session_route_prompt (the MCP tool)
|
|
@@ -8,6 +9,17 @@
|
|
|
8
9
|
* prompt regardless of model behaviour, on both hosts, which is what the
|
|
9
10
|
* operator requires ("i need automatic").
|
|
10
11
|
*
|
|
12
|
+
* WHY A SECOND EVENT. Compaction discards the bootstrap with the rest of the
|
|
13
|
+
* transcript, and nothing the MCP server can do brings it back — the server
|
|
14
|
+
* is never told a compaction happened. Both hosts run SessionStart hooks
|
|
15
|
+
* with source "compact" before the next model request (Claude Code:
|
|
16
|
+
* matcher "compact"; Codex hooks reference, verified 2026-09-01: "SessionStart
|
|
17
|
+
* hooks that match source: compact run before the next model request"). The
|
|
18
|
+
* same script answers that event with `prism floor-digest` — the protected
|
|
19
|
+
* floor, one line per rule — so the model does not spend the rest of the
|
|
20
|
+
* session re-reading SKILL.md files it can no longer see. Once per
|
|
21
|
+
* compaction, zero per-prompt cost.
|
|
22
|
+
*
|
|
11
23
|
* WHY SELF-INSTALLING. The previous generation of prism hooks was provisioned
|
|
12
24
|
* by a bootstrap script once, then hand-maintained per machine — which is why
|
|
13
25
|
* this machine has them and the other team machines do not. This module is
|
|
@@ -28,7 +40,11 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
|
|
|
28
40
|
import { homedir } from "node:os";
|
|
29
41
|
import { dirname, join, resolve } from "node:path";
|
|
30
42
|
/** Bump to force the on-disk script to be rewritten on the next ensure. */
|
|
31
|
-
export const PROMPT_ROUTE_HOOK_VERSION = "
|
|
43
|
+
export const PROMPT_ROUTE_HOOK_VERSION = "4";
|
|
44
|
+
/** SessionStart matcher: only the post-compaction fire carries the digest.
|
|
45
|
+
* The script re-checks `source` itself, so a host that ignores matchers on
|
|
46
|
+
* this event still injects nothing at startup/resume. */
|
|
47
|
+
const SESSION_START_MATCHER = "compact";
|
|
32
48
|
const MARKER_FILE = ".prism-managed.json";
|
|
33
49
|
const SCRIPT_FILE = "on_prompt.py";
|
|
34
50
|
const HOOK_DIR = "prism-route";
|
|
@@ -66,6 +82,9 @@ export const PROMPT_ROUTE_HOOK_SCRIPT = `#!/usr/bin/env python3
|
|
|
66
82
|
|
|
67
83
|
Routes every user prompt through the on-device skill matcher via
|
|
68
84
|
'prism route-prompt'. Injects newly matched skill bodies as context.
|
|
85
|
+
On SessionStart with source "compact" (the host just discarded the
|
|
86
|
+
transcript, bootstrap included) it re-injects the protected-floor digest
|
|
87
|
+
via 'prism floor-digest' instead.
|
|
69
88
|
Managed by prism; edits are overwritten on version bumps.
|
|
70
89
|
"""
|
|
71
90
|
import json
|
|
@@ -76,16 +95,106 @@ import subprocess
|
|
|
76
95
|
import sys
|
|
77
96
|
|
|
78
97
|
|
|
79
|
-
def emit(extra=None):
|
|
98
|
+
def emit(extra=None, event="UserPromptSubmit"):
|
|
80
99
|
out = {"continue": True, "suppressOutput": True}
|
|
81
100
|
if extra:
|
|
82
101
|
out["hookSpecificOutput"] = {
|
|
83
|
-
"hookEventName":
|
|
102
|
+
"hookEventName": event,
|
|
84
103
|
"additionalContext": extra,
|
|
85
104
|
}
|
|
86
105
|
print(json.dumps(out))
|
|
87
106
|
|
|
88
107
|
|
|
108
|
+
def run_cli_json(cli, args, stdin_text=""):
|
|
109
|
+
"""Run a prism CLI subcommand; return its last JSON stdout line or None."""
|
|
110
|
+
try:
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
[cli] + args,
|
|
113
|
+
input=stdin_text,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
timeout=10,
|
|
117
|
+
)
|
|
118
|
+
except Exception:
|
|
119
|
+
return None
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
return None
|
|
122
|
+
# Parse the LAST line that is JSON: wrappers hooked into node via
|
|
123
|
+
# NODE_OPTIONS (dotenv banners and the like) print to stdout BEFORE the
|
|
124
|
+
# CLI's own output, and one polluted line must not kill routing.
|
|
125
|
+
for line in reversed(result.stdout.strip().splitlines()):
|
|
126
|
+
line = line.strip()
|
|
127
|
+
if line.startswith("{"):
|
|
128
|
+
try:
|
|
129
|
+
data = json.loads(line)
|
|
130
|
+
except Exception:
|
|
131
|
+
continue
|
|
132
|
+
return data if isinstance(data, dict) else None
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def session_state_path(payload):
|
|
137
|
+
session = str(
|
|
138
|
+
payload.get("session_id")
|
|
139
|
+
or payload.get("sessionId")
|
|
140
|
+
or payload.get("conversation_id")
|
|
141
|
+
or "default"
|
|
142
|
+
)
|
|
143
|
+
session = re.sub(r"[^A-Za-z0-9._-]", "_", session).lstrip(".")[:80] or "default"
|
|
144
|
+
state_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
|
|
145
|
+
return state_dir, os.path.join(state_dir, session + ".json")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def payload_field(payload, *keys):
|
|
149
|
+
"""First non-empty string among alternative spellings of one field.
|
|
150
|
+
|
|
151
|
+
Claude Code documents snake_case keys; Codex's payloads are described as
|
|
152
|
+
Claude-compatible, not Claude-identical, which is why the prompt lookup
|
|
153
|
+
below already hedges three ways. The SessionStart detection used to read
|
|
154
|
+
exactly one spelling, so a Codex payload spelled hookEventName/trigger
|
|
155
|
+
would fall to the prompt path and pass through with no digest and no
|
|
156
|
+
signal (round-11 review).
|
|
157
|
+
"""
|
|
158
|
+
for key in keys:
|
|
159
|
+
value = payload.get(key)
|
|
160
|
+
if isinstance(value, str) and value.strip():
|
|
161
|
+
return value.strip()
|
|
162
|
+
return ""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def event_name(payload):
|
|
166
|
+
name = payload_field(payload, "hook_event_name", "hookEventName", "event_name", "eventName", "event")
|
|
167
|
+
return re.sub(r"[^a-z]", "", name.lower())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def on_session_start(payload):
|
|
171
|
+
# Only the post-compaction fire: at startup/resume the bootstrap carries
|
|
172
|
+
# the digest itself, and a second copy would be pure cost. "compact",
|
|
173
|
+
# "compaction", "compacted" all mean the transcript was just discarded.
|
|
174
|
+
source = payload_field(payload, "source", "trigger", "reason").lower()
|
|
175
|
+
if not source.startswith("compact"):
|
|
176
|
+
emit(event="SessionStart")
|
|
177
|
+
return
|
|
178
|
+
# The skills injected before compaction are gone with it; forget the
|
|
179
|
+
# dedupe list so the next matching prompt can bring them back.
|
|
180
|
+
state_dir, state_path = session_state_path(payload)
|
|
181
|
+
try:
|
|
182
|
+
os.remove(state_path)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
cli = find_cli()
|
|
186
|
+
if not cli:
|
|
187
|
+
emit(event="SessionStart")
|
|
188
|
+
return
|
|
189
|
+
data = run_cli_json(cli, ["floor-digest"])
|
|
190
|
+
text = (data or {}).get("text") or ""
|
|
191
|
+
names = [n for n in ((data or {}).get("names") or []) if isinstance(n, str)]
|
|
192
|
+
if not names or not isinstance(text, str) or not text:
|
|
193
|
+
emit(event="SessionStart")
|
|
194
|
+
return
|
|
195
|
+
emit(text, event="SessionStart")
|
|
196
|
+
|
|
197
|
+
|
|
89
198
|
def find_cli():
|
|
90
199
|
override = os.environ.get("PRISM_ROUTE_CLI")
|
|
91
200
|
if override and os.path.exists(override):
|
|
@@ -111,6 +220,12 @@ def main():
|
|
|
111
220
|
payload = json.loads(raw) if raw.strip() else {}
|
|
112
221
|
except Exception:
|
|
113
222
|
payload = {}
|
|
223
|
+
if not isinstance(payload, dict):
|
|
224
|
+
payload = {}
|
|
225
|
+
|
|
226
|
+
if event_name(payload) == "sessionstart":
|
|
227
|
+
on_session_start(payload)
|
|
228
|
+
return
|
|
114
229
|
|
|
115
230
|
prompt = str(
|
|
116
231
|
payload.get("prompt")
|
|
@@ -127,16 +242,7 @@ def main():
|
|
|
127
242
|
# stretch, and the CLI caps identically on its side.
|
|
128
243
|
prompt = prompt[:100_000]
|
|
129
244
|
|
|
130
|
-
|
|
131
|
-
payload.get("session_id")
|
|
132
|
-
or payload.get("sessionId")
|
|
133
|
-
or payload.get("conversation_id")
|
|
134
|
-
or "default"
|
|
135
|
-
)
|
|
136
|
-
session = re.sub(r"[^A-Za-z0-9._-]", "_", session).lstrip(".")[:80] or "default"
|
|
137
|
-
|
|
138
|
-
state_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
|
|
139
|
-
state_path = os.path.join(state_dir, session + ".json")
|
|
245
|
+
state_dir, state_path = session_state_path(payload)
|
|
140
246
|
loaded = []
|
|
141
247
|
try:
|
|
142
248
|
with open(state_path) as fh:
|
|
@@ -151,34 +257,8 @@ def main():
|
|
|
151
257
|
emit()
|
|
152
258
|
return
|
|
153
259
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
[cli, "route-prompt", "--loaded", ",".join(loaded)],
|
|
157
|
-
input=prompt,
|
|
158
|
-
capture_output=True,
|
|
159
|
-
text=True,
|
|
160
|
-
timeout=10,
|
|
161
|
-
)
|
|
162
|
-
except Exception:
|
|
163
|
-
emit()
|
|
164
|
-
return
|
|
165
|
-
if result.returncode != 0:
|
|
166
|
-
emit()
|
|
167
|
-
return
|
|
168
|
-
|
|
169
|
-
# Parse the LAST line that is JSON: wrappers hooked into node via
|
|
170
|
-
# NODE_OPTIONS (dotenv banners and the like) print to stdout BEFORE the
|
|
171
|
-
# CLI's own output, and one polluted line must not kill routing.
|
|
172
|
-
data = None
|
|
173
|
-
for line in reversed(result.stdout.strip().splitlines()):
|
|
174
|
-
line = line.strip()
|
|
175
|
-
if line.startswith("{"):
|
|
176
|
-
try:
|
|
177
|
-
data = json.loads(line)
|
|
178
|
-
break
|
|
179
|
-
except Exception:
|
|
180
|
-
continue
|
|
181
|
-
if not isinstance(data, dict):
|
|
260
|
+
data = run_cli_json(cli, ["route-prompt", "--loaded", ",".join(loaded)], prompt)
|
|
261
|
+
if data is None:
|
|
182
262
|
emit()
|
|
183
263
|
return
|
|
184
264
|
names = [n for n in (data.get("names") or []) if isinstance(n, str)]
|
|
@@ -205,21 +285,10 @@ if __name__ == "__main__":
|
|
|
205
285
|
print(json.dumps({"continue": True, "suppressOutput": True}))
|
|
206
286
|
sys.exit(0)
|
|
207
287
|
`;
|
|
208
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const evidenceFiles = spec.host === "claude"
|
|
213
|
-
? [join(homeDir, ".claude.json"), spec.configPath]
|
|
214
|
-
: [join(spec.root, "config.toml"), spec.configPath];
|
|
215
|
-
for (const file of evidenceFiles) {
|
|
216
|
-
try {
|
|
217
|
-
if (/prism/i.test(readFileSync(file, "utf8")))
|
|
218
|
-
return true;
|
|
219
|
-
}
|
|
220
|
-
catch { /* unreadable = no evidence */ }
|
|
221
|
-
}
|
|
222
|
-
return false;
|
|
288
|
+
/** A prior EXPLICIT install left our managed marker; only that authorizes
|
|
289
|
+
* the auto paths to touch this host again (refresh/upgrade). */
|
|
290
|
+
function hostHasManagedHook(spec) {
|
|
291
|
+
return existsSync(join(spec.root, "hooks", HOOK_DIR, MARKER_FILE));
|
|
223
292
|
}
|
|
224
293
|
function hostSpecs(homeDir, env) {
|
|
225
294
|
const codexHome = env.CODEX_HOME?.trim() ? resolve(env.CODEX_HOME.trim()) : join(homeDir, ".codex");
|
|
@@ -232,22 +301,37 @@ function hostSpecs(homeDir, env) {
|
|
|
232
301
|
/**
|
|
233
302
|
* Coarse Codex approval detection. Codex persists hook approvals as a
|
|
234
303
|
* [hooks.state] table in config.toml keyed by definition hash; the hashing
|
|
235
|
-
* algorithm is not public, so the only honest
|
|
236
|
-
*
|
|
237
|
-
* (
|
|
304
|
+
* algorithm is not public, so the only honest signal is the state section
|
|
305
|
+
* naming the EXACT command we register — `… --v<version>` — once per event
|
|
306
|
+
* (two hooks, two approvals). The version-agnostic path alone is not
|
|
307
|
+
* evidence: a v3 approval names the same script, and Codex will skip the
|
|
308
|
+
* v4 definition it never saw. Matching the bare path let a stale approval
|
|
309
|
+
* read as "detected" from the second `prism connect` after every hook
|
|
310
|
+
* bump (round-8 review). Never treat unknown as approved.
|
|
238
311
|
*/
|
|
239
|
-
function detectCodexApproval(codexRoot) {
|
|
312
|
+
function detectCodexApproval(codexRoot, wantedCommand) {
|
|
240
313
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
314
|
+
// A Windows path lands in a TOML basic string with its backslashes
|
|
315
|
+
// ESCAPED (`C:\\Users\\…`); one-for-one replacement turned that into
|
|
316
|
+
// `C://Users//…` and the approval never matched (round-10 review). A
|
|
317
|
+
// run of backslashes is one separator.
|
|
318
|
+
const toml = readFileSync(join(codexRoot, "config.toml"), "utf8").replace(/\\+/g, "/");
|
|
319
|
+
if (!/\[hooks\.state/.test(toml))
|
|
320
|
+
return "pending-or-unknown";
|
|
321
|
+
const wanted = wantedCommand.replace(/\\+/g, "/");
|
|
322
|
+
const exact = toml.split(wanted).length - 1;
|
|
323
|
+
if (exact >= 2)
|
|
244
324
|
return "detected";
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
325
|
+
// Our script is named, but not under the current definition: positive
|
|
326
|
+
// evidence that the trust on file is for an OLDER hook version, which
|
|
327
|
+
// Codex will not honour for this one.
|
|
328
|
+
if (exact === 0 && toml.includes(COMMAND_SIGNATURE))
|
|
329
|
+
return "pending-or-unknown";
|
|
330
|
+
// Trust state exists (one of our two entries, or hash-only entries that
|
|
331
|
+
// never name a command) and we cannot distinguish ours from here.
|
|
332
|
+
// Claiming AWAITING TRUST after the operator pressed t would be a false
|
|
333
|
+
// alarm against their own action. Distinct state, distinct wording.
|
|
334
|
+
return "state-present-unverifiable";
|
|
251
335
|
}
|
|
252
336
|
catch { /* unreadable = no evidence */ }
|
|
253
337
|
return "pending-or-unknown";
|
|
@@ -299,60 +383,78 @@ function ensureRegistered(configPath, scriptPath, host) {
|
|
|
299
383
|
// alone, not stripped).
|
|
300
384
|
const wantsLimit = host === "codex";
|
|
301
385
|
let config = {};
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
386
|
+
if (existsSync(configPath)) {
|
|
387
|
+
// An existing file we cannot parse is the operator's file with a syntax
|
|
388
|
+
// slip in it (model, env, permissions…) — "replace with {hooks}" would
|
|
389
|
+
// be a silent wipe. Register nothing; the caller reports it.
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
392
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
393
|
+
return "skipped-unparseable";
|
|
307
394
|
config = parsed;
|
|
308
395
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
396
|
+
catch {
|
|
397
|
+
return "skipped-unparseable";
|
|
398
|
+
}
|
|
312
399
|
}
|
|
313
400
|
const hooks = (config.hooks && typeof config.hooks === "object" && !Array.isArray(config.hooks)
|
|
314
401
|
? config.hooks
|
|
315
402
|
: {});
|
|
316
|
-
const entries = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
|
|
317
403
|
const wanted = hookCommand(scriptPath);
|
|
318
|
-
let
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
404
|
+
let updated = false;
|
|
405
|
+
let registered = false;
|
|
406
|
+
// One script, two events. Each event is converged independently so a
|
|
407
|
+
// machine registered under an older release (UserPromptSubmit only) gains
|
|
408
|
+
// the SessionStart entry on refresh, and a hand-removed entry is not
|
|
409
|
+
// resurrected by the other event still being current — the disabled marker
|
|
410
|
+
// is the opt-out for both.
|
|
411
|
+
const converge = (event, matcher) => {
|
|
412
|
+
const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
413
|
+
let found = false;
|
|
414
|
+
for (const entry of entries) {
|
|
415
|
+
if (!entry || typeof entry !== "object")
|
|
327
416
|
continue;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// would re-register a duplicate entry.
|
|
331
|
-
const command = String(h.command ?? "");
|
|
332
|
-
if (!command.replace(/\\/g, "/").includes(COMMAND_SIGNATURE))
|
|
417
|
+
const inner = entry.hooks;
|
|
418
|
+
if (!Array.isArray(inner))
|
|
333
419
|
continue;
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
420
|
+
for (const h of inner) {
|
|
421
|
+
if (!h || typeof h !== "object")
|
|
422
|
+
continue;
|
|
423
|
+
// Normalize separators: on Windows join() registers a backslash path,
|
|
424
|
+
// and a forward-slash signature would never match — so every ensure
|
|
425
|
+
// would re-register a duplicate entry.
|
|
426
|
+
const command = String(h.command ?? "");
|
|
427
|
+
if (!command.replace(/\\/g, "/").includes(COMMAND_SIGNATURE))
|
|
428
|
+
continue;
|
|
429
|
+
found = true;
|
|
430
|
+
const limitCurrent = !wantsLimit || h.additionalContextLimit === 0;
|
|
431
|
+
if (command === wanted && limitCurrent)
|
|
432
|
+
continue;
|
|
433
|
+
// Same hook, older definition: UPDATE it in place. This is what makes a
|
|
434
|
+
// refresh visible to Codex's definition-hash — and on Claude it is a
|
|
435
|
+
// harmless argv change.
|
|
436
|
+
h.command = wanted;
|
|
437
|
+
if (wantsLimit)
|
|
438
|
+
h.additionalContextLimit = 0;
|
|
439
|
+
updated = true;
|
|
440
|
+
}
|
|
344
441
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
442
|
+
if (!found) {
|
|
443
|
+
entries.push({
|
|
444
|
+
matcher,
|
|
445
|
+
hooks: [{ type: "command", command: wanted, timeout: 15, ...(wantsLimit ? { additionalContextLimit: 0 } : {}) }],
|
|
446
|
+
});
|
|
447
|
+
registered = true;
|
|
448
|
+
}
|
|
449
|
+
hooks[event] = entries;
|
|
450
|
+
};
|
|
451
|
+
converge("UserPromptSubmit", "*");
|
|
452
|
+
converge("SessionStart", SESSION_START_MATCHER);
|
|
453
|
+
if (!updated && !registered)
|
|
454
|
+
return "unchanged";
|
|
353
455
|
config.hooks = hooks;
|
|
354
456
|
writeAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
355
|
-
return
|
|
457
|
+
return registered ? "registered" : "updated";
|
|
356
458
|
}
|
|
357
459
|
/**
|
|
358
460
|
* Idempotently install the prism-route hook for both hosts.
|
|
@@ -369,7 +471,7 @@ export function ensurePromptRouteHook(options = {}) {
|
|
|
369
471
|
continue;
|
|
370
472
|
if (onlyExisting && !existsSync(spec.root))
|
|
371
473
|
continue;
|
|
372
|
-
if ((options.mode ?? "explicit") === "auto" && !
|
|
474
|
+
if ((options.mode ?? "explicit") === "auto" && !hostHasManagedHook(spec))
|
|
373
475
|
continue;
|
|
374
476
|
try {
|
|
375
477
|
const hookDir = join(spec.root, "hooks", HOOK_DIR);
|
|
@@ -382,7 +484,14 @@ export function ensurePromptRouteHook(options = {}) {
|
|
|
382
484
|
// Never report a green "registered" as if it were active: Codex
|
|
383
485
|
// SILENTLY SKIPS untrusted hooks, and "installed but inert" is the
|
|
384
486
|
// exact failure class this feature exists to end.
|
|
385
|
-
|
|
487
|
+
//
|
|
488
|
+
// Approvals are keyed by definition hash, so a hooks.json we just
|
|
489
|
+
// rewrote (new entry, changed command) carries definitions the
|
|
490
|
+
// operator has never trusted — whatever config.toml says about the
|
|
491
|
+
// OLD ones. Only an untouched config can inherit prior trust.
|
|
492
|
+
result.codexApproval = config === "unchanged"
|
|
493
|
+
? detectCodexApproval(spec.root, hookCommand(result.scriptPath))
|
|
494
|
+
: "pending-or-unknown";
|
|
386
495
|
}
|
|
387
496
|
results.push(result);
|
|
388
497
|
}
|
package/dist/server.js
CHANGED
|
@@ -265,9 +265,11 @@ let storageIsReady = false;
|
|
|
265
265
|
// native host complies with the hook-free first-turn instruction.
|
|
266
266
|
let contextLoadedByClient = false;
|
|
267
267
|
// ─── prism-route hook self-heal ──────────────────────────────
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
268
|
+
// REFRESH-ONLY safety net: repairs/upgrades a hook that a prior EXPLICIT
|
|
269
|
+
// `prism connect` installed (managed marker present). It never performs a
|
|
270
|
+
// first install — a machine that merely installed this package (or the
|
|
271
|
+
// Claude Code plugin, which registers prism-mcp in host config) is not
|
|
272
|
+
// touched. Idempotent and version-marked, so the steady-state cost is two
|
|
271
273
|
// stat calls a few seconds after boot. Opt out: PRISM_DISABLE_HOOK_AUTOINSTALL=1.
|
|
272
274
|
// Guarded against test runners: five suites import this module, and a
|
|
273
275
|
// module-scope timer would otherwise rewrite the DEVELOPER'S real host
|