getprismo 0.1.41 → 0.1.43
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 +69 -2
- package/docs/announcement.md +56 -0
- package/docs/privacy-telemetry.md +67 -0
- package/lib/prismo-dev/agent.js +10 -0
- package/lib/prismo-dev/cli.js +51 -1
- package/lib/prismo-dev/cloud-sync.js +96 -0
- package/lib/prismo-dev/enforce.js +418 -0
- package/lib/prismo-dev/help.js +26 -0
- package/lib/prismo-dev/repair-planner.js +28 -1
- package/lib/prismo-dev-scan.js +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/getprismo)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
an autonomous cost agent for ai coding. it finds token waste, fixes the cause, verifies the fix against your next sessions in dollars, and escalates or backs off based on what actually worked. unattended.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx getprismo doctor
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
that's it. run it on any repo. no api keys, no login, no data leaves your machine.
|
|
13
|
+
that's it. run it on any repo. no api keys, no login, no data leaves your machine. connect it once and it runs itself.
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
@@ -31,14 +31,20 @@ prismodev covers the full AI coding session:
|
|
|
31
31
|
```
|
|
32
32
|
before you code npx getprismo doctor
|
|
33
33
|
while you code npx getprismo guard --watch
|
|
34
|
+
enforce at runtime npx getprismo enforce install
|
|
34
35
|
noisy commands npx getprismo shield -- npm test
|
|
36
|
+
targeted repairs npx getprismo repair auto
|
|
35
37
|
after you code npx getprismo receipt
|
|
36
38
|
postmortem npx getprismo replay
|
|
39
|
+
weekly receipt npx getprismo digest
|
|
37
40
|
workspace agent npx getprismo agent --watch
|
|
38
41
|
agent-native npx getprismo mcp
|
|
39
42
|
```
|
|
40
43
|
|
|
41
44
|
**doctor** diagnoses the repo, applies safe fixes, and shows the before/after score.
|
|
45
|
+
**repair** runs the targeted fix for one waste cause; `repair auto` lets the planner pick.
|
|
46
|
+
**enforce** turns the context firewall into actual runtime enforcement via Claude Code hooks.
|
|
47
|
+
**digest** prints the verified-savings summary for the week, ready to paste into Slack.
|
|
42
48
|
**guard** runs live guardrails, context throttle, rescue prompts, context firewall, and dashboard-ready prevention events.
|
|
43
49
|
**watch** monitors context pressure live and is the lower-level diagnostic view behind guard.
|
|
44
50
|
**receipt** explains what repeated, what output dominated, what artifacts leaked, what likely influenced the run, and a heuristic context-efficiency score.
|
|
@@ -49,6 +55,58 @@ agent-native npx getprismo mcp
|
|
|
49
55
|
|
|
50
56
|
---
|
|
51
57
|
|
|
58
|
+
## new: the self-driving loop
|
|
59
|
+
|
|
60
|
+
connect once and prismodev operates itself:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx getprismo connect --token <your prismo api key>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
from that point, on every machine running the connector:
|
|
67
|
+
|
|
68
|
+
1. **detect** — session telemetry syncs continuously; waste is attributed to one of five causes: repeated file reads, tool-output floods, generated artifacts, context loops, long-session buildup.
|
|
69
|
+
2. **decide** — a local planner scores causes against thresholds, respects cooldowns, and won't re-repair a cause until enough new sessions arrived to judge the last attempt. the backend auto-queues repairs the same way — no dashboard clicks.
|
|
70
|
+
3. **repair** — each cause has a dedicated executor (not doctor-for-everything): ignore rules + hot-file maps, shield staging, firewall policies, tightened guard budgets, scoped context packs with restart routines.
|
|
71
|
+
4. **verify** — after a repair, the waste rate for that cause is measured in your *later* sessions (14-day baseline, real before/after math). verdicts: `improved`, `no-change`, `regressed`.
|
|
72
|
+
5. **adapt** — `improved` stays mild. `no-change`/`regressed` escalates to an aggressive tier (context firewall + tighter budgets). a cause that fails both tiers is held for your review instead of being retried forever — the one moment a human is genuinely needed, surfaced loudly.
|
|
73
|
+
|
|
74
|
+
savings are reported in **dollars, verified** — converted with a model-aware blended rate weighted across your actual sessions — on the dashboard and via `prismo digest`.
|
|
75
|
+
|
|
76
|
+
and it learns across the fleet: anonymized repair verdicts (counts only, no repo/org identifiers) aggregate into priors, so when the fleet already knows mild repairs rarely fix a cause, your first repair starts at the tier that works. your own verdicts always outrank the fleet's.
|
|
77
|
+
|
|
78
|
+
run one planner cycle by hand to see it think:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx getprismo repair auto --dry-run
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## new: runtime enforcement
|
|
87
|
+
|
|
88
|
+
advisory guardrails only help if the agent reads them. for claude code, prismodev can enforce them:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx getprismo enforce install
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
this wires a `PreToolUse` hook (with a backup of `.claude/settings.json`) that:
|
|
95
|
+
|
|
96
|
+
- **denies reads into blocked context** — `node_modules/`, build output, logs, lockfiles — with a reason pointing the agent at the compact `.prismo/` context packs instead
|
|
97
|
+
- **denies the fourth attempt of an identical command** in one session, suggesting one shielded run instead of an expensive retry loop
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
permissionDecision: deny
|
|
101
|
+
reason: Prismo context firewall: "logs/huge.log" is blocked context (rule: logs/**).
|
|
102
|
+
Use the .prismo/ context packs instead, or run `npx getprismo shield -- <command>`
|
|
103
|
+
if you need its contents summarized.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
enforcement fails open — malformed events or missing policy files allow the call, so it can never break a working agent. `enforce uninstall` removes only the prismo hook. other agents keep following the advisory `.prismo` files.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
52
110
|
## what prismodev catches
|
|
53
111
|
|
|
54
112
|
- missing `.claudeignore` / `.cursorignore` (the biggest single fix for most repos)
|
|
@@ -762,6 +820,9 @@ no install needed. npx runs it directly.
|
|
|
762
820
|
| command | what it does |
|
|
763
821
|
|---------|-------------|
|
|
764
822
|
| `doctor` | diagnose, fix, optimize, show before/after |
|
|
823
|
+
| `repair <cause\|auto>` | targeted repair for one waste cause; auto = planner picks with cooldowns and verdict feedback |
|
|
824
|
+
| `enforce` | runtime enforcement of the context firewall via claude code hooks |
|
|
825
|
+
| `digest` | verified-savings summary for the week, in dollars, ready for slack |
|
|
765
826
|
| `watch` | live session monitoring with warnings |
|
|
766
827
|
| `cc` | claude code cost breakdown |
|
|
767
828
|
| `cc timeline` | session reconstruction with events |
|
|
@@ -1067,6 +1128,9 @@ lib/prismo-dev/instructions.js instruction ROI, partial-compliance, and ablati
|
|
|
1067
1128
|
lib/prismo-dev/mcp.js local MCP server and Prismo tool bindings
|
|
1068
1129
|
lib/prismo-dev/receipt.js run receipts for reads, output, artifacts, and next scope
|
|
1069
1130
|
lib/prismo-dev/report.js terminal, markdown, ci reports
|
|
1131
|
+
lib/prismo-dev/repair-executors.js cause-specific repair executors with mild/aggressive tiers
|
|
1132
|
+
lib/prismo-dev/repair-planner.js autonomous planner: cause scoring, cooldowns, local verdicts, escalation
|
|
1133
|
+
lib/prismo-dev/enforce.js claude code PreToolUse hook enforcement and settings wiring
|
|
1070
1134
|
lib/prismo-dev/replay.js incident replay and recovery prompts
|
|
1071
1135
|
lib/prismo-dev/scan.js repo scanning, scoring, readiness
|
|
1072
1136
|
lib/prismo-dev/scan-path-utils.js scan ignore/path helper logic
|
|
@@ -1090,6 +1154,8 @@ lib/prismo-dev/watch-render.js watch terminal and guardrail renderers
|
|
|
1090
1154
|
npx getprismo --help
|
|
1091
1155
|
npx getprismo --version
|
|
1092
1156
|
npx getprismo doctor --help
|
|
1157
|
+
npx getprismo repair --help
|
|
1158
|
+
npx getprismo enforce --help
|
|
1093
1159
|
npx getprismo watch --help
|
|
1094
1160
|
npx getprismo shield --help
|
|
1095
1161
|
npx getprismo mcp --help
|
|
@@ -1108,3 +1174,4 @@ More docs:
|
|
|
1108
1174
|
|
|
1109
1175
|
- [MCP setup and tools](docs/mcp.md)
|
|
1110
1176
|
- [Live demo flow](docs/live-demo.md)
|
|
1177
|
+
- [Privacy & telemetry — exactly what leaves your machine](docs/privacy-telemetry.md)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Announcement drafts
|
|
2
|
+
|
|
3
|
+
Working drafts for the autonomous-loop release (getprismo 0.1.42). Edit freely; numbers in brackets should be replaced with real figures from the dashboard once a week of verdicts has accumulated.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Show HN
|
|
8
|
+
|
|
9
|
+
**Title:** Show HN: Prismo — an autonomous cost agent for AI coding that verifies its own fixes
|
|
10
|
+
|
|
11
|
+
**Body:**
|
|
12
|
+
|
|
13
|
+
AI coding agents (Claude Code, Codex, Cursor) waste a surprising share of their tokens: re-reading the same file hundreds of times, dumping full test output into context, loading lockfiles and build artifacts, retrying the same failing command. Most tools in this space show you a dashboard of the damage and stop there.
|
|
14
|
+
|
|
15
|
+
Prismo closes the loop. It runs locally (`npx getprismo doctor` to try it — no login, nothing leaves your machine), reads your agents' own session logs, and attributes waste to one of five causes. Then, if you connect it:
|
|
16
|
+
|
|
17
|
+
- a local planner repairs the top cause automatically — each cause has a dedicated fix, not a generic one
|
|
18
|
+
- after every repair, it measures the waste rate for that cause in your *later* sessions and stores a verdict: improved, no-change, or regressed
|
|
19
|
+
- failed repairs escalate to a stronger tier (context firewall, tighter budgets); a cause that fails both tiers is held for human review instead of being retried forever
|
|
20
|
+
- for Claude Code it goes further than advice: a PreToolUse hook actually denies reads into blocked context and the fourth retry of an identical command (fail-open, removable with one command)
|
|
21
|
+
- savings are reported in dollars, verified against real usage — not estimated — with a weekly digest you can paste into Slack
|
|
22
|
+
- and the planner learns from the fleet: anonymized repair verdicts (counts only) aggregate into priors, so your first repair starts at the tier that's known to work
|
|
23
|
+
|
|
24
|
+
In our own dogfooding it [verified ~$X saved across N sessions in the first week].
|
|
25
|
+
|
|
26
|
+
The CLI is MIT-licensed: https://github.com/shanirsh/prismodev — the verification loop math is in the repo (14-day baseline, before/after waste rates, 1% epsilon). Would love feedback on the enforcement design and the verdict thresholds.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## X / Twitter thread
|
|
31
|
+
|
|
32
|
+
1/ Every AI-coding-cost tool shows you a dashboard of waste. We built the thing that fixes it — and then proves the fix worked, in dollars.
|
|
33
|
+
|
|
34
|
+
2/ Prismo watches your Claude Code / Codex / Cursor sessions locally, attributes waste to 5 causes, and repairs the top one automatically. Ignore rules, shielded commands, context firewalls, scoped restarts — each cause gets its own fix.
|
|
35
|
+
|
|
36
|
+
3/ The part nobody else does: after a repair, it measures that cause's waste rate in your NEXT sessions. Improved → stand down. No change → escalate to a stronger repair. Failed twice → stop and ask a human. It's a feedback controller, not a script.
|
|
37
|
+
|
|
38
|
+
4/ For Claude Code it's not advisory. `prismo enforce install` wires a hook that *denies* reads into node_modules/logs/build output and blocks the 4th retry of an identical failing command. Fail-open, one command to remove.
|
|
39
|
+
|
|
40
|
+
5/ And it learns across every install: anonymized repair verdicts roll up into fleet priors, so your first repair starts at the tier the fleet already knows works. The more users, the smarter every agent gets.
|
|
41
|
+
|
|
42
|
+
6/ Monday morning: `prismo digest` → "Prismo saved you ~$X this week — verified against your sessions." Paste it in Slack. The product re-justifies itself weekly.
|
|
43
|
+
|
|
44
|
+
7/ Try it in 10 seconds, no login, local-only: `npx getprismo doctor` — MIT licensed → github.com/shanirsh/prismodev
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Release notes (0.1.40 → 0.1.42)
|
|
49
|
+
|
|
50
|
+
- **Cause-specific repair executors** — workspace actions with a `targetCause` run a targeted repair (repeated-file-reads, tool-output-flood, generated-artifacts, context-loop, long-session-buildup) instead of generic doctor; `prismo repair <cause>` runs them standalone.
|
|
51
|
+
- **Autonomous repair planner** — `agent --watch` self-repairs on an interval with thresholds, per-cause cooldowns, local before/after verdicts, and mild→aggressive escalation; `prismo repair auto [--dry-run]`.
|
|
52
|
+
- **Runtime enforcement** — `prismo enforce install` adds a Claude Code PreToolUse hook denying blocked-context reads and identical-command loops; fails open; `enforce uninstall` reverts.
|
|
53
|
+
- **Verified savings in dollars** — `prismo digest [--days N]` prints the weekly verified-savings summary; the dashboard leads with dollars.
|
|
54
|
+
- **Fleet priors** — first repairs start at the tier the fleet's verified outcomes recommend (anonymized counts only; local verdicts always win).
|
|
55
|
+
- **Cloud escalation + dedupe** — auto-queued repairs escalate after failed verdicts; duplicate actions are deduped at creation and claim.
|
|
56
|
+
- **CI releases** — tag push runs tests and publishes.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Privacy & telemetry
|
|
2
|
+
|
|
3
|
+
prismodev is local-first. Most commands (`doctor`, `watch`, `scan`, `shield`, `repair`, `enforce`, ...) read your repo and your coding tools' local logs and write files under `.prismo/`. Nothing leaves your machine unless you explicitly connect (`prismo connect --token ...`).
|
|
4
|
+
|
|
5
|
+
This page lists **exactly** what the connector sends after you connect, field by field, taken from the code that builds the payloads ([`buildSyncPayload` / `sanitizeSession` in cloud-sync.js](../lib/prismo-dev/cloud-sync.js)). If the code and this page ever disagree, the code wins and the page has a bug — please file an issue.
|
|
6
|
+
|
|
7
|
+
## What never leaves your machine
|
|
8
|
+
|
|
9
|
+
- prompts and conversation text
|
|
10
|
+
- source code and file contents
|
|
11
|
+
- stdout/stderr of your commands (shield stores full output **locally** under `.prismo/shield/`)
|
|
12
|
+
- full command strings from your sessions (only *counts* of repeated commands are sent)
|
|
13
|
+
- file paths beyond the repo identity below (repeated-read and artifact signals are sent as counts, not paths)
|
|
14
|
+
- environment variables, API keys for model providers, git history
|
|
15
|
+
|
|
16
|
+
## What session sync sends (`prismo sync`, and the connector on an interval)
|
|
17
|
+
|
|
18
|
+
Per machine:
|
|
19
|
+
|
|
20
|
+
| field | example | note |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| client name/version/platform/hostname | `prismodev 0.1.42, darwin arm64, Shans-MacBook-Air.local` | hostname identifies the device in your workspace |
|
|
23
|
+
|
|
24
|
+
Repo identity (so the dashboard can group by project):
|
|
25
|
+
|
|
26
|
+
| field | example |
|
|
27
|
+
|---|---|
|
|
28
|
+
| repo folder name | `prismodev` |
|
|
29
|
+
| git remote, credentials stripped | `github.com/you/repo` |
|
|
30
|
+
| current branch + short commit | `main`, `abc123def456` |
|
|
31
|
+
| current branch's PR number + state, when the `gh` CLI is installed and authenticated | `#142, merged` |
|
|
32
|
+
|
|
33
|
+
Per session (numbers and category labels only):
|
|
34
|
+
|
|
35
|
+
| field | example |
|
|
36
|
+
|---|---|
|
|
37
|
+
| session id, tool, model | `claude-code`, `sonnet` |
|
|
38
|
+
| session title | whatever your coding tool stored as the session title — usually a short task summary. If your titles are sensitive, know that they sync. |
|
|
39
|
+
| timestamps, turns, tool-call counts | `18 turns, 42 tool calls` |
|
|
40
|
+
| token totals | display/context/exact/tool-output token counts |
|
|
41
|
+
| waste estimate | wasted tokens, waste percent, top cause label (e.g. `tool-output-flood`) |
|
|
42
|
+
| signals | counts of repeated file reads, artifact mentions, repeated commands; loop suspicion boolean |
|
|
43
|
+
|
|
44
|
+
Plus a repo scan summary (score, risk level, issue counts — not file contents) and the aggregate totals of the above.
|
|
45
|
+
|
|
46
|
+
## What other connector calls send
|
|
47
|
+
|
|
48
|
+
- **heartbeat** — agent version, mode, online/offline, device name
|
|
49
|
+
- **action status** — for each workspace action: status, a one-line status message, and a result object (counts, scores, generated `.prismo/` file *names*)
|
|
50
|
+
- **guard events** — prevention event category, token counts, cause label
|
|
51
|
+
- **auto-detect / self-repair reports** — finding categories and messages generated by prismodev itself
|
|
52
|
+
|
|
53
|
+
## Fleet learning is counts-only
|
|
54
|
+
|
|
55
|
+
The fleet priors endpoint aggregates repair verdicts across all customers as `cause x tier -> attempts / improved`. The aggregate contains **no** org ids, user ids, repo names, branches, or labels — it is six numbers per cause. Your connector reads this aggregate; it cannot read anything about other customers.
|
|
56
|
+
|
|
57
|
+
## Runtime enforcement is fully local
|
|
58
|
+
|
|
59
|
+
`prismo enforce` hooks run on your machine, decide locally against `.prismo/blocked-context.txt` and `.prismo/enforce-state.json`, and send nothing anywhere. Denial counts stay in the local state file.
|
|
60
|
+
|
|
61
|
+
## Verify it yourself
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx getprismo sync --dry-run --json # prints the exact payload without sending it
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That command is the contract: what you see there is the entirety of what `sync` would send.
|
package/lib/prismo-dev/agent.js
CHANGED
|
@@ -458,10 +458,20 @@ module.exports = function createAgent(deps) {
|
|
|
458
458
|
|
|
459
459
|
let plannerResult = null;
|
|
460
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 (_) {}
|
|
461
470
|
try {
|
|
462
471
|
plannerResult = await repairPlanner.runPlannerOnce(rootDir, {
|
|
463
472
|
execute: mode === "autopilot",
|
|
464
473
|
sessionLimit: options.plannerSessionLimit,
|
|
474
|
+
fleetPriors,
|
|
465
475
|
});
|
|
466
476
|
const registered = plannerResult.executed
|
|
467
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,12 @@ function createCli(deps) {
|
|
|
73
76
|
runRepair,
|
|
74
77
|
renderPlannerTerminal,
|
|
75
78
|
runPlannerOnce,
|
|
79
|
+
decidePostToolUse,
|
|
80
|
+
decidePreToolUse,
|
|
81
|
+
renderEnforceTerminal,
|
|
82
|
+
runEnforceInstall,
|
|
83
|
+
runEnforceStatus,
|
|
84
|
+
runEnforceUninstall,
|
|
76
85
|
renderAgentTerminal,
|
|
77
86
|
runAgent,
|
|
78
87
|
renderConnectorTerminal,
|
|
@@ -464,6 +473,17 @@ function createCli(deps) {
|
|
|
464
473
|
return;
|
|
465
474
|
}
|
|
466
475
|
|
|
476
|
+
if (command === "digest") {
|
|
477
|
+
const json = rest.includes("--json");
|
|
478
|
+
const daysIndex = rest.indexOf("--days");
|
|
479
|
+
const result = await runDigest({
|
|
480
|
+
days: parsePositiveInt(daysIndex >= 0 ? rest[daysIndex + 1] : null, 7),
|
|
481
|
+
});
|
|
482
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
483
|
+
else console.log(renderDigestTerminal(result));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
467
487
|
if (command === "status") {
|
|
468
488
|
const json = rest.includes("--json");
|
|
469
489
|
const result = runStatus();
|
|
@@ -801,6 +821,36 @@ function createCli(deps) {
|
|
|
801
821
|
return;
|
|
802
822
|
}
|
|
803
823
|
|
|
824
|
+
if (command === "hook") {
|
|
825
|
+
const subcommand = (rest[0] || "").toLowerCase();
|
|
826
|
+
if (subcommand !== "pretooluse" && subcommand !== "posttooluse") {
|
|
827
|
+
printCommandHelp("enforce");
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const chunks = [];
|
|
831
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
832
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
833
|
+
const decision = subcommand === "pretooluse"
|
|
834
|
+
? decidePreToolUse(process.cwd(), raw)
|
|
835
|
+
: decidePostToolUse(process.cwd(), raw);
|
|
836
|
+
if (decision) console.log(JSON.stringify(decision));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (command === "enforce") {
|
|
841
|
+
const json = rest.includes("--json");
|
|
842
|
+
const subcommand = (getPositionals(rest, new Set())[0] || "status").toLowerCase();
|
|
843
|
+
const target = getPositionals(rest, new Set())[1] || process.cwd();
|
|
844
|
+
const result = subcommand === "install"
|
|
845
|
+
? runEnforceInstall(target)
|
|
846
|
+
: subcommand === "uninstall"
|
|
847
|
+
? runEnforceUninstall(target)
|
|
848
|
+
: runEnforceStatus(target);
|
|
849
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
850
|
+
else console.log(renderEnforceTerminal(result));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
804
854
|
if (command === "usage" || command === "watch") {
|
|
805
855
|
const json = rest.includes("--json");
|
|
806
856
|
const knownTools = new Set(["codex", "claude", "cursor", "all"]);
|
|
@@ -65,6 +65,27 @@ module.exports = function createCloudSync(deps) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Best-effort PR linkage for the current branch via the GitHub CLI, so
|
|
69
|
+
// branch costs can roll up to cost-per-merged-PR. Silently absent when gh
|
|
70
|
+
// is not installed, not authenticated, or the branch has no PR.
|
|
71
|
+
function detectPullRequest(root) {
|
|
72
|
+
try {
|
|
73
|
+
const { spawnSync } = require("child_process");
|
|
74
|
+
const result = spawnSync("gh", ["pr", "view", "--json", "number,state"], {
|
|
75
|
+
cwd: root,
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
timeout: 4000,
|
|
78
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
79
|
+
});
|
|
80
|
+
if (result.status !== 0) return null;
|
|
81
|
+
const parsed = JSON.parse(String(result.stdout || ""));
|
|
82
|
+
if (!parsed || typeof parsed.number !== "number") return null;
|
|
83
|
+
return { number: parsed.number, state: String(parsed.state || "").toLowerCase() || null };
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
function repoIdentity(root) {
|
|
69
90
|
const resolved = path.resolve(root || process.cwd());
|
|
70
91
|
const remote = runGit(resolved, ["config", "--get", "remote.origin.url"]);
|
|
@@ -75,6 +96,7 @@ module.exports = function createCloudSync(deps) {
|
|
|
75
96
|
remote: redactRemote(remote),
|
|
76
97
|
branch: branch || null,
|
|
77
98
|
commit: commit || null,
|
|
99
|
+
pr: detectPullRequest(resolved),
|
|
78
100
|
};
|
|
79
101
|
}
|
|
80
102
|
|
|
@@ -371,6 +393,78 @@ module.exports = function createCloudSync(deps) {
|
|
|
371
393
|
};
|
|
372
394
|
}
|
|
373
395
|
|
|
396
|
+
async function runDigest(options = {}) {
|
|
397
|
+
const config = loadConfig();
|
|
398
|
+
if (!config || !config.token) {
|
|
399
|
+
return {
|
|
400
|
+
schemaVersion: 1,
|
|
401
|
+
command: "digest",
|
|
402
|
+
connected: false,
|
|
403
|
+
digest: null,
|
|
404
|
+
error: "not-connected",
|
|
405
|
+
next: [`${NPX_COMMAND} connect --token <token>`],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const base = String(config.apiUrl || DEFAULT_API_URL).replace(/\/$/, "");
|
|
409
|
+
const days = Math.max(1, Number(options.days || 7));
|
|
410
|
+
const endpoint = options.endpoint || `${base}/v1/dev/workspace/digest/agent?days=${encodeURIComponent(days)}`;
|
|
411
|
+
// Local enforcement stats never sync; fold them into the digest here.
|
|
412
|
+
let localEnforcement = null;
|
|
413
|
+
try {
|
|
414
|
+
const statePath = path.join(options.cwd || process.cwd(), ".prismo", "enforce-state.json");
|
|
415
|
+
const denials = (JSON.parse(fs.readFileSync(statePath, "utf8")).denials) || null;
|
|
416
|
+
if (denials && denials.total > 0) {
|
|
417
|
+
localEnforcement = {
|
|
418
|
+
denials: denials.total,
|
|
419
|
+
estimatedTokensSaved: denials.estimatedTokensSaved || 0,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
} catch {}
|
|
423
|
+
try {
|
|
424
|
+
const response = await requestJson("GET", endpoint, config.token, null, options.timeoutMs || 10000);
|
|
425
|
+
return {
|
|
426
|
+
schemaVersion: 1,
|
|
427
|
+
command: "digest",
|
|
428
|
+
connected: true,
|
|
429
|
+
apiUrl: base,
|
|
430
|
+
digest: response.data,
|
|
431
|
+
localEnforcement,
|
|
432
|
+
};
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return {
|
|
435
|
+
schemaVersion: 1,
|
|
436
|
+
command: "digest",
|
|
437
|
+
connected: true,
|
|
438
|
+
apiUrl: base,
|
|
439
|
+
digest: null,
|
|
440
|
+
error: error && error.message ? error.message : String(error),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function renderDigestTerminal(result) {
|
|
446
|
+
const lines = [];
|
|
447
|
+
lines.push("");
|
|
448
|
+
lines.push("PrismoDev Digest");
|
|
449
|
+
lines.push("");
|
|
450
|
+
if (!result.connected) {
|
|
451
|
+
lines.push("Status: not connected");
|
|
452
|
+
lines.push("");
|
|
453
|
+
lines.push("Next");
|
|
454
|
+
(result.next || []).forEach((item, index) => lines.push(`${index + 1}. ${item}`));
|
|
455
|
+
return lines.join("\n");
|
|
456
|
+
}
|
|
457
|
+
if (!result.digest) {
|
|
458
|
+
lines.push(`Could not load digest${result.error ? `: ${result.error}` : "."}`);
|
|
459
|
+
return lines.join("\n");
|
|
460
|
+
}
|
|
461
|
+
(result.digest.lines || [result.digest.headline]).forEach((line) => lines.push(line));
|
|
462
|
+
if (result.localEnforcement) {
|
|
463
|
+
lines.push(`Local enforcement: ${result.localEnforcement.denials} denial(s), ~${result.localEnforcement.estimatedTokensSaved.toLocaleString()} tokens kept out of context on this machine.`);
|
|
464
|
+
}
|
|
465
|
+
return lines.join("\n");
|
|
466
|
+
}
|
|
467
|
+
|
|
374
468
|
function runStatus() {
|
|
375
469
|
const config = loadConfig();
|
|
376
470
|
const state = readJson(statePath());
|
|
@@ -499,10 +593,12 @@ module.exports = function createCloudSync(deps) {
|
|
|
499
593
|
estimateWaste,
|
|
500
594
|
loadConfig,
|
|
501
595
|
renderConnectTerminal,
|
|
596
|
+
renderDigestTerminal,
|
|
502
597
|
renderDisconnectTerminal,
|
|
503
598
|
renderStatusTerminal,
|
|
504
599
|
renderSyncTerminal,
|
|
505
600
|
runConnect,
|
|
601
|
+
runDigest,
|
|
506
602
|
runDisconnect,
|
|
507
603
|
runStatus,
|
|
508
604
|
runSync,
|
|
@@ -0,0 +1,418 @@
|
|
|
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 POST_HOOK_COMMAND = `${NPX_COMMAND} hook posttooluse`;
|
|
11
|
+
const FILE_TOOLS = new Set(["Read", "Glob", "Grep", "NotebookRead"]);
|
|
12
|
+
const MAX_IDENTICAL_COMMANDS = 3;
|
|
13
|
+
const MAX_COMMAND_FAILURES = 3;
|
|
14
|
+
const MAX_TRACKED_SESSIONS = 8;
|
|
15
|
+
const DENIAL_LOG_LIMIT = 50;
|
|
16
|
+
// Conservative token estimate for a denied loop retry (one round of
|
|
17
|
+
// command output that never entered context).
|
|
18
|
+
const LOOP_DENY_TOKEN_ESTIMATE = 2000;
|
|
19
|
+
|
|
20
|
+
function blockedContextPath(root) {
|
|
21
|
+
return path.join(root, ".prismo", "blocked-context.txt");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function enforceStatePath(root) {
|
|
25
|
+
return path.join(root, ".prismo", "enforce-state.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function settingsPath(root) {
|
|
29
|
+
return path.join(root, ".claude", "settings.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readBlockedPatterns(root) {
|
|
33
|
+
try {
|
|
34
|
+
return fs.readFileSync(blockedContextPath(root), "utf8")
|
|
35
|
+
.split(/\r?\n/)
|
|
36
|
+
.map((line) => line.trim())
|
|
37
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readState(root) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(fs.readFileSync(enforceStatePath(root), "utf8"));
|
|
46
|
+
return parsed && typeof parsed === "object" ? { sessions: {}, ...parsed } : { sessions: {} };
|
|
47
|
+
} catch {
|
|
48
|
+
return { sessions: {} };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeState(root, state) {
|
|
53
|
+
const filePath = enforceStatePath(root);
|
|
54
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
55
|
+
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Command records were plain attempt counters before outcome tracking;
|
|
59
|
+
// normalize either shape to {attempts, failures, succeeded, outcomes}.
|
|
60
|
+
function commandRecord(session, command) {
|
|
61
|
+
const existing = session.commands[command];
|
|
62
|
+
if (existing && typeof existing === "object") {
|
|
63
|
+
return { attempts: 0, failures: 0, succeeded: false, outcomes: 0, ...existing };
|
|
64
|
+
}
|
|
65
|
+
return { attempts: Number(existing || 0), failures: 0, succeeded: false, outcomes: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sessionRecord(state, sessionId) {
|
|
69
|
+
const sessions = state.sessions || {};
|
|
70
|
+
state.sessions = sessions;
|
|
71
|
+
const session = sessions[sessionId] || { commands: {}, updatedAt: null };
|
|
72
|
+
sessions[sessionId] = session;
|
|
73
|
+
return session;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pruneSessions(state) {
|
|
77
|
+
const sessions = state.sessions || {};
|
|
78
|
+
const ids = Object.keys(sessions)
|
|
79
|
+
.sort((a, b) => String(sessions[b].updatedAt || "").localeCompare(String(sessions[a].updatedAt || "")));
|
|
80
|
+
state.sessions = Object.fromEntries(ids.slice(0, MAX_TRACKED_SESSIONS).map((id) => [id, sessions[id]]));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function recordDenial(root, state, rule, target, estimatedTokens) {
|
|
84
|
+
const denials = state.denials || { total: 0, blockedContext: 0, loops: 0, estimatedTokensSaved: 0, recent: [] };
|
|
85
|
+
denials.total += 1;
|
|
86
|
+
if (rule === "blocked-context") denials.blockedContext += 1;
|
|
87
|
+
if (rule === "loop") denials.loops += 1;
|
|
88
|
+
denials.estimatedTokensSaved += Math.max(0, Math.round(estimatedTokens));
|
|
89
|
+
denials.recent = [{ at: new Date().toISOString(), rule, target }, ...(denials.recent || [])].slice(0, DENIAL_LOG_LIMIT);
|
|
90
|
+
state.denials = denials;
|
|
91
|
+
writeState(root, state);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function estimateBlockedFileTokens(root, target) {
|
|
95
|
+
try {
|
|
96
|
+
const fullPath = path.isAbsolute(target) ? target : path.join(root, target);
|
|
97
|
+
const stat = fs.statSync(fullPath);
|
|
98
|
+
if (stat.isFile()) return Math.min(200000, Math.round(stat.size / 4));
|
|
99
|
+
} catch {}
|
|
100
|
+
return 1500;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function relativePath(root, filePath) {
|
|
104
|
+
const value = String(filePath || "");
|
|
105
|
+
const resolvedRoot = path.resolve(root);
|
|
106
|
+
if (value.startsWith(`${resolvedRoot}${path.sep}`)) return value.slice(resolvedRoot.length + 1);
|
|
107
|
+
if (value === resolvedRoot) return ".";
|
|
108
|
+
return value.replace(/^\.\//, "");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchesBlocked(relPath, pattern) {
|
|
112
|
+
const candidate = String(relPath || "").replace(/\\/g, "/");
|
|
113
|
+
const rule = String(pattern || "").trim();
|
|
114
|
+
if (!candidate || !rule) return false;
|
|
115
|
+
if (rule.endsWith("/**")) {
|
|
116
|
+
const dir = rule.slice(0, -3).replace(/\/$/, "");
|
|
117
|
+
return candidate === dir || candidate.startsWith(`${dir}/`) || candidate.includes(`/${dir}/`);
|
|
118
|
+
}
|
|
119
|
+
if (rule.startsWith("*.")) {
|
|
120
|
+
return candidate.endsWith(rule.slice(1));
|
|
121
|
+
}
|
|
122
|
+
return candidate === rule
|
|
123
|
+
|| candidate.endsWith(`/${rule}`)
|
|
124
|
+
|| candidate.includes(`/${rule}/`)
|
|
125
|
+
|| candidate.startsWith(`${rule}/`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function deny(reason) {
|
|
129
|
+
return {
|
|
130
|
+
hookSpecificOutput: {
|
|
131
|
+
hookEventName: "PreToolUse",
|
|
132
|
+
permissionDecision: "deny",
|
|
133
|
+
permissionDecisionReason: reason,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Decide whether a PreToolUse event should be blocked. Returns the hook
|
|
139
|
+
// response object for a deny, or null to allow. Fails open: any parse or
|
|
140
|
+
// state error allows the call rather than breaking the user's agent.
|
|
141
|
+
function decidePreToolUse(rootDir, rawEvent) {
|
|
142
|
+
let event;
|
|
143
|
+
try {
|
|
144
|
+
event = typeof rawEvent === "string" ? JSON.parse(rawEvent) : rawEvent;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (!event || typeof event !== "object") return null;
|
|
149
|
+
const root = path.resolve(event.cwd || rootDir || process.cwd());
|
|
150
|
+
const toolName = String(event.tool_name || "");
|
|
151
|
+
const toolInput = event.tool_input && typeof event.tool_input === "object" ? event.tool_input : {};
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
if (FILE_TOOLS.has(toolName)) {
|
|
155
|
+
const target = toolInput.file_path || toolInput.notebook_path || toolInput.path || null;
|
|
156
|
+
if (!target) return null;
|
|
157
|
+
const relPath = relativePath(root, target);
|
|
158
|
+
const patterns = readBlockedPatterns(root);
|
|
159
|
+
const hit = patterns.find((pattern) => matchesBlocked(relPath, pattern));
|
|
160
|
+
if (hit) {
|
|
161
|
+
recordDenial(root, readState(root), "blocked-context", relPath, estimateBlockedFileTokens(root, target));
|
|
162
|
+
return deny(
|
|
163
|
+
`Prismo context firewall: "${relPath}" is blocked context (rule: ${hit}). `
|
|
164
|
+
+ "It is generated output that wastes agent tokens. Use the .prismo/ context packs instead, "
|
|
165
|
+
+ `or run \`${NPX_COMMAND} shield -- <command>\` if you need its contents summarized.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (toolName === "Bash") {
|
|
172
|
+
const command = String(toolInput.command || "").trim().replace(/\s+/g, " ");
|
|
173
|
+
if (!command) return null;
|
|
174
|
+
const sessionId = String(event.session_id || "unknown");
|
|
175
|
+
const state = readState(root);
|
|
176
|
+
const session = sessionRecord(state, sessionId);
|
|
177
|
+
const record = commandRecord(session, command);
|
|
178
|
+
|
|
179
|
+
// Outcome-aware loop breaking: a command that ever succeeded in
|
|
180
|
+
// this session is legitimate to repeat (test loops while iterating).
|
|
181
|
+
// With outcome data, deny only after repeated failures; without it
|
|
182
|
+
// (PostToolUse hook absent), fall back to attempt counting.
|
|
183
|
+
const deniedByFailures = !record.succeeded && record.outcomes > 0 && record.failures >= MAX_COMMAND_FAILURES;
|
|
184
|
+
const deniedByAttempts = record.outcomes === 0 && record.attempts >= MAX_IDENTICAL_COMMANDS;
|
|
185
|
+
if (deniedByFailures || deniedByAttempts) {
|
|
186
|
+
recordDenial(root, state, "loop", command, LOOP_DENY_TOKEN_ESTIMATE);
|
|
187
|
+
const observation = deniedByFailures
|
|
188
|
+
? `this exact command has already failed ${record.failures} times in this session`
|
|
189
|
+
: `this exact command has already run ${record.attempts} times in this session`;
|
|
190
|
+
return deny(
|
|
191
|
+
`Prismo loop breaker: ${observation}. `
|
|
192
|
+
+ "Repeating it again will not change the outcome and floods context. Change the approach, "
|
|
193
|
+
+ `or capture its output once with \`${NPX_COMMAND} shield -- ${command}\`.`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
record.attempts += 1;
|
|
197
|
+
session.commands[command] = record;
|
|
198
|
+
session.updatedAt = new Date().toISOString();
|
|
199
|
+
pruneSessions(state);
|
|
200
|
+
writeState(root, state);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// PostToolUse: record whether the Bash command actually failed, so the
|
|
210
|
+
// loop breaker can tell a failing retry loop from a legitimate test loop.
|
|
211
|
+
// Output shape varies by Claude Code version; unknown shapes record
|
|
212
|
+
// nothing rather than guessing.
|
|
213
|
+
function decidePostToolUse(rootDir, rawEvent) {
|
|
214
|
+
let event;
|
|
215
|
+
try {
|
|
216
|
+
event = typeof rawEvent === "string" ? JSON.parse(rawEvent) : rawEvent;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (!event || typeof event !== "object" || String(event.tool_name || "") !== "Bash") return null;
|
|
221
|
+
const toolInput = event.tool_input && typeof event.tool_input === "object" ? event.tool_input : {};
|
|
222
|
+
const command = String(toolInput.command || "").trim().replace(/\s+/g, " ");
|
|
223
|
+
if (!command) return null;
|
|
224
|
+
|
|
225
|
+
const response = event.tool_response;
|
|
226
|
+
let failed = null;
|
|
227
|
+
if (response && typeof response === "object") {
|
|
228
|
+
if (typeof response.exit_code === "number") failed = response.exit_code !== 0;
|
|
229
|
+
else if (typeof response.exitCode === "number") failed = response.exitCode !== 0;
|
|
230
|
+
else if (typeof response.is_error === "boolean") failed = response.is_error;
|
|
231
|
+
else if (response.interrupted === true) failed = true;
|
|
232
|
+
}
|
|
233
|
+
if (failed === null) return null;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const root = path.resolve(event.cwd || rootDir || process.cwd());
|
|
237
|
+
const state = readState(root);
|
|
238
|
+
const session = sessionRecord(state, String(event.session_id || "unknown"));
|
|
239
|
+
const record = commandRecord(session, command);
|
|
240
|
+
record.outcomes += 1;
|
|
241
|
+
if (failed) record.failures += 1;
|
|
242
|
+
else record.succeeded = true;
|
|
243
|
+
session.commands[command] = record;
|
|
244
|
+
session.updatedAt = new Date().toISOString();
|
|
245
|
+
pruneSessions(state);
|
|
246
|
+
writeState(root, state);
|
|
247
|
+
} catch {}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readSettings(root) {
|
|
252
|
+
try {
|
|
253
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath(root), "utf8"));
|
|
254
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
255
|
+
} catch {
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isPrismoHookEntry(entry) {
|
|
261
|
+
try {
|
|
262
|
+
const text = JSON.stringify(entry);
|
|
263
|
+
return text.includes("hook pretooluse") || text.includes("hook posttooluse");
|
|
264
|
+
} catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function hookInstalled(root) {
|
|
270
|
+
const settings = readSettings(root);
|
|
271
|
+
const entries = settings.hooks && Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
272
|
+
return entries.some(isPrismoHookEntry);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function runEnforceInstall(rootDir = process.cwd(), options = {}) {
|
|
276
|
+
const root = path.resolve(rootDir);
|
|
277
|
+
const actions = [];
|
|
278
|
+
|
|
279
|
+
if (!fs.existsSync(blockedContextPath(root)) && runFirewall && !options.noFirewall) {
|
|
280
|
+
runFirewall(root, { task: "enforcement", dryRun: false });
|
|
281
|
+
actions.push("Generated .prismo context firewall policy (allowed/blocked context)");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const filePath = settingsPath(root);
|
|
285
|
+
const settings = readSettings(root);
|
|
286
|
+
settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
287
|
+
const preEntries = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
288
|
+
const postEntries = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
|
|
289
|
+
if (preEntries.some(isPrismoHookEntry) && postEntries.some(isPrismoHookEntry)) {
|
|
290
|
+
actions.push("Prismo hooks already installed in .claude/settings.json");
|
|
291
|
+
} else {
|
|
292
|
+
const existed = fs.existsSync(filePath);
|
|
293
|
+
if (existed) {
|
|
294
|
+
fs.copyFileSync(filePath, `${filePath}.prismo-backup`);
|
|
295
|
+
actions.push("Backed up .claude/settings.json to settings.json.prismo-backup");
|
|
296
|
+
}
|
|
297
|
+
if (!preEntries.some(isPrismoHookEntry)) {
|
|
298
|
+
preEntries.push({
|
|
299
|
+
matcher: "Read|Glob|Grep|NotebookRead|Bash",
|
|
300
|
+
hooks: [{ type: "command", command: HOOK_COMMAND }],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (!postEntries.some(isPrismoHookEntry)) {
|
|
304
|
+
postEntries.push({
|
|
305
|
+
matcher: "Bash",
|
|
306
|
+
hooks: [{ type: "command", command: POST_HOOK_COMMAND }],
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
settings.hooks.PreToolUse = preEntries;
|
|
310
|
+
settings.hooks.PostToolUse = postEntries;
|
|
311
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
312
|
+
fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
313
|
+
actions.push(`${existed ? "Updated" : "Created"} .claude/settings.json with the Prismo PreToolUse + PostToolUse hooks`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
schemaVersion: 1,
|
|
318
|
+
command: "enforce",
|
|
319
|
+
mode: "install",
|
|
320
|
+
installed: true,
|
|
321
|
+
blockedRules: readBlockedPatterns(root).length,
|
|
322
|
+
actions,
|
|
323
|
+
generatedAt: new Date().toISOString(),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function runEnforceUninstall(rootDir = process.cwd()) {
|
|
328
|
+
const root = path.resolve(rootDir);
|
|
329
|
+
const filePath = settingsPath(root);
|
|
330
|
+
const settings = readSettings(root);
|
|
331
|
+
const actions = [];
|
|
332
|
+
let removed = false;
|
|
333
|
+
for (const eventName of ["PreToolUse", "PostToolUse"]) {
|
|
334
|
+
const entries = settings.hooks && Array.isArray(settings.hooks[eventName]) ? settings.hooks[eventName] : [];
|
|
335
|
+
const kept = entries.filter((entry) => !isPrismoHookEntry(entry));
|
|
336
|
+
if (kept.length !== entries.length) {
|
|
337
|
+
removed = true;
|
|
338
|
+
if (kept.length) settings.hooks[eventName] = kept;
|
|
339
|
+
else if (settings.hooks) delete settings.hooks[eventName];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (removed) {
|
|
343
|
+
fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
344
|
+
actions.push("Removed the Prismo hooks from .claude/settings.json");
|
|
345
|
+
} else {
|
|
346
|
+
actions.push("No Prismo hooks found in .claude/settings.json");
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
schemaVersion: 1,
|
|
350
|
+
command: "enforce",
|
|
351
|
+
mode: "uninstall",
|
|
352
|
+
installed: false,
|
|
353
|
+
actions,
|
|
354
|
+
generatedAt: new Date().toISOString(),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function runEnforceStatus(rootDir = process.cwd()) {
|
|
359
|
+
const root = path.resolve(rootDir);
|
|
360
|
+
const state = readState(root);
|
|
361
|
+
const denials = state.denials || { total: 0, blockedContext: 0, loops: 0, estimatedTokensSaved: 0 };
|
|
362
|
+
return {
|
|
363
|
+
schemaVersion: 1,
|
|
364
|
+
command: "enforce",
|
|
365
|
+
mode: "status",
|
|
366
|
+
installed: hookInstalled(root),
|
|
367
|
+
blockedRules: readBlockedPatterns(root).length,
|
|
368
|
+
trackedSessions: Object.keys(state.sessions || {}).length,
|
|
369
|
+
denials: {
|
|
370
|
+
total: denials.total || 0,
|
|
371
|
+
blockedContext: denials.blockedContext || 0,
|
|
372
|
+
loops: denials.loops || 0,
|
|
373
|
+
estimatedTokensSaved: denials.estimatedTokensSaved || 0,
|
|
374
|
+
},
|
|
375
|
+
settingsPath: path.join(".claude", "settings.json"),
|
|
376
|
+
generatedAt: new Date().toISOString(),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function renderEnforceTerminal(result) {
|
|
381
|
+
const lines = [];
|
|
382
|
+
lines.push("");
|
|
383
|
+
lines.push("PrismoDev Enforce");
|
|
384
|
+
lines.push("");
|
|
385
|
+
if (result.mode === "status") {
|
|
386
|
+
lines.push(`Hook installed: ${result.installed ? "yes" : "no"}`);
|
|
387
|
+
lines.push(`Blocked-context rules: ${result.blockedRules}`);
|
|
388
|
+
lines.push(`Sessions tracked for loop breaking: ${result.trackedSessions}`);
|
|
389
|
+
if (result.denials && result.denials.total > 0) {
|
|
390
|
+
lines.push(`Denials: ${result.denials.total} (${result.denials.blockedContext} blocked-context, ${result.denials.loops} loop)`);
|
|
391
|
+
lines.push(`Estimated tokens kept out of context: ~${result.denials.estimatedTokensSaved.toLocaleString()}`);
|
|
392
|
+
}
|
|
393
|
+
if (!result.installed) {
|
|
394
|
+
lines.push("");
|
|
395
|
+
lines.push(`Run \`${NPX_COMMAND} enforce install\` to enforce the context firewall at runtime.`);
|
|
396
|
+
}
|
|
397
|
+
return lines.join("\n");
|
|
398
|
+
}
|
|
399
|
+
(result.actions || []).forEach((action) => lines.push(`- ${action}`));
|
|
400
|
+
if (result.mode === "install") {
|
|
401
|
+
lines.push("");
|
|
402
|
+
lines.push(`Blocked-context rules enforced: ${result.blockedRules}`);
|
|
403
|
+
lines.push("Claude Code will now be denied reads of blocked context and fourth retries of an identical command.");
|
|
404
|
+
lines.push("Other agents still follow the .prismo policy files advisorily.");
|
|
405
|
+
}
|
|
406
|
+
return lines.join("\n");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
decidePostToolUse,
|
|
411
|
+
decidePreToolUse,
|
|
412
|
+
matchesBlocked,
|
|
413
|
+
renderEnforceTerminal,
|
|
414
|
+
runEnforceInstall,
|
|
415
|
+
runEnforceStatus,
|
|
416
|
+
runEnforceUninstall,
|
|
417
|
+
};
|
|
418
|
+
};
|
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]
|
|
@@ -34,6 +35,7 @@ Usage:
|
|
|
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
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.
|
|
@@ -321,6 +325,28 @@ Output:
|
|
|
321
325
|
--dry-run with auto prints the planner decision without executing.
|
|
322
326
|
--tier aggressive forces the stronger repair; cloud-queued actions carry it automatically after a no-change/regressed verdict.
|
|
323
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.`,
|
|
324
350
|
watch: `Prismo Watch
|
|
325
351
|
|
|
326
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,20 @@ const {
|
|
|
332
334
|
runRepair,
|
|
333
335
|
} = repairExecutors;
|
|
334
336
|
|
|
337
|
+
const {
|
|
338
|
+
decidePostToolUse,
|
|
339
|
+
decidePreToolUse,
|
|
340
|
+
renderEnforceTerminal,
|
|
341
|
+
runEnforceInstall,
|
|
342
|
+
runEnforceStatus,
|
|
343
|
+
runEnforceUninstall,
|
|
344
|
+
} = require("./prismo-dev/enforce")({
|
|
345
|
+
fs,
|
|
346
|
+
path,
|
|
347
|
+
NPX_COMMAND,
|
|
348
|
+
runFirewall,
|
|
349
|
+
});
|
|
350
|
+
|
|
335
351
|
const repairPlanner = require("./prismo-dev/repair-planner")({
|
|
336
352
|
fs,
|
|
337
353
|
path,
|
|
@@ -477,10 +493,12 @@ const { runCli } = require("./prismo-dev/cli")({
|
|
|
477
493
|
renderReceiptTerminal,
|
|
478
494
|
buildReceipt,
|
|
479
495
|
renderConnectTerminal,
|
|
496
|
+
renderDigestTerminal,
|
|
480
497
|
renderDisconnectTerminal,
|
|
481
498
|
renderStatusTerminal,
|
|
482
499
|
renderSyncTerminal,
|
|
483
500
|
runConnect,
|
|
501
|
+
runDigest,
|
|
484
502
|
runDisconnect,
|
|
485
503
|
runStatus,
|
|
486
504
|
runSync,
|
|
@@ -491,6 +509,12 @@ const { runCli } = require("./prismo-dev/cli")({
|
|
|
491
509
|
runRepair,
|
|
492
510
|
renderPlannerTerminal,
|
|
493
511
|
runPlannerOnce,
|
|
512
|
+
decidePostToolUse,
|
|
513
|
+
decidePreToolUse,
|
|
514
|
+
renderEnforceTerminal,
|
|
515
|
+
runEnforceInstall,
|
|
516
|
+
runEnforceStatus,
|
|
517
|
+
runEnforceUninstall,
|
|
494
518
|
renderAgentTerminal,
|
|
495
519
|
runAgent,
|
|
496
520
|
renderConnectorTerminal,
|
|
@@ -596,6 +620,12 @@ module.exports = {
|
|
|
596
620
|
REPAIR_CAUSES,
|
|
597
621
|
runPlannerOnce,
|
|
598
622
|
renderPlannerTerminal,
|
|
623
|
+
decidePostToolUse,
|
|
624
|
+
decidePreToolUse,
|
|
625
|
+
renderEnforceTerminal,
|
|
626
|
+
runEnforceInstall,
|
|
627
|
+
runEnforceStatus,
|
|
628
|
+
runEnforceUninstall,
|
|
599
629
|
runConnectorInstall,
|
|
600
630
|
runConnectorStart,
|
|
601
631
|
runConnectorStatus,
|
package/package.json
CHANGED