@plur-ai/cli 0.9.10 → 0.9.11

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 CHANGED
@@ -21,7 +21,7 @@ npx @plur-ai/cli@0.9.4 status
21
21
  ## Quick Start
22
22
 
23
23
  ```bash
24
- # Install Claude Code hooks (automatic memory injection)
24
+ # Install Claude Code hooks + local hook binary (automatic memory injection)
25
25
  plur init
26
26
 
27
27
  # Store a learning
@@ -59,7 +59,8 @@ plur forget ENG-2026-0329-001
59
59
  | `plur sync` | Cross-device sync via git |
60
60
  | `plur packs list` | List installed engram packs |
61
61
  | `plur packs install <source>` | Install an engram pack |
62
- | `plur init` | Install Claude Code hooks for automatic injection |
62
+ | `plur init` | Install Claude Code hooks + local hook binary for automatic injection |
63
+ | `plur doctor` | Diagnose installation health (hooks, MCP, shim, embedder) |
63
64
 
64
65
  ## Global Flags
65
66
 
@@ -10,23 +10,50 @@ import {
10
10
  outputText,
11
11
  shouldOutputJson
12
12
  } from "./chunk-7U4W4J3G.js";
13
- import {
14
- createPlur
15
- } from "./chunk-O6WTH7H7.js";
16
13
 
17
14
  // src/commands/doctor.ts
18
15
  import { spawn } from "child_process";
16
+ import { existsSync, readFileSync, realpathSync } from "fs";
17
+ import { join, extname } from "path";
18
+ import { homedir, platform } from "os";
19
19
  function hasAnyPlurHook(config) {
20
20
  const hooks = config.hooks ?? {};
21
21
  for (const entries of Object.values(hooks)) {
22
22
  for (const entry of entries) {
23
23
  for (const h of entry.hooks ?? []) {
24
- if (h.command && h.command.includes("@plur-ai/cli")) return true;
24
+ if (h.command && (h.command.includes("@plur-ai/cli") || h.command.includes(".plur/bin/plur-hook"))) return true;
25
+ }
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+ function hasStaleNpxHooks(config) {
31
+ const hooks = config.hooks ?? {};
32
+ for (const entries of Object.values(hooks)) {
33
+ for (const entry of entries) {
34
+ for (const h of entry.hooks ?? []) {
35
+ if (h.command && h.command.includes("npx") && h.command.includes("@plur-ai/cli")) return true;
25
36
  }
26
37
  }
27
38
  }
28
39
  return false;
29
40
  }
41
+ function validateHookShim() {
42
+ const name = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
43
+ const path = join(homedir(), ".plur", "bin", name);
44
+ if (!existsSync(path)) {
45
+ return { valid: false, shimPath: path, error: "shim not found \u2014 run `plur init` to create it" };
46
+ }
47
+ const content = readFileSync(path, "utf-8");
48
+ const match = content.match(/"([^"]+index\.js)"/);
49
+ if (!match) {
50
+ return { valid: false, shimPath: path, error: "shim has unexpected format" };
51
+ }
52
+ if (!existsSync(match[1])) {
53
+ return { valid: false, shimPath: path, error: `entrypoint missing: ${match[1]} \u2014 run \`plur init\` to fix` };
54
+ }
55
+ return { valid: true, shimPath: path };
56
+ }
30
57
  function inspectConfigs() {
31
58
  return knownConfigFiles().map((cf) => {
32
59
  if (!cf.exists) {
@@ -121,46 +148,111 @@ async function mcpHandshake(timeoutMs = 2e4) {
121
148
  proc.stdin?.write(JSON.stringify(initRequest) + "\n");
122
149
  });
123
150
  }
124
- async function checkEmbedder(flags) {
151
+ function resolveCliJsEntry() {
152
+ const argv1 = process.argv[1];
153
+ if (!argv1) return null;
154
+ let resolved;
125
155
  try {
126
- const plur = createPlur(flags);
127
- const preStatus = plur.embedderStatus();
128
- if (!preStatus.disabled) {
129
- plur.resetEmbedder();
130
- try {
131
- await plur.recallSemantic("plur doctor probe", { limit: 1 });
132
- } catch {
133
- }
134
- }
135
- const status = plur.embedderStatus();
136
- return {
137
- available: status.available,
138
- loaded: status.loaded,
139
- lastError: status.lastError,
140
- modelLoaded: status.available && status.loaded,
141
- disabled: status.disabled,
142
- disabledReason: status.disabledReason
143
- };
144
- } catch (err) {
145
- return {
156
+ resolved = realpathSync(argv1);
157
+ } catch {
158
+ return null;
159
+ }
160
+ const ext = extname(resolved).toLowerCase();
161
+ if (ext !== ".js" && ext !== ".mjs" && ext !== ".cjs") return null;
162
+ return resolved;
163
+ }
164
+ async function checkEmbedder(_flags, timeoutMs = 1e4) {
165
+ return new Promise((resolve) => {
166
+ const fallback = (lastError) => ({
146
167
  available: false,
147
168
  loaded: false,
148
- lastError: err instanceof Error ? err.message : String(err),
169
+ lastError,
149
170
  modelLoaded: false,
150
171
  disabled: false,
151
172
  disabledReason: null
173
+ });
174
+ const cliEntry = resolveCliJsEntry();
175
+ if (!cliEntry) {
176
+ resolve(fallback("embedder probe skipped: CLI entry is not a JS file (compiled binary?)"));
177
+ return;
178
+ }
179
+ let resolved = false;
180
+ const finish = (result) => {
181
+ if (resolved) return;
182
+ resolved = true;
183
+ try {
184
+ proc.kill();
185
+ } catch {
186
+ }
187
+ resolve(result);
152
188
  };
153
- }
189
+ let proc;
190
+ try {
191
+ proc = spawn(process.execPath, [cliEntry, "_embedder-probe"], {
192
+ stdio: ["ignore", "pipe", "pipe"],
193
+ // Mark the subprocess as the parent-spawned probe — the probe checks
194
+ // this and refuses to run if invoked directly by a curious user.
195
+ env: { ...process.env, PLUR_INTERNAL_PROBE: "1" }
196
+ });
197
+ } catch (err) {
198
+ finish(fallback(`spawn failed: ${err.message}`));
199
+ return;
200
+ }
201
+ const timeout = setTimeout(() => {
202
+ finish(fallback(`probe timeout after ${timeoutMs}ms`));
203
+ }, timeoutMs);
204
+ let stdoutBuf = "";
205
+ let stderrBuf = "";
206
+ proc.stdout?.on("data", (chunk) => {
207
+ stdoutBuf += chunk.toString("utf8");
208
+ });
209
+ proc.stderr?.on("data", (chunk) => {
210
+ stderrBuf += chunk.toString("utf8");
211
+ });
212
+ proc.on("error", (err) => {
213
+ clearTimeout(timeout);
214
+ finish(fallback(`probe spawn error: ${err.message}`));
215
+ });
216
+ proc.on("exit", (code, signal) => {
217
+ clearTimeout(timeout);
218
+ const lines = stdoutBuf.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("{"));
219
+ const resultLine = lines[lines.length - 1];
220
+ if (resultLine) {
221
+ try {
222
+ const parsed = JSON.parse(resultLine);
223
+ finish({
224
+ available: !!parsed.available,
225
+ loaded: !!parsed.loaded,
226
+ lastError: parsed.lastError ?? null,
227
+ modelLoaded: !!parsed.modelLoaded,
228
+ disabled: !!parsed.disabled,
229
+ disabledReason: parsed.disabledReason ?? null
230
+ });
231
+ return;
232
+ } catch {
233
+ }
234
+ }
235
+ const detail = signal ? `signal ${signal}` : `exit ${code}`;
236
+ const stderrTrim = stderrBuf.trim().slice(0, 200);
237
+ finish(fallback(`embedder probe failed (${detail})${stderrTrim ? `: ${stderrTrim}` : ""}`));
238
+ });
239
+ });
154
240
  }
155
241
  function buildReport(skipHandshake, flags) {
156
242
  const configs = inspectConfigs();
157
243
  const hooksInstalled = configs.some((c) => c.hasPlurHooks);
158
244
  const mcpRegistered = configs.some((c) => c.hasPlurMcp);
159
245
  const datacoreCollision = configs.some((c) => c.hasDatacoreMcp);
246
+ const staleNpxHooks = configs.some((c) => {
247
+ if (!c.exists) return false;
248
+ const config = readConfig(c.path);
249
+ return hasStaleNpxHooks(config);
250
+ });
251
+ const hookShim = validateHookShim();
160
252
  const handshakePromise = skipHandshake ? Promise.resolve({ ok: false, error: "skipped (--no-handshake)" }) : mcpHandshake();
161
253
  return Promise.all([handshakePromise, checkEmbedder(flags)]).then(([handshake, embedder]) => {
162
254
  const overall = hooksInstalled && mcpRegistered && (skipHandshake || handshake.ok) ? "ok" : "fail";
163
- return { configs, hooksInstalled, mcpRegistered, datacoreCollision, handshake, embedder, overall };
255
+ return { configs, hooksInstalled, mcpRegistered, datacoreCollision, staleNpxHooks, hookShim, handshake, embedder, overall };
164
256
  });
165
257
  }
166
258
  function printText(report) {
@@ -192,6 +284,17 @@ function printText(report) {
192
284
  outputText(" If your agent confuses them, this is the cause.");
193
285
  }
194
286
  outputText("");
287
+ if (report.hookShim.valid) {
288
+ outputText(`\u2713 Hook shim: ${report.hookShim.shimPath}`);
289
+ } else {
290
+ outputText(`\u2717 Hook shim: ${report.hookShim.error}`);
291
+ }
292
+ if (report.staleNpxHooks) {
293
+ outputText("");
294
+ outputText("\u26A0 Hooks still use npx \u2014 slow (200-2000ms per hook) and vulnerable to cache corruption.");
295
+ outputText(" Fix: run `plur init` to migrate to the local hook binary (<5ms per hook).");
296
+ }
297
+ outputText("");
195
298
  if (report.handshake.ok) {
196
299
  outputText(`\u2713 MCP handshake: ${report.handshake.serverName} v${report.handshake.serverVersion}`);
197
300
  } else {
@@ -0,0 +1,47 @@
1
+ import {
2
+ createPlur
3
+ } from "./chunk-O6WTH7H7.js";
4
+
5
+ // src/commands/embedder-probe.ts
6
+ async function run(_args, flags) {
7
+ if (process.env.PLUR_INTERNAL_PROBE !== "1") {
8
+ process.stderr.write(
9
+ "_embedder-probe is an internal subcommand spawned by `plur doctor`. Run `plur doctor` instead.\n"
10
+ );
11
+ process.exit(1);
12
+ }
13
+ try {
14
+ const plur = createPlur(flags);
15
+ const preStatus = plur.embedderStatus();
16
+ if (!preStatus.disabled) {
17
+ plur.resetEmbedder();
18
+ try {
19
+ await plur.recallSemantic("plur doctor probe", { limit: 1 });
20
+ } catch {
21
+ }
22
+ }
23
+ const status = plur.embedderStatus();
24
+ process.stdout.write(JSON.stringify({
25
+ available: status.available,
26
+ loaded: status.loaded,
27
+ lastError: status.lastError,
28
+ modelLoaded: status.available && status.loaded,
29
+ disabled: status.disabled,
30
+ disabledReason: status.disabledReason
31
+ }) + "\n");
32
+ process.exit(0);
33
+ } catch (err) {
34
+ process.stdout.write(JSON.stringify({
35
+ available: false,
36
+ loaded: false,
37
+ lastError: err instanceof Error ? err.message : String(err),
38
+ modelLoaded: false,
39
+ disabled: false,
40
+ disabledReason: null
41
+ }) + "\n");
42
+ process.exit(0);
43
+ }
44
+ }
45
+ export {
46
+ run
47
+ };
@@ -3,13 +3,14 @@ import {
3
3
  } from "./chunk-6RANUJMM.js";
4
4
 
5
5
  // src/commands/hook-session-guard.ts
6
- import { readSync, existsSync } from "fs";
6
+ import { readSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
7
7
  import { join } from "path";
8
8
  import { tmpdir } from "os";
9
9
  var EXEMPT_TOOLS = /* @__PURE__ */ new Set([
10
10
  "mcp__plur__plur_session_start",
11
11
  "ToolSearch"
12
12
  ]);
13
+ var MAX_BLOCKS_BEFORE_FALLBACK = 5;
13
14
  function readStdinRaw() {
14
15
  try {
15
16
  const chunks = [];
@@ -31,6 +32,22 @@ function readStdinRaw() {
31
32
  function sentinelPath(sessionId) {
32
33
  return join(tmpdir(), `plur-session-${sessionId}`);
33
34
  }
35
+ function blockCountPath(sessionId) {
36
+ const dir = join(tmpdir(), "plur-sessions");
37
+ mkdirSync(dir, { recursive: true });
38
+ return join(dir, `${sessionId}.guard-count`);
39
+ }
40
+ function incrementBlockCount(sessionId) {
41
+ const path = blockCountPath(sessionId);
42
+ let count = 0;
43
+ try {
44
+ count = parseInt(readFileSync(path, "utf8"), 10) || 0;
45
+ } catch {
46
+ }
47
+ count++;
48
+ writeFileSync(path, String(count));
49
+ return count;
50
+ }
34
51
  async function run(_args, _flags) {
35
52
  if (!isPlurConfigured()) return;
36
53
  const raw = readStdinRaw();
@@ -45,6 +62,14 @@ async function run(_args, _flags) {
45
62
  if (EXEMPT_TOOLS.has(toolName)) return;
46
63
  if (!sessionId) return;
47
64
  if (existsSync(sentinelPath(sessionId))) return;
65
+ const blockCount = incrementBlockCount(sessionId);
66
+ if (blockCount > MAX_BLOCKS_BEFORE_FALLBACK) {
67
+ process.stderr.write(
68
+ `[plur] WARNING: session guard gave up after ${MAX_BLOCKS_BEFORE_FALLBACK} blocked calls. The plur MCP server may not be running. Run \`plur doctor\` to diagnose.
69
+ `
70
+ );
71
+ return;
72
+ }
48
73
  const output = {
49
74
  hookSpecificOutput: {
50
75
  hookEventName: "PreToolUse",
@@ -11,115 +11,153 @@ import {
11
11
  } from "./chunk-7U4W4J3G.js";
12
12
 
13
13
  // src/commands/init.ts
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
15
- import { join } from "path";
16
- import { homedir } from "os";
17
- var CLI = "npx @plur-ai/cli";
18
- var PLUR_HOOKS_ENFORCEMENT = {
19
- SessionStart: [
20
- {
21
- hooks: [
22
- { type: "command", command: `${CLI} hook-session-remind`, timeout: 3 }
23
- ]
24
- }
25
- ],
26
- PreToolUse: [
27
- // Session guard blocks all tools until plur_session_start is called.
28
- // Must be first so it runs before any other PreToolUse hook.
29
- {
30
- matcher: "*",
31
- hooks: [
32
- { type: "command", command: `${CLI} hook-session-guard`, timeout: 3 }
33
- ]
34
- }
35
- ],
36
- PostToolUse: [
37
- // Session sentinel — creates marker file after plur_session_start succeeds
38
- {
39
- matcher: "mcp__plur__plur_session_start",
40
- hooks: [
41
- { type: "command", command: `${CLI} hook-session-mark`, timeout: 3 }
42
- ]
43
- }
44
- ]
45
- };
46
- var PLUR_HOOKS_INJECTION = {
47
- // First message: inject engrams based on the prompt.
48
- // Subsequent messages: periodic reminder to call plur_learn (~1ms skip).
49
- UserPromptSubmit: [
50
- {
51
- hooks: [
52
- { type: "command", command: `${CLI} hook-inject`, timeout: 15 }
53
- ]
54
- }
55
- ],
56
- // Re-inject after context compaction so engrams survive long conversations.
57
- PostCompact: [
58
- {
59
- matcher: "auto|manual",
60
- hooks: [
61
- { type: "command", command: `${CLI} hook-inject --rehydrate`, timeout: 15 }
62
- ]
63
- }
64
- ],
65
- PreToolUse: [
66
- // Full injection when entering plan mode — planning needs broad context
67
- {
68
- matcher: "EnterPlanMode",
69
- hooks: [
70
- { type: "command", command: `${CLI} hook-inject --event plan_mode`, timeout: 10 }
71
- ]
72
- },
73
- // Domain-specific engrams when a skill is invoked
74
- {
75
- matcher: "Skill",
76
- hooks: [
77
- { type: "command", command: `${CLI} hook-inject --event skill`, timeout: 10 }
78
- ]
79
- },
80
- // Agent-scoped engrams when spawning an agent
81
- {
82
- matcher: "Agent",
83
- hooks: [
84
- { type: "command", command: `${CLI} hook-inject --event agent`, timeout: 10 }
85
- ]
86
- },
87
- // Observation capture — log tool calls for offline pattern extraction
88
- {
89
- matcher: "Bash|Edit|Write|Agent",
90
- hooks: [
91
- { type: "command", command: `${CLI} hook-observe`, timeout: 3 }
92
- ]
93
- }
94
- ],
95
- PostToolUse: [
96
- {
97
- matcher: "Bash|Edit|Write|Agent",
98
- hooks: [
99
- { type: "command", command: `${CLI} hook-observe --post`, timeout: 3 }
100
- ]
101
- }
102
- ],
103
- // Inject agent-scoped engrams into subagent context
104
- SubagentStart: [
105
- {
106
- matcher: ".*",
107
- hooks: [
108
- { type: "command", command: `${CLI} hook-inject --event subagent`, timeout: 10 }
109
- ]
110
- }
111
- ],
112
- // Learning reflection — nudge the LLM to call plur_learn after responses
113
- // where it discovered or learned something. Fires every 3rd Stop to avoid fatigue.
114
- Stop: [
115
- {
116
- matcher: "*",
117
- hooks: [
118
- { type: "command", command: `${CLI} hook-learn-check`, timeout: 2 }
119
- ]
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
15
+ import { join, dirname } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import { homedir, platform } from "os";
18
+ function resolveCliEntrypoint() {
19
+ const thisFile = fileURLToPath(import.meta.url);
20
+ return join(dirname(thisFile), "..", "index.js");
21
+ }
22
+ function shimPath() {
23
+ const name = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
24
+ return join(homedir(), ".plur", "bin", name);
25
+ }
26
+ function installHookBinary() {
27
+ const binDir = join(homedir(), ".plur", "bin");
28
+ mkdirSync(binDir, { recursive: true });
29
+ const entrypoint = resolveCliEntrypoint();
30
+ const nodeBin = process.execPath;
31
+ if (!existsSync(entrypoint)) {
32
+ return { shimPath: "", status: `error: CLI entrypoint not found at ${entrypoint}` };
33
+ }
34
+ const target = shimPath();
35
+ if (platform() === "win32") {
36
+ writeFileSync(target, `@echo off\r
37
+ "${nodeBin}" "${entrypoint}" %*\r
38
+ `);
39
+ } else {
40
+ writeFileSync(target, `#!/bin/sh
41
+ exec "${nodeBin}" "${entrypoint}" "$@"
42
+ `, { mode: 493 });
43
+ try {
44
+ chmodSync(target, 493);
45
+ } catch {
120
46
  }
121
- ]
122
- };
47
+ }
48
+ const meta = { entrypoint, node: nodeBin, installed: (/* @__PURE__ */ new Date()).toISOString() };
49
+ writeFileSync(join(binDir, "plur-hook.meta.json"), JSON.stringify(meta, null, 2) + "\n");
50
+ return { shimPath: target, status: "installed" };
51
+ }
52
+ function buildEnforcementHooks(cmd) {
53
+ return {
54
+ SessionStart: [
55
+ {
56
+ hooks: [
57
+ { type: "command", command: `${cmd} hook-session-remind`, timeout: 3 }
58
+ ]
59
+ }
60
+ ],
61
+ PreToolUse: [
62
+ // Session guard — blocks all tools until plur_session_start is called.
63
+ // Must be first so it runs before any other PreToolUse hook.
64
+ {
65
+ matcher: "*",
66
+ hooks: [
67
+ { type: "command", command: `${cmd} hook-session-guard`, timeout: 3 }
68
+ ]
69
+ }
70
+ ],
71
+ PostToolUse: [
72
+ // Session sentinel — creates marker file after plur_session_start succeeds
73
+ {
74
+ matcher: "mcp__plur__plur_session_start",
75
+ hooks: [
76
+ { type: "command", command: `${cmd} hook-session-mark`, timeout: 3 }
77
+ ]
78
+ }
79
+ ]
80
+ };
81
+ }
82
+ function buildInjectionHooks(cmd) {
83
+ return {
84
+ // First message: inject engrams based on the prompt.
85
+ // Subsequent messages: periodic reminder to call plur_learn (~1ms skip).
86
+ UserPromptSubmit: [
87
+ {
88
+ hooks: [
89
+ { type: "command", command: `${cmd} hook-inject`, timeout: 15 }
90
+ ]
91
+ }
92
+ ],
93
+ // Re-inject after context compaction so engrams survive long conversations.
94
+ PostCompact: [
95
+ {
96
+ matcher: "auto|manual",
97
+ hooks: [
98
+ { type: "command", command: `${cmd} hook-inject --rehydrate`, timeout: 15 }
99
+ ]
100
+ }
101
+ ],
102
+ PreToolUse: [
103
+ // Full injection when entering plan mode — planning needs broad context
104
+ {
105
+ matcher: "EnterPlanMode",
106
+ hooks: [
107
+ { type: "command", command: `${cmd} hook-inject --event plan_mode`, timeout: 10 }
108
+ ]
109
+ },
110
+ // Domain-specific engrams when a skill is invoked
111
+ {
112
+ matcher: "Skill",
113
+ hooks: [
114
+ { type: "command", command: `${cmd} hook-inject --event skill`, timeout: 10 }
115
+ ]
116
+ },
117
+ // Agent-scoped engrams when spawning an agent
118
+ {
119
+ matcher: "Agent",
120
+ hooks: [
121
+ { type: "command", command: `${cmd} hook-inject --event agent`, timeout: 10 }
122
+ ]
123
+ },
124
+ // Observation capture — log tool calls for offline pattern extraction
125
+ {
126
+ matcher: "Bash|Edit|Write|Agent",
127
+ hooks: [
128
+ { type: "command", command: `${cmd} hook-observe`, timeout: 3 }
129
+ ]
130
+ }
131
+ ],
132
+ PostToolUse: [
133
+ {
134
+ matcher: "Bash|Edit|Write|Agent",
135
+ hooks: [
136
+ { type: "command", command: `${cmd} hook-observe --post`, timeout: 3 }
137
+ ]
138
+ }
139
+ ],
140
+ // Inject agent-scoped engrams into subagent context
141
+ SubagentStart: [
142
+ {
143
+ matcher: ".*",
144
+ hooks: [
145
+ { type: "command", command: `${cmd} hook-inject --event subagent`, timeout: 10 }
146
+ ]
147
+ }
148
+ ],
149
+ // Learning reflection — nudge the LLM to call plur_learn after responses
150
+ // where it discovered or learned something. Fires every 3rd Stop to avoid fatigue.
151
+ Stop: [
152
+ {
153
+ matcher: "*",
154
+ hooks: [
155
+ { type: "command", command: `${cmd} hook-learn-check`, timeout: 2 }
156
+ ]
157
+ }
158
+ ]
159
+ };
160
+ }
123
161
  function mergeHookMaps(a, b) {
124
162
  const out = {};
125
163
  for (const [event, entries] of Object.entries(a)) out[event] = [...entries];
@@ -224,7 +262,9 @@ function loadSettings(path) {
224
262
  }
225
263
  }
226
264
  function isPlurHook(entry) {
227
- return (entry.hooks ?? []).some((h) => h.command.includes("@plur-ai/cli"));
265
+ return (entry.hooks ?? []).some(
266
+ (h) => h.command.includes("@plur-ai/cli") || h.command.includes(".plur/bin/plur-hook")
267
+ );
228
268
  }
229
269
  function hasPlurHooks(settings) {
230
270
  const hooks = settings.hooks ?? {};
@@ -278,6 +318,10 @@ function hooksStatusFor(before, after, hadHooks) {
278
318
  return before === after ? "already up to date" : "upgraded";
279
319
  }
280
320
  async function run(args, flags) {
321
+ const shim = installHookBinary();
322
+ const cmd = shim.shimPath || "npx @plur-ai/cli";
323
+ const PLUR_HOOKS_ENFORCEMENT = buildEnforcementHooks(cmd);
324
+ const PLUR_HOOKS_INJECTION = buildInjectionHooks(cmd);
281
325
  const injectionPath = findSettingsPath(flags, args);
282
326
  const enforcementPath = join(homedir(), ".claude", "settings.json");
283
327
  const samePath = injectionPath === enforcementPath;
@@ -330,6 +374,8 @@ async function run(args, flags) {
330
374
  const entry = buildMcpServerEntry();
331
375
  outputText("PLUR installed for Claude Code.");
332
376
  outputText("");
377
+ outputText(`Hook binary: ${shim.status}${shim.shimPath ? ` (${shim.shimPath})` : ""}`);
378
+ outputText("");
333
379
  outputText("Architecture: One global engram store (~/.plur/), enforcement hooks global, injection hooks project-scoped.");
334
380
  outputText("Multi-project scoping via domain/scope fields on engrams, not separate installs.");
335
381
  outputText("");
@@ -29,7 +29,7 @@ async function run(args, flags) {
29
29
  return;
30
30
  }
31
31
  if (!subcommand || subcommand === "list") {
32
- const storeList = plur.listStores();
32
+ const storeList = await plur.listStoresAsync();
33
33
  if (shouldOutputJson(flags)) {
34
34
  outputJson({ stores: storeList, count: storeList.length });
35
35
  } else {
@@ -0,0 +1,168 @@
1
+ import {
2
+ exit,
3
+ outputJson,
4
+ outputText,
5
+ shouldOutputJson
6
+ } from "./chunk-7U4W4J3G.js";
7
+ import {
8
+ createPlur
9
+ } from "./chunk-O6WTH7H7.js";
10
+
11
+ // src/commands/tensions.ts
12
+ function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
13
+ return async (prompt) => {
14
+ const response = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
15
+ method: "POST",
16
+ headers: {
17
+ "Content-Type": "application/json",
18
+ Authorization: `Bearer ${apiKey}`
19
+ },
20
+ body: JSON.stringify({
21
+ model,
22
+ messages: [{ role: "user", content: prompt }],
23
+ temperature: 0.1
24
+ })
25
+ });
26
+ if (!response.ok) {
27
+ throw new Error(`LLM API error: ${response.status} ${response.statusText}`);
28
+ }
29
+ const data = await response.json();
30
+ return data.choices?.[0]?.message?.content ?? "";
31
+ };
32
+ }
33
+ function getLlmFunction() {
34
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
35
+ const openaiKey = process.env.OPENAI_API_KEY;
36
+ if (openrouterKey) return makeHttpLlm("https://openrouter.ai/api/v1", openrouterKey, "openai/gpt-4o-mini");
37
+ if (openaiKey) return makeHttpLlm("https://api.openai.com/v1", openaiKey, "gpt-4o-mini");
38
+ return void 0;
39
+ }
40
+ async function run(args, flags) {
41
+ let scan = false;
42
+ let scope;
43
+ let domain;
44
+ let minConfidence = 0.7;
45
+ let maxPairs = 50;
46
+ let llmBaseUrl;
47
+ let llmApiKey;
48
+ let llmModel;
49
+ let i = 0;
50
+ while (i < args.length) {
51
+ const arg = args[i];
52
+ if (arg === "--scan") {
53
+ scan = true;
54
+ i++;
55
+ } else if (arg === "--scope" && i + 1 < args.length) {
56
+ scope = args[++i];
57
+ i++;
58
+ } else if (arg === "--domain" && i + 1 < args.length) {
59
+ domain = args[++i];
60
+ i++;
61
+ } else if (arg === "--min-confidence" && i + 1 < args.length) {
62
+ minConfidence = parseFloat(args[++i]);
63
+ i++;
64
+ } else if (arg === "--max-pairs" && i + 1 < args.length) {
65
+ maxPairs = parseInt(args[++i], 10);
66
+ i++;
67
+ } else if (arg === "--llm-base-url" && i + 1 < args.length) {
68
+ llmBaseUrl = args[++i];
69
+ i++;
70
+ } else if (arg === "--llm-api-key" && i + 1 < args.length) {
71
+ llmApiKey = args[++i];
72
+ i++;
73
+ } else if (arg === "--model" && i + 1 < args.length) {
74
+ llmModel = args[++i];
75
+ i++;
76
+ } else {
77
+ i++;
78
+ }
79
+ }
80
+ const plur = createPlur(flags, { readonly: true });
81
+ const engrams = plur.list({ scope, domain });
82
+ if (scan) {
83
+ const llm = llmBaseUrl ? makeHttpLlm(llmBaseUrl, llmApiKey ?? "", llmModel) : getLlmFunction();
84
+ if (!llm) {
85
+ exit(
86
+ 1,
87
+ "tensions --scan requires an LLM.\nSet OPENROUTER_API_KEY or OPENAI_API_KEY, or pass --llm-base-url + --llm-api-key."
88
+ );
89
+ return;
90
+ }
91
+ if (!shouldOutputJson(flags)) {
92
+ outputText(`Scanning ${engrams.length} engrams for contradictions\u2026`);
93
+ if (scope) outputText(` scope: ${scope}`);
94
+ if (domain) outputText(` domain: ${domain}`);
95
+ outputText(` min-confidence: ${minConfidence} max-pairs: ${maxPairs}`);
96
+ outputText("");
97
+ }
98
+ const { scanForTensions } = await import("@plur-ai/core");
99
+ const result = await scanForTensions(engrams, llm, { min_confidence: minConfidence, max_pairs: maxPairs });
100
+ if (shouldOutputJson(flags)) {
101
+ outputJson({
102
+ pairs_checked: result.pairs_checked,
103
+ count: result.new_tensions,
104
+ tensions: result.tensions.map((t) => ({
105
+ engram_a: { id: t.id_a, statement: t.statement_a },
106
+ engram_b: { id: t.id_b, statement: t.statement_b },
107
+ confidence: t.confidence,
108
+ reason: t.reason
109
+ }))
110
+ });
111
+ return;
112
+ }
113
+ outputText(`Checked: ${result.pairs_checked} candidate pairs`);
114
+ outputText(`Found: ${result.new_tensions} tension${result.new_tensions === 1 ? "" : "s"} (confidence >= ${minConfidence})`);
115
+ outputText("");
116
+ if (result.tensions.length === 0) {
117
+ outputText("No contradictions detected.");
118
+ return;
119
+ }
120
+ for (const t of result.tensions) {
121
+ outputText(`\u2500\u2500 TENSION (confidence: ${t.confidence.toFixed(2)}) \u2500\u2500`);
122
+ outputText(` A [${t.id_a}]: ${t.statement_a}`);
123
+ outputText(` B [${t.id_b}]: ${t.statement_b}`);
124
+ outputText(` Reason: ${t.reason}`);
125
+ outputText("");
126
+ }
127
+ outputText("Next steps:");
128
+ outputText(" Resolve: determine which statement is correct, retire the other via plur forget <id>");
129
+ outputText(" Dismiss: if not a real conflict, both statements can coexist");
130
+ return;
131
+ }
132
+ const tensions = [];
133
+ const seen = /* @__PURE__ */ new Set();
134
+ for (const engram of engrams) {
135
+ if (!engram.relations?.conflicts?.length) continue;
136
+ for (const conflictId of engram.relations.conflicts) {
137
+ const pairKey = [engram.id, conflictId].sort().join(":");
138
+ if (seen.has(pairKey)) continue;
139
+ seen.add(pairKey);
140
+ const other = engrams.find((e) => e.id === conflictId);
141
+ if (!other) continue;
142
+ tensions.push({
143
+ engram_a: { id: engram.id, statement: engram.statement },
144
+ engram_b: { id: other.id, statement: other.statement },
145
+ detected_at: engram.activation.last_accessed
146
+ });
147
+ }
148
+ }
149
+ if (shouldOutputJson(flags)) {
150
+ outputJson({ tensions, count: tensions.length });
151
+ return;
152
+ }
153
+ if (tensions.length === 0) {
154
+ outputText("No stored tensions. Run `plur tensions --scan` to detect live contradictions.");
155
+ return;
156
+ }
157
+ outputText(`Stored tensions: ${tensions.length}`);
158
+ outputText("");
159
+ for (const t of tensions) {
160
+ outputText(` A [${t.engram_a.id}]: ${t.engram_a.statement}`);
161
+ outputText(` B [${t.engram_b.id}]: ${t.engram_b.statement}`);
162
+ outputText(` Detected: ${t.detected_at}`);
163
+ outputText("");
164
+ }
165
+ }
166
+ export {
167
+ run
168
+ };
package/dist/index.js CHANGED
@@ -49,7 +49,7 @@ function createPlur(flags2) {
49
49
  }
50
50
 
51
51
  // src/index.ts
52
- var VERSION = "0.9.10";
52
+ var VERSION = "0.9.11";
53
53
  var argv = process.argv.slice(2);
54
54
  if (argv.includes("--version") || argv.includes("-v")) {
55
55
  console.log(VERSION);
@@ -84,6 +84,7 @@ Commands:
84
84
  init Install Claude Code hooks + register plur MCP server
85
85
  init-remote Opt this project into recall from a PLUR Enterprise server
86
86
  doctor Diagnose Claude Code / Claude Desktop integration
87
+ tensions [--scan] List or scan for engram contradictions
87
88
  audit [--source X] Audit working memory (claude-code|claw|hermes) for conflicts vs engrams
88
89
  hook-inject (internal) Hook handler for engram injection
89
90
  hook-observe (internal) Hook handler for observation capture
@@ -127,6 +128,7 @@ var COMMANDS = {
127
128
  init: "./commands/init.js",
128
129
  "init-remote": "./commands/init-remote.js",
129
130
  doctor: "./commands/doctor.js",
131
+ tensions: "./commands/tensions.js",
130
132
  audit: "./commands/audit.js",
131
133
  "hook-inject": "./commands/hook-inject.js",
132
134
  "hook-observe": "./commands/hook-observe.js",
@@ -135,7 +137,11 @@ var COMMANDS = {
135
137
  "hook-session-mark": "./commands/hook-session-mark.js",
136
138
  "hook-session-remind": "./commands/hook-session-remind.js",
137
139
  "hook-correction-detect": "./commands/hook-correction-detect.js",
138
- "hook-revert-detect": "./commands/hook-revert-detect.js"
140
+ "hook-revert-detect": "./commands/hook-revert-detect.js",
141
+ // Hidden internal subcommand — spawned by `plur doctor` to isolate the
142
+ // ONNX embedder probe (issue #197). If the probe crashes with SIGABRT
143
+ // on libc++ thread pool cleanup, only the subprocess dies; doctor stays alive.
144
+ "_embedder-probe": "./commands/embedder-probe.js"
139
145
  };
140
146
  if (!command || !COMMANDS[command]) {
141
147
  exit(1, `Unknown command: ${command}. Run 'plur --help' for usage.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plur-ai/cli",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "plur": "dist/index.js"
@@ -10,7 +10,7 @@
10
10
  "dist"
11
11
  ],
12
12
  "dependencies": {
13
- "@plur-ai/core": "0.9.9"
13
+ "@plur-ai/core": "0.9.11"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^25.5.0"