getprismo 0.1.42 → 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 CHANGED
@@ -4,13 +4,13 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dw/getprismo.svg)](https://www.npmjs.com/package/getprismo)
5
5
  [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
6
 
7
- local ai coding cost control. one command to diagnose token waste, fix it, and prove the improvement.
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.
@@ -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,
@@ -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 decision = decidePreToolUse(process.cwd(), Buffer.concat(chunks).toString("utf8"));
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
  }
@@ -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 {
@@ -424,6 +459,9 @@ module.exports = function createCloudSync(deps) {
424
459
  return lines.join("\n");
425
460
  }
426
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
+ }
427
465
  return lines.join("\n");
428
466
  }
429
467
 
@@ -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,51 @@ 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 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
+
52
103
  function relativePath(root, filePath) {
53
104
  const value = String(filePath || "");
54
105
  const resolvedRoot = path.resolve(root);
@@ -107,6 +158,7 @@ module.exports = function createEnforce(deps) {
107
158
  const patterns = readBlockedPatterns(root);
108
159
  const hit = patterns.find((pattern) => matchesBlocked(relPath, pattern));
109
160
  if (hit) {
161
+ recordDenial(root, readState(root), "blocked-context", relPath, estimateBlockedFileTokens(root, target));
110
162
  return deny(
111
163
  `Prismo context firewall: "${relPath}" is blocked context (rule: ${hit}). `
112
164
  + "It is generated output that wastes agent tokens. Use the .prismo/ context packs instead, "
@@ -121,22 +173,30 @@ module.exports = function createEnforce(deps) {
121
173
  if (!command) return null;
122
174
  const sessionId = String(event.session_id || "unknown");
123
175
  const state = readState(root);
124
- const sessions = state.sessions || {};
125
- const session = sessions[sessionId] || { commands: {}, updatedAt: null };
126
- const count = Number(session.commands[command] || 0);
127
- if (count >= MAX_IDENTICAL_COMMANDS) {
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`;
128
190
  return deny(
129
- `Prismo loop breaker: this exact command has already run ${count} times in this session. `
191
+ `Prismo loop breaker: ${observation}. `
130
192
  + "Repeating it again will not change the outcome and floods context. Change the approach, "
131
193
  + `or capture its output once with \`${NPX_COMMAND} shield -- ${command}\`.`
132
194
  );
133
195
  }
134
- session.commands[command] = count + 1;
196
+ record.attempts += 1;
197
+ session.commands[command] = record;
135
198
  session.updatedAt = new Date().toISOString();
136
- sessions[sessionId] = session;
137
- const ids = Object.keys(sessions)
138
- .sort((a, b) => String(sessions[b].updatedAt || "").localeCompare(String(sessions[a].updatedAt || "")));
139
- state.sessions = Object.fromEntries(ids.slice(0, MAX_TRACKED_SESSIONS).map((id) => [id, sessions[id]]));
199
+ pruneSessions(state);
140
200
  writeState(root, state);
141
201
  return null;
142
202
  }
@@ -146,6 +206,48 @@ module.exports = function createEnforce(deps) {
146
206
  return null;
147
207
  }
148
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
+
149
251
  function readSettings(root) {
150
252
  try {
151
253
  const parsed = JSON.parse(fs.readFileSync(settingsPath(root), "utf8"));
@@ -157,7 +259,8 @@ module.exports = function createEnforce(deps) {
157
259
 
158
260
  function isPrismoHookEntry(entry) {
159
261
  try {
160
- return JSON.stringify(entry).includes("hook pretooluse");
262
+ const text = JSON.stringify(entry);
263
+ return text.includes("hook pretooluse") || text.includes("hook posttooluse");
161
264
  } catch {
162
265
  return false;
163
266
  }
@@ -181,23 +284,33 @@ module.exports = function createEnforce(deps) {
181
284
  const filePath = settingsPath(root);
182
285
  const settings = readSettings(root);
183
286
  settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
184
- const entries = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
185
- if (entries.some(isPrismoHookEntry)) {
186
- actions.push("Prismo PreToolUse hook already installed in .claude/settings.json");
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");
187
291
  } else {
188
292
  const existed = fs.existsSync(filePath);
189
293
  if (existed) {
190
294
  fs.copyFileSync(filePath, `${filePath}.prismo-backup`);
191
295
  actions.push("Backed up .claude/settings.json to settings.json.prismo-backup");
192
296
  }
193
- entries.push({
194
- matcher: "Read|Glob|Grep|NotebookRead|Bash",
195
- hooks: [{ type: "command", command: HOOK_COMMAND }],
196
- });
197
- settings.hooks.PreToolUse = entries;
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;
198
311
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
199
312
  fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
200
- actions.push(`${existed ? "Updated" : "Created"} .claude/settings.json with the Prismo PreToolUse hook`);
313
+ actions.push(`${existed ? "Updated" : "Created"} .claude/settings.json with the Prismo PreToolUse + PostToolUse hooks`);
201
314
  }
202
315
 
203
316
  return {
@@ -216,15 +329,21 @@ module.exports = function createEnforce(deps) {
216
329
  const filePath = settingsPath(root);
217
330
  const settings = readSettings(root);
218
331
  const actions = [];
219
- const entries = settings.hooks && Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
220
- const kept = entries.filter((entry) => !isPrismoHookEntry(entry));
221
- if (kept.length !== entries.length) {
222
- if (kept.length) settings.hooks.PreToolUse = kept;
223
- else if (settings.hooks) delete settings.hooks.PreToolUse;
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) {
224
343
  fs.writeFileSync(filePath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
225
- actions.push("Removed the Prismo PreToolUse hook from .claude/settings.json");
344
+ actions.push("Removed the Prismo hooks from .claude/settings.json");
226
345
  } else {
227
- actions.push("No Prismo PreToolUse hook found in .claude/settings.json");
346
+ actions.push("No Prismo hooks found in .claude/settings.json");
228
347
  }
229
348
  return {
230
349
  schemaVersion: 1,
@@ -239,6 +358,7 @@ module.exports = function createEnforce(deps) {
239
358
  function runEnforceStatus(rootDir = process.cwd()) {
240
359
  const root = path.resolve(rootDir);
241
360
  const state = readState(root);
361
+ const denials = state.denials || { total: 0, blockedContext: 0, loops: 0, estimatedTokensSaved: 0 };
242
362
  return {
243
363
  schemaVersion: 1,
244
364
  command: "enforce",
@@ -246,6 +366,12 @@ module.exports = function createEnforce(deps) {
246
366
  installed: hookInstalled(root),
247
367
  blockedRules: readBlockedPatterns(root).length,
248
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
+ },
249
375
  settingsPath: path.join(".claude", "settings.json"),
250
376
  generatedAt: new Date().toISOString(),
251
377
  };
@@ -260,6 +386,10 @@ module.exports = function createEnforce(deps) {
260
386
  lines.push(`Hook installed: ${result.installed ? "yes" : "no"}`);
261
387
  lines.push(`Blocked-context rules: ${result.blockedRules}`);
262
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
+ }
263
393
  if (!result.installed) {
264
394
  lines.push("");
265
395
  lines.push(`Run \`${NPX_COMMAND} enforce install\` to enforce the context firewall at runtime.`);
@@ -277,6 +407,7 @@ module.exports = function createEnforce(deps) {
277
407
  }
278
408
 
279
409
  return {
410
+ decidePostToolUse,
280
411
  decidePreToolUse,
281
412
  matchesBlocked,
282
413
  renderEnforceTerminal,
@@ -335,6 +335,7 @@ const {
335
335
  } = repairExecutors;
336
336
 
337
337
  const {
338
+ decidePostToolUse,
338
339
  decidePreToolUse,
339
340
  renderEnforceTerminal,
340
341
  runEnforceInstall,
@@ -508,6 +509,7 @@ const { runCli } = require("./prismo-dev/cli")({
508
509
  runRepair,
509
510
  renderPlannerTerminal,
510
511
  runPlannerOnce,
512
+ decidePostToolUse,
511
513
  decidePreToolUse,
512
514
  renderEnforceTerminal,
513
515
  runEnforceInstall,
@@ -618,6 +620,7 @@ module.exports = {
618
620
  REPAIR_CAUSES,
619
621
  runPlannerOnce,
620
622
  renderPlannerTerminal,
623
+ decidePostToolUse,
621
624
  decidePreToolUse,
622
625
  renderEnforceTerminal,
623
626
  runEnforceInstall,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getprismo",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "Local AI coding workflow scanner for Codex, Claude Code, Cursor, and token-waste diagnostics.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/shanirsh/prismodev#readme",