@plur-ai/cli 0.9.10 → 0.9.12

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
 
@@ -2,7 +2,16 @@
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { homedir, platform } from "os";
5
+ function findMcpShim() {
6
+ const name = platform() === "win32" ? "plur-mcp.cmd" : "plur-mcp";
7
+ const path = join(homedir(), ".plur", "bin", name);
8
+ return existsSync(path) ? path : null;
9
+ }
5
10
  function buildMcpServerEntry() {
11
+ const shim = findMcpShim();
12
+ if (shim) {
13
+ return { command: shim, args: [] };
14
+ }
6
15
  if (platform() === "win32") {
7
16
  return {
8
17
  command: "cmd.exe",
@@ -4,29 +4,80 @@ import {
4
4
  hasPlurMcp,
5
5
  knownConfigFiles,
6
6
  readConfig
7
- } from "./chunk-57GFJSEE.js";
7
+ } from "./chunk-OAIEWP3Q.js";
8
8
  import {
9
9
  outputJson,
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 hasStaleNpxMcp(config) {
42
+ const servers = config.mcpServers ?? {};
43
+ const plur = servers.plur;
44
+ if (!plur) return false;
45
+ if (plur.command && plur.command.includes("npx")) return true;
46
+ const argsBlob = (plur.args ?? []).join(" ");
47
+ return argsBlob.includes("npx") && argsBlob.includes("@plur-ai/mcp");
48
+ }
49
+ function validateMcpShim() {
50
+ const name = platform() === "win32" ? "plur-mcp.cmd" : "plur-mcp";
51
+ const path = join(homedir(), ".plur", "bin", name);
52
+ if (!existsSync(path)) {
53
+ return { valid: false, shimPath: path, error: "shim not found \u2014 run `plur init` to create it (requires @plur-ai/mcp installed alongside CLI)" };
54
+ }
55
+ const content = readFileSync(path, "utf-8");
56
+ const match = content.match(/"([^"]+index\.js)"/);
57
+ if (!match) {
58
+ return { valid: false, shimPath: path, error: "shim has unexpected format" };
59
+ }
60
+ if (!existsSync(match[1])) {
61
+ return { valid: false, shimPath: path, error: `entrypoint missing: ${match[1]} \u2014 run \`plur init\` to fix` };
62
+ }
63
+ return { valid: true, shimPath: path };
64
+ }
65
+ function validateHookShim() {
66
+ const name = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
67
+ const path = join(homedir(), ".plur", "bin", name);
68
+ if (!existsSync(path)) {
69
+ return { valid: false, shimPath: path, error: "shim not found \u2014 run `plur init` to create it" };
70
+ }
71
+ const content = readFileSync(path, "utf-8");
72
+ const match = content.match(/"([^"]+index\.js)"/);
73
+ if (!match) {
74
+ return { valid: false, shimPath: path, error: "shim has unexpected format" };
75
+ }
76
+ if (!existsSync(match[1])) {
77
+ return { valid: false, shimPath: path, error: `entrypoint missing: ${match[1]} \u2014 run \`plur init\` to fix` };
78
+ }
79
+ return { valid: true, shimPath: path };
80
+ }
30
81
  function inspectConfigs() {
31
82
  return knownConfigFiles().map((cf) => {
32
83
  if (!cf.exists) {
@@ -121,46 +172,117 @@ async function mcpHandshake(timeoutMs = 2e4) {
121
172
  proc.stdin?.write(JSON.stringify(initRequest) + "\n");
122
173
  });
123
174
  }
124
- async function checkEmbedder(flags) {
175
+ function resolveCliJsEntry() {
176
+ const argv1 = process.argv[1];
177
+ if (!argv1) return null;
178
+ let resolved;
125
179
  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 {
180
+ resolved = realpathSync(argv1);
181
+ } catch {
182
+ return null;
183
+ }
184
+ const ext = extname(resolved).toLowerCase();
185
+ if (ext !== ".js" && ext !== ".mjs" && ext !== ".cjs") return null;
186
+ return resolved;
187
+ }
188
+ async function checkEmbedder(_flags, timeoutMs = 1e4) {
189
+ return new Promise((resolve) => {
190
+ const fallback = (lastError) => ({
146
191
  available: false,
147
192
  loaded: false,
148
- lastError: err instanceof Error ? err.message : String(err),
193
+ lastError,
149
194
  modelLoaded: false,
150
195
  disabled: false,
151
196
  disabledReason: null
197
+ });
198
+ const cliEntry = resolveCliJsEntry();
199
+ if (!cliEntry) {
200
+ resolve(fallback("embedder probe skipped: CLI entry is not a JS file (compiled binary?)"));
201
+ return;
202
+ }
203
+ let resolved = false;
204
+ const finish = (result) => {
205
+ if (resolved) return;
206
+ resolved = true;
207
+ try {
208
+ proc.kill();
209
+ } catch {
210
+ }
211
+ resolve(result);
152
212
  };
153
- }
213
+ let proc;
214
+ try {
215
+ proc = spawn(process.execPath, [cliEntry, "_embedder-probe"], {
216
+ stdio: ["ignore", "pipe", "pipe"],
217
+ // Mark the subprocess as the parent-spawned probe — the probe checks
218
+ // this and refuses to run if invoked directly by a curious user.
219
+ env: { ...process.env, PLUR_INTERNAL_PROBE: "1" }
220
+ });
221
+ } catch (err) {
222
+ finish(fallback(`spawn failed: ${err.message}`));
223
+ return;
224
+ }
225
+ const timeout = setTimeout(() => {
226
+ finish(fallback(`probe timeout after ${timeoutMs}ms`));
227
+ }, timeoutMs);
228
+ let stdoutBuf = "";
229
+ let stderrBuf = "";
230
+ proc.stdout?.on("data", (chunk) => {
231
+ stdoutBuf += chunk.toString("utf8");
232
+ });
233
+ proc.stderr?.on("data", (chunk) => {
234
+ stderrBuf += chunk.toString("utf8");
235
+ });
236
+ proc.on("error", (err) => {
237
+ clearTimeout(timeout);
238
+ finish(fallback(`probe spawn error: ${err.message}`));
239
+ });
240
+ proc.on("exit", (code, signal) => {
241
+ clearTimeout(timeout);
242
+ const lines = stdoutBuf.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("{"));
243
+ const resultLine = lines[lines.length - 1];
244
+ if (resultLine) {
245
+ try {
246
+ const parsed = JSON.parse(resultLine);
247
+ finish({
248
+ available: !!parsed.available,
249
+ loaded: !!parsed.loaded,
250
+ lastError: parsed.lastError ?? null,
251
+ modelLoaded: !!parsed.modelLoaded,
252
+ disabled: !!parsed.disabled,
253
+ disabledReason: parsed.disabledReason ?? null
254
+ });
255
+ return;
256
+ } catch {
257
+ }
258
+ }
259
+ const detail = signal ? `signal ${signal}` : `exit ${code}`;
260
+ const stderrTrim = stderrBuf.trim().slice(0, 200);
261
+ finish(fallback(`embedder probe failed (${detail})${stderrTrim ? `: ${stderrTrim}` : ""}`));
262
+ });
263
+ });
154
264
  }
155
265
  function buildReport(skipHandshake, flags) {
156
266
  const configs = inspectConfigs();
157
267
  const hooksInstalled = configs.some((c) => c.hasPlurHooks);
158
268
  const mcpRegistered = configs.some((c) => c.hasPlurMcp);
159
269
  const datacoreCollision = configs.some((c) => c.hasDatacoreMcp);
270
+ const staleNpxHooks = configs.some((c) => {
271
+ if (!c.exists) return false;
272
+ const config = readConfig(c.path);
273
+ return hasStaleNpxHooks(config);
274
+ });
275
+ const staleNpxMcp = configs.some((c) => {
276
+ if (!c.exists) return false;
277
+ const config = readConfig(c.path);
278
+ return hasStaleNpxMcp(config);
279
+ });
280
+ const hookShim = validateHookShim();
281
+ const mcpShim = validateMcpShim();
160
282
  const handshakePromise = skipHandshake ? Promise.resolve({ ok: false, error: "skipped (--no-handshake)" }) : mcpHandshake();
161
283
  return Promise.all([handshakePromise, checkEmbedder(flags)]).then(([handshake, embedder]) => {
162
284
  const overall = hooksInstalled && mcpRegistered && (skipHandshake || handshake.ok) ? "ok" : "fail";
163
- return { configs, hooksInstalled, mcpRegistered, datacoreCollision, handshake, embedder, overall };
285
+ return { configs, hooksInstalled, mcpRegistered, datacoreCollision, staleNpxHooks, staleNpxMcp, hookShim, mcpShim, handshake, embedder, overall };
164
286
  });
165
287
  }
166
288
  function printText(report) {
@@ -192,6 +314,29 @@ function printText(report) {
192
314
  outputText(" If your agent confuses them, this is the cause.");
193
315
  }
194
316
  outputText("");
317
+ if (report.hookShim.valid) {
318
+ outputText(`\u2713 Hook shim: ${report.hookShim.shimPath}`);
319
+ } else {
320
+ outputText(`\u2717 Hook shim: ${report.hookShim.error}`);
321
+ }
322
+ if (report.staleNpxHooks) {
323
+ outputText("");
324
+ outputText("\u26A0 Hooks still use npx \u2014 slow (200-2000ms per hook) and vulnerable to cache corruption.");
325
+ outputText(" Fix: run `plur init` to migrate to the local hook binary (<5ms per hook).");
326
+ }
327
+ if (report.mcpShim.valid) {
328
+ outputText(`\u2713 MCP shim: ${report.mcpShim.shimPath}`);
329
+ } else {
330
+ outputText(`\u2717 MCP shim: ${report.mcpShim.error}`);
331
+ }
332
+ if (report.staleNpxMcp) {
333
+ outputText("");
334
+ outputText("\u26A0 plur MCP still launched via npx \u2014 vulnerable to ENOTEMPTY cache corruption on version bumps (#234).");
335
+ outputText(" This is the same bug class as #178 (which fixed hooks). Symptom: Claude Code");
336
+ outputText(" sessions silently lose PLUR memory after a new @plur-ai/mcp publish.");
337
+ outputText(" Fix: run `plur init` to migrate to the local MCP binary (no npx, no race).");
338
+ }
339
+ outputText("");
195
340
  if (report.handshake.ok) {
196
341
  outputText(`\u2713 MCP handshake: ${report.handshake.serverName} v${report.handshake.serverVersion}`);
197
342
  } 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,10 +3,11 @@ import {
3
3
  } from "./chunk-O6WTH7H7.js";
4
4
 
5
5
  // src/commands/hook-inject.ts
6
- import { existsSync, writeFileSync, readFileSync, appendFileSync, mkdirSync, readSync, statSync } from "fs";
7
- import { dirname, join, resolve } from "path";
6
+ import { existsSync, writeFileSync, readFileSync, appendFileSync, mkdirSync, readSync, statSync, readdirSync, unlinkSync } from "fs";
7
+ import { join } from "path";
8
8
  import { tmpdir, homedir } from "os";
9
9
  import { randomUUID } from "crypto";
10
+ import { readProjectConfig } from "@plur-ai/core";
10
11
  var MAX_REMOTE_TASK_CHARS = 1e3;
11
12
  var MAX_REMOTE_RESPONSE_BYTES = 128 * 1024;
12
13
  var REMOTE_TIMEOUT_MS = 1500;
@@ -22,83 +23,6 @@ function logRemoteAttempt(entry) {
22
23
  }
23
24
  }
24
25
  var REMINDER_INTERVAL_MS = 10 * 60 * 1e3;
25
- function findProjectConfigPath(startDir = process.cwd()) {
26
- const home = resolve(homedir());
27
- let dir = resolve(startDir);
28
- const MAX_DEPTH = 12;
29
- for (let depth = 0; depth < MAX_DEPTH; depth++) {
30
- if (dir !== home) {
31
- const candidate = join(dir, ".plur.yaml");
32
- if (existsSync(candidate)) return candidate;
33
- }
34
- if (existsSync(join(dir, ".git"))) return null;
35
- if (dir === home || dir === "/" || dir === ".") return null;
36
- const parent = dirname(dir);
37
- if (parent === dir) return null;
38
- dir = parent;
39
- }
40
- return null;
41
- }
42
- function unquoteYamlValue(v) {
43
- return v.replace(/^(['"])(.*)\1$/, "$2");
44
- }
45
- function readProjectConfig() {
46
- const configPath = findProjectConfigPath();
47
- if (!configPath) return {};
48
- try {
49
- const content = readFileSync(configPath, "utf8").replace(/^/, "");
50
- const config = {};
51
- let inListKey = null;
52
- let listAcc = [];
53
- const finishList = () => {
54
- if (inListKey === "remote_scopes") {
55
- config.remote_scopes = listAcc;
56
- }
57
- inListKey = null;
58
- listAcc = [];
59
- };
60
- for (const rawLine of content.split("\n")) {
61
- const line = rawLine.replace(/\r$/, "");
62
- const trimmed = line.trim();
63
- if (trimmed.startsWith("#") || !trimmed) continue;
64
- if (inListKey === "remote_scopes" && trimmed.startsWith("-")) {
65
- listAcc.push(unquoteYamlValue(trimmed.slice(1).trim()));
66
- continue;
67
- }
68
- if (inListKey) finishList();
69
- const colonIdx = trimmed.indexOf(":");
70
- if (colonIdx < 0) continue;
71
- const key = trimmed.slice(0, colonIdx).trim();
72
- const value = unquoteYamlValue(trimmed.slice(colonIdx + 1).trim());
73
- switch (key) {
74
- case "domain":
75
- config.domain = value;
76
- break;
77
- case "scope":
78
- config.scope = value;
79
- break;
80
- case "remote_url":
81
- config.remote_url = value;
82
- break;
83
- case "remote_token":
84
- config.remote_token = value;
85
- break;
86
- case "remote_scopes":
87
- if (value === "" || value === "|" || value === ">") {
88
- inListKey = "remote_scopes";
89
- listAcc = [];
90
- } else {
91
- config.remote_scopes = value.split(",").map((s) => unquoteYamlValue(s.trim())).filter(Boolean);
92
- }
93
- break;
94
- }
95
- }
96
- finishList();
97
- return config;
98
- } catch {
99
- return {};
100
- }
101
- }
102
26
  async function tryRemoteInject(config, task) {
103
27
  if (!config.remote_url || !config.remote_token) return null;
104
28
  const startTs = Date.now();
@@ -228,6 +152,44 @@ function extractEventTask(input, event) {
228
152
  return "";
229
153
  }
230
154
  }
155
+ function processDeferredWrapups() {
156
+ const plurDir = process.env.PLUR_PATH ?? join(homedir(), ".plur");
157
+ const sessionsDir = join(plurDir, "sessions");
158
+ if (!existsSync(sessionsDir)) return null;
159
+ const notices = [];
160
+ try {
161
+ const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".checkpoint.json"));
162
+ const now = Date.now();
163
+ const staleMin = parseInt(process.env.PLUR_CHECKPOINT_STALE_MIN ?? "5", 10);
164
+ const STALE_THRESHOLD_MS = Math.max(1, staleMin) * 60 * 1e3;
165
+ for (const file of files) {
166
+ const path = join(sessionsDir, file);
167
+ try {
168
+ const checkpoint = JSON.parse(readFileSync(path, "utf8"));
169
+ const lastCheckpoint = new Date(checkpoint.last_checkpoint).getTime();
170
+ if (now - lastCheckpoint < STALE_THRESHOLD_MS) continue;
171
+ const started = new Date(checkpoint.started_at);
172
+ const ended = new Date(checkpoint.last_checkpoint);
173
+ const durationMin = Math.round((ended.getTime() - started.getTime()) / 6e4);
174
+ const durationStr = durationMin >= 60 ? `${Math.floor(durationMin / 60)}h ${durationMin % 60}m` : `${durationMin}m`;
175
+ notices.push(
176
+ `Previous session (${durationStr}, ${checkpoint.stop_count} responses${checkpoint.cwd ? ", " + checkpoint.cwd.split("/").slice(-2).join("/") : ""}) ended without wrap-up.`
177
+ );
178
+ unlinkSync(path);
179
+ } catch {
180
+ try {
181
+ unlinkSync(path);
182
+ } catch {
183
+ }
184
+ }
185
+ }
186
+ } catch {
187
+ return null;
188
+ }
189
+ if (notices.length === 0) return null;
190
+ return `[PLUR] ${notices.join(" ")}
191
+ Consider running plur_session_end with engram_suggestions when this session ends.`;
192
+ }
231
193
  async function run(args, flags) {
232
194
  const isRehydrate = args.includes("--rehydrate");
233
195
  const eventIdx = args.indexOf("--event");
@@ -359,6 +321,8 @@ ${parts2.join("\n")}` };
359
321
  if (sessionId) parts.push(`Session ID: ${sessionId}`);
360
322
  if (projectConfig.domain) parts.push(`Project domain: ${projectConfig.domain}`);
361
323
  if (projectConfig.scope) parts.push(`Project scope: ${projectConfig.scope} \u2014 use this scope for plur_learn calls`);
324
+ const deferredNotice = processDeferredWrapups();
325
+ if (deferredNotice) parts.push("", deferredNotice);
362
326
  }
363
327
  if (context) {
364
328
  parts.push("");
@@ -1,14 +1,47 @@
1
1
  // src/commands/hook-learn-check.ts
2
2
  import { readSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
3
  import { join } from "path";
4
- import { tmpdir } from "os";
5
- var INTERVAL = 3;
4
+ import { tmpdir, homedir } from "os";
5
+ var LEARN_INTERVAL = 3;
6
+ var CHECKPOINT_INTERVAL = parseInt(process.env.PLUR_CHECKPOINT_INTERVAL || "10", 10);
7
+ function sessionKey() {
8
+ const raw = process.env.CLAUDE_SESSION_ID || String(process.ppid || "unknown");
9
+ return raw.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "default";
10
+ }
6
11
  function counterPath() {
7
12
  const dir = join(tmpdir(), "plur-sessions");
8
13
  mkdirSync(dir, { recursive: true });
9
- const sessionKey = process.env.CLAUDE_SESSION_ID || String(process.ppid || "unknown");
10
- const safeKey = sessionKey.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "default";
11
- return join(dir, `${safeKey}.stop-count`);
14
+ return join(dir, `${sessionKey()}.stop-count`);
15
+ }
16
+ function plurPath() {
17
+ return process.env.PLUR_PATH ?? join(homedir(), ".plur");
18
+ }
19
+ function checkpointDir() {
20
+ const dir = join(plurPath(), "sessions");
21
+ mkdirSync(dir, { recursive: true });
22
+ return dir;
23
+ }
24
+ function writeCheckpoint(count, cwd) {
25
+ const id = sessionKey();
26
+ const dir = checkpointDir();
27
+ const path = join(dir, `${id}.checkpoint.json`);
28
+ const now = (/* @__PURE__ */ new Date()).toISOString();
29
+ const dateStr = now.slice(0, 10);
30
+ let startedAt = now;
31
+ try {
32
+ const existing = JSON.parse(readFileSync(path, "utf8"));
33
+ if (existing.started_at) startedAt = existing.started_at;
34
+ } catch {
35
+ }
36
+ const checkpoint = {
37
+ session_id: id,
38
+ started_at: startedAt,
39
+ last_checkpoint: now,
40
+ stop_count: count,
41
+ cwd,
42
+ observation_file: `${dateStr}.jsonl`
43
+ };
44
+ writeFileSync(path, JSON.stringify(checkpoint, null, 2) + "\n");
12
45
  }
13
46
  function readStdinRaw() {
14
47
  try {
@@ -31,6 +64,12 @@ function readStdinRaw() {
31
64
  var LEARN_PROMPT = `[PLUR] Did you discover, learn, or get corrected on something in your last response? If yes \u2014 call plur_learn now before moving on. If no \u2014 continue.`;
32
65
  async function run(_args, _flags) {
33
66
  const raw = readStdinRaw();
67
+ let cwd = process.cwd();
68
+ try {
69
+ const data = JSON.parse(raw);
70
+ if (data.cwd) cwd = data.cwd;
71
+ } catch {
72
+ }
34
73
  const cPath = counterPath();
35
74
  let count = 1;
36
75
  try {
@@ -42,7 +81,13 @@ async function run(_args, _flags) {
42
81
  writeFileSync(cPath, String(count));
43
82
  } catch {
44
83
  }
45
- if (count % INTERVAL !== 0) {
84
+ if (count % CHECKPOINT_INTERVAL === 0) {
85
+ try {
86
+ writeCheckpoint(count, cwd);
87
+ } catch {
88
+ }
89
+ }
90
+ if (count % LEARN_INTERVAL !== 0) {
46
91
  process.stdout.write(raw);
47
92
  return;
48
93
  }
@@ -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",
@@ -5,121 +5,204 @@ import {
5
5
  mergePlurMcp,
6
6
  readConfig,
7
7
  writeConfig
8
- } from "./chunk-57GFJSEE.js";
8
+ } from "./chunk-OAIEWP3Q.js";
9
9
  import {
10
10
  outputText
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
- ]
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 {
110
46
  }
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
- ]
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 resolveMcpEntrypoint() {
53
+ const cliEntry = resolveCliEntrypoint();
54
+ let dir = dirname(cliEntry);
55
+ const MAX_DEPTH = 12;
56
+ for (let depth = 0; depth < MAX_DEPTH; depth++) {
57
+ const candidate = join(dir, "node_modules", "@plur-ai", "mcp", "dist", "index.js");
58
+ if (existsSync(candidate)) return candidate;
59
+ const adjacent = join(dir, "..", "@plur-ai", "mcp", "dist", "index.js");
60
+ if (existsSync(adjacent)) return adjacent;
61
+ const parent = dirname(dir);
62
+ if (parent === dir) break;
63
+ dir = parent;
64
+ }
65
+ return null;
66
+ }
67
+ function mcpShimPath() {
68
+ const name = platform() === "win32" ? "plur-mcp.cmd" : "plur-mcp";
69
+ return join(homedir(), ".plur", "bin", name);
70
+ }
71
+ function installMcpBinary() {
72
+ const binDir = join(homedir(), ".plur", "bin");
73
+ mkdirSync(binDir, { recursive: true });
74
+ const entrypoint = resolveMcpEntrypoint();
75
+ const nodeBin = process.execPath;
76
+ if (!entrypoint) {
77
+ return { shimPath: "", status: "skipped: @plur-ai/mcp not installed alongside CLI" };
78
+ }
79
+ const target = mcpShimPath();
80
+ if (platform() === "win32") {
81
+ writeFileSync(target, `@echo off\r
82
+ "${nodeBin}" "${entrypoint}" %*\r
83
+ `);
84
+ } else {
85
+ writeFileSync(target, `#!/bin/sh
86
+ exec "${nodeBin}" "${entrypoint}" "$@"
87
+ `, { mode: 493 });
88
+ try {
89
+ chmodSync(target, 493);
90
+ } catch {
120
91
  }
121
- ]
122
- };
92
+ }
93
+ const meta = { entrypoint, node: nodeBin, installed: (/* @__PURE__ */ new Date()).toISOString() };
94
+ writeFileSync(join(binDir, "plur-mcp.meta.json"), JSON.stringify(meta, null, 2) + "\n");
95
+ return { shimPath: target, status: "installed" };
96
+ }
97
+ function buildEnforcementHooks(cmd) {
98
+ return {
99
+ SessionStart: [
100
+ {
101
+ hooks: [
102
+ { type: "command", command: `${cmd} hook-session-remind`, timeout: 3 }
103
+ ]
104
+ }
105
+ ],
106
+ PreToolUse: [
107
+ // Session guard — blocks all tools until plur_session_start is called.
108
+ // Must be first so it runs before any other PreToolUse hook.
109
+ {
110
+ matcher: "*",
111
+ hooks: [
112
+ { type: "command", command: `${cmd} hook-session-guard`, timeout: 3 }
113
+ ]
114
+ }
115
+ ],
116
+ PostToolUse: [
117
+ // Session sentinel — creates marker file after plur_session_start succeeds
118
+ {
119
+ matcher: "mcp__plur__plur_session_start",
120
+ hooks: [
121
+ { type: "command", command: `${cmd} hook-session-mark`, timeout: 3 }
122
+ ]
123
+ }
124
+ ]
125
+ };
126
+ }
127
+ function buildInjectionHooks(cmd) {
128
+ return {
129
+ // First message: inject engrams based on the prompt.
130
+ // Subsequent messages: periodic reminder to call plur_learn (~1ms skip).
131
+ UserPromptSubmit: [
132
+ {
133
+ hooks: [
134
+ { type: "command", command: `${cmd} hook-inject`, timeout: 15 }
135
+ ]
136
+ }
137
+ ],
138
+ // Re-inject after context compaction so engrams survive long conversations.
139
+ PostCompact: [
140
+ {
141
+ matcher: "auto|manual",
142
+ hooks: [
143
+ { type: "command", command: `${cmd} hook-inject --rehydrate`, timeout: 15 }
144
+ ]
145
+ }
146
+ ],
147
+ PreToolUse: [
148
+ // Full injection when entering plan mode — planning needs broad context
149
+ {
150
+ matcher: "EnterPlanMode",
151
+ hooks: [
152
+ { type: "command", command: `${cmd} hook-inject --event plan_mode`, timeout: 10 }
153
+ ]
154
+ },
155
+ // Domain-specific engrams when a skill is invoked
156
+ {
157
+ matcher: "Skill",
158
+ hooks: [
159
+ { type: "command", command: `${cmd} hook-inject --event skill`, timeout: 10 }
160
+ ]
161
+ },
162
+ // Agent-scoped engrams when spawning an agent
163
+ {
164
+ matcher: "Agent",
165
+ hooks: [
166
+ { type: "command", command: `${cmd} hook-inject --event agent`, timeout: 10 }
167
+ ]
168
+ },
169
+ // Observation capture — log tool calls for offline pattern extraction
170
+ {
171
+ matcher: "Bash|Edit|Write|Agent",
172
+ hooks: [
173
+ { type: "command", command: `${cmd} hook-observe`, timeout: 3 }
174
+ ]
175
+ }
176
+ ],
177
+ PostToolUse: [
178
+ {
179
+ matcher: "Bash|Edit|Write|Agent",
180
+ hooks: [
181
+ { type: "command", command: `${cmd} hook-observe --post`, timeout: 3 }
182
+ ]
183
+ }
184
+ ],
185
+ // Inject agent-scoped engrams into subagent context
186
+ SubagentStart: [
187
+ {
188
+ matcher: ".*",
189
+ hooks: [
190
+ { type: "command", command: `${cmd} hook-inject --event subagent`, timeout: 10 }
191
+ ]
192
+ }
193
+ ],
194
+ // Learning reflection — nudge the LLM to call plur_learn after responses
195
+ // where it discovered or learned something. Fires every 3rd Stop to avoid fatigue.
196
+ Stop: [
197
+ {
198
+ matcher: "*",
199
+ hooks: [
200
+ { type: "command", command: `${cmd} hook-learn-check`, timeout: 2 }
201
+ ]
202
+ }
203
+ ]
204
+ };
205
+ }
123
206
  function mergeHookMaps(a, b) {
124
207
  const out = {};
125
208
  for (const [event, entries] of Object.entries(a)) out[event] = [...entries];
@@ -224,7 +307,9 @@ function loadSettings(path) {
224
307
  }
225
308
  }
226
309
  function isPlurHook(entry) {
227
- return (entry.hooks ?? []).some((h) => h.command.includes("@plur-ai/cli"));
310
+ return (entry.hooks ?? []).some(
311
+ (h) => h.command.includes("@plur-ai/cli") || h.command.includes(".plur/bin/plur-hook")
312
+ );
228
313
  }
229
314
  function hasPlurHooks(settings) {
230
315
  const hooks = settings.hooks ?? {};
@@ -278,6 +363,11 @@ function hooksStatusFor(before, after, hadHooks) {
278
363
  return before === after ? "already up to date" : "upgraded";
279
364
  }
280
365
  async function run(args, flags) {
366
+ const shim = installHookBinary();
367
+ const cmd = shim.shimPath || "npx @plur-ai/cli";
368
+ const mcpShim = installMcpBinary();
369
+ const PLUR_HOOKS_ENFORCEMENT = buildEnforcementHooks(cmd);
370
+ const PLUR_HOOKS_INJECTION = buildInjectionHooks(cmd);
281
371
  const injectionPath = findSettingsPath(flags, args);
282
372
  const enforcementPath = join(homedir(), ".claude", "settings.json");
283
373
  const samePath = injectionPath === enforcementPath;
@@ -330,6 +420,9 @@ async function run(args, flags) {
330
420
  const entry = buildMcpServerEntry();
331
421
  outputText("PLUR installed for Claude Code.");
332
422
  outputText("");
423
+ outputText(`Hook binary: ${shim.status}${shim.shimPath ? ` (${shim.shimPath})` : ""}`);
424
+ outputText(`MCP binary: ${mcpShim.status}${mcpShim.shimPath ? ` (${mcpShim.shimPath})` : ""}`);
425
+ outputText("");
333
426
  outputText("Architecture: One global engram store (~/.plur/), enforcement hooks global, injection hooks project-scoped.");
334
427
  outputText("Multi-project scoping via domain/scope fields on engrams, not separate installs.");
335
428
  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.12";
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.12",
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.12"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^25.5.0"