getprismo 0.1.40 → 0.1.42
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/lib/prismo-dev/agent.js +16 -1
- package/lib/prismo-dev/cli.js +50 -2
- package/lib/prismo-dev/cloud-sync.js +58 -0
- package/lib/prismo-dev/enforce.js +287 -0
- package/lib/prismo-dev/help.js +29 -2
- package/lib/prismo-dev/repair-planner.js +28 -1
- package/lib/prismo-dev-scan.js +27 -0
- package/package.json +1 -1
package/lib/prismo-dev/agent.js
CHANGED
|
@@ -269,7 +269,12 @@ module.exports = function createAgent(deps) {
|
|
|
269
269
|
|| (parsed.command === "repair" ? parsed.args.find((arg) => !arg.startsWith("-")) : null);
|
|
270
270
|
const causeExecutor = repairExecutors ? repairExecutors.forCause(targetCause) : null;
|
|
271
271
|
if (causeExecutor) {
|
|
272
|
-
|
|
272
|
+
// The backend escalates a repair by queueing "repair <cause> --tier
|
|
273
|
+
// aggressive" when the previous repair's verdict was no-change/regressed.
|
|
274
|
+
const tierIndex = parsed.args.indexOf("--tier");
|
|
275
|
+
const tierArg = tierIndex >= 0 ? parsed.args[tierIndex + 1] : null;
|
|
276
|
+
const executorOptions = tierArg ? { ...options, tier: tierArg } : options;
|
|
277
|
+
return causeExecutor(action, root, { progress, parsed, options: executorOptions });
|
|
273
278
|
}
|
|
274
279
|
|
|
275
280
|
if (parsed.command === "doctor" || action.actionType === "doctor") {
|
|
@@ -453,10 +458,20 @@ module.exports = function createAgent(deps) {
|
|
|
453
458
|
|
|
454
459
|
let plannerResult = null;
|
|
455
460
|
if (options.planRepairs && repairPlanner) {
|
|
461
|
+
// Fleet priors are anonymized cause x tier improve rates across all
|
|
462
|
+
// orgs; the planner uses them to pick a starting tier. Best-effort:
|
|
463
|
+
// planning works without them.
|
|
464
|
+
let fleetPriors = null;
|
|
465
|
+
try {
|
|
466
|
+
const endpoint = options.fleetPriorsEndpoint || `${apiBase(config)}/v1/dev/fleet/repair-priors`;
|
|
467
|
+
const response = await requestJson("GET", endpoint, config.token, null, options.timeoutMs || 8000);
|
|
468
|
+
fleetPriors = response.data && Array.isArray(response.data.priors) ? response.data.priors : null;
|
|
469
|
+
} catch (_) {}
|
|
456
470
|
try {
|
|
457
471
|
plannerResult = await repairPlanner.runPlannerOnce(rootDir, {
|
|
458
472
|
execute: mode === "autopilot",
|
|
459
473
|
sessionLimit: options.plannerSessionLimit,
|
|
474
|
+
fleetPriors,
|
|
460
475
|
});
|
|
461
476
|
const registered = plannerResult.executed
|
|
462
477
|
? await registerSelfRepair(config, plannerResult, options)
|
package/lib/prismo-dev/cli.js
CHANGED
|
@@ -3,9 +3,10 @@ const { printHelp, printCommandHelp } = require("./help");
|
|
|
3
3
|
|
|
4
4
|
const VALID_COMMANDS = new Set([
|
|
5
5
|
"dev", "init", "doctor", "firewall", "benchmark", "shield", "mcp",
|
|
6
|
-
"connect", "sync", "status", "disconnect", "agent", "connector", "setup", "scan",
|
|
6
|
+
"connect", "sync", "status", "disconnect", "agent", "connector", "setup", "scan", "digest",
|
|
7
7
|
"optimize", "context", "cc", "cursor", "receipt", "instructions",
|
|
8
8
|
"timeline", "replay", "boundaries", "usage", "guard", "watch", "demo", "repair",
|
|
9
|
+
"enforce", "hook",
|
|
9
10
|
]);
|
|
10
11
|
|
|
11
12
|
function parseTokenBudget(value) {
|
|
@@ -59,10 +60,12 @@ function createCli(deps) {
|
|
|
59
60
|
renderReceiptTerminal,
|
|
60
61
|
buildReceipt,
|
|
61
62
|
renderConnectTerminal,
|
|
63
|
+
renderDigestTerminal,
|
|
62
64
|
renderDisconnectTerminal,
|
|
63
65
|
renderStatusTerminal,
|
|
64
66
|
renderSyncTerminal,
|
|
65
67
|
runConnect,
|
|
68
|
+
runDigest,
|
|
66
69
|
runDisconnect,
|
|
67
70
|
runStatus,
|
|
68
71
|
runSync,
|
|
@@ -73,6 +76,11 @@ function createCli(deps) {
|
|
|
73
76
|
runRepair,
|
|
74
77
|
renderPlannerTerminal,
|
|
75
78
|
runPlannerOnce,
|
|
79
|
+
decidePreToolUse,
|
|
80
|
+
renderEnforceTerminal,
|
|
81
|
+
runEnforceInstall,
|
|
82
|
+
runEnforceStatus,
|
|
83
|
+
runEnforceUninstall,
|
|
76
84
|
renderAgentTerminal,
|
|
77
85
|
runAgent,
|
|
78
86
|
renderConnectorTerminal,
|
|
@@ -464,6 +472,17 @@ function createCli(deps) {
|
|
|
464
472
|
return;
|
|
465
473
|
}
|
|
466
474
|
|
|
475
|
+
if (command === "digest") {
|
|
476
|
+
const json = rest.includes("--json");
|
|
477
|
+
const daysIndex = rest.indexOf("--days");
|
|
478
|
+
const result = await runDigest({
|
|
479
|
+
days: parsePositiveInt(daysIndex >= 0 ? rest[daysIndex + 1] : null, 7),
|
|
480
|
+
});
|
|
481
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
482
|
+
else console.log(renderDigestTerminal(result));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
467
486
|
if (command === "status") {
|
|
468
487
|
const json = rest.includes("--json");
|
|
469
488
|
const result = runStatus();
|
|
@@ -771,12 +790,13 @@ function createCli(deps) {
|
|
|
771
790
|
const separatorIndex = rest.indexOf("--");
|
|
772
791
|
const ownArgs = separatorIndex >= 0 ? rest.slice(0, separatorIndex) : rest;
|
|
773
792
|
const commandArgs = separatorIndex >= 0 ? rest.slice(separatorIndex + 1) : [];
|
|
774
|
-
const positional = getPositionals(ownArgs, new Set(["--limit", "--budget", "--scope"]));
|
|
793
|
+
const positional = getPositionals(ownArgs, new Set(["--limit", "--budget", "--scope", "--tier"]));
|
|
775
794
|
const cause = (positional[0] || "").toLowerCase();
|
|
776
795
|
const target = positional[1] || process.cwd();
|
|
777
796
|
const limitIndex = ownArgs.indexOf("--limit");
|
|
778
797
|
const budgetIndex = ownArgs.indexOf("--budget");
|
|
779
798
|
const scopeIndex = ownArgs.indexOf("--scope");
|
|
799
|
+
const tierIndex = ownArgs.indexOf("--tier");
|
|
780
800
|
if (cause === "auto") {
|
|
781
801
|
const result = await runPlannerOnce(target, {
|
|
782
802
|
execute: !ownArgs.includes("--dry-run"),
|
|
@@ -791,6 +811,7 @@ function createCli(deps) {
|
|
|
791
811
|
limit: parsePositiveInt(limitIndex >= 0 ? ownArgs[limitIndex + 1] : null, 5),
|
|
792
812
|
tokenBudget: parseTokenBudget(budgetIndex >= 0 ? ownArgs[budgetIndex + 1] : null),
|
|
793
813
|
scope: scopeIndex >= 0 ? ownArgs[scopeIndex + 1] : null,
|
|
814
|
+
tier: tierIndex >= 0 ? ownArgs[tierIndex + 1] : null,
|
|
794
815
|
commandArgs,
|
|
795
816
|
});
|
|
796
817
|
if (json) console.log(JSON.stringify(result, null, 2));
|
|
@@ -799,6 +820,33 @@ function createCli(deps) {
|
|
|
799
820
|
return;
|
|
800
821
|
}
|
|
801
822
|
|
|
823
|
+
if (command === "hook") {
|
|
824
|
+
const subcommand = (rest[0] || "").toLowerCase();
|
|
825
|
+
if (subcommand !== "pretooluse") {
|
|
826
|
+
printCommandHelp("enforce");
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const chunks = [];
|
|
830
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
831
|
+
const decision = decidePreToolUse(process.cwd(), Buffer.concat(chunks).toString("utf8"));
|
|
832
|
+
if (decision) console.log(JSON.stringify(decision));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (command === "enforce") {
|
|
837
|
+
const json = rest.includes("--json");
|
|
838
|
+
const subcommand = (getPositionals(rest, new Set())[0] || "status").toLowerCase();
|
|
839
|
+
const target = getPositionals(rest, new Set())[1] || process.cwd();
|
|
840
|
+
const result = subcommand === "install"
|
|
841
|
+
? runEnforceInstall(target)
|
|
842
|
+
: subcommand === "uninstall"
|
|
843
|
+
? runEnforceUninstall(target)
|
|
844
|
+
: runEnforceStatus(target);
|
|
845
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
846
|
+
else console.log(renderEnforceTerminal(result));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
802
850
|
if (command === "usage" || command === "watch") {
|
|
803
851
|
const json = rest.includes("--json");
|
|
804
852
|
const knownTools = new Set(["codex", "claude", "cursor", "all"]);
|
|
@@ -371,6 +371,62 @@ module.exports = function createCloudSync(deps) {
|
|
|
371
371
|
};
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
async function runDigest(options = {}) {
|
|
375
|
+
const config = loadConfig();
|
|
376
|
+
if (!config || !config.token) {
|
|
377
|
+
return {
|
|
378
|
+
schemaVersion: 1,
|
|
379
|
+
command: "digest",
|
|
380
|
+
connected: false,
|
|
381
|
+
digest: null,
|
|
382
|
+
error: "not-connected",
|
|
383
|
+
next: [`${NPX_COMMAND} connect --token <token>`],
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const base = String(config.apiUrl || DEFAULT_API_URL).replace(/\/$/, "");
|
|
387
|
+
const days = Math.max(1, Number(options.days || 7));
|
|
388
|
+
const endpoint = options.endpoint || `${base}/v1/dev/workspace/digest/agent?days=${encodeURIComponent(days)}`;
|
|
389
|
+
try {
|
|
390
|
+
const response = await requestJson("GET", endpoint, config.token, null, options.timeoutMs || 10000);
|
|
391
|
+
return {
|
|
392
|
+
schemaVersion: 1,
|
|
393
|
+
command: "digest",
|
|
394
|
+
connected: true,
|
|
395
|
+
apiUrl: base,
|
|
396
|
+
digest: response.data,
|
|
397
|
+
};
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return {
|
|
400
|
+
schemaVersion: 1,
|
|
401
|
+
command: "digest",
|
|
402
|
+
connected: true,
|
|
403
|
+
apiUrl: base,
|
|
404
|
+
digest: null,
|
|
405
|
+
error: error && error.message ? error.message : String(error),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function renderDigestTerminal(result) {
|
|
411
|
+
const lines = [];
|
|
412
|
+
lines.push("");
|
|
413
|
+
lines.push("PrismoDev Digest");
|
|
414
|
+
lines.push("");
|
|
415
|
+
if (!result.connected) {
|
|
416
|
+
lines.push("Status: not connected");
|
|
417
|
+
lines.push("");
|
|
418
|
+
lines.push("Next");
|
|
419
|
+
(result.next || []).forEach((item, index) => lines.push(`${index + 1}. ${item}`));
|
|
420
|
+
return lines.join("\n");
|
|
421
|
+
}
|
|
422
|
+
if (!result.digest) {
|
|
423
|
+
lines.push(`Could not load digest${result.error ? `: ${result.error}` : "."}`);
|
|
424
|
+
return lines.join("\n");
|
|
425
|
+
}
|
|
426
|
+
(result.digest.lines || [result.digest.headline]).forEach((line) => lines.push(line));
|
|
427
|
+
return lines.join("\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
374
430
|
function runStatus() {
|
|
375
431
|
const config = loadConfig();
|
|
376
432
|
const state = readJson(statePath());
|
|
@@ -499,10 +555,12 @@ module.exports = function createCloudSync(deps) {
|
|
|
499
555
|
estimateWaste,
|
|
500
556
|
loadConfig,
|
|
501
557
|
renderConnectTerminal,
|
|
558
|
+
renderDigestTerminal,
|
|
502
559
|
renderDisconnectTerminal,
|
|
503
560
|
renderStatusTerminal,
|
|
504
561
|
renderSyncTerminal,
|
|
505
562
|
runConnect,
|
|
563
|
+
runDigest,
|
|
506
564
|
runDisconnect,
|
|
507
565
|
runStatus,
|
|
508
566
|
runSync,
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
module.exports = function createEnforce(deps) {
|
|
2
|
+
const {
|
|
3
|
+
fs,
|
|
4
|
+
path,
|
|
5
|
+
NPX_COMMAND,
|
|
6
|
+
runFirewall,
|
|
7
|
+
} = deps;
|
|
8
|
+
|
|
9
|
+
const HOOK_COMMAND = `${NPX_COMMAND} hook pretooluse`;
|
|
10
|
+
const FILE_TOOLS = new Set(["Read", "Glob", "Grep", "NotebookRead"]);
|
|
11
|
+
const MAX_IDENTICAL_COMMANDS = 3;
|
|
12
|
+
const MAX_TRACKED_SESSIONS = 8;
|
|
13
|
+
|
|
14
|
+
function blockedContextPath(root) {
|
|
15
|
+
return path.join(root, ".prismo", "blocked-context.txt");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function enforceStatePath(root) {
|
|
19
|
+
return path.join(root, ".prismo", "enforce-state.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function settingsPath(root) {
|
|
23
|
+
return path.join(root, ".claude", "settings.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readBlockedPatterns(root) {
|
|
27
|
+
try {
|
|
28
|
+
return fs.readFileSync(blockedContextPath(root), "utf8")
|
|
29
|
+
.split(/\r?\n/)
|
|
30
|
+
.map((line) => line.trim())
|
|
31
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readState(root) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(enforceStatePath(root), "utf8"));
|
|
40
|
+
return parsed && typeof parsed === "object" ? { sessions: {}, ...parsed } : { sessions: {} };
|
|
41
|
+
} catch {
|
|
42
|
+
return { sessions: {} };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeState(root, state) {
|
|
47
|
+
const filePath = enforceStatePath(root);
|
|
48
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
49
|
+
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function relativePath(root, filePath) {
|
|
53
|
+
const value = String(filePath || "");
|
|
54
|
+
const resolvedRoot = path.resolve(root);
|
|
55
|
+
if (value.startsWith(`${resolvedRoot}${path.sep}`)) return value.slice(resolvedRoot.length + 1);
|
|
56
|
+
if (value === resolvedRoot) return ".";
|
|
57
|
+
return value.replace(/^\.\//, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function matchesBlocked(relPath, pattern) {
|
|
61
|
+
const candidate = String(relPath || "").replace(/\\/g, "/");
|
|
62
|
+
const rule = String(pattern || "").trim();
|
|
63
|
+
if (!candidate || !rule) return false;
|
|
64
|
+
if (rule.endsWith("/**")) {
|
|
65
|
+
const dir = rule.slice(0, -3).replace(/\/$/, "");
|
|
66
|
+
return candidate === dir || candidate.startsWith(`${dir}/`) || candidate.includes(`/${dir}/`);
|
|
67
|
+
}
|
|
68
|
+
if (rule.startsWith("*.")) {
|
|
69
|
+
return candidate.endsWith(rule.slice(1));
|
|
70
|
+
}
|
|
71
|
+
return candidate === rule
|
|
72
|
+
|| candidate.endsWith(`/${rule}`)
|
|
73
|
+
|| candidate.includes(`/${rule}/`)
|
|
74
|
+
|| candidate.startsWith(`${rule}/`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function deny(reason) {
|
|
78
|
+
return {
|
|
79
|
+
hookSpecificOutput: {
|
|
80
|
+
hookEventName: "PreToolUse",
|
|
81
|
+
permissionDecision: "deny",
|
|
82
|
+
permissionDecisionReason: reason,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Decide whether a PreToolUse event should be blocked. Returns the hook
|
|
88
|
+
// response object for a deny, or null to allow. Fails open: any parse or
|
|
89
|
+
// state error allows the call rather than breaking the user's agent.
|
|
90
|
+
function decidePreToolUse(rootDir, rawEvent) {
|
|
91
|
+
let event;
|
|
92
|
+
try {
|
|
93
|
+
event = typeof rawEvent === "string" ? JSON.parse(rawEvent) : rawEvent;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if (!event || typeof event !== "object") return null;
|
|
98
|
+
const root = path.resolve(event.cwd || rootDir || process.cwd());
|
|
99
|
+
const toolName = String(event.tool_name || "");
|
|
100
|
+
const toolInput = event.tool_input && typeof event.tool_input === "object" ? event.tool_input : {};
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
if (FILE_TOOLS.has(toolName)) {
|
|
104
|
+
const target = toolInput.file_path || toolInput.notebook_path || toolInput.path || null;
|
|
105
|
+
if (!target) return null;
|
|
106
|
+
const relPath = relativePath(root, target);
|
|
107
|
+
const patterns = readBlockedPatterns(root);
|
|
108
|
+
const hit = patterns.find((pattern) => matchesBlocked(relPath, pattern));
|
|
109
|
+
if (hit) {
|
|
110
|
+
return deny(
|
|
111
|
+
`Prismo context firewall: "${relPath}" is blocked context (rule: ${hit}). `
|
|
112
|
+
+ "It is generated output that wastes agent tokens. Use the .prismo/ context packs instead, "
|
|
113
|
+
+ `or run \`${NPX_COMMAND} shield -- <command>\` if you need its contents summarized.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (toolName === "Bash") {
|
|
120
|
+
const command = String(toolInput.command || "").trim().replace(/\s+/g, " ");
|
|
121
|
+
if (!command) return null;
|
|
122
|
+
const sessionId = String(event.session_id || "unknown");
|
|
123
|
+
const state = readState(root);
|
|
124
|
+
const sessions = state.sessions || {};
|
|
125
|
+
const session = sessions[sessionId] || { commands: {}, updatedAt: null };
|
|
126
|
+
const count = Number(session.commands[command] || 0);
|
|
127
|
+
if (count >= MAX_IDENTICAL_COMMANDS) {
|
|
128
|
+
return deny(
|
|
129
|
+
`Prismo loop breaker: this exact command has already run ${count} times in this session. `
|
|
130
|
+
+ "Repeating it again will not change the outcome and floods context. Change the approach, "
|
|
131
|
+
+ `or capture its output once with \`${NPX_COMMAND} shield -- ${command}\`.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
session.commands[command] = count + 1;
|
|
135
|
+
session.updatedAt = new Date().toISOString();
|
|
136
|
+
sessions[sessionId] = session;
|
|
137
|
+
const ids = Object.keys(sessions)
|
|
138
|
+
.sort((a, b) => String(sessions[b].updatedAt || "").localeCompare(String(sessions[a].updatedAt || "")));
|
|
139
|
+
state.sessions = Object.fromEntries(ids.slice(0, MAX_TRACKED_SESSIONS).map((id) => [id, sessions[id]]));
|
|
140
|
+
writeState(root, state);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readSettings(root) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath(root), "utf8"));
|
|
152
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
153
|
+
} catch {
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isPrismoHookEntry(entry) {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.stringify(entry).includes("hook pretooluse");
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hookInstalled(root) {
|
|
167
|
+
const settings = readSettings(root);
|
|
168
|
+
const entries = settings.hooks && Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
169
|
+
return entries.some(isPrismoHookEntry);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function runEnforceInstall(rootDir = process.cwd(), options = {}) {
|
|
173
|
+
const root = path.resolve(rootDir);
|
|
174
|
+
const actions = [];
|
|
175
|
+
|
|
176
|
+
if (!fs.existsSync(blockedContextPath(root)) && runFirewall && !options.noFirewall) {
|
|
177
|
+
runFirewall(root, { task: "enforcement", dryRun: false });
|
|
178
|
+
actions.push("Generated .prismo context firewall policy (allowed/blocked context)");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const filePath = settingsPath(root);
|
|
182
|
+
const settings = readSettings(root);
|
|
183
|
+
settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
184
|
+
const entries = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
185
|
+
if (entries.some(isPrismoHookEntry)) {
|
|
186
|
+
actions.push("Prismo PreToolUse hook already installed in .claude/settings.json");
|
|
187
|
+
} else {
|
|
188
|
+
const existed = fs.existsSync(filePath);
|
|
189
|
+
if (existed) {
|
|
190
|
+
fs.copyFileSync(filePath, `${filePath}.prismo-backup`);
|
|
191
|
+
actions.push("Backed up .claude/settings.json to settings.json.prismo-backup");
|
|
192
|
+
}
|
|
193
|
+
entries.push({
|
|
194
|
+
matcher: "Read|Glob|Grep|NotebookRead|Bash",
|
|
195
|
+
hooks: [{ type: "command", command: HOOK_COMMAND }],
|
|
196
|
+
});
|
|
197
|
+
settings.hooks.PreToolUse = entries;
|
|
198
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
199
|
+
fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
200
|
+
actions.push(`${existed ? "Updated" : "Created"} .claude/settings.json with the Prismo PreToolUse hook`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
schemaVersion: 1,
|
|
205
|
+
command: "enforce",
|
|
206
|
+
mode: "install",
|
|
207
|
+
installed: true,
|
|
208
|
+
blockedRules: readBlockedPatterns(root).length,
|
|
209
|
+
actions,
|
|
210
|
+
generatedAt: new Date().toISOString(),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function runEnforceUninstall(rootDir = process.cwd()) {
|
|
215
|
+
const root = path.resolve(rootDir);
|
|
216
|
+
const filePath = settingsPath(root);
|
|
217
|
+
const settings = readSettings(root);
|
|
218
|
+
const actions = [];
|
|
219
|
+
const entries = settings.hooks && Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
220
|
+
const kept = entries.filter((entry) => !isPrismoHookEntry(entry));
|
|
221
|
+
if (kept.length !== entries.length) {
|
|
222
|
+
if (kept.length) settings.hooks.PreToolUse = kept;
|
|
223
|
+
else if (settings.hooks) delete settings.hooks.PreToolUse;
|
|
224
|
+
fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
225
|
+
actions.push("Removed the Prismo PreToolUse hook from .claude/settings.json");
|
|
226
|
+
} else {
|
|
227
|
+
actions.push("No Prismo PreToolUse hook found in .claude/settings.json");
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
command: "enforce",
|
|
232
|
+
mode: "uninstall",
|
|
233
|
+
installed: false,
|
|
234
|
+
actions,
|
|
235
|
+
generatedAt: new Date().toISOString(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function runEnforceStatus(rootDir = process.cwd()) {
|
|
240
|
+
const root = path.resolve(rootDir);
|
|
241
|
+
const state = readState(root);
|
|
242
|
+
return {
|
|
243
|
+
schemaVersion: 1,
|
|
244
|
+
command: "enforce",
|
|
245
|
+
mode: "status",
|
|
246
|
+
installed: hookInstalled(root),
|
|
247
|
+
blockedRules: readBlockedPatterns(root).length,
|
|
248
|
+
trackedSessions: Object.keys(state.sessions || {}).length,
|
|
249
|
+
settingsPath: path.join(".claude", "settings.json"),
|
|
250
|
+
generatedAt: new Date().toISOString(),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderEnforceTerminal(result) {
|
|
255
|
+
const lines = [];
|
|
256
|
+
lines.push("");
|
|
257
|
+
lines.push("PrismoDev Enforce");
|
|
258
|
+
lines.push("");
|
|
259
|
+
if (result.mode === "status") {
|
|
260
|
+
lines.push(`Hook installed: ${result.installed ? "yes" : "no"}`);
|
|
261
|
+
lines.push(`Blocked-context rules: ${result.blockedRules}`);
|
|
262
|
+
lines.push(`Sessions tracked for loop breaking: ${result.trackedSessions}`);
|
|
263
|
+
if (!result.installed) {
|
|
264
|
+
lines.push("");
|
|
265
|
+
lines.push(`Run \`${NPX_COMMAND} enforce install\` to enforce the context firewall at runtime.`);
|
|
266
|
+
}
|
|
267
|
+
return lines.join("\n");
|
|
268
|
+
}
|
|
269
|
+
(result.actions || []).forEach((action) => lines.push(`- ${action}`));
|
|
270
|
+
if (result.mode === "install") {
|
|
271
|
+
lines.push("");
|
|
272
|
+
lines.push(`Blocked-context rules enforced: ${result.blockedRules}`);
|
|
273
|
+
lines.push("Claude Code will now be denied reads of blocked context and fourth retries of an identical command.");
|
|
274
|
+
lines.push("Other agents still follow the .prismo policy files advisorily.");
|
|
275
|
+
}
|
|
276
|
+
return lines.join("\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
decidePreToolUse,
|
|
281
|
+
matchesBlocked,
|
|
282
|
+
renderEnforceTerminal,
|
|
283
|
+
runEnforceInstall,
|
|
284
|
+
runEnforceStatus,
|
|
285
|
+
runEnforceUninstall,
|
|
286
|
+
};
|
|
287
|
+
};
|
package/lib/prismo-dev/help.js
CHANGED
|
@@ -16,6 +16,7 @@ Usage:
|
|
|
16
16
|
prismo connector [status|install|start|stop|uninstall] [--json] [--interval N] [--sync-interval N] [--mode observe|suggest|autopilot] [path]
|
|
17
17
|
prismo sync [--json] [--dry-run] [--watch] [--interval N] [--limit N] [--tool all|codex|claude|cursor] [path]
|
|
18
18
|
prismo status [--json]
|
|
19
|
+
prismo digest [--json] [--days N]
|
|
19
20
|
prismo disconnect [--json]
|
|
20
21
|
prismo agent [--json] [--once] [--watch] [--interval N] [--sync-interval N] [--limit N] [--mode MODE] [path]
|
|
21
22
|
prismo setup [--json] [--proxy-url URL] [path]
|
|
@@ -33,7 +34,8 @@ Usage:
|
|
|
33
34
|
prismo boundaries [codex|claude|cursor|all] [--json] [--limit N] [path]
|
|
34
35
|
prismo usage [codex|claude|cursor|all] [--json] [--limit N] [path]
|
|
35
36
|
prismo guard [codex|claude|cursor|all] [--json] [--watch] [--once] [--no-sync] [--dry-run] [--limit N] [--budget N] [--interval N] [path]
|
|
36
|
-
prismo repair <cause|auto> [--json] [--dry-run] [--limit N] [--budget N] [--scope SCOPE] [path] [-- <command ...>]
|
|
37
|
+
prismo repair <cause|auto> [--json] [--dry-run] [--tier mild|aggressive] [--limit N] [--budget N] [--scope SCOPE] [path] [-- <command ...>]
|
|
38
|
+
prismo enforce [status|install|uninstall] [--json] [path]
|
|
37
39
|
prismo watch [codex|claude|cursor|all] [--json] [--once] [--agents] [--report] [--rescue] [--guardrails] [--throttle] [--events] [--no-events] [--auto] [--budget N] [--redact-paths] [--interval N] [path]
|
|
38
40
|
prismo demo
|
|
39
41
|
|
|
@@ -49,6 +51,7 @@ Commands:
|
|
|
49
51
|
connector Install or manage the background Prismo Workspace connector.
|
|
50
52
|
sync Send safe aggregate local agent telemetry to Prismo; use --watch for background-style sync.
|
|
51
53
|
status Show local PrismoDev connection and last sync state.
|
|
54
|
+
digest Print the verified-savings summary for the last N days, ready to paste into Slack.
|
|
52
55
|
disconnect Remove the local PrismoDev cloud connection.
|
|
53
56
|
agent Claim and execute safe workspace actions queued from Prismo Cloud.
|
|
54
57
|
scan Run PrismoDev for Claude Code, Codex, Cursor, and AI coding workflows.
|
|
@@ -64,6 +67,7 @@ Commands:
|
|
|
64
67
|
usage Read local Codex/Claude Code/Cursor session logs and summarize token usage.
|
|
65
68
|
guard Run proactive local guardrails and sync prevention events to Prismo.
|
|
66
69
|
repair Run the cause-specific repair for a detected waste cause; "auto" lets the planner pick.
|
|
70
|
+
enforce Enforce the context firewall at runtime via Claude Code hooks (block blocked-context reads and command loops).
|
|
67
71
|
watch Refresh local session usage in the terminal.
|
|
68
72
|
demo Show sample output without needing a messy repo.
|
|
69
73
|
setup Detect coding tools, tracking modes, local logs, and Prismo proxy readiness.
|
|
@@ -293,7 +297,7 @@ Output:
|
|
|
293
297
|
repair: `PrismoDev Repair
|
|
294
298
|
|
|
295
299
|
Usage:
|
|
296
|
-
prismo repair <cause|auto> [--json] [--dry-run] [--limit N] [--budget N] [--scope SCOPE] [path] [-- <command ...>]
|
|
300
|
+
prismo repair <cause|auto> [--json] [--dry-run] [--tier mild|aggressive] [--limit N] [--budget N] [--scope SCOPE] [path] [-- <command ...>]
|
|
297
301
|
|
|
298
302
|
Causes:
|
|
299
303
|
repeated-file-reads Refresh ignore rules and context packs, and map hot files into .prismo/hot-files.md.
|
|
@@ -319,7 +323,30 @@ Output:
|
|
|
319
323
|
no-change or regressed escalates to an aggressive tier (adds a context firewall policy and tighter budgets);
|
|
320
324
|
a cause that already failed both tiers is held for review instead of being retried forever.
|
|
321
325
|
--dry-run with auto prints the planner decision without executing.
|
|
326
|
+
--tier aggressive forces the stronger repair; cloud-queued actions carry it automatically after a no-change/regressed verdict.
|
|
322
327
|
Repairs only write .prismo/ files and append ignore rules with backups; they never overwrite CLAUDE.md, AGENTS.md, .gitignore, or source code.`,
|
|
328
|
+
enforce: `PrismoDev Enforce
|
|
329
|
+
|
|
330
|
+
Usage:
|
|
331
|
+
prismo enforce [status|install|uninstall] [--json] [path]
|
|
332
|
+
|
|
333
|
+
Examples:
|
|
334
|
+
prismo enforce status
|
|
335
|
+
prismo enforce install
|
|
336
|
+
prismo enforce uninstall
|
|
337
|
+
|
|
338
|
+
What install does:
|
|
339
|
+
1. Generates the .prismo context firewall policy if it does not exist yet.
|
|
340
|
+
2. Adds a PreToolUse hook to .claude/settings.json (with a backup) that runs "prismo hook pretooluse".
|
|
341
|
+
|
|
342
|
+
What the hook enforces (Claude Code only):
|
|
343
|
+
- Denies Read/Glob/Grep/NotebookRead calls into blocked context (.prismo/blocked-context.txt) with a reason pointing at the context packs.
|
|
344
|
+
- Denies the 4th attempt of an identical Bash command in one session and suggests prismo shield instead.
|
|
345
|
+
|
|
346
|
+
Notes:
|
|
347
|
+
Enforcement fails open: malformed events or missing policy files allow the call.
|
|
348
|
+
Other coding agents still follow the .prismo policy files advisorily.
|
|
349
|
+
Uninstall removes only the Prismo hook entry and leaves the rest of settings.json untouched.`,
|
|
323
350
|
watch: `Prismo Watch
|
|
324
351
|
|
|
325
352
|
Usage:
|
|
@@ -20,6 +20,11 @@ module.exports = function createRepairPlanner(deps) {
|
|
|
20
20
|
baselineDays: 14,
|
|
21
21
|
rateEpsilon: 0.01,
|
|
22
22
|
historyLimit: 20,
|
|
23
|
+
// Fleet priors: minimum cross-org sample before trusting a rate, and the
|
|
24
|
+
// rates at which a first repair starts aggressive instead of mild.
|
|
25
|
+
fleetMinSample: 5,
|
|
26
|
+
fleetMildMaxRate: 0.4,
|
|
27
|
+
fleetAggressiveMinRate: 0.5,
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
function nowIso() {
|
|
@@ -127,6 +132,21 @@ module.exports = function createRepairPlanner(deps) {
|
|
|
127
132
|
return verdict;
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
// When the fleet has enough verified outcomes showing mild repairs rarely
|
|
136
|
+
// fix a cause while aggressive ones usually do, start aggressive on the
|
|
137
|
+
// first local repair instead of re-learning what the fleet already knows.
|
|
138
|
+
function fleetStartingTier(cause, priors, config) {
|
|
139
|
+
if (!Array.isArray(priors)) return null;
|
|
140
|
+
const mild = priors.find((p) => p && p.cause === cause && p.tier === "mild");
|
|
141
|
+
const aggressive = priors.find((p) => p && p.cause === cause && p.tier === "aggressive");
|
|
142
|
+
if (!mild || !aggressive) return null;
|
|
143
|
+
if (Number(mild.attempts || 0) < config.fleetMinSample || Number(aggressive.attempts || 0) < config.fleetMinSample) return null;
|
|
144
|
+
if (Number(mild.improveRate || 0) <= config.fleetMildMaxRate && Number(aggressive.improveRate || 0) >= config.fleetAggressiveMinRate) {
|
|
145
|
+
return { mild, aggressive };
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
130
150
|
// Decide what (if anything) to repair next. Pure read: does not execute
|
|
131
151
|
// or modify state, so observe/suggest modes can call it safely.
|
|
132
152
|
function plan(root, options = {}) {
|
|
@@ -150,6 +170,11 @@ module.exports = function createRepairPlanner(deps) {
|
|
|
150
170
|
const entry = state.causes[scored.cause] || null;
|
|
151
171
|
let verdict = null;
|
|
152
172
|
let tier = "mild";
|
|
173
|
+
let fleet = null;
|
|
174
|
+
if (!entry || !entry.lastRepairAt) {
|
|
175
|
+
fleet = fleetStartingTier(scored.cause, options.fleetPriors, config);
|
|
176
|
+
if (fleet) tier = "aggressive";
|
|
177
|
+
}
|
|
153
178
|
if (entry && entry.lastRepairAt) {
|
|
154
179
|
const repairedAtMs = Date.parse(entry.lastRepairAt);
|
|
155
180
|
if (Number.isFinite(repairedAtMs)) {
|
|
@@ -182,7 +207,9 @@ module.exports = function createRepairPlanner(deps) {
|
|
|
182
207
|
previousVerdict: verdict ? verdict.status : null,
|
|
183
208
|
reason: verdict && tier === "aggressive"
|
|
184
209
|
? `Last ${entry.lastTier} repair came back ${verdict.status}; escalating to aggressive.`
|
|
185
|
-
:
|
|
210
|
+
: fleet
|
|
211
|
+
? `Fleet experience: mild repairs improved this cause ${(Number(fleet.mild.improveRate) * 100).toFixed(0)}% of the time vs ${(Number(fleet.aggressive.improveRate) * 100).toFixed(0)}% aggressive — starting aggressive.`
|
|
212
|
+
: `${scored.cause} is the top waste cause (${scored.wastedTokens.toLocaleString()} tokens, ${(scored.wasteRate * 100).toFixed(1)}% of observed).`,
|
|
186
213
|
};
|
|
187
214
|
} else {
|
|
188
215
|
skipped.push({ cause: scored.cause, reason: "lower-priority" });
|
package/lib/prismo-dev-scan.js
CHANGED
|
@@ -275,10 +275,12 @@ const {
|
|
|
275
275
|
estimateWaste,
|
|
276
276
|
loadConfig,
|
|
277
277
|
renderConnectTerminal,
|
|
278
|
+
renderDigestTerminal,
|
|
278
279
|
renderDisconnectTerminal,
|
|
279
280
|
renderStatusTerminal,
|
|
280
281
|
renderSyncTerminal,
|
|
281
282
|
runConnect,
|
|
283
|
+
runDigest,
|
|
282
284
|
runDisconnect,
|
|
283
285
|
runStatus,
|
|
284
286
|
runSync,
|
|
@@ -332,6 +334,19 @@ const {
|
|
|
332
334
|
runRepair,
|
|
333
335
|
} = repairExecutors;
|
|
334
336
|
|
|
337
|
+
const {
|
|
338
|
+
decidePreToolUse,
|
|
339
|
+
renderEnforceTerminal,
|
|
340
|
+
runEnforceInstall,
|
|
341
|
+
runEnforceStatus,
|
|
342
|
+
runEnforceUninstall,
|
|
343
|
+
} = require("./prismo-dev/enforce")({
|
|
344
|
+
fs,
|
|
345
|
+
path,
|
|
346
|
+
NPX_COMMAND,
|
|
347
|
+
runFirewall,
|
|
348
|
+
});
|
|
349
|
+
|
|
335
350
|
const repairPlanner = require("./prismo-dev/repair-planner")({
|
|
336
351
|
fs,
|
|
337
352
|
path,
|
|
@@ -477,10 +492,12 @@ const { runCli } = require("./prismo-dev/cli")({
|
|
|
477
492
|
renderReceiptTerminal,
|
|
478
493
|
buildReceipt,
|
|
479
494
|
renderConnectTerminal,
|
|
495
|
+
renderDigestTerminal,
|
|
480
496
|
renderDisconnectTerminal,
|
|
481
497
|
renderStatusTerminal,
|
|
482
498
|
renderSyncTerminal,
|
|
483
499
|
runConnect,
|
|
500
|
+
runDigest,
|
|
484
501
|
runDisconnect,
|
|
485
502
|
runStatus,
|
|
486
503
|
runSync,
|
|
@@ -491,6 +508,11 @@ const { runCli } = require("./prismo-dev/cli")({
|
|
|
491
508
|
runRepair,
|
|
492
509
|
renderPlannerTerminal,
|
|
493
510
|
runPlannerOnce,
|
|
511
|
+
decidePreToolUse,
|
|
512
|
+
renderEnforceTerminal,
|
|
513
|
+
runEnforceInstall,
|
|
514
|
+
runEnforceStatus,
|
|
515
|
+
runEnforceUninstall,
|
|
494
516
|
renderAgentTerminal,
|
|
495
517
|
runAgent,
|
|
496
518
|
renderConnectorTerminal,
|
|
@@ -596,6 +618,11 @@ module.exports = {
|
|
|
596
618
|
REPAIR_CAUSES,
|
|
597
619
|
runPlannerOnce,
|
|
598
620
|
renderPlannerTerminal,
|
|
621
|
+
decidePreToolUse,
|
|
622
|
+
renderEnforceTerminal,
|
|
623
|
+
runEnforceInstall,
|
|
624
|
+
runEnforceStatus,
|
|
625
|
+
runEnforceUninstall,
|
|
599
626
|
runConnectorInstall,
|
|
600
627
|
runConnectorStart,
|
|
601
628
|
runConnectorStatus,
|
package/package.json
CHANGED