SLOPSHOPPER

Intentic

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…

newpanecommandstatusprocess
★ 74v0.0.0MITupdated 2026-10-04intentic/intentic/_sandbox/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · intentic
│ ┃ intentic savings ✕ › fix the failing auth test and add an audit log call │ ┃ dev │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Refresh ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /intentic-pane │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · intentic savings
dev [ Refresh ]
README

intentic plugin for Claude 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.

What it does

WhenWhat happensSwitch
A session opensClaude 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 opensClaude gets this project's field notes, cut by rank to 4,000 characters, if /intentic:field-notes has written them.field_notes
A session opensClaude is taught iq, a code search that answers a question with ranked path:line anchors, if iq is installed.iq
A prompt is sentiq points a prompt that matches earlier work at the session that did it.iq
A Bash command succeedsThe 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 documentfileq turns docx, pdf, xlsx, pptx, epub, ipynb, images, audio and archives into markdown.fileq
Claude makes a documentfileq 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.

Switches

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.

KeyDefaultEffect
output_cleanersonClean successful Bash output, ledger what each cleaner saved, and show the saving under the prompt. Off, none of the three happens
cleanersempty (all)Which cleaners run: -cap,-wide switches some off, git,pnpm allows only those
output_holdout0Share of commands left uncleaned, to measure the saving against real output
project_maponSend the project map at session start
field_notesonSend this project's field notes at session start
iqonTeach iq and run its session recall, when iq is installed
fileqonLet the bundled fileq run. Off, it refuses, and its skill stays listed because a plugin cannot hide its own skills
holdout0.1Share of sessions opened without the map, the notes and the iq teaching, so the report can compare

What it saved

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:

  • The cleaners' ledger, one row per command with the bytes before and after and what each cleaner removed. That saving is exact. With output_holdout above 0 it also compares the median cleaned output against commands left uncleaned.
  • Claude Code's own session transcripts, compared across the two arms each session was drawn into. A hash of the session id puts 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.

In the interface

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.

WhatWhere it showsNeeds
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 appoutput_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 appnode 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.

Field notes

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.

Where it keeps things

PathHolds
~/.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.toonThis 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.

Not included

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.

Development

The plugin is built from the packages that own each mechanism, so the sandbox and the plugin run the same code:

  • @intentic/output-cleaners: the cleaners, filterRun, and summarizeStats.
  • @intentic/agent-context: the project map, the reader for field notes and the brief their writer gets, how a session draws its arm, what each turn is scored on, the comparison between the arms, and the transcript reader.
  • @intentic/fileq: the CLI, bundled as dist/fileq.mjs, the text of its skill, and its NOTICE (rules adapted from SurfSense under Apache-2.0), copied to generated/fileq-NOTICE.
  • @intentic/iq: the iq skill and the hint a session opens with. The build rewords the hint's two sandbox phrases for a plain install and fails if they change upstream.

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.

Key files

Commands

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.

Source 1 files
hooks/register.ts 312 lines
1// 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