getprismo 0.1.42 → 0.1.44
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 +92 -3
- package/docs/announcement.md +56 -0
- package/docs/privacy-telemetry.md +67 -0
- package/lib/prismo-dev/agent.js +262 -0
- package/lib/prismo-dev/cli.js +72 -4
- package/lib/prismo-dev/cloud-sync.js +42 -1
- package/lib/prismo-dev/connector.js +3 -2
- package/lib/prismo-dev/enforce.js +186 -27
- package/lib/prismo-dev/help.js +21 -1
- package/lib/prismo-dev-scan.js +4 -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 agent control plane for ai coding. it watches local coding agents, finds token waste, stages or executes safe interventions, 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
|
|
|
@@ -20,7 +20,7 @@ ai coding agents (claude code, codex, cursor) burn tokens on things that don't h
|
|
|
20
20
|
|
|
21
21
|
most developers don't realize this is happening until the bill arrives or the agent starts looping.
|
|
22
22
|
|
|
23
|
-
prismodev
|
|
23
|
+
prismodev gives you a control plane for it before, during, and after.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -31,14 +31,21 @@ 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
|
|
42
|
+
optional bridge npx getprismo bridge
|
|
39
43
|
```
|
|
40
44
|
|
|
41
45
|
**doctor** diagnoses the repo, applies safe fixes, and shows the before/after score.
|
|
46
|
+
**repair** runs the targeted fix for one waste cause; `repair auto` lets the planner pick.
|
|
47
|
+
**enforce** turns the context firewall into actual runtime enforcement via Claude Code hooks.
|
|
48
|
+
**digest** prints the launch report: verified saved tokens/dollars first, live prevention clearly labeled as estimated, ready to post or paste into Slack.
|
|
42
49
|
**guard** runs live guardrails, context throttle, rescue prompts, context firewall, and dashboard-ready prevention events.
|
|
43
50
|
**watch** monitors context pressure live and is the lower-level diagnostic view behind guard.
|
|
44
51
|
**receipt** explains what repeated, what output dominated, what artifacts leaked, what likely influenced the run, and a heuristic context-efficiency score.
|
|
@@ -46,6 +53,77 @@ agent-native npx getprismo mcp
|
|
|
46
53
|
**shield** runs noisy commands without dumping full output back into the agent context.
|
|
47
54
|
**agent** connects Prismo Cloud to your local repo so dashboard actions can safely run on this machine.
|
|
48
55
|
**mcp** exposes PrismoDev as local tools so compatible agents can scan, search shield output, and request scoped context directly.
|
|
56
|
+
**bridge** explains the optional tighter control layer for teams that want Prismo closer to the agent execution path.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## new: the self-driving loop
|
|
61
|
+
|
|
62
|
+
connect once and prismodev operates itself:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx getprismo connect --token <your prismo api key>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
from that point, on every machine running the connector:
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
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.
|
|
72
|
+
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.
|
|
73
|
+
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`.
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
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`.
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
run one planner cycle by hand to see it think:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx getprismo repair auto --dry-run
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## new: runtime enforcement
|
|
89
|
+
|
|
90
|
+
advisory guardrails only help if the agent reads them. for claude code, prismodev can enforce them:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npx getprismo enforce install
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
this wires a `PreToolUse` hook (with a backup of `.claude/settings.json`) that:
|
|
97
|
+
|
|
98
|
+
- **denies reads into blocked context** — `node_modules/`, build output, logs, lockfiles — with a reason pointing the agent at the compact `.prismo/` context packs instead
|
|
99
|
+
- **denies the fourth attempt of an identical command** in one session, suggesting one shielded run instead of an expensive retry loop
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
permissionDecision: deny
|
|
103
|
+
reason: Prismo context firewall: "logs/huge.log" is blocked context (rule: logs/**).
|
|
104
|
+
Use the .prismo/ context packs instead, or run `npx getprismo shield -- <command>`
|
|
105
|
+
if you need its contents summarized.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## new: optional bridge mode
|
|
113
|
+
|
|
114
|
+
the background connector is the default. it observes local sessions, syncs safe aggregate telemetry, applies queued repairs, verifies the next sessions, and shows live events in the dashboard. it does not sit in front of every agent action.
|
|
115
|
+
|
|
116
|
+
bridge mode is optional context for teams that want Prismo closer to the agent execution path, especially for live loop stopping:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx getprismo bridge
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- **Claude Code**: hard-block capable today through `npx getprismo enforce install`, which adds a `PreToolUse` hook that can deny blocked-context reads and repeated command loops before they run.
|
|
123
|
+
- **Codex**: visible and repairable through local session logs, guardrails, shield, and MCP. universal hard-blocking needs Codex to run through a wrapper/bridge or expose a pre-tool hook.
|
|
124
|
+
- **Cursor**: visible and repairable through local telemetry and staged repairs. universal hard-blocking needs Cursor to run through a wrapper/bridge or expose a pre-tool hook.
|
|
125
|
+
|
|
126
|
+
that is why Prismo is not described as a proxy by default. connector mode is safer and simpler; bridge mode is the opt-in path when stronger live interception matters more than staying fully out of the agent execution path.
|
|
49
127
|
|
|
50
128
|
---
|
|
51
129
|
|
|
@@ -762,6 +840,9 @@ no install needed. npx runs it directly.
|
|
|
762
840
|
| command | what it does |
|
|
763
841
|
|---------|-------------|
|
|
764
842
|
| `doctor` | diagnose, fix, optimize, show before/after |
|
|
843
|
+
| `repair <cause\|auto>` | targeted repair for one waste cause; auto = planner picks with cooldowns and verdict feedback |
|
|
844
|
+
| `enforce` | runtime enforcement of the context firewall via claude code hooks |
|
|
845
|
+
| `digest` | verified-savings summary for the week, in dollars, ready for slack |
|
|
765
846
|
| `watch` | live session monitoring with warnings |
|
|
766
847
|
| `cc` | claude code cost breakdown |
|
|
767
848
|
| `cc timeline` | session reconstruction with events |
|
|
@@ -785,6 +866,7 @@ no install needed. npx runs it directly.
|
|
|
785
866
|
| `shield` | run noisy commands while keeping full output out of chat |
|
|
786
867
|
| `agent` | claim and execute safe Prismo Cloud workspace actions locally |
|
|
787
868
|
| `mcp` | expose PrismoDev tools over local MCP stdio |
|
|
869
|
+
| `bridge` | explain optional bridge mode and live interception levels for Claude Code, Codex, and Cursor |
|
|
788
870
|
| `setup` | detect tools, logs, proxy readiness |
|
|
789
871
|
| `usage` | show raw session token usage |
|
|
790
872
|
| `init` | add npm scripts and .prismo/README.md |
|
|
@@ -1067,6 +1149,9 @@ lib/prismo-dev/instructions.js instruction ROI, partial-compliance, and ablati
|
|
|
1067
1149
|
lib/prismo-dev/mcp.js local MCP server and Prismo tool bindings
|
|
1068
1150
|
lib/prismo-dev/receipt.js run receipts for reads, output, artifacts, and next scope
|
|
1069
1151
|
lib/prismo-dev/report.js terminal, markdown, ci reports
|
|
1152
|
+
lib/prismo-dev/repair-executors.js cause-specific repair executors with mild/aggressive tiers
|
|
1153
|
+
lib/prismo-dev/repair-planner.js autonomous planner: cause scoring, cooldowns, local verdicts, escalation
|
|
1154
|
+
lib/prismo-dev/enforce.js claude code PreToolUse hook enforcement and settings wiring
|
|
1070
1155
|
lib/prismo-dev/replay.js incident replay and recovery prompts
|
|
1071
1156
|
lib/prismo-dev/scan.js repo scanning, scoring, readiness
|
|
1072
1157
|
lib/prismo-dev/scan-path-utils.js scan ignore/path helper logic
|
|
@@ -1090,6 +1175,9 @@ lib/prismo-dev/watch-render.js watch terminal and guardrail renderers
|
|
|
1090
1175
|
npx getprismo --help
|
|
1091
1176
|
npx getprismo --version
|
|
1092
1177
|
npx getprismo doctor --help
|
|
1178
|
+
npx getprismo repair --help
|
|
1179
|
+
npx getprismo enforce --help
|
|
1180
|
+
npx getprismo bridge --help
|
|
1093
1181
|
npx getprismo watch --help
|
|
1094
1182
|
npx getprismo shield --help
|
|
1095
1183
|
npx getprismo mcp --help
|
|
@@ -1108,3 +1196,4 @@ More docs:
|
|
|
1108
1196
|
|
|
1109
1197
|
- [MCP setup and tools](docs/mcp.md)
|
|
1110
1198
|
- [Live demo flow](docs/live-demo.md)
|
|
1199
|
+
- [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
|
@@ -15,6 +15,7 @@ module.exports = function createAgent(deps) {
|
|
|
15
15
|
openUrl,
|
|
16
16
|
repairExecutors,
|
|
17
17
|
repairPlanner,
|
|
18
|
+
getUsageSummary,
|
|
18
19
|
} = deps;
|
|
19
20
|
|
|
20
21
|
const DEFAULT_WORKSPACE_URL = "https://getprismo.dev/dashboard/dev";
|
|
@@ -117,6 +118,29 @@ module.exports = function createAgent(deps) {
|
|
|
117
118
|
return rootDir;
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
function repoPayload(rootDir) {
|
|
122
|
+
return { pathBasename: path.basename(path.resolve(rootDir || process.cwd())) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function safeReadJson(filePath) {
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.existsSync(filePath)) return null;
|
|
128
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function enforceStatePath(rootDir) {
|
|
135
|
+
return path.join(path.resolve(rootDir || process.cwd()), ".prismo", "enforce-state.json");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function summarizeCommand(command) {
|
|
139
|
+
const value = String(command || "").replace(/\s+/g, " ").trim();
|
|
140
|
+
if (!value) return "a repeated command";
|
|
141
|
+
return value.length > 90 ? `${value.slice(0, 87)}...` : value;
|
|
142
|
+
}
|
|
143
|
+
|
|
120
144
|
async function updateAction(config, actionId, payload, options = {}) {
|
|
121
145
|
const endpoint = options.endpoint || `${apiBase(config)}/v1/dev/workspace/actions/${actionId}`;
|
|
122
146
|
const response = await requestJson("PATCH", endpoint, config.token, payload, options.timeoutMs || 15000);
|
|
@@ -142,6 +166,103 @@ module.exports = function createAgent(deps) {
|
|
|
142
166
|
return response.data;
|
|
143
167
|
}
|
|
144
168
|
|
|
169
|
+
async function sendLiveEvent(config, event, options = {}) {
|
|
170
|
+
const endpoint = options.liveEventEndpoint || `${apiBase(config)}/v1/dev/workspace/live-events`;
|
|
171
|
+
try {
|
|
172
|
+
const body = {
|
|
173
|
+
phase: "watching",
|
|
174
|
+
eventType: "status",
|
|
175
|
+
severity: "info",
|
|
176
|
+
occurredAt: new Date().toISOString(),
|
|
177
|
+
...event,
|
|
178
|
+
};
|
|
179
|
+
await requestJson("POST", endpoint, config.token, body, options.timeoutMs || 5000);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (_) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function publishClaudeLoopStops(config, rootDir, repo, options = {}) {
|
|
187
|
+
const state = safeReadJson(enforceStatePath(rootDir));
|
|
188
|
+
const stops = Array.isArray(state?.loopStops) ? state.loopStops : [];
|
|
189
|
+
if (!stops.length) return 0;
|
|
190
|
+
let sent = 0;
|
|
191
|
+
for (const stop of stops.slice(0, 10)) {
|
|
192
|
+
const ok = await sendLiveEvent(config, {
|
|
193
|
+
eventId: stop.eventId || `claude-loop-stop-${stop.at || Date.now()}`,
|
|
194
|
+
phase: "stopped",
|
|
195
|
+
eventType: "loop_stopped",
|
|
196
|
+
severity: "success",
|
|
197
|
+
headline: "Stopped a Claude Code retry loop",
|
|
198
|
+
detail: `${summarizeCommand(stop.command)} was blocked after ${stop.failures || stop.attempts || 3} repeated ${stop.failures ? "failure" : "attempt"}${(stop.failures || stop.attempts || 3) === 1 ? "" : "s"}.`,
|
|
199
|
+
repo,
|
|
200
|
+
targetCause: "context-loop",
|
|
201
|
+
tokensPrevented: Number(stop.estimatedTokensSaved || 0),
|
|
202
|
+
occurredAt: stop.at || new Date().toISOString(),
|
|
203
|
+
payload: {
|
|
204
|
+
tool: "claude-code",
|
|
205
|
+
reason: stop.reason || "repeated-command",
|
|
206
|
+
sessionId: stop.sessionId || null,
|
|
207
|
+
rawPrompts: false,
|
|
208
|
+
rawCode: false,
|
|
209
|
+
rawStdout: false,
|
|
210
|
+
rawStderr: false,
|
|
211
|
+
},
|
|
212
|
+
}, options);
|
|
213
|
+
if (ok) sent += 1;
|
|
214
|
+
}
|
|
215
|
+
return sent;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function publishAgentLoopSignals(config, rootDir, repo, options = {}) {
|
|
219
|
+
if (!getUsageSummary) return 0;
|
|
220
|
+
let summary = null;
|
|
221
|
+
try {
|
|
222
|
+
summary = getUsageSummary({ cwd: rootDir, limit: options.loopSignalLimit || 8, tool: "all" });
|
|
223
|
+
} catch {
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
const sessions = Array.isArray(summary?.sessions) ? summary.sessions : [];
|
|
227
|
+
let sent = 0;
|
|
228
|
+
for (const session of sessions) {
|
|
229
|
+
const tool = String(session.tool || "unknown").toLowerCase();
|
|
230
|
+
if (!tool.includes("codex") && !tool.includes("cursor")) continue;
|
|
231
|
+
const repeated = Array.isArray(session.repeatedCommands)
|
|
232
|
+
? session.repeatedCommands.filter((item) => Number(item.count || 0) >= 3)
|
|
233
|
+
: [];
|
|
234
|
+
if (!session.loopSuspicion && repeated.length === 0) continue;
|
|
235
|
+
const command = repeated[0]?.value || null;
|
|
236
|
+
const count = Number(repeated[0]?.count || 0);
|
|
237
|
+
const eventTool = tool.includes("codex") ? "codex" : "cursor";
|
|
238
|
+
const sessionId = String(session.sessionId || session.updatedAt || eventTool);
|
|
239
|
+
const ok = await sendLiveEvent(config, {
|
|
240
|
+
eventId: `${eventTool}-loop-detected-${sessionId.replace(/[^a-z0-9_-]/gi, "").slice(0, 48)}-${count || "suspicion"}`,
|
|
241
|
+
phase: "detected",
|
|
242
|
+
eventType: "loop_detected",
|
|
243
|
+
severity: "warning",
|
|
244
|
+
headline: `${eventTool === "codex" ? "Codex" : "Cursor"} loop pattern detected`,
|
|
245
|
+
detail: command
|
|
246
|
+
? `${summarizeCommand(command)} appeared ${count} times. Prismo can stage guard or shield repairs, but this integration cannot hard-block that agent yet.`
|
|
247
|
+
: "Repeated tool activity suggests a loop. Prismo can stage guard repairs, but this integration cannot hard-block that agent yet.",
|
|
248
|
+
repo,
|
|
249
|
+
targetCause: "context-loop",
|
|
250
|
+
occurredAt: session.updatedAt || new Date().toISOString(),
|
|
251
|
+
payload: {
|
|
252
|
+
tool: eventTool,
|
|
253
|
+
repeatedCommandCount: count,
|
|
254
|
+
loopSuspicion: Boolean(session.loopSuspicion),
|
|
255
|
+
rawPrompts: false,
|
|
256
|
+
rawCode: false,
|
|
257
|
+
rawStdout: false,
|
|
258
|
+
rawStderr: false,
|
|
259
|
+
},
|
|
260
|
+
}, options);
|
|
261
|
+
if (ok) sent += 1;
|
|
262
|
+
}
|
|
263
|
+
return sent;
|
|
264
|
+
}
|
|
265
|
+
|
|
145
266
|
function runAutoDetect(rootDir, options = {}) {
|
|
146
267
|
const mode = options.mode || "autopilot";
|
|
147
268
|
const startedAt = new Date().toISOString();
|
|
@@ -417,23 +538,72 @@ module.exports = function createAgent(deps) {
|
|
|
417
538
|
|
|
418
539
|
const mode = options.mode || "autopilot";
|
|
419
540
|
const pollTime = new Date().toISOString();
|
|
541
|
+
const repo = repoPayload(rootDir);
|
|
420
542
|
|
|
421
543
|
try {
|
|
422
544
|
await sendHeartbeat(config, { mode, status: "online", lastPollAt: pollTime }, options);
|
|
423
545
|
} catch (_) {}
|
|
546
|
+
await sendLiveEvent(config, {
|
|
547
|
+
eventId: `heartbeat-${repo.pathBasename}-${pollTime.slice(0, 16)}`,
|
|
548
|
+
phase: "watching",
|
|
549
|
+
eventType: "heartbeat",
|
|
550
|
+
headline: "Connector is watching this repo",
|
|
551
|
+
detail: `Mode: ${mode}. Polling for safe repairs and syncing telemetry.`,
|
|
552
|
+
repo,
|
|
553
|
+
}, options);
|
|
554
|
+
await publishClaudeLoopStops(config, rootDir, repo, options);
|
|
555
|
+
await publishAgentLoopSignals(config, rootDir, repo, options);
|
|
424
556
|
|
|
425
557
|
let autoDetectResult = null;
|
|
426
558
|
if (options.autoDetect) {
|
|
427
559
|
autoDetectResult = runAutoDetect(rootDir, { mode });
|
|
428
560
|
await reportAutoDetect(config, autoDetectResult, options);
|
|
561
|
+
await sendLiveEvent(config, {
|
|
562
|
+
eventId: `auto-detect-${repo.pathBasename}-${autoDetectResult.completedAt || pollTime}`,
|
|
563
|
+
phase: autoDetectResult.applied ? "fixed" : "detected",
|
|
564
|
+
eventType: "auto_detect",
|
|
565
|
+
severity: autoDetectResult.findings.length ? "warning" : "info",
|
|
566
|
+
headline: autoDetectResult.applied ? "Auto-detect applied safe context fixes" : "Auto-detect scanned the repo",
|
|
567
|
+
detail: `Score: ${autoDetectResult.score ?? "unknown"}/100. Findings: ${autoDetectResult.findings.length}. Generated ${autoDetectResult.generatedFiles.length} file(s).`,
|
|
568
|
+
repo,
|
|
569
|
+
payload: {
|
|
570
|
+
score: autoDetectResult.score,
|
|
571
|
+
findings: autoDetectResult.findings.length,
|
|
572
|
+
generatedFiles: autoDetectResult.generatedFiles,
|
|
573
|
+
rawPrompts: false,
|
|
574
|
+
rawCode: false,
|
|
575
|
+
},
|
|
576
|
+
}, options);
|
|
429
577
|
}
|
|
430
578
|
|
|
431
579
|
const actions = await claimActions(config, options);
|
|
580
|
+
if (actions.length > 0) {
|
|
581
|
+
await sendLiveEvent(config, {
|
|
582
|
+
eventId: `actions-claimed-${repo.pathBasename}-${pollTime}`,
|
|
583
|
+
phase: "detected",
|
|
584
|
+
eventType: "action_claimed",
|
|
585
|
+
severity: "info",
|
|
586
|
+
headline: `Claimed ${actions.length} repair action${actions.length === 1 ? "" : "s"}`,
|
|
587
|
+
detail: "The local connector is about to apply queued repairs from the workspace.",
|
|
588
|
+
repo,
|
|
589
|
+
}, options);
|
|
590
|
+
}
|
|
432
591
|
const results = [];
|
|
433
592
|
for (const action of actions) {
|
|
434
593
|
if (TERMINAL_STATUSES.has(action.status)) continue;
|
|
435
594
|
|
|
436
595
|
if (mode === "observe") {
|
|
596
|
+
await sendLiveEvent(config, {
|
|
597
|
+
eventId: `action-observed-${action.id}`,
|
|
598
|
+
phase: "detected",
|
|
599
|
+
eventType: "action_observed",
|
|
600
|
+
headline: action.label || "Repair observed",
|
|
601
|
+
detail: "Observe mode is on, so Prismo did not execute this repair.",
|
|
602
|
+
repo,
|
|
603
|
+
actionId: action.id,
|
|
604
|
+
actionType: action.actionType,
|
|
605
|
+
targetCause: action.targetCause,
|
|
606
|
+
}, options);
|
|
437
607
|
results.push({ id: action.id, label: action.label, status: "observed", statusMessage: "Agent is in observe mode. Action not executed." });
|
|
438
608
|
continue;
|
|
439
609
|
}
|
|
@@ -443,6 +613,17 @@ module.exports = function createAgent(deps) {
|
|
|
443
613
|
status: "pending_approval",
|
|
444
614
|
statusMessage: "Agent recommends this action. Waiting for approval in workspace.",
|
|
445
615
|
}, options);
|
|
616
|
+
await sendLiveEvent(config, {
|
|
617
|
+
eventId: `action-suggested-${action.id}`,
|
|
618
|
+
phase: "detected",
|
|
619
|
+
eventType: "action_suggested",
|
|
620
|
+
headline: action.label || "Repair needs approval",
|
|
621
|
+
detail: "Suggest mode is on, so Prismo is waiting for dashboard approval.",
|
|
622
|
+
repo,
|
|
623
|
+
actionId: action.id,
|
|
624
|
+
actionType: action.actionType,
|
|
625
|
+
targetCause: action.targetCause,
|
|
626
|
+
}, options);
|
|
446
627
|
results.push({ id: action.id, label: action.label, status: "pending_approval", statusMessage: "Suggested; awaiting approval." });
|
|
447
628
|
continue;
|
|
448
629
|
}
|
|
@@ -451,8 +632,39 @@ module.exports = function createAgent(deps) {
|
|
|
451
632
|
status: "running",
|
|
452
633
|
statusMessage: "Running locally through PrismoDev agent.",
|
|
453
634
|
}, options);
|
|
635
|
+
await sendLiveEvent(config, {
|
|
636
|
+
eventId: `action-running-${action.id}`,
|
|
637
|
+
phase: "detected",
|
|
638
|
+
eventType: "action_running",
|
|
639
|
+
headline: action.label || "Repair is running locally",
|
|
640
|
+
detail: "The connector is applying this repair in your repo now.",
|
|
641
|
+
repo,
|
|
642
|
+
actionId: action.id,
|
|
643
|
+
actionType: action.actionType,
|
|
644
|
+
targetCause: action.targetCause,
|
|
645
|
+
}, options);
|
|
454
646
|
const result = await executeAction(action, rootDir, { ...options, _config: config });
|
|
455
647
|
await updateAction(config, action.id, result, options);
|
|
648
|
+
await sendLiveEvent(config, {
|
|
649
|
+
eventId: `action-${result.status}-${action.id}`,
|
|
650
|
+
phase: result.status === "completed" ? "fixed" : "detected",
|
|
651
|
+
eventType: "action_completed",
|
|
652
|
+
severity: result.status === "completed" ? "success" : "warning",
|
|
653
|
+
headline: result.status === "completed" ? `${action.label || "Repair"} applied` : `${action.label || "Repair"} did not complete`,
|
|
654
|
+
detail: result.statusMessage || null,
|
|
655
|
+
repo,
|
|
656
|
+
actionId: action.id,
|
|
657
|
+
actionType: action.actionType,
|
|
658
|
+
targetCause: action.targetCause,
|
|
659
|
+
payload: {
|
|
660
|
+
status: result.status,
|
|
661
|
+
result: result.result || null,
|
|
662
|
+
rawPrompts: false,
|
|
663
|
+
rawCode: false,
|
|
664
|
+
rawStdout: false,
|
|
665
|
+
rawStderr: false,
|
|
666
|
+
},
|
|
667
|
+
}, options);
|
|
456
668
|
results.push({ id: action.id, label: action.label, ...result });
|
|
457
669
|
}
|
|
458
670
|
|
|
@@ -478,6 +690,27 @@ module.exports = function createAgent(deps) {
|
|
|
478
690
|
: false;
|
|
479
691
|
plannerResult.registered = registered;
|
|
480
692
|
if (!registered) await reportPlanner(config, plannerResult, mode, options);
|
|
693
|
+
await sendLiveEvent(config, {
|
|
694
|
+
eventId: plannerResult.decision
|
|
695
|
+
? `self-repair-${plannerResult.decision.cause}-${plannerResult.generatedAt || pollTime}`
|
|
696
|
+
: `self-repair-none-${repo.pathBasename}-${pollTime.slice(0, 16)}`,
|
|
697
|
+
phase: plannerResult.executed ? "fixed" : "watching",
|
|
698
|
+
eventType: "self_repair",
|
|
699
|
+
severity: plannerResult.decision ? "info" : "info",
|
|
700
|
+
headline: plannerResult.decision ? `Self-repair checked ${plannerResult.decision.cause}` : "Self-repair found nothing to run",
|
|
701
|
+
detail: plannerResult.decision
|
|
702
|
+
? (plannerResult.outcome?.statusMessage || plannerResult.decision.reason)
|
|
703
|
+
: "Current sessions do not need another automated repair.",
|
|
704
|
+
repo,
|
|
705
|
+
targetCause: plannerResult.decision?.cause || null,
|
|
706
|
+
payload: {
|
|
707
|
+
decision: plannerResult.decision || null,
|
|
708
|
+
executed: Boolean(plannerResult.executed),
|
|
709
|
+
registered: Boolean(plannerResult.registered),
|
|
710
|
+
rawPrompts: false,
|
|
711
|
+
rawCode: false,
|
|
712
|
+
},
|
|
713
|
+
}, options);
|
|
481
714
|
} catch (error) {
|
|
482
715
|
plannerResult = { error: error && error.message ? error.message : String(error) };
|
|
483
716
|
}
|
|
@@ -493,11 +726,39 @@ module.exports = function createAgent(deps) {
|
|
|
493
726
|
estimatedWastedTokens: Number(result.aggregate?.estimatedWastedTokens || 0),
|
|
494
727
|
wastePercent: Number(result.aggregate?.wastePercent || 0),
|
|
495
728
|
};
|
|
729
|
+
await sendLiveEvent(config, {
|
|
730
|
+
eventId: `sync-${repo.pathBasename}-${new Date().toISOString().slice(0, 16)}`,
|
|
731
|
+
phase: "watching",
|
|
732
|
+
eventType: "sync",
|
|
733
|
+
severity: result.synced ? "info" : "warning",
|
|
734
|
+
headline: result.synced ? "Telemetry synced" : "Telemetry sync did not complete",
|
|
735
|
+
detail: result.synced
|
|
736
|
+
? `${syncResult.sessions} session(s), ${syncResult.estimatedWastedTokens.toLocaleString()} likely wasted tokens.`
|
|
737
|
+
: (result.error || "Sync did not complete."),
|
|
738
|
+
repo,
|
|
739
|
+
tokensObserved: Number(result.aggregate?.displayTokens || result.aggregate?.contextTokens || result.aggregate?.exactTokens || 0),
|
|
740
|
+
payload: {
|
|
741
|
+
aggregate: result.aggregate || null,
|
|
742
|
+
rawPrompts: false,
|
|
743
|
+
rawCode: false,
|
|
744
|
+
rawStdout: false,
|
|
745
|
+
rawStderr: false,
|
|
746
|
+
},
|
|
747
|
+
}, options);
|
|
496
748
|
} catch (error) {
|
|
497
749
|
syncResult = {
|
|
498
750
|
synced: false,
|
|
499
751
|
error: error && error.message ? error.message : String(error),
|
|
500
752
|
};
|
|
753
|
+
await sendLiveEvent(config, {
|
|
754
|
+
eventId: `sync-failed-${repo.pathBasename}-${new Date().toISOString().slice(0, 16)}`,
|
|
755
|
+
phase: "detected",
|
|
756
|
+
eventType: "sync_failed",
|
|
757
|
+
severity: "warning",
|
|
758
|
+
headline: "Telemetry sync failed",
|
|
759
|
+
detail: syncResult.error,
|
|
760
|
+
repo,
|
|
761
|
+
}, options);
|
|
501
762
|
}
|
|
502
763
|
}
|
|
503
764
|
|
|
@@ -682,6 +943,7 @@ module.exports = function createAgent(deps) {
|
|
|
682
943
|
runAgentOnce,
|
|
683
944
|
runAutoDetect,
|
|
684
945
|
sendHeartbeat,
|
|
946
|
+
sendLiveEvent,
|
|
685
947
|
updateAction,
|
|
686
948
|
VALID_MODES,
|
|
687
949
|
};
|
package/lib/prismo-dev/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ const VALID_COMMANDS = new Set([
|
|
|
6
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
|
+
"enforce", "bridge", "hook",
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
12
|
function parseTokenBudget(value) {
|
|
@@ -76,6 +76,7 @@ function createCli(deps) {
|
|
|
76
76
|
runRepair,
|
|
77
77
|
renderPlannerTerminal,
|
|
78
78
|
runPlannerOnce,
|
|
79
|
+
decidePostToolUse,
|
|
79
80
|
decidePreToolUse,
|
|
80
81
|
renderEnforceTerminal,
|
|
81
82
|
runEnforceInstall,
|
|
@@ -150,7 +151,7 @@ function createCli(deps) {
|
|
|
150
151
|
return;
|
|
151
152
|
}
|
|
152
153
|
if (!VALID_COMMANDS.has(command)) {
|
|
153
|
-
throw new Error(`Unknown command: ${command}. Try: prismo connect, prismo connector, prismo agent, prismo guard, prismo sync, prismo doctor, prismo watch, prismo receipt, prismo benchmark, prismo shield, prismo mcp, prismo firewall, prismo init, prismo scan, prismo optimize, prismo context, prismo cc, prismo cursor, or prismo usage`);
|
|
154
|
+
throw new Error(`Unknown command: ${command}. Try: prismo connect, prismo connector, prismo bridge, prismo agent, prismo guard, prismo sync, prismo doctor, prismo watch, prismo receipt, prismo benchmark, prismo shield, prismo mcp, prismo firewall, prismo init, prismo scan, prismo optimize, prismo context, prismo cc, prismo cursor, or prismo usage`);
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
if (command === "demo") {
|
|
@@ -822,13 +823,16 @@ function createCli(deps) {
|
|
|
822
823
|
|
|
823
824
|
if (command === "hook") {
|
|
824
825
|
const subcommand = (rest[0] || "").toLowerCase();
|
|
825
|
-
if (subcommand !== "pretooluse") {
|
|
826
|
+
if (subcommand !== "pretooluse" && subcommand !== "posttooluse") {
|
|
826
827
|
printCommandHelp("enforce");
|
|
827
828
|
return;
|
|
828
829
|
}
|
|
829
830
|
const chunks = [];
|
|
830
831
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
831
|
-
const
|
|
832
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
833
|
+
const decision = subcommand === "pretooluse"
|
|
834
|
+
? decidePreToolUse(process.cwd(), raw)
|
|
835
|
+
: decidePostToolUse(process.cwd(), raw);
|
|
832
836
|
if (decision) console.log(JSON.stringify(decision));
|
|
833
837
|
return;
|
|
834
838
|
}
|
|
@@ -847,6 +851,70 @@ function createCli(deps) {
|
|
|
847
851
|
return;
|
|
848
852
|
}
|
|
849
853
|
|
|
854
|
+
if (command === "bridge") {
|
|
855
|
+
const json = rest.includes("--json");
|
|
856
|
+
const target = getPositionals(rest, new Set())[0] || process.cwd();
|
|
857
|
+
const result = {
|
|
858
|
+
schemaVersion: 1,
|
|
859
|
+
command: "bridge",
|
|
860
|
+
optional: true,
|
|
861
|
+
root: path.resolve(target),
|
|
862
|
+
why: "The connector observes, repairs, and verifies by default. Bridge mode is optional when you want Prismo closer to the agent execution path so loops and blocked context can be stopped earlier.",
|
|
863
|
+
defaultMode: {
|
|
864
|
+
name: "connector",
|
|
865
|
+
command: `${NPX_COMMAND} connector install`,
|
|
866
|
+
behavior: "syncs telemetry, applies safe repairs, and shows live events without sitting in front of every agent action",
|
|
867
|
+
},
|
|
868
|
+
agents: [
|
|
869
|
+
{
|
|
870
|
+
tool: "Claude Code",
|
|
871
|
+
level: "hard-block",
|
|
872
|
+
command: `${NPX_COMMAND} enforce install`,
|
|
873
|
+
behavior: "uses Claude hooks to deny blocked-context reads and repeated failing command loops before they run",
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
tool: "Codex",
|
|
877
|
+
level: "detect-and-repair",
|
|
878
|
+
command: `${NPX_COMMAND} mcp`,
|
|
879
|
+
behavior: "Prismo can detect loops from local sessions and expose tools via MCP/shield; universal hard-blocking needs a Codex pre-tool hook or wrapper",
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
tool: "Cursor",
|
|
883
|
+
level: "detect-and-repair",
|
|
884
|
+
command: `${NPX_COMMAND} mcp`,
|
|
885
|
+
behavior: "Prismo can detect loop patterns from Cursor telemetry and stage repairs; universal hard-blocking needs a Cursor pre-tool hook or wrapper",
|
|
886
|
+
},
|
|
887
|
+
],
|
|
888
|
+
privacy: {
|
|
889
|
+
rawPrompts: false,
|
|
890
|
+
rawCode: false,
|
|
891
|
+
rawStdout: false,
|
|
892
|
+
rawStderr: false,
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
896
|
+
else {
|
|
897
|
+
console.log("");
|
|
898
|
+
console.log("PrismoDev Bridge");
|
|
899
|
+
console.log("");
|
|
900
|
+
console.log("Optional control layer for teams that want stronger live interception.");
|
|
901
|
+
console.log("");
|
|
902
|
+
console.log(`Default connector: ${result.defaultMode.command}`);
|
|
903
|
+
console.log(` ${result.defaultMode.behavior}`);
|
|
904
|
+
console.log("");
|
|
905
|
+
console.log("Agent control levels");
|
|
906
|
+
result.agents.forEach((agent) => {
|
|
907
|
+
console.log(`- ${agent.tool}: ${agent.level}`);
|
|
908
|
+
console.log(` ${agent.behavior}`);
|
|
909
|
+
console.log(` Start with: ${agent.command}`);
|
|
910
|
+
});
|
|
911
|
+
console.log("");
|
|
912
|
+
console.log("Why this exists");
|
|
913
|
+
console.log(` ${result.why}`);
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
850
918
|
if (command === "usage" || command === "watch") {
|
|
851
919
|
const json = rest.includes("--json");
|
|
852
920
|
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
|
|
|
@@ -386,6 +408,18 @@ module.exports = function createCloudSync(deps) {
|
|
|
386
408
|
const base = String(config.apiUrl || DEFAULT_API_URL).replace(/\/$/, "");
|
|
387
409
|
const days = Math.max(1, Number(options.days || 7));
|
|
388
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 {}
|
|
389
423
|
try {
|
|
390
424
|
const response = await requestJson("GET", endpoint, config.token, null, options.timeoutMs || 10000);
|
|
391
425
|
return {
|
|
@@ -394,6 +428,7 @@ module.exports = function createCloudSync(deps) {
|
|
|
394
428
|
connected: true,
|
|
395
429
|
apiUrl: base,
|
|
396
430
|
digest: response.data,
|
|
431
|
+
localEnforcement,
|
|
397
432
|
};
|
|
398
433
|
} catch (error) {
|
|
399
434
|
return {
|
|
@@ -423,7 +458,13 @@ module.exports = function createCloudSync(deps) {
|
|
|
423
458
|
lines.push(`Could not load digest${result.error ? `: ${result.error}` : "."}`);
|
|
424
459
|
return lines.join("\n");
|
|
425
460
|
}
|
|
426
|
-
|
|
461
|
+
const reportLines = result.digest.launchReportLines && result.digest.launchReportLines.length
|
|
462
|
+
? result.digest.launchReportLines
|
|
463
|
+
: (result.digest.lines || [result.digest.headline]);
|
|
464
|
+
reportLines.forEach((line) => lines.push(line));
|
|
465
|
+
if (result.localEnforcement) {
|
|
466
|
+
lines.push(`Local enforcement: ${result.localEnforcement.denials} denial(s), ~${result.localEnforcement.estimatedTokensSaved.toLocaleString()} tokens kept out of context on this machine.`);
|
|
467
|
+
}
|
|
427
468
|
return lines.join("\n");
|
|
428
469
|
}
|
|
429
470
|
|
|
@@ -8,9 +8,9 @@ module.exports = function createConnector(deps) {
|
|
|
8
8
|
} = deps;
|
|
9
9
|
|
|
10
10
|
const LABEL = "dev.getprismo.connector";
|
|
11
|
-
const BACKGROUND_COMMAND = String(NPX_COMMAND || "").includes(" -y ")
|
|
11
|
+
const BACKGROUND_COMMAND = process.env.PRISMO_CONNECTOR_COMMAND || (String(NPX_COMMAND || "").includes(" -y ")
|
|
12
12
|
? NPX_COMMAND
|
|
13
|
-
: "npx -y getprismo@latest";
|
|
13
|
+
: "npx -y getprismo@latest");
|
|
14
14
|
|
|
15
15
|
function prismoHome() {
|
|
16
16
|
return process.env.PRISMO_HOME || path.join(os.homedir(), ".prismo");
|
|
@@ -87,6 +87,7 @@ module.exports = function createConnector(deps) {
|
|
|
87
87
|
const contents = [
|
|
88
88
|
"#!/bin/sh",
|
|
89
89
|
"set -eu",
|
|
90
|
+
"export PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH\"",
|
|
90
91
|
`cd ${shellEscape(root)}`,
|
|
91
92
|
`exec ${command}`,
|
|
92
93
|
"",
|
|
@@ -7,9 +7,15 @@ module.exports = function createEnforce(deps) {
|
|
|
7
7
|
} = deps;
|
|
8
8
|
|
|
9
9
|
const HOOK_COMMAND = `${NPX_COMMAND} hook pretooluse`;
|
|
10
|
+
const POST_HOOK_COMMAND = `${NPX_COMMAND} hook posttooluse`;
|
|
10
11
|
const FILE_TOOLS = new Set(["Read", "Glob", "Grep", "NotebookRead"]);
|
|
11
12
|
const MAX_IDENTICAL_COMMANDS = 3;
|
|
13
|
+
const MAX_COMMAND_FAILURES = 3;
|
|
12
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;
|
|
13
19
|
|
|
14
20
|
function blockedContextPath(root) {
|
|
15
21
|
return path.join(root, ".prismo", "blocked-context.txt");
|
|
@@ -49,6 +55,72 @@ module.exports = function createEnforce(deps) {
|
|
|
49
55
|
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
50
56
|
}
|
|
51
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 recordLoopStop(root, state, payload) {
|
|
95
|
+
const loopStops = Array.isArray(state.loopStops) ? state.loopStops : [];
|
|
96
|
+
const at = new Date().toISOString();
|
|
97
|
+
const command = String(payload.command || "").slice(0, 240);
|
|
98
|
+
const reason = payload.reason || "repeated-command";
|
|
99
|
+
const sessionId = payload.sessionId || "unknown";
|
|
100
|
+
const eventId = `claude-loop-stop-${sessionId}-${Buffer.from(`${reason}:${command}`).toString("base64").replace(/[^a-z0-9]/gi, "").slice(0, 24)}-${at.slice(0, 16)}`;
|
|
101
|
+
state.loopStops = [{
|
|
102
|
+
eventId,
|
|
103
|
+
at,
|
|
104
|
+
tool: "claude-code",
|
|
105
|
+
command,
|
|
106
|
+
reason,
|
|
107
|
+
failures: payload.failures || 0,
|
|
108
|
+
attempts: payload.attempts || 0,
|
|
109
|
+
estimatedTokensSaved: LOOP_DENY_TOKEN_ESTIMATE,
|
|
110
|
+
sessionId,
|
|
111
|
+
}, ...loopStops].slice(0, DENIAL_LOG_LIMIT);
|
|
112
|
+
writeState(root, state);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function estimateBlockedFileTokens(root, target) {
|
|
116
|
+
try {
|
|
117
|
+
const fullPath = path.isAbsolute(target) ? target : path.join(root, target);
|
|
118
|
+
const stat = fs.statSync(fullPath);
|
|
119
|
+
if (stat.isFile()) return Math.min(200000, Math.round(stat.size / 4));
|
|
120
|
+
} catch {}
|
|
121
|
+
return 1500;
|
|
122
|
+
}
|
|
123
|
+
|
|
52
124
|
function relativePath(root, filePath) {
|
|
53
125
|
const value = String(filePath || "");
|
|
54
126
|
const resolvedRoot = path.resolve(root);
|
|
@@ -107,6 +179,7 @@ module.exports = function createEnforce(deps) {
|
|
|
107
179
|
const patterns = readBlockedPatterns(root);
|
|
108
180
|
const hit = patterns.find((pattern) => matchesBlocked(relPath, pattern));
|
|
109
181
|
if (hit) {
|
|
182
|
+
recordDenial(root, readState(root), "blocked-context", relPath, estimateBlockedFileTokens(root, target));
|
|
110
183
|
return deny(
|
|
111
184
|
`Prismo context firewall: "${relPath}" is blocked context (rule: ${hit}). `
|
|
112
185
|
+ "It is generated output that wastes agent tokens. Use the .prismo/ context packs instead, "
|
|
@@ -121,22 +194,37 @@ module.exports = function createEnforce(deps) {
|
|
|
121
194
|
if (!command) return null;
|
|
122
195
|
const sessionId = String(event.session_id || "unknown");
|
|
123
196
|
const state = readState(root);
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
197
|
+
const session = sessionRecord(state, sessionId);
|
|
198
|
+
const record = commandRecord(session, command);
|
|
199
|
+
|
|
200
|
+
// Outcome-aware loop breaking: a command that ever succeeded in
|
|
201
|
+
// this session is legitimate to repeat (test loops while iterating).
|
|
202
|
+
// With outcome data, deny only after repeated failures; without it
|
|
203
|
+
// (PostToolUse hook absent), fall back to attempt counting.
|
|
204
|
+
const deniedByFailures = !record.succeeded && record.outcomes > 0 && record.failures >= MAX_COMMAND_FAILURES;
|
|
205
|
+
const deniedByAttempts = record.outcomes === 0 && record.attempts >= MAX_IDENTICAL_COMMANDS;
|
|
206
|
+
if (deniedByFailures || deniedByAttempts) {
|
|
207
|
+
recordDenial(root, state, "loop", command, LOOP_DENY_TOKEN_ESTIMATE);
|
|
208
|
+
recordLoopStop(root, state, {
|
|
209
|
+
command,
|
|
210
|
+
sessionId,
|
|
211
|
+
reason: deniedByFailures ? "repeated-failing-command" : "repeated-identical-command",
|
|
212
|
+
failures: record.failures,
|
|
213
|
+
attempts: record.attempts,
|
|
214
|
+
});
|
|
215
|
+
const observation = deniedByFailures
|
|
216
|
+
? `this exact command has already failed ${record.failures} times in this session`
|
|
217
|
+
: `this exact command has already run ${record.attempts} times in this session`;
|
|
128
218
|
return deny(
|
|
129
|
-
`Prismo loop breaker:
|
|
219
|
+
`Prismo loop breaker: ${observation}. `
|
|
130
220
|
+ "Repeating it again will not change the outcome and floods context. Change the approach, "
|
|
131
221
|
+ `or capture its output once with \`${NPX_COMMAND} shield -- ${command}\`.`
|
|
132
222
|
);
|
|
133
223
|
}
|
|
134
|
-
|
|
224
|
+
record.attempts += 1;
|
|
225
|
+
session.commands[command] = record;
|
|
135
226
|
session.updatedAt = new Date().toISOString();
|
|
136
|
-
|
|
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]]));
|
|
227
|
+
pruneSessions(state);
|
|
140
228
|
writeState(root, state);
|
|
141
229
|
return null;
|
|
142
230
|
}
|
|
@@ -146,6 +234,48 @@ module.exports = function createEnforce(deps) {
|
|
|
146
234
|
return null;
|
|
147
235
|
}
|
|
148
236
|
|
|
237
|
+
// PostToolUse: record whether the Bash command actually failed, so the
|
|
238
|
+
// loop breaker can tell a failing retry loop from a legitimate test loop.
|
|
239
|
+
// Output shape varies by Claude Code version; unknown shapes record
|
|
240
|
+
// nothing rather than guessing.
|
|
241
|
+
function decidePostToolUse(rootDir, rawEvent) {
|
|
242
|
+
let event;
|
|
243
|
+
try {
|
|
244
|
+
event = typeof rawEvent === "string" ? JSON.parse(rawEvent) : rawEvent;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (!event || typeof event !== "object" || String(event.tool_name || "") !== "Bash") return null;
|
|
249
|
+
const toolInput = event.tool_input && typeof event.tool_input === "object" ? event.tool_input : {};
|
|
250
|
+
const command = String(toolInput.command || "").trim().replace(/\s+/g, " ");
|
|
251
|
+
if (!command) return null;
|
|
252
|
+
|
|
253
|
+
const response = event.tool_response;
|
|
254
|
+
let failed = null;
|
|
255
|
+
if (response && typeof response === "object") {
|
|
256
|
+
if (typeof response.exit_code === "number") failed = response.exit_code !== 0;
|
|
257
|
+
else if (typeof response.exitCode === "number") failed = response.exitCode !== 0;
|
|
258
|
+
else if (typeof response.is_error === "boolean") failed = response.is_error;
|
|
259
|
+
else if (response.interrupted === true) failed = true;
|
|
260
|
+
}
|
|
261
|
+
if (failed === null) return null;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const root = path.resolve(event.cwd || rootDir || process.cwd());
|
|
265
|
+
const state = readState(root);
|
|
266
|
+
const session = sessionRecord(state, String(event.session_id || "unknown"));
|
|
267
|
+
const record = commandRecord(session, command);
|
|
268
|
+
record.outcomes += 1;
|
|
269
|
+
if (failed) record.failures += 1;
|
|
270
|
+
else record.succeeded = true;
|
|
271
|
+
session.commands[command] = record;
|
|
272
|
+
session.updatedAt = new Date().toISOString();
|
|
273
|
+
pruneSessions(state);
|
|
274
|
+
writeState(root, state);
|
|
275
|
+
} catch {}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
149
279
|
function readSettings(root) {
|
|
150
280
|
try {
|
|
151
281
|
const parsed = JSON.parse(fs.readFileSync(settingsPath(root), "utf8"));
|
|
@@ -157,7 +287,8 @@ module.exports = function createEnforce(deps) {
|
|
|
157
287
|
|
|
158
288
|
function isPrismoHookEntry(entry) {
|
|
159
289
|
try {
|
|
160
|
-
|
|
290
|
+
const text = JSON.stringify(entry);
|
|
291
|
+
return text.includes("hook pretooluse") || text.includes("hook posttooluse");
|
|
161
292
|
} catch {
|
|
162
293
|
return false;
|
|
163
294
|
}
|
|
@@ -181,23 +312,33 @@ module.exports = function createEnforce(deps) {
|
|
|
181
312
|
const filePath = settingsPath(root);
|
|
182
313
|
const settings = readSettings(root);
|
|
183
314
|
settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
315
|
+
const preEntries = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
316
|
+
const postEntries = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
|
|
317
|
+
if (preEntries.some(isPrismoHookEntry) && postEntries.some(isPrismoHookEntry)) {
|
|
318
|
+
actions.push("Prismo hooks already installed in .claude/settings.json");
|
|
187
319
|
} else {
|
|
188
320
|
const existed = fs.existsSync(filePath);
|
|
189
321
|
if (existed) {
|
|
190
322
|
fs.copyFileSync(filePath, `${filePath}.prismo-backup`);
|
|
191
323
|
actions.push("Backed up .claude/settings.json to settings.json.prismo-backup");
|
|
192
324
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
325
|
+
if (!preEntries.some(isPrismoHookEntry)) {
|
|
326
|
+
preEntries.push({
|
|
327
|
+
matcher: "Read|Glob|Grep|NotebookRead|Bash",
|
|
328
|
+
hooks: [{ type: "command", command: HOOK_COMMAND }],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (!postEntries.some(isPrismoHookEntry)) {
|
|
332
|
+
postEntries.push({
|
|
333
|
+
matcher: "Bash",
|
|
334
|
+
hooks: [{ type: "command", command: POST_HOOK_COMMAND }],
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
settings.hooks.PreToolUse = preEntries;
|
|
338
|
+
settings.hooks.PostToolUse = postEntries;
|
|
198
339
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
199
340
|
fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
200
|
-
actions.push(`${existed ? "Updated" : "Created"} .claude/settings.json with the Prismo PreToolUse
|
|
341
|
+
actions.push(`${existed ? "Updated" : "Created"} .claude/settings.json with the Prismo PreToolUse + PostToolUse hooks`);
|
|
201
342
|
}
|
|
202
343
|
|
|
203
344
|
return {
|
|
@@ -216,15 +357,21 @@ module.exports = function createEnforce(deps) {
|
|
|
216
357
|
const filePath = settingsPath(root);
|
|
217
358
|
const settings = readSettings(root);
|
|
218
359
|
const actions = [];
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
360
|
+
let removed = false;
|
|
361
|
+
for (const eventName of ["PreToolUse", "PostToolUse"]) {
|
|
362
|
+
const entries = settings.hooks && Array.isArray(settings.hooks[eventName]) ? settings.hooks[eventName] : [];
|
|
363
|
+
const kept = entries.filter((entry) => !isPrismoHookEntry(entry));
|
|
364
|
+
if (kept.length !== entries.length) {
|
|
365
|
+
removed = true;
|
|
366
|
+
if (kept.length) settings.hooks[eventName] = kept;
|
|
367
|
+
else if (settings.hooks) delete settings.hooks[eventName];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (removed) {
|
|
224
371
|
fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
225
|
-
actions.push("Removed the Prismo
|
|
372
|
+
actions.push("Removed the Prismo hooks from .claude/settings.json");
|
|
226
373
|
} else {
|
|
227
|
-
actions.push("No Prismo
|
|
374
|
+
actions.push("No Prismo hooks found in .claude/settings.json");
|
|
228
375
|
}
|
|
229
376
|
return {
|
|
230
377
|
schemaVersion: 1,
|
|
@@ -239,6 +386,7 @@ module.exports = function createEnforce(deps) {
|
|
|
239
386
|
function runEnforceStatus(rootDir = process.cwd()) {
|
|
240
387
|
const root = path.resolve(rootDir);
|
|
241
388
|
const state = readState(root);
|
|
389
|
+
const denials = state.denials || { total: 0, blockedContext: 0, loops: 0, estimatedTokensSaved: 0 };
|
|
242
390
|
return {
|
|
243
391
|
schemaVersion: 1,
|
|
244
392
|
command: "enforce",
|
|
@@ -246,6 +394,12 @@ module.exports = function createEnforce(deps) {
|
|
|
246
394
|
installed: hookInstalled(root),
|
|
247
395
|
blockedRules: readBlockedPatterns(root).length,
|
|
248
396
|
trackedSessions: Object.keys(state.sessions || {}).length,
|
|
397
|
+
denials: {
|
|
398
|
+
total: denials.total || 0,
|
|
399
|
+
blockedContext: denials.blockedContext || 0,
|
|
400
|
+
loops: denials.loops || 0,
|
|
401
|
+
estimatedTokensSaved: denials.estimatedTokensSaved || 0,
|
|
402
|
+
},
|
|
249
403
|
settingsPath: path.join(".claude", "settings.json"),
|
|
250
404
|
generatedAt: new Date().toISOString(),
|
|
251
405
|
};
|
|
@@ -260,6 +414,10 @@ module.exports = function createEnforce(deps) {
|
|
|
260
414
|
lines.push(`Hook installed: ${result.installed ? "yes" : "no"}`);
|
|
261
415
|
lines.push(`Blocked-context rules: ${result.blockedRules}`);
|
|
262
416
|
lines.push(`Sessions tracked for loop breaking: ${result.trackedSessions}`);
|
|
417
|
+
if (result.denials && result.denials.total > 0) {
|
|
418
|
+
lines.push(`Denials: ${result.denials.total} (${result.denials.blockedContext} blocked-context, ${result.denials.loops} loop)`);
|
|
419
|
+
lines.push(`Estimated tokens kept out of context: ~${result.denials.estimatedTokensSaved.toLocaleString()}`);
|
|
420
|
+
}
|
|
263
421
|
if (!result.installed) {
|
|
264
422
|
lines.push("");
|
|
265
423
|
lines.push(`Run \`${NPX_COMMAND} enforce install\` to enforce the context firewall at runtime.`);
|
|
@@ -277,6 +435,7 @@ module.exports = function createEnforce(deps) {
|
|
|
277
435
|
}
|
|
278
436
|
|
|
279
437
|
return {
|
|
438
|
+
decidePostToolUse,
|
|
280
439
|
decidePreToolUse,
|
|
281
440
|
matchesBlocked,
|
|
282
441
|
renderEnforceTerminal,
|
package/lib/prismo-dev/help.js
CHANGED
|
@@ -14,6 +14,7 @@ Usage:
|
|
|
14
14
|
prismo mcp doctor [--json] [path]
|
|
15
15
|
prismo connect [--json] [--token TOKEN] [--api-url URL] [--org ORG] [--user USER] [--device NAME]
|
|
16
16
|
prismo connector [status|install|start|stop|uninstall] [--json] [--interval N] [--sync-interval N] [--mode observe|suggest|autopilot] [path]
|
|
17
|
+
prismo bridge [--json] [path]
|
|
17
18
|
prismo sync [--json] [--dry-run] [--watch] [--interval N] [--limit N] [--tool all|codex|claude|cursor] [path]
|
|
18
19
|
prismo status [--json]
|
|
19
20
|
prismo digest [--json] [--days N]
|
|
@@ -49,9 +50,10 @@ Commands:
|
|
|
49
50
|
mcp Start a local MCP server exposing Prismo tools over stdio.
|
|
50
51
|
connect Store a PrismoDev cloud connection for seamless dashboard sync.
|
|
51
52
|
connector Install or manage the background Prismo Workspace connector.
|
|
53
|
+
bridge Explain optional agent bridge mode and live interception levels.
|
|
52
54
|
sync Send safe aggregate local agent telemetry to Prismo; use --watch for background-style sync.
|
|
53
55
|
status Show local PrismoDev connection and last sync state.
|
|
54
|
-
digest Print the
|
|
56
|
+
digest Print the launch report: verified saved tokens/dollars first, with live prevention labeled estimated.
|
|
55
57
|
disconnect Remove the local PrismoDev cloud connection.
|
|
56
58
|
agent Claim and execute safe workspace actions queued from Prismo Cloud.
|
|
57
59
|
scan Run PrismoDev for Claude Code, Codex, Cursor, and AI coding workflows.
|
|
@@ -520,6 +522,24 @@ Output:
|
|
|
520
522
|
On macOS this creates a LaunchAgent so Prismo stays online after the terminal closes.
|
|
521
523
|
The connector claims safe repairs queued from Prismo Cloud, runs them locally, continuously syncs aggregate telemetry, and reports status back.
|
|
522
524
|
It does not upload prompts, source code, file contents, stdout, stderr, or full command logs.`,
|
|
525
|
+
bridge: `PrismoDev Bridge
|
|
526
|
+
|
|
527
|
+
Usage:
|
|
528
|
+
prismo bridge [--json] [path]
|
|
529
|
+
|
|
530
|
+
Examples:
|
|
531
|
+
prismo bridge
|
|
532
|
+
prismo bridge --json
|
|
533
|
+
|
|
534
|
+
What this explains:
|
|
535
|
+
Bridge mode is optional. The connector is still the default: it observes local agent sessions, applies safe queued repairs, verifies impact, and shows live events without sitting in front of every agent action.
|
|
536
|
+
|
|
537
|
+
Agent control levels:
|
|
538
|
+
Claude Code can use "prismo enforce install" for hard-blocking through PreToolUse hooks.
|
|
539
|
+
Codex and Cursor can be detected and repaired through local logs, MCP, shield, and guardrails. Universal hard-blocking needs a wrapper, bridge, or deeper pre-tool hook from those agents.
|
|
540
|
+
|
|
541
|
+
Privacy:
|
|
542
|
+
Bridge status does not upload raw prompts, source code, stdout, stderr, or full command logs.`,
|
|
523
543
|
sync: `PrismoDev Sync
|
|
524
544
|
|
|
525
545
|
Usage:
|
package/lib/prismo-dev-scan.js
CHANGED
|
@@ -335,6 +335,7 @@ const {
|
|
|
335
335
|
} = repairExecutors;
|
|
336
336
|
|
|
337
337
|
const {
|
|
338
|
+
decidePostToolUse,
|
|
338
339
|
decidePreToolUse,
|
|
339
340
|
renderEnforceTerminal,
|
|
340
341
|
runEnforceInstall,
|
|
@@ -380,6 +381,7 @@ const {
|
|
|
380
381
|
openUrl,
|
|
381
382
|
repairExecutors,
|
|
382
383
|
repairPlanner,
|
|
384
|
+
getUsageSummary,
|
|
383
385
|
});
|
|
384
386
|
|
|
385
387
|
const {
|
|
@@ -508,6 +510,7 @@ const { runCli } = require("./prismo-dev/cli")({
|
|
|
508
510
|
runRepair,
|
|
509
511
|
renderPlannerTerminal,
|
|
510
512
|
runPlannerOnce,
|
|
513
|
+
decidePostToolUse,
|
|
511
514
|
decidePreToolUse,
|
|
512
515
|
renderEnforceTerminal,
|
|
513
516
|
runEnforceInstall,
|
|
@@ -618,6 +621,7 @@ module.exports = {
|
|
|
618
621
|
REPAIR_CAUSES,
|
|
619
622
|
runPlannerOnce,
|
|
620
623
|
renderPlannerTerminal,
|
|
624
|
+
decidePostToolUse,
|
|
621
625
|
decidePreToolUse,
|
|
622
626
|
renderEnforceTerminal,
|
|
623
627
|
runEnforceInstall,
|
package/package.json
CHANGED