Cheaper, sharper Claude Code sessions: Bash output trimmed before Claude reads it, a project map and this project's field notes at session start, iq code…

Trims Bash output before Claude reads it, opens each session with a map of the project and its field notes, teaches the fileq and iq CLIs, and reports what each of these saved. Every mechanism is a switch in /config.
/plugin marketplace add intentic/intentic
/plugin install intentic@intentic
The hooks and the bundled commands run on Node.js 20.11 or later, which has to be on PATH. Without it the plugin does nothing, and each session opens with one line saying so. Claude Code fetches the plugin from npm (@intentic/claude-plugin), so npm has to be installed too.
| When | What happens | Switch |
|---|---|---|
| A session opens | Claude gets a map of the project: its areas, what each is for, and where the session started. It is recomputed from the tree for every session. | project_map |
| A session opens | Claude gets this project's field notes, cut by rank to 4,000 characters, if /intentic:field-notes has written them. | field_notes |
| A session opens | Claude is taught iq, a code search that answers a question with ranked path:line anchors, if iq is installed. | iq |
| A prompt is sent | iq points a prompt that matches earlier work at the session that did it. | iq |
| A Bash command succeeds | The output is cleaned before Claude reads it: progress bars, install chatter, repeated lines, the middle of huge logs. A footer names the command that reads the full text back. | output_cleaners, cleaners |
| Claude reads a document | fileq turns docx, pdf, xlsx, pptx, epub, ipynb, images, audio and archives into markdown. | fileq |
| Claude makes a document | fileq check lists what is wrong with a docx, pptx, xlsx or pdf by slide, page or cell, and fileq render draws its pages as PNGs for Claude to look at (with LibreOffice and poppler installed). The fileq skill says to run both before handing the file over. | fileq |
| A Bash command succeeds, in Claude Code 2.1.287 or later | A line under the prompt says what the cleaners saved so far this session, and /intentic-pane shows the full report in a pane. See In the interface. | output_cleaners |
A failed command reaches Claude unchanged. Outside an Intentic sandbox there is no secret store to mask values from, so the cleaners mask only text that looks like a credential: an assignment to a name like API_KEY or TOKEN whose value looks generated, bearer tokens, AWS access keys, and passwords in URLs.
The intentic:Intentic output style adds three working habits to Claude Code's own instructions: batch independent lookups, reuse what is already in context, and run long commands in the background instead of sleeping. Pick it in /output-style. It is off until you do.
Open /config and find the rows under intentic. The switches read at session start (project_map, field_notes, iq, fileq, holdout) apply from the next session.
| Key | Default | Effect |
|---|---|---|
output_cleaners | on | Clean successful Bash output, ledger what each cleaner saved, and show the saving under the prompt. Off, none of the three happens |
cleaners | empty (all) | Which cleaners run: -cap,-wide switches some off, git,pnpm allows only those |
output_holdout | 0 | Share of commands left uncleaned, to measure the saving against real output |
project_map | on | Send the project map at session start |
field_notes | on | Send this project's field notes at session start |
iq | on | Teach iq and run its session recall, when iq is installed |
fileq | on | Let the bundled fileq run. Off, it refuses, and its skill stays listed because a plugin cannot hide its own skills |
holdout | 0.1 | Share of sessions opened without the map, the notes and the iq teaching, so the report can compare |
Run /intentic:stats for this project, or /intentic:stats all for every project the plugin has seen.
## Bash output
412 commands trimmed: 1.9M tokens of output became 310k (84% saved, exact per command).
Biggest cleaners: cap 1.1M (96 commands), pnpm 210k (40 commands), dedup 88k (131 commands).
## Session context
Project map, root listings in the opening turn: -71.4% ±18.2pp (95%): 0.4 with it vs 1.4 without, over 118 and 31 opening turns.
Field notes, failed tool calls per turn: no difference yet beyond ±24.1pp (95%): 0.6 with it vs 0.7 without, over 104 and 33 conversations.
iq teaching, search calls per turn: measuring, 12 of 30 conversations in the smaller arm: 2.1 with it vs 2.9 without, over 96 and 12 conversations.
The numbers above are an illustration of the format. The report reads two things:
output_holdout above 0 it also compares the median cleaned output against commands left uncleaned.holdout of the sessions in the control arm for each mechanism, so the draw needs no state and gives the same answer every time it is read. The map is judged on directory listings of the project root in the first turn, the field notes on failed tool calls, and the iq teaching on search calls. A change is stated only once both arms hold 30 samples and the 95% margin excludes zero.The arithmetic is the one behind the savings page of an Intentic sandbox, from the same packages, so a number here means what it means there.
Claude Code 2.1.287 and later can load a small mod from a plugin, code that runs inside Claude Code and draws in its interface. The plugin's mod (hooks/register.ts) makes the cleaners' work visible, and does nothing else: it changes no output and sends nothing.
| What | Where it shows | Needs |
|---|---|---|
A status line under the prompt, such as intentic: Bash output trimmed 84% · 1.6M tokens saved over 12 commands. It updates after each Bash command and stays empty until a command has been shortened. | The terminal and the Desktop app | output_cleaners on |
/intentic-pane, the /intentic:stats report in a pane with a Refresh button. /intentic-pane all reports every project. | The terminal and the Desktop app | node on PATH, as for the rest of the plugin |
Where Claude Code draws nothing, the mod still runs and the status line has no place to go. /intentic-pane then answers with the report as text, as it also does in the VS Code extension and in claude -p. /intentic:stats works the same everywhere, whatever the version, and is the command to use in a script.
The status line counts from the ledger rows the session has already written (when it resumes) and adds each Bash command as it finishes. It is the same ledger /intentic:stats reads, so the two agree, except that a command left untrimmed on purpose by output_holdout counts as saving nothing in the status line. The mod does a few additions and one status update per Bash command and reads the ledger once, when the session starts. Claude Code gives a mod hook 10 seconds, and 50 ms for the hook on prompt edits, which the mod does not use.
Older Claude Code versions are not harmed by the mod. modules in hooks/hooks.json is a key those versions either do not know, in which case they read the file as before (checked on 2.1.200), or know and keep it switched off until a rollout reaches the account (seen on 2.1.283), in which case the Bash and session hooks still run and Claude Code may print one line at start saying that a hooks module was not loaded. From 2.1.287 on the module loads by default.
Run /intentic:field-notes every few weeks. It counts this project's sessions (failures by how many sessions hit them, the commands that ran and how often they failed, the files edited most) and asks Claude to rewrite .claude/intentic/field-notes.toon from that evidence, checking each fact against the project before writing it. Commit the file to share it with a team, or ignore it to keep it yours.
| Path | Holds |
|---|---|
~/.claude/plugins/data/intentic-intentic/ | sessions.jsonl (the arms each session drew), options.json (the switches of the last session), output/ (the cleaners' ledger, the full text of trimmed commands, the repeat cache), iq/ (the logs of iq's transcript ingest) |
.claude/intentic/field-notes.toon | This project's field notes |
.intentic/local/cache/derived/ | fileq's cache: the markdown of each document it has read, reused while the document is unchanged. The directory ignores itself in git |
The plugin makes no network requests. Uninstalling it deletes the data directory unless you pass --keep-data.
The Intentic sandbox also replaces lines and sections of Claude Code's system prompt. A plugin can add an output style or replace the whole prompt with fixed text, but it cannot edit the prompt line by line, and a fixed copy would go stale with each Claude Code release. The working habits that hold outside a sandbox are in the intentic:Intentic output style instead.
The plugin is built from the packages that own each mechanism, so the sandbox and the plugin run the same code:
filterRun, and summarizeStats.dist/fileq.mjs, the text of its skill, and its NOTICE (rules adapted from SurfSense under Apache-2.0), copied to generated/fileq-NOTICE.The mod is one source file, hooks/register.ts, shipped as it is: Claude Code loads TypeScript hooks modules itself, in an environment with no Node and no imports beyond the plugin's own files, so there is nothing to bundle. Its helpers are exported and tested with bun (src/mod.test.ts); the hooks as Claude Code wires them are tested with its own test runner (tests/mod.test.ts).
The build writes dist/ (one esbuild bundle per hook and command, so an install needs no node_modules) and generated/ (the skills and the output style, from the texts those packages own). Both are ignored by git.
userConfig and what the hooks read.PostToolUse hook that returns updatedToolOutput./intentic:stats report./intentic:field-notes is written from.pnpm --filter @intentic/claude-plugin build # dist/, generated/, and the version in plugin.json
pnpm --filter @intentic/claude-plugin test
pnpm --filter @intentic/claude-plugin test:mod # the mod's tests, through `claude plugin test` (needs `claude` 2.1.287 or later on PATH)
pnpm --filter @intentic/claude-plugin validate # build, then `claude plugin validate --strict`
claude --plugin-dir _sandbox/claude-plugin # a session with the checkout loaded, after a build
publishConfig.executableFiles lists every file in bin/. pnpm packs the release tarball and clears the executable bit of anything not listed there, and src/manifest.test.ts fails when a script is missing from the list.
hooks/register.ts 312 lines1// The plugin's mod: what the Bash output cleaners saved, drawn in Claude Code (2.1.287 or later). Claude Code loads this
2// file in-process, from `modules` in hooks.json, next to the shell hooks that do the cleaning; it only shows their work.
3// - a status entry under the prompt, updated after each Bash call: "Bash trimmed 84% · 1.6M tokens saved · 12 commands";
4// - `/intentic-pane`, the /intentic:stats report in a pane, or as text where nothing draws.
5// A mod runs in an environment with no Node, so files and processes go through `$`, and this one file imports nothing:
6// the engine's own types (`import type … from 'claude-code'`) are only written once a mod has loaded, so the few members
7// used here are declared below instead, which keeps the package's typecheck working on a fresh checkout.
8// Limits (code.claude.com/docs/en/plugins/mods/reference): a hook gets 10 s, a prompt.edit hook 50 ms; nothing here hooks
9// prompt.edit, and the per-call hook does a string-length sum and one `$.ui.status` call.
10
11// ---- the slice of the mods API this file uses ----------------------------------------------------------------------
12
13interface Element {
14 readonly type: string;
15}
16
17type Component<P> = (props: P) => Element;
18
19interface Api {
20 readonly plugin: { readonly name: string; readonly root: string };
21 readonly env: { get(name: string): Promise<string | undefined> };
22 readonly fs: {
23 read(path: string): Promise<string>;
24 exists(path: string): Promise<boolean>;
25 list(path: string): Promise<readonly { readonly name: string; readonly kind: string }[]>;
26 stat(path: string): Promise<{ readonly mtimeMs: number }>;
27 };
28 readonly process: { run(argv: readonly string[], init?: { cwd?: string; timeoutMs?: number }): Promise<{ exitCode: number; stdout: string; stderr: string }> };
29 readonly session: { id(): Promise<string>; cwd(): Promise<string>; surfaces(): Promise<readonly string[]> };
30 readonly command: { register(spec: { name: string; description: string; argumentHint?: string }): Promise<object> };
31 readonly ui: {
32 status(text: string | undefined): void;
33 open(pane: { id: string; title?: string }): Promise<{ isPlaced: boolean }>;
34 close(pane: { id: string }): Promise<void>;
35 invalidate(event: "ui.render"): void;
36 resolve(e: RenderEvent): {
37 Box: Component<{ flexDirection?: "column" | "row"; gap?: number; children?: readonly Element[] }>;
38 Text: Component<{ dimColor?: boolean; children?: string }>;
39 Markdown: Component<{ text: string }>;
40 Button: Component<{ key: string; label: string; hotkey?: string; onPress: () => Promise<void> }>;
41 };
42 };
43}
44
45type Next<E, R> = (e: E) => Promise<R>;
46type Hook<E, R> = ($: Api, e: E, next: Next<E, R>) => Promise<R> | R;
47
48interface PostToolUse {
49 readonly session_id?: string;
50 readonly tool_name?: string;
51 readonly tool_response?: BashResponse | string | null;
52}
53interface PostToolUseResult {
54 readonly updatedToolOutput?: BashResponse | string | null;
55}
56interface CommandRun {
57 readonly args: string;
58}
59interface SessionStart {
60 readonly cwd?: string;
61}
62// A tool's result as Claude Code hands it over; Bash's carries these, and whatever else it carries is left alone.
63interface BashResponse {
64 readonly stdout?: string | number | boolean | object | null;
65 readonly stderr?: string | number | boolean | object | null;
66 readonly interrupted?: boolean;
67 readonly isImage?: boolean;
68 readonly backgroundTaskId?: string | number | null;
69}
70interface LedgerRow {
71 readonly session?: string | number | null;
72 readonly rawBytes?: string | number | null;
73 readonly emittedBytes?: string | number | null;
74}
75interface Options {
76 readonly output_cleaners?: boolean | string | number | readonly string[];
77}
78
79interface RenderEvent {
80 readonly surface?: string;
81}
82
83interface On {
84 (event: "session.start", hook: Hook<SessionStart, SessionStart>): void;
85 (event: "classic.PostToolUse", hook: Hook<PostToolUse, PostToolUseResult | undefined>): void;
86 (event: "command.run", matcher: { command: string }, hook: Hook<CommandRun, { text?: string }>): void;
87 (event: "ui.render", matcher: { component: "Pane"; requestId: string }, hook: Hook<RenderEvent, Element>): void;
88}
89
90export type Register = (on: On, options: Options) => void;
91
92// ---- what the cleaners saved: pure helpers, tested without an engine ---------------------------------------------------
93
94export interface Tally {
95 readonly commands: number;
96 readonly raw: number;
97 readonly emitted: number;
98}
99
100export const EMPTY: Tally = { commands: 0, raw: 0, emitted: 0 };
101
102export const add = (tally: Tally, raw: number, emitted: number): Tally => ({ commands: tally.commands + 1, raw: tally.raw + raw, emitted: tally.emitted + emitted });
103
104// A ledger row, as @intentic/output-cleaners writes it into filter-stats.jsonl: lengths of the text before and after the
105// cleaners, tagged by the plugin's post-bash hook with the project and the session.
106export const seedFromLedger = (text: string, session: string): Tally => {
107 let tally = EMPTY;
108 for (const line of text.split("\n")) {
109 // The session id is a substring of its own rows, so most lines are skipped before they are parsed.
110 if (!line.includes(session)) {
111 continue;
112 }
113 try {
114 // SAFETY: a ledger line is whatever a past hook wrote; each field is checked by type before it is used.
115 const row = JSON.parse(line) as LedgerRow;
116 if (row.session === session && typeof row.rawBytes === "number" && typeof row.emittedBytes === "number") {
117 tally = add(tally, row.rawBytes, row.emittedBytes);
118 }
119 } catch {
120 // allow(silent-catch): a torn or foreign line is not a row; the ledger's own reader skips these the same way.
121 }
122 }
123 return tally;
124};
125
126// What the model would have read of a Bash result, which is what post-bash.ts counts as the raw text: stdout, then stderr.
127export const textLength = (response: BashResponse | string | null | undefined): number | undefined => {
128 if (typeof response !== "object" || response === null) {
129 return undefined;
130 }
131 const { stdout, stderr, interrupted, isImage, backgroundTaskId } = response;
132 if (typeof stdout !== "string" || interrupted === true || isImage === true || backgroundTaskId !== undefined) {
133 return undefined;
134 }
135 const err = typeof stderr === "string" ? stderr : "";
136 return err === "" ? stdout.length : stdout.length + (stdout === "" || stdout.endsWith("\n") ? 0 : 1) + err.length;
137};
138
139export const emittedLength = (result: PostToolUseResult | undefined): number | undefined => {
140 return textLength(result?.updatedToolOutput);
141};
142
143// Four characters to a token, as the ledger's own summary counts.
144const tokens = (length: number): string => {
145 const count = Math.round(length / 4);
146 if (count >= 1_000_000) {
147 return `${(count / 1_000_000).toFixed(1)}M`;
148 }
149 if (count >= 10_000) {
150 return `${Math.round(count / 1000)}k`;
151 }
152 return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
153};
154
155// Nothing until a command has actually been shortened: "0% saved" under every prompt is noise.
156export const statusText = (tally: Tally): string | undefined => {
157 const saved = tally.raw - tally.emitted;
158 if (saved <= 0 || tally.raw === 0) {
159 return undefined;
160 }
161 return `intentic: Bash output trimmed ${Math.round((saved / tally.raw) * 100)}% · ${tokens(saved)} tokens saved over ${tally.commands} ${tally.commands === 1 ? "command" : "commands"}`;
162};
163
164// The plugin's data directory, where post-bash.ts keeps the ledger and session-start.ts the switches. A hook gets it as
165// CLAUDE_PLUGIN_DATA; a mod is not given it, so it is worked out the way Claude Code does: `plugins/data/<name>-<marketplace>`
166// under the config directory, the marketplace read off the plugin's own install path (`plugins/cache/<marketplace>/<name>/<version>`),
167// and `<name>-inline` for a plugin loaded from a folder.
168export const dataDirOf = (name: string, root: string, env: { data?: string | undefined; config?: string | undefined; home?: string | undefined }): string | undefined => {
169 if (env.data !== undefined && env.data !== "") {
170 return env.data;
171 }
172 const config = env.config !== undefined && env.config !== "" ? env.config : env.home !== undefined && env.home !== "" ? `${env.home}/.claude` : undefined;
173 if (config === undefined) {
174 return undefined;
175 }
176 const cached = /[\\/]plugins[\\/]cache[\\/]([^\\/]+)[\\/]([^\\/]+)[\\/][^\\/]+[\\/]?$/.exec(root);
177 const id = `${name}-${cached?.[1] ?? "inline"}`.replace(/[^a-zA-Z0-9_-]/g, "-");
178 return `${config.replace(/[\\/]+$/, "")}/plugins/data/${id}`;
179};
180
181// ---- the mod ---------------------------------------------------------------------------------------------------------
182
183const PANE = "intentic-savings";
184const COMMAND = "intentic-pane";
185// A pane's Markdown holds 10,000 characters; the report is a fraction of that, but a project list could grow.
186const REPORT_LIMIT = 9000;
187
188// Where this plugin keeps its ledger: the directory Claude Code names for its hooks (see dataDirOf). A plugin read from a
189// folder (a marketplace that is a directory) has an install path that says nothing about its marketplace, so when the
190// derived directory holds nothing the session-start hook wrote (`options.json`, written every session), the plugin's own
191// newest sibling under plugins/data is taken instead.
192const dataDir = async ($: Api): Promise<string | undefined> => {
193 const derived = dataDirOf($.plugin.name, $.plugin.root, {
194 data: await $.env.get("CLAUDE_PLUGIN_DATA"),
195 config: await $.env.get("CLAUDE_CONFIG_DIR"),
196 home: (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")),
197 });
198 if (derived === undefined || (await $.fs.exists(`${derived}/options.json`))) {
199 return derived;
200 }
201 const parent = derived.slice(0, derived.lastIndexOf("/"));
202 let best = derived;
203 let newest = -1;
204 try {
205 for (const entry of await $.fs.list(parent)) {
206 if (entry.kind !== "dir" || !entry.name.startsWith(`${$.plugin.name}-`)) {
207 continue;
208 }
209 const written = await $.fs.stat(`${parent}/${entry.name}/options.json`).then(
210 (stat) => stat.mtimeMs,
211 () => -1,
212 );
213 if (written > newest) {
214 newest = written;
215 best = `${parent}/${entry.name}`;
216 }
217 }
218 } catch {
219 // allow(silent-catch): no plugins/data yet means nothing has run, and the derived directory is as good as any.
220 }
221 return best;
222};
223
224// The /intentic:stats report, from the bundle the plugin ships; the same code the skill runs, so the pane and the command
225// say the same thing.
226const buildReport = async ($: Api, args: string): Promise<string> => {
227 const data = await dataDir($);
228 if (data === undefined) {
229 return "intentic: the plugin's data directory could not be found, so there is no report to show.";
230 }
231 const argv = ["node", `${$.plugin.root}/dist/stats.mjs`, "--data", data, "--project", await $.session.cwd(), ...(args.trim() === "all" ? ["all"] : [])];
232 try {
233 const ran = await $.process.run(argv, { timeoutMs: 20_000 });
234 return ran.exitCode === 0 ? ran.stdout.slice(0, REPORT_LIMIT) : `intentic: the report failed (exit ${ran.exitCode}): ${ran.stderr.trim().slice(0, 300)}`;
235 } catch (error) {
236 return `intentic: the report needs Node.js 20.11 or later on PATH (${error instanceof Error ? error.message : String(error)}).`;
237 }
238};
239
240export const register: Register = (on, options) => {
241 // The same switch the shell hooks read: off, nothing is cleaned, nothing is ledgered, and there is nothing to show.
242 const cleaning = options["output_cleaners"] !== false;
243 let tally: Tally = EMPTY;
244 let report = "";
245
246 on("session.start", async ($, e, next) => {
247 await $.command.register({ name: COMMAND, description: "Show what the intentic plugin saved, in a pane", argumentHint: "[all]" });
248 if (cleaning) {
249 try {
250 const data = await dataDir($);
251 const session = await $.session.id();
252 // Resumed or reloaded: what this session already saved is in the ledger. A ledger over the 4 MiB a mod may
253 // read at once is not an error; the count starts from this point.
254 tally = data === undefined ? EMPTY : seedFromLedger(await $.fs.read(`${data}/output/filter-stats.jsonl`), session);
255 } catch {
256 tally = EMPTY;
257 }
258 $.ui.status(statusText(tally));
259 }
260 return next(e);
261 });
262
263 if (cleaning) {
264 on("classic.PostToolUse", async ($, e, next) => {
265 const raw = e.tool_name === "Bash" ? textLength(e.tool_response) : undefined;
266 const result = await next(e);
267 if (raw !== undefined) {
268 // The shell hook answers with the trimmed result, or with nothing when it left the output as it was.
269 tally = add(tally, raw, emittedLength(result) ?? raw);
270 $.ui.status(statusText(tally));
271 }
272 return result;
273 });
274 }
275
276 on("command.run", { command: COMMAND }, async ($, e) => {
277 report = await buildReport($, e.args);
278 // A plain `-p` run has no surface at all, and `ui.open` would answer "placed" into nothing; a surface that places no
279 // panes answers "not placed". Either way the report goes out as the command's own text, which every Claude Code shows.
280 const draws = (await $.session.surfaces()).length > 0;
281 const opened = draws ? await $.ui.open({ id: PANE, title: "intentic savings" }) : undefined;
282 if (opened?.isPlaced === true) {
283 $.ui.invalidate("ui.render");
284 return {};
285 }
286 if (opened !== undefined) {
287 await $.ui.close({ id: PANE });
288 }
289 return { text: report };
290 });
291
292 on("ui.render", { component: "Pane", requestId: PANE }, ($, e) => {
293 const { Box, Text, Markdown, Button } = $.ui.resolve(e);
294 return Box({
295 flexDirection: "column",
296 gap: 1,
297 children: [
298 report === "" ? Text({ dimColor: true, children: "Run /intentic-pane to fill this." }) : Markdown({ text: report }),
299 Button({
300 key: "refresh",
301 label: "Refresh",
302 hotkey: "r",
303 onPress: async () => {
304 report = await buildReport($, "");
305 $.ui.invalidate("ui.render");
306 },
307 }),
308 ],
309 });
310 });
311};
312