@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 +3 -2
- package/dist/commands/{chunk-57GFJSEE.js → chunk-OAIEWP3Q.js} +9 -0
- package/dist/commands/doctor.js +174 -29
- package/dist/commands/embedder-probe.js +47 -0
- package/dist/commands/hook-inject.js +43 -79
- package/dist/commands/hook-learn-check.js +51 -6
- package/dist/commands/hook-session-guard.js +26 -1
- package/dist/commands/init.js +202 -109
- package/dist/commands/stores.js +1 -1
- package/dist/commands/tensions.js +168 -0
- package/dist/index.js +8 -2
- package/package.json +2 -2
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",
|
package/dist/commands/doctor.js
CHANGED
|
@@ -4,29 +4,80 @@ import {
|
|
|
4
4
|
hasPlurMcp,
|
|
5
5
|
knownConfigFiles,
|
|
6
6
|
readConfig
|
|
7
|
-
} from "./chunk-
|
|
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
|
-
|
|
175
|
+
function resolveCliJsEntry() {
|
|
176
|
+
const argv1 = process.argv[1];
|
|
177
|
+
if (!argv1) return null;
|
|
178
|
+
let resolved;
|
|
125
179
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 %
|
|
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",
|
package/dist/commands/init.js
CHANGED
|
@@ -5,121 +5,204 @@ import {
|
|
|
5
5
|
mergePlurMcp,
|
|
6
6
|
readConfig,
|
|
7
7
|
writeConfig
|
|
8
|
-
} from "./chunk-
|
|
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 {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
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("");
|
package/dist/commands/stores.js
CHANGED
|
@@ -29,7 +29,7 @@ async function run(args, flags) {
|
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
if (!subcommand || subcommand === "list") {
|
|
32
|
-
const storeList = plur.
|
|
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.
|
|
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.
|
|
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.
|
|
13
|
+
"@plur-ai/core": "0.9.12"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^25.5.0"
|