@plur-ai/cli 0.9.9 → 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/commands/doctor.js +131 -28
- package/dist/commands/embedder-probe.js +47 -0
- package/dist/commands/hook-inject.js +178 -31
- package/dist/commands/hook-session-guard.js +26 -1
- package/dist/commands/init-remote.js +298 -0
- package/dist/commands/init.js +155 -109
- package/dist/commands/stores.js +1 -1
- package/dist/commands/tensions.js +168 -0
- package/dist/index.js +10 -2
- package/package.json +2 -2
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
outputText
|
|
3
|
+
} from "./chunk-7U4W4J3G.js";
|
|
4
|
+
|
|
5
|
+
// src/commands/init-remote.ts
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "fs";
|
|
7
|
+
import { dirname, join, resolve } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
var HELP = `plur init-remote \u2014 opt this project into recall from PLUR Enterprise
|
|
10
|
+
|
|
11
|
+
USAGE
|
|
12
|
+
plur init-remote --url <enterprise-url> --token <api-key> [--scopes <list>]
|
|
13
|
+
plur init-remote --verify Check connectivity against existing .plur.yaml
|
|
14
|
+
|
|
15
|
+
OPTIONS
|
|
16
|
+
--url URL Enterprise base URL, e.g. https://plur.datafund.io
|
|
17
|
+
--token KEY API key for authentication
|
|
18
|
+
--scopes SCOPES Optional comma-separated scope whitelist
|
|
19
|
+
e.g. "org:plur,group:plur/engineering"
|
|
20
|
+
--no-gitignore Skip adding .plur.yaml to .gitignore (NOT RECOMMENDED \u2014
|
|
21
|
+
the token is sensitive)
|
|
22
|
+
--verify Read existing .plur.yaml and test the /api/v1/me
|
|
23
|
+
endpoint against the configured remote
|
|
24
|
+
|
|
25
|
+
WHAT THIS DOES
|
|
26
|
+
Writes .plur.yaml in the current directory with remote_url, remote_token,
|
|
27
|
+
and optional remote_scopes fields. The UserPromptSubmit hook will then
|
|
28
|
+
call \${remote_url}/api/v1/inject for each prompt (before falling back to
|
|
29
|
+
local PLUR). The hook walks upward from the current working directory to
|
|
30
|
+
find .plur.yaml, so you can work from any subdirectory.
|
|
31
|
+
|
|
32
|
+
WITHOUT this command, projects stay 100% local-only and Enterprise
|
|
33
|
+
never sees their prompts.
|
|
34
|
+
`;
|
|
35
|
+
function parseArgs(args) {
|
|
36
|
+
const out = {};
|
|
37
|
+
const consumeValue = (i, flag) => {
|
|
38
|
+
const next = args[i + 1];
|
|
39
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
40
|
+
return { error: `${flag} requires a value (got ${next === void 0 ? "nothing" : `another flag: ${next}`})` };
|
|
41
|
+
}
|
|
42
|
+
return { value: next };
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < args.length; i++) {
|
|
45
|
+
const a = args[i];
|
|
46
|
+
if (a === "--help" || a === "-h") {
|
|
47
|
+
out.help = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (a === "--verify") {
|
|
51
|
+
out.verify = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (a === "--no-gitignore") {
|
|
55
|
+
out.noGitignore = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (a === "--url" || a === "--token" || a === "--scopes") {
|
|
59
|
+
const r = consumeValue(i, a);
|
|
60
|
+
if ("error" in r) return r;
|
|
61
|
+
i++;
|
|
62
|
+
if (a === "--url") out.url = r.value;
|
|
63
|
+
if (a === "--token") out.token = r.value;
|
|
64
|
+
if (a === "--scopes") out.scopes = r.value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function stripRemoteKeys(content) {
|
|
71
|
+
const lines = content.split("\n");
|
|
72
|
+
const out = [];
|
|
73
|
+
let skippingList = false;
|
|
74
|
+
const REMOTE_KEY = /^remote_(url|token|scopes)\s*:(.*)$/;
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const trimmed = line.trim();
|
|
77
|
+
if (skippingList) {
|
|
78
|
+
if (trimmed === "" || trimmed.startsWith("-")) continue;
|
|
79
|
+
skippingList = false;
|
|
80
|
+
}
|
|
81
|
+
const m = trimmed.match(REMOTE_KEY);
|
|
82
|
+
if (m) {
|
|
83
|
+
const key = m[1];
|
|
84
|
+
const rest = m[2].trim();
|
|
85
|
+
if (key === "scopes" && (rest === "" || rest === "|" || rest === ">")) {
|
|
86
|
+
skippingList = true;
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out.push(line);
|
|
91
|
+
}
|
|
92
|
+
while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
|
|
93
|
+
return out.join("\n");
|
|
94
|
+
}
|
|
95
|
+
function buildConfigBody(existing, url, token, scopes) {
|
|
96
|
+
const stripped = stripRemoteKeys(existing);
|
|
97
|
+
const sep = stripped.length > 0 && !stripped.endsWith("\n") ? "\n\n" : stripped.length > 0 ? "\n" : "";
|
|
98
|
+
const block = [];
|
|
99
|
+
block.push("# --- PLUR Enterprise remote (opt-in for this project) ---");
|
|
100
|
+
block.push("# remote_token is sensitive \u2014 keep .plur.yaml in .gitignore.");
|
|
101
|
+
block.push(`remote_url: ${url}`);
|
|
102
|
+
block.push(`remote_token: ${token}`);
|
|
103
|
+
if (scopes && scopes.length > 0) {
|
|
104
|
+
block.push("remote_scopes:");
|
|
105
|
+
for (const s of scopes) block.push(` - ${s}`);
|
|
106
|
+
}
|
|
107
|
+
return stripped + sep + block.join("\n") + "\n";
|
|
108
|
+
}
|
|
109
|
+
function ensureGitignore() {
|
|
110
|
+
const home = resolve(homedir());
|
|
111
|
+
let dir = resolve(process.cwd());
|
|
112
|
+
let gitignorePath = null;
|
|
113
|
+
const MAX_DEPTH = 12;
|
|
114
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
115
|
+
const candidate = join(dir, ".gitignore");
|
|
116
|
+
if (existsSync(candidate)) {
|
|
117
|
+
gitignorePath = candidate;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (existsSync(join(dir, ".git"))) break;
|
|
121
|
+
if (dir === home || dir === "/" || dir === ".") break;
|
|
122
|
+
const parent = dirname(dir);
|
|
123
|
+
if (parent === dir) break;
|
|
124
|
+
dir = parent;
|
|
125
|
+
}
|
|
126
|
+
const PATTERN = ".plur.yaml";
|
|
127
|
+
if (!gitignorePath) {
|
|
128
|
+
const newPath = join(process.cwd(), ".gitignore");
|
|
129
|
+
writeFileSync(newPath, `# Added by 'plur init-remote' \u2014 .plur.yaml may hold an API token
|
|
130
|
+
${PATTERN}
|
|
131
|
+
`);
|
|
132
|
+
return { path: newPath, action: "created" };
|
|
133
|
+
}
|
|
134
|
+
const content = readFileSync(gitignorePath, "utf8");
|
|
135
|
+
const already = content.split("\n").some((l) => l.trim() === PATTERN);
|
|
136
|
+
if (already) return { path: gitignorePath, action: "already" };
|
|
137
|
+
const sep = content.endsWith("\n") ? "" : "\n";
|
|
138
|
+
appendFileSync(gitignorePath, `${sep}# Added by 'plur init-remote' \u2014 .plur.yaml may hold an API token
|
|
139
|
+
${PATTERN}
|
|
140
|
+
`);
|
|
141
|
+
return { path: gitignorePath, action: "added" };
|
|
142
|
+
}
|
|
143
|
+
function findExistingConfigPath() {
|
|
144
|
+
const home = resolve(homedir());
|
|
145
|
+
let dir = resolve(process.cwd());
|
|
146
|
+
const MAX_DEPTH = 12;
|
|
147
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
148
|
+
if (dir !== home) {
|
|
149
|
+
const candidate = join(dir, ".plur.yaml");
|
|
150
|
+
if (existsSync(candidate)) return candidate;
|
|
151
|
+
}
|
|
152
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
153
|
+
if (dir === home || dir === "/" || dir === ".") return null;
|
|
154
|
+
const parent = dirname(dir);
|
|
155
|
+
if (parent === dir) return null;
|
|
156
|
+
dir = parent;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function readRemoteFromConfig(path) {
|
|
161
|
+
if (!existsSync(path)) return {};
|
|
162
|
+
const content = readFileSync(path, "utf8");
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const line of content.split("\n")) {
|
|
165
|
+
const trimmed = line.trim();
|
|
166
|
+
if (trimmed.startsWith("#") || !trimmed) continue;
|
|
167
|
+
const m = trimmed.match(/^(remote_url|remote_token)\s*:\s*(.+)$/);
|
|
168
|
+
if (m) {
|
|
169
|
+
if (m[1] === "remote_url") out.url = m[2].trim();
|
|
170
|
+
if (m[1] === "remote_token") out.token = m[2].trim();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
async function verifyConnectivity(url, token) {
|
|
176
|
+
let base;
|
|
177
|
+
try {
|
|
178
|
+
base = new URL(url).origin;
|
|
179
|
+
} catch {
|
|
180
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
181
|
+
}
|
|
182
|
+
const probeUrl = `${base}/api/v1/me`;
|
|
183
|
+
const ctrl = new AbortController();
|
|
184
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
185
|
+
try {
|
|
186
|
+
const r = await fetch(probeUrl, {
|
|
187
|
+
signal: ctrl.signal,
|
|
188
|
+
headers: { "authorization": `Bearer ${token}`, "accept": "application/json" }
|
|
189
|
+
});
|
|
190
|
+
if (r.status === 401) throw new Error(`401 Unauthorized \u2014 check your API token`);
|
|
191
|
+
if (r.status === 403) throw new Error(`403 Forbidden \u2014 token lacks /me access`);
|
|
192
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} from ${probeUrl}`);
|
|
193
|
+
const data = await r.json();
|
|
194
|
+
if (!data.username) throw new Error(`Unexpected response shape from ${probeUrl}`);
|
|
195
|
+
return {
|
|
196
|
+
username: data.username,
|
|
197
|
+
org_id: data.org_id ?? "(unknown)",
|
|
198
|
+
scopes: Array.isArray(data.scopes) ? data.scopes : []
|
|
199
|
+
};
|
|
200
|
+
} finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function run(args, flags) {
|
|
205
|
+
const parsed = parseArgs(args);
|
|
206
|
+
if ("error" in parsed) {
|
|
207
|
+
outputText(`Error: ${parsed.error}
|
|
208
|
+
|
|
209
|
+
${HELP}`, flags);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
const opts = parsed;
|
|
213
|
+
if (opts.help) {
|
|
214
|
+
outputText(HELP, flags);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const configPath = join(process.cwd(), ".plur.yaml");
|
|
218
|
+
if (opts.verify) {
|
|
219
|
+
const verifyPath = findExistingConfigPath() ?? configPath;
|
|
220
|
+
const cfg = readRemoteFromConfig(verifyPath);
|
|
221
|
+
if (!cfg.url || !cfg.token) {
|
|
222
|
+
outputText(`No remote config found (walked upward from ${process.cwd()}). Run \`plur init-remote --url <url> --token <key>\` first.`, flags);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
outputText(`Using config at ${verifyPath}`, flags);
|
|
226
|
+
try {
|
|
227
|
+
const me = await verifyConnectivity(cfg.url, cfg.token);
|
|
228
|
+
outputText(`\u2713 Connected to ${cfg.url} as ${me.username} (org: ${me.org_id})`, flags);
|
|
229
|
+
outputText(` readable scopes: ${me.scopes.length === 0 ? "(none)" : me.scopes.join(", ")}`, flags);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
outputText(`\u2717 Connection failed: ${err.message}`, flags);
|
|
232
|
+
process.exit(2);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!opts.url || !opts.token) {
|
|
237
|
+
outputText(`Missing required flags.
|
|
238
|
+
${HELP}`, flags);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
if (/[\n\r\t]/.test(opts.token)) {
|
|
242
|
+
outputText(`Error: token contains newline/tab characters. Refusing to write a corrupt config.`, flags);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const u = new URL(opts.url);
|
|
247
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
248
|
+
outputText(`Error: remote_url must be http:// or https:// (got ${u.protocol})`, flags);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
outputText(`Error: remote_url is not a valid URL: ${opts.url}`, flags);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
outputText(`Testing connectivity to ${opts.url}...`, flags);
|
|
256
|
+
try {
|
|
257
|
+
const me = await verifyConnectivity(opts.url, opts.token);
|
|
258
|
+
outputText(`\u2713 Authenticated as ${me.username} (org: ${me.org_id})`, flags);
|
|
259
|
+
outputText(` readable scopes: ${me.scopes.length === 0 ? "(none)" : me.scopes.join(", ")}`, flags);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
outputText(`\u2717 Connection failed: ${err.message}`, flags);
|
|
262
|
+
outputText(` Refusing to write a broken config. Fix the URL/token and re-run.`, flags);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
266
|
+
const next = buildConfigBody(existing, opts.url, opts.token, opts.scopes);
|
|
267
|
+
writeFileSync(configPath, next);
|
|
268
|
+
outputText(`\u2713 Wrote ${configPath}`, flags);
|
|
269
|
+
if (opts.scopes && opts.scopes.length > 0) {
|
|
270
|
+
outputText(` scope whitelist: ${opts.scopes.join(", ")}`, flags);
|
|
271
|
+
} else {
|
|
272
|
+
outputText(` scope whitelist: (none \u2014 hook will query all readable scopes)`, flags);
|
|
273
|
+
}
|
|
274
|
+
if (!opts.noGitignore) {
|
|
275
|
+
const gi = ensureGitignore();
|
|
276
|
+
if (gi.action === "added") outputText(`\u2713 Added .plur.yaml to ${gi.path}`, flags);
|
|
277
|
+
else if (gi.action === "created") outputText(`\u2713 Created ${gi.path} with .plur.yaml entry`, flags);
|
|
278
|
+
else outputText(`\u2713 ${gi.path} already excludes .plur.yaml`, flags);
|
|
279
|
+
} else {
|
|
280
|
+
outputText(`\u26A0 Skipped .gitignore (--no-gitignore). The token in .plur.yaml is sensitive.`, flags);
|
|
281
|
+
}
|
|
282
|
+
outputText(`
|
|
283
|
+
Done. The UserPromptSubmit hook will now query ${opts.url} on every prompt`, flags);
|
|
284
|
+
outputText(`from this directory tree (bounded by the nearest .git). Personal/non-project`, flags);
|
|
285
|
+
outputText(`sessions (without a .plur.yaml in the path) stay local-only.`, flags);
|
|
286
|
+
outputText(``, flags);
|
|
287
|
+
outputText(`\u26A0 Token sensitivity:`, flags);
|
|
288
|
+
outputText(` .plur.yaml now contains an API token in plaintext.`, flags);
|
|
289
|
+
outputText(` - .gitignore protects against git commits but NOT against cloud sync`, flags);
|
|
290
|
+
outputText(` (iCloud Drive, Dropbox, Google Drive). If this project lives in a`, flags);
|
|
291
|
+
outputText(` synced folder, the token will leave your machine.`, flags);
|
|
292
|
+
outputText(` - Also not protected: \`cp -r\`, \`zip\`, \`rsync\`, archived backups.`, flags);
|
|
293
|
+
outputText(` - Consider moving the token to an env var if your project ships with`, flags);
|
|
294
|
+
outputText(` others (future: env-var substitution in .plur.yaml).`, flags);
|
|
295
|
+
}
|
|
296
|
+
export {
|
|
297
|
+
run
|
|
298
|
+
};
|
package/dist/commands/init.js
CHANGED
|
@@ -11,115 +11,153 @@ import {
|
|
|
11
11
|
} from "./chunk-7U4W4J3G.js";
|
|
12
12
|
|
|
13
13
|
// src/commands/init.ts
|
|
14
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
15
|
-
import { join } from "path";
|
|
16
|
-
import {
|
|
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
|
-
]
|
|
110
|
-
}
|
|
111
|
-
],
|
|
112
|
-
// Learning reflection — nudge the LLM to call plur_learn after responses
|
|
113
|
-
// where it discovered or learned something. Fires every 3rd Stop to avoid fatigue.
|
|
114
|
-
Stop: [
|
|
115
|
-
{
|
|
116
|
-
matcher: "*",
|
|
117
|
-
hooks: [
|
|
118
|
-
{ type: "command", command: `${CLI} hook-learn-check`, timeout: 2 }
|
|
119
|
-
]
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
|
|
15
|
+
import { join, dirname } from "path";
|
|
16
|
+
import { fileURLToPath } from "url";
|
|
17
|
+
import { homedir, platform } from "os";
|
|
18
|
+
function resolveCliEntrypoint() {
|
|
19
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
20
|
+
return join(dirname(thisFile), "..", "index.js");
|
|
21
|
+
}
|
|
22
|
+
function shimPath() {
|
|
23
|
+
const name = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
|
|
24
|
+
return join(homedir(), ".plur", "bin", name);
|
|
25
|
+
}
|
|
26
|
+
function installHookBinary() {
|
|
27
|
+
const binDir = join(homedir(), ".plur", "bin");
|
|
28
|
+
mkdirSync(binDir, { recursive: true });
|
|
29
|
+
const entrypoint = resolveCliEntrypoint();
|
|
30
|
+
const nodeBin = process.execPath;
|
|
31
|
+
if (!existsSync(entrypoint)) {
|
|
32
|
+
return { shimPath: "", status: `error: CLI entrypoint not found at ${entrypoint}` };
|
|
33
|
+
}
|
|
34
|
+
const target = shimPath();
|
|
35
|
+
if (platform() === "win32") {
|
|
36
|
+
writeFileSync(target, `@echo off\r
|
|
37
|
+
"${nodeBin}" "${entrypoint}" %*\r
|
|
38
|
+
`);
|
|
39
|
+
} else {
|
|
40
|
+
writeFileSync(target, `#!/bin/sh
|
|
41
|
+
exec "${nodeBin}" "${entrypoint}" "$@"
|
|
42
|
+
`, { mode: 493 });
|
|
43
|
+
try {
|
|
44
|
+
chmodSync(target, 493);
|
|
45
|
+
} catch {
|
|
120
46
|
}
|
|
121
|
-
|
|
122
|
-
};
|
|
47
|
+
}
|
|
48
|
+
const meta = { entrypoint, node: nodeBin, installed: (/* @__PURE__ */ new Date()).toISOString() };
|
|
49
|
+
writeFileSync(join(binDir, "plur-hook.meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
50
|
+
return { shimPath: target, status: "installed" };
|
|
51
|
+
}
|
|
52
|
+
function buildEnforcementHooks(cmd) {
|
|
53
|
+
return {
|
|
54
|
+
SessionStart: [
|
|
55
|
+
{
|
|
56
|
+
hooks: [
|
|
57
|
+
{ type: "command", command: `${cmd} hook-session-remind`, timeout: 3 }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
PreToolUse: [
|
|
62
|
+
// Session guard — blocks all tools until plur_session_start is called.
|
|
63
|
+
// Must be first so it runs before any other PreToolUse hook.
|
|
64
|
+
{
|
|
65
|
+
matcher: "*",
|
|
66
|
+
hooks: [
|
|
67
|
+
{ type: "command", command: `${cmd} hook-session-guard`, timeout: 3 }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
PostToolUse: [
|
|
72
|
+
// Session sentinel — creates marker file after plur_session_start succeeds
|
|
73
|
+
{
|
|
74
|
+
matcher: "mcp__plur__plur_session_start",
|
|
75
|
+
hooks: [
|
|
76
|
+
{ type: "command", command: `${cmd} hook-session-mark`, timeout: 3 }
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function buildInjectionHooks(cmd) {
|
|
83
|
+
return {
|
|
84
|
+
// First message: inject engrams based on the prompt.
|
|
85
|
+
// Subsequent messages: periodic reminder to call plur_learn (~1ms skip).
|
|
86
|
+
UserPromptSubmit: [
|
|
87
|
+
{
|
|
88
|
+
hooks: [
|
|
89
|
+
{ type: "command", command: `${cmd} hook-inject`, timeout: 15 }
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
// Re-inject after context compaction so engrams survive long conversations.
|
|
94
|
+
PostCompact: [
|
|
95
|
+
{
|
|
96
|
+
matcher: "auto|manual",
|
|
97
|
+
hooks: [
|
|
98
|
+
{ type: "command", command: `${cmd} hook-inject --rehydrate`, timeout: 15 }
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
PreToolUse: [
|
|
103
|
+
// Full injection when entering plan mode — planning needs broad context
|
|
104
|
+
{
|
|
105
|
+
matcher: "EnterPlanMode",
|
|
106
|
+
hooks: [
|
|
107
|
+
{ type: "command", command: `${cmd} hook-inject --event plan_mode`, timeout: 10 }
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
// Domain-specific engrams when a skill is invoked
|
|
111
|
+
{
|
|
112
|
+
matcher: "Skill",
|
|
113
|
+
hooks: [
|
|
114
|
+
{ type: "command", command: `${cmd} hook-inject --event skill`, timeout: 10 }
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
// Agent-scoped engrams when spawning an agent
|
|
118
|
+
{
|
|
119
|
+
matcher: "Agent",
|
|
120
|
+
hooks: [
|
|
121
|
+
{ type: "command", command: `${cmd} hook-inject --event agent`, timeout: 10 }
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
// Observation capture — log tool calls for offline pattern extraction
|
|
125
|
+
{
|
|
126
|
+
matcher: "Bash|Edit|Write|Agent",
|
|
127
|
+
hooks: [
|
|
128
|
+
{ type: "command", command: `${cmd} hook-observe`, timeout: 3 }
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
PostToolUse: [
|
|
133
|
+
{
|
|
134
|
+
matcher: "Bash|Edit|Write|Agent",
|
|
135
|
+
hooks: [
|
|
136
|
+
{ type: "command", command: `${cmd} hook-observe --post`, timeout: 3 }
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
],
|
|
140
|
+
// Inject agent-scoped engrams into subagent context
|
|
141
|
+
SubagentStart: [
|
|
142
|
+
{
|
|
143
|
+
matcher: ".*",
|
|
144
|
+
hooks: [
|
|
145
|
+
{ type: "command", command: `${cmd} hook-inject --event subagent`, timeout: 10 }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
// Learning reflection — nudge the LLM to call plur_learn after responses
|
|
150
|
+
// where it discovered or learned something. Fires every 3rd Stop to avoid fatigue.
|
|
151
|
+
Stop: [
|
|
152
|
+
{
|
|
153
|
+
matcher: "*",
|
|
154
|
+
hooks: [
|
|
155
|
+
{ type: "command", command: `${cmd} hook-learn-check`, timeout: 2 }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
};
|
|
160
|
+
}
|
|
123
161
|
function mergeHookMaps(a, b) {
|
|
124
162
|
const out = {};
|
|
125
163
|
for (const [event, entries] of Object.entries(a)) out[event] = [...entries];
|
|
@@ -224,7 +262,9 @@ function loadSettings(path) {
|
|
|
224
262
|
}
|
|
225
263
|
}
|
|
226
264
|
function isPlurHook(entry) {
|
|
227
|
-
return (entry.hooks ?? []).some(
|
|
265
|
+
return (entry.hooks ?? []).some(
|
|
266
|
+
(h) => h.command.includes("@plur-ai/cli") || h.command.includes(".plur/bin/plur-hook")
|
|
267
|
+
);
|
|
228
268
|
}
|
|
229
269
|
function hasPlurHooks(settings) {
|
|
230
270
|
const hooks = settings.hooks ?? {};
|
|
@@ -278,6 +318,10 @@ function hooksStatusFor(before, after, hadHooks) {
|
|
|
278
318
|
return before === after ? "already up to date" : "upgraded";
|
|
279
319
|
}
|
|
280
320
|
async function run(args, flags) {
|
|
321
|
+
const shim = installHookBinary();
|
|
322
|
+
const cmd = shim.shimPath || "npx @plur-ai/cli";
|
|
323
|
+
const PLUR_HOOKS_ENFORCEMENT = buildEnforcementHooks(cmd);
|
|
324
|
+
const PLUR_HOOKS_INJECTION = buildInjectionHooks(cmd);
|
|
281
325
|
const injectionPath = findSettingsPath(flags, args);
|
|
282
326
|
const enforcementPath = join(homedir(), ".claude", "settings.json");
|
|
283
327
|
const samePath = injectionPath === enforcementPath;
|
|
@@ -330,6 +374,8 @@ async function run(args, flags) {
|
|
|
330
374
|
const entry = buildMcpServerEntry();
|
|
331
375
|
outputText("PLUR installed for Claude Code.");
|
|
332
376
|
outputText("");
|
|
377
|
+
outputText(`Hook binary: ${shim.status}${shim.shimPath ? ` (${shim.shimPath})` : ""}`);
|
|
378
|
+
outputText("");
|
|
333
379
|
outputText("Architecture: One global engram store (~/.plur/), enforcement hooks global, injection hooks project-scoped.");
|
|
334
380
|
outputText("Multi-project scoping via domain/scope fields on engrams, not separate installs.");
|
|
335
381
|
outputText("");
|
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 {
|